Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

8 changed files with 94 additions and 1976 deletions

View file

@ -1,14 +1,9 @@
.PHONY: build run test clean install release .PHONY: build run test clean install release
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
EXE := $(shell go env GOEXE)
OCGO_BIN := bin/ocgo$(EXE)
GOBIN := $(shell go env GOBIN)
GOPATH := $(shell go env GOPATH)
INSTALL_DIR := $(HOME)/.local/bin
build: build:
go build -ldflags "-X main.version=$(VERSION)" -o $(OCGO_BIN) ./cmd/ocgo go build -ldflags "-X main.version=$(VERSION)" -o bin/ocgo ./cmd/ocgo
run: run:
go run ./cmd/ocgo go run ./cmd/ocgo
@ -20,8 +15,7 @@ clean:
rm -rf bin rm -rf bin
install: build install: build
mkdir -p "$(INSTALL_DIR)" install -m 0755 bin/ocgo $(HOME)/go/bin/ocgo
install -m 0755 "$(OCGO_BIN)" "$(INSTALL_DIR)/ocgo$(EXE)"
release: release:
@[ -n "$(TAG)" ] || (echo "Usage: make release TAG=v0.1.0" && exit 1) @[ -n "$(TAG)" ] || (echo "Usage: make release TAG=v0.1.0" && exit 1)

100
README.md
View file

@ -1,57 +1,30 @@
# ocgo # ocgo
`ocgo` is a small Go CLI that lets [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Codex CLI](https://developers.openai.com/codex/cli/) run against an OpenCode Go subscription. It starts a local compatibility proxy, translates Claude Code's Anthropic Messages API requests when needed, exposes OpenAI-compatible endpoints for Codex, and launches tools with the right configuration. `ocgo` is a small Go CLI that lets [Claude Code](https://docs.anthropic.com/en/docs/claude-code) run against an OpenCode Go subscription. It starts a local Anthropic-compatible proxy, translates Claude Code's Anthropic Messages API requests to OpenCode Go's OpenAI-compatible chat completions endpoint, and launches `claude` with the right environment variables.
```bash
# 1. Setup your OpenCode API key
ocgo setup
# 2. Start coding!
ocgo launch claude --model kimi-k2.6
ocgo launch codex --model kimi-k2.6
```
Use your OpenCode Go subscription from Claude Code or Codex CLI in one command — no manual proxy setup required.
## Features ## Features
- Save and reuse your OpenCode Go API key. - Save and reuse your OpenCode Go API key.
- List known OpenCode Go model IDs. - List known OpenCode Go model IDs.
- Run Claude Code through OpenCode Go with one command. - Run Claude Code through OpenCode Go with one command.
- Run Codex CLI through OpenCode Go with one command.
- Start, stop, and inspect a local proxy server. - Start, stop, and inspect a local proxy server.
- Exposes Anthropic-compatible and OpenAI-compatible local API layers.
- Supports streaming text responses and basic tool-call translation. - Supports streaming text responses and basic tool-call translation.
## Requirements ## Requirements
- Go 1.22 or newer. - Go 1.22 or newer.
- A valid OpenCode Go API key. - A valid OpenCode Go API key.
- Claude Code or Codex CLI installed and available. - Claude Code installed and available as `claude` in your `PATH` when using `ocgo launch claude`.
## Installation ## Installation
Install with Homebrew: Homebrew installation is coming soon.
```bash ```bash
brew install emanuelcasco/tap/ocgo # TODO: replace with the published Homebrew tap/formula
```
Or tap the repository first:
```bash
brew tap emanuelcasco/tap
brew install ocgo brew install ocgo
``` ```
Build from source:
```bash
git clone https://github.com/emanuelcasco/ocgo.git
cd ocgo
make install
```
## Configuration ## Configuration
Run setup and paste your OpenCode Go API key when prompted: Run setup and paste your OpenCode Go API key when prompted:
@ -137,55 +110,6 @@ ANTHROPIC_SMALL_FAST_MODEL=<model>
If Claude Code requests a Claude model name or does not provide a model, `ocgo` defaults the upstream OpenCode Go model to `kimi-k2.6`. If Claude Code requests a Claude model name or does not provide a model, `ocgo` defaults the upstream OpenCode Go model to `kimi-k2.6`.
### Launch Codex CLI
Start Codex CLI through the local proxy:
```bash
ocgo launch codex
```
Use a specific OpenCode Go model:
```bash
ocgo launch codex --model kimi-k2.6
```
Pass arguments through to Codex after `--`:
```bash
ocgo launch codex --model kimi-k2.6 -- --sandbox workspace-write
```
Configure Codex without launching it:
```bash
ocgo launch codex --config
```
When `ocgo launch codex` runs, it writes or updates this profile in `~/.codex/config.toml`:
```toml
[profiles.ocgo-launch]
openai_base_url = "http://127.0.0.1:3456/v1/"
forced_login_method = "api"
model_provider = "ocgo-launch"
model_catalog_json = "/Users/you/.codex/ocgo-models.json"
[model_providers.ocgo-launch]
name = "OpenCode Go"
base_url = "http://127.0.0.1:3456/v1/"
wire_api = "responses"
```
It then launches:
```bash
codex --profile ocgo-launch -m <model>
```
The Codex process receives `OPENAI_API_KEY=ocgo`; the local proxy injects your real OpenCode Go API key upstream. `ocgo` also writes `~/.codex/ocgo-models.json` so Codex has metadata for OpenCode Go model IDs such as `deepseek-v4-pro`.
## Proxy commands ## Proxy commands
Run the proxy in the foreground: Run the proxy in the foreground:
@ -311,31 +235,23 @@ The script builds macOS/Linux `amd64` and `arm64` archives, uploads them to GitH
## How it works ## How it works
`ocgo` exposes a local compatibility API used by Claude Code and Codex CLI: `ocgo` exposes a local subset of the Anthropic API used by Claude Code:
- `GET /health` - `GET /health`
- `POST /v1/messages` - `POST /v1/messages`
- `POST /v1/messages/count_tokens` - `POST /v1/messages/count_tokens`
- `POST /v1/chat/completions`
- `POST /v1/responses`
Requests sent to `/v1/messages` are converted from Anthropic Messages format into OpenAI-compatible chat completion requests. Requests sent to `/v1/messages` are converted into OpenAI-compatible chat completion requests and forwarded to:
Requests sent to `/v1/chat/completions` are passed through as OpenAI-compatible chat completion requests while `ocgo` injects the configured OpenCode Go API key.
Requests sent to `/v1/responses` use a lightweight OpenAI Responses API adapter for Codex CLI. The adapter converts common Responses input, tool definitions, and streaming text events to and from chat completions.
All upstream requests are forwarded to:
```text ```text
https://opencode.ai/zen/go/v1/chat/completions https://opencode.ai/zen/go/v1/chat/completions
``` ```
Claude Code responses are converted back into Anthropic-compatible responses. Codex responses are returned in OpenAI-compatible Chat Completions or Responses API shapes depending on the requested endpoint. Responses are converted back into Anthropic-compatible responses for Claude Code.
## Limitations ## Limitations
`ocgo` is intentionally lightweight. Token counting currently returns `0`, and Anthropic/OpenAI compatibility is focused on the request and response shapes needed by Claude Code and Codex CLI rather than full API parity. The `/v1/responses` adapter is minimal and targets text/tool workflows used by Codex; it is not a complete OpenAI Responses API implementation. `ocgo` is intentionally lightweight. Token counting currently returns `0`, and Anthropic/OpenAI compatibility is focused on the request and response shapes needed by Claude Code rather than full API parity.
## License ## License

View file

@ -1,9 +0,0 @@
@echo off
setlocal
for /f "delims=" %%v in ('git describe --tags --always --dirty 2^>NUL') do set "OCGO_VERSION=%%v"
if not defined OCGO_VERSION set "OCGO_VERSION=dev"
if not exist bin mkdir bin
go build -ldflags "-X main.version=%OCGO_VERSION%" -o bin\ocgo.exe .\cmd\ocgo
exit /b %ERRORLEVEL%

View file

@ -1,40 +0,0 @@
package main
import (
"errors"
"strconv"
"strings"
)
func parseWindowsNetstatPID(output string, port int) (int, error) {
wanted := strconv.Itoa(port)
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) < 5 || !strings.EqualFold(fields[0], "tcp") {
continue
}
if !strings.EqualFold(fields[len(fields)-2], "listening") {
continue
}
if !netstatAddressUsesPort(fields[1], wanted) {
continue
}
pid, err := strconv.Atoi(fields[len(fields)-1])
if err == nil && pid > 0 {
return pid, nil
}
}
return 0, errors.New("no listener found")
}
func netstatAddressUsesPort(address, port string) bool {
address = strings.TrimSpace(address)
if address == "" {
return false
}
if strings.HasPrefix(address, "[") {
return strings.HasSuffix(address, "]:"+port)
}
idx := strings.LastIndex(address, ":")
return idx >= 0 && address[idx+1:] == port
}

View file

@ -1,27 +0,0 @@
//go:build !windows
package main
import (
"errors"
"os/exec"
"strconv"
"strings"
)
func findListenerPID(port int) (int, error) {
if port == 0 {
return 0, errors.New("missing port")
}
out, err := exec.Command("lsof", "-nP", "-tiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN").Output()
if err != nil {
return 0, err
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
pid, err := strconv.Atoi(strings.TrimSpace(line))
if err == nil && pid > 0 {
return pid, nil
}
}
return 0, errors.New("no listener found")
}

View file

@ -1,19 +0,0 @@
//go:build windows
package main
import (
"errors"
"os/exec"
)
func findListenerPID(port int) (int, error) {
if port == 0 {
return 0, errors.New("missing port")
}
out, err := exec.Command("netstat", "-ano", "-p", "tcp").Output()
if err != nil {
return 0, err
}
return parseWindowsNetstatPID(string(out), port)
}

File diff suppressed because it is too large Load diff

View file

@ -1,500 +0,0 @@
package main
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteCodexProfile(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
if err := writeCodexProfile(path, "http://127.0.0.1:3456/v1/"); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
content := string(b)
for _, want := range []string{
"[profiles.ocgo-launch]",
`openai_base_url = "http://127.0.0.1:3456/v1/"`,
`forced_login_method = "api"`,
`model_provider = "ocgo-launch"`,
`model_catalog_json = `,
`model_reasoning_effort = "minimal"`,
`model_reasoning_summary = "none"`,
"[model_providers.ocgo-launch]",
`name = "OpenCode Go"`,
`base_url = "http://127.0.0.1:3456/v1/"`,
`wire_api = "responses"`,
} {
if !strings.Contains(content, want) {
t.Fatalf("missing %q in:\n%s", want, content)
}
}
}
func TestWriteCodexProfileReplacesExistingSections(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
existing := "[profiles.ocgo-launch]\nopenai_base_url = \"http://old/v1/\"\n\n[other]\nkey = \"value\"\n\n[model_providers.ocgo-launch]\nbase_url = \"http://old/v1/\"\n"
if err := os.WriteFile(path, []byte(existing), 0644); err != nil {
t.Fatal(err)
}
if err := writeCodexProfile(path, "http://new/v1/"); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(path)
content := string(b)
if strings.Contains(content, "http://old") {
t.Fatalf("old profile was not replaced:\n%s", content)
}
if strings.Count(content, "[profiles.ocgo-launch]") != 1 || strings.Count(content, "[model_providers.ocgo-launch]") != 1 {
t.Fatalf("profile sections should be unique:\n%s", content)
}
if !strings.Contains(content, "[other]") || !strings.Contains(content, `key = "value"`) {
t.Fatalf("unrelated section was not preserved:\n%s", content)
}
}
func TestModelContextWindow(t *testing.T) {
if got := modelContextWindow("deepseek-v4-flash[1M]"); got != 1000000 {
t.Fatalf("deepseek-v4-flash[1M]: got %d, want 1000000", got)
}
if got := modelContextWindow("deepseek-v4-pro[1M]"); got != 1000000 {
t.Fatalf("deepseek-v4-pro[1M]: got %d, want 1000000", got)
}
if got := modelContextWindow("kimi-k2.6"); got != 128000 {
t.Fatalf("kimi-k2.6: got %d, want 128000", got)
}
if got := modelContextWindow("unknown-model"); got != 128000 {
t.Fatalf("unknown-model: got %d, want 128000", got)
}
}
func TestCleanModelName(t *testing.T) {
tests := []struct{ in, want string }{
{"deepseek-v4-flash[1M]", "deepseek-v4-flash"},
{"deepseek-v4-pro[1M]", "deepseek-v4-pro"},
{"deepseek-v4-flash", "deepseek-v4-flash"},
{"kimi-k2.6", "kimi-k2.6"},
{"kimi-k2.6[128K]", "kimi-k2.6"},
}
for _, tt := range tests {
if got := cleanModelName(tt.in); got != tt.want {
t.Errorf("cleanModelName(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestWriteCodexModelCatalog(t *testing.T) {
path := filepath.Join(t.TempDir(), "ocgo-models.json")
if err := writeCodexModelCatalog(path); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
content := string(b)
for _, want := range []string{`"models"`, `"slug": "deepseek-v4-pro[1M]"`, `"context_window": 1000000`, `"truncation_policy"`, `"supports_image_detail_original": false`, `"image"`} {
if !strings.Contains(content, want) {
t.Fatalf("missing %q in:\n%s", want, content)
}
}
}
func TestCodexModelCatalogAllowsImagesForKnownVisionModels(t *testing.T) {
if !modelSupportsImages("kimi-k2.6") {
t.Fatal("kimi-k2.6 should support image inputs")
}
if modelSupportsImages("deepseek-v4-pro") {
t.Fatal("deepseek-v4-pro should not support image inputs")
}
for _, tc := range []struct {
model string
want []string
}{
{model: "kimi-k2.6", want: []string{"text", "image"}},
{model: "deepseek-v4-pro", want: []string{"text"}},
} {
got := modelInputModalities(tc.model)
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Fatalf("%s modalities = %+v, want %+v", tc.model, got, tc.want)
}
}
}
func TestCompareVersions(t *testing.T) {
if compareVersions("0.80.9", "0.81.0") >= 0 {
t.Fatal("0.80.9 should be older")
}
if compareVersions("0.81.0", "0.81.0") != 0 {
t.Fatal("same versions should compare equal")
}
if compareVersions("codex-cli", "0.81.0") >= 0 {
t.Fatal("invalid version should compare as old")
}
if compareVersions("0.87.0", "0.81.0") <= 0 {
t.Fatal("0.87.0 should be newer")
}
}
func TestResponsesInputToMessages(t *testing.T) {
messages := responsesInputToMessages([]byte(`[{"type":"message","role":"developer","content":"rules"},{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]`))
if len(messages) != 2 {
t.Fatalf("got %d messages", len(messages))
}
if messages[0].Role != "system" || messages[0].Content != "rules" {
t.Fatalf("bad developer conversion: %+v", messages[0])
}
if messages[1].Role != "user" || messages[1].Content != "hello" {
t.Fatalf("bad user conversion: %+v", messages[1])
}
}
func TestResponsesInputFunctionCallUsesCallID(t *testing.T) {
messages := responsesInputToMessages([]byte(`[{"type":"function_call","id":"fc_123","call_id":"call_123","name":"shell","arguments":"{\"cmd\":\"pwd\"}"},{"type":"function_call_output","call_id":"call_123","output":"/tmp"}]`))
if len(messages) != 2 {
t.Fatalf("got %d messages", len(messages))
}
if messages[0].ToolCalls[0].ID != "call_123" {
t.Fatalf("tool call ID should match call_id for follow-up tool output: %+v", messages[0].ToolCalls[0])
}
if messages[0].ReasoningContent == "" {
t.Fatalf("assistant tool call history should include fallback reasoning_content: %+v", messages[0])
}
if messages[1].ToolCallID != "call_123" {
t.Fatalf("bad tool output ID: %+v", messages[1])
}
}
func TestAnthropicToolUseHistoryIncludesFallbackReasoning(t *testing.T) {
messages := contentToOpenAI(AMessage{Role: "assistant", Content: []byte(`[{"type":"tool_use","id":"call_123","name":"Bash","input":{"command":"pwd"}}]`)})
if len(messages) != 1 {
t.Fatalf("got %d messages", len(messages))
}
if messages[0].Role != "assistant" || len(messages[0].ToolCalls) != 1 {
t.Fatalf("bad tool call conversion: %+v", messages[0])
}
if messages[0].ReasoningContent == "" {
t.Fatalf("assistant tool call history should include fallback reasoning_content: %+v", messages[0])
}
}
func TestAnthropicToolResultPreservesFollowingUserText(t *testing.T) {
messages := contentToOpenAI(AMessage{Role: "user", Content: []byte(`[{"type":"tool_result","tool_use_id":"call_123","content":"09:33:16"},{"type":"text","text":"https://figma.example/design what's going on here?"}]`)})
if len(messages) != 2 {
t.Fatalf("got %d messages: %+v", len(messages), messages)
}
if messages[0].Role != "tool" || messages[0].ToolCallID != "call_123" || messages[0].Content != "09:33:16" {
t.Fatalf("bad tool result conversion: %+v", messages[0])
}
if messages[1].Role != "user" || !strings.Contains(contentString(messages[1].Content), "figma.example") {
t.Fatalf("following user text was not preserved: %+v", messages[1])
}
}
func TestResponsesInputPreservesImages(t *testing.T) {
messages := responsesInputToMessages([]byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":"describe this"},{"type":"input_image","image_url":"data:image/png;base64,abc","detail":"high"}]}]`))
if len(messages) != 1 {
t.Fatalf("got %d messages", len(messages))
}
parts, ok := messages[0].Content.([]OAIContentPart)
if !ok {
t.Fatalf("content should be multimodal parts: %+v", messages[0].Content)
}
if len(parts) != 2 || parts[0].Type != "text" || parts[0].Text != "describe this" {
t.Fatalf("bad text part: %+v", parts)
}
if parts[1].Type != "image_url" || parts[1].ImageURL == nil || parts[1].ImageURL.URL != "data:image/png;base64,abc" || parts[1].ImageURL.Detail != "" {
t.Fatalf("bad image part: %+v", parts[1])
}
}
func TestResponsesImageKeepsKimiModel(t *testing.T) {
req := ResponsesRequest{Model: "kimi-k2.6", Input: []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":"describe this"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]}]`)}
out := responsesToChat(req, "")
if out.Model != "kimi-k2.6" {
t.Fatalf("image request should keep Kimi model, got %q", out.Model)
}
if err := validateImageSupport(out); err != nil {
t.Fatalf("Kimi image request should validate: %v", err)
}
}
func TestResponsesImageRejectsUnsupportedModel(t *testing.T) {
req := ResponsesRequest{Model: "deepseek-v4-pro", Input: []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":"describe this"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]}]`)}
out := responsesToChat(req, "")
if err := validateImageSupport(out); err == nil || !strings.Contains(err.Error(), "deepseek-v4-pro") {
t.Fatalf("DeepSeek image request should be rejected, got %v", err)
}
}
func TestRawChatImageKeepsKimiAndStripsDetail(t *testing.T) {
body, err := prepareChatBody([]byte(`{"model":"kimi-k2.6","messages":[{"role":"user","content":[{"type":"text","text":"describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc","detail":"high"}}]}]}`))
if err != nil {
t.Fatalf("Kimi image request should validate: %v", err)
}
if !strings.Contains(string(body), `"model":"kimi-k2.6"`) {
t.Fatalf("image chat body should keep Kimi model: %s", string(body))
}
if strings.Contains(string(body), `"detail"`) {
t.Fatalf("image detail should be stripped for compatibility: %s", string(body))
}
}
func TestRawChatImageRejectsUnsupportedModel(t *testing.T) {
_, err := prepareChatBody([]byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":[{"type":"text","text":"describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc"}}]}]}`))
if err == nil || !strings.Contains(err.Error(), "deepseek-v4-pro") {
t.Fatalf("DeepSeek image request should be rejected, got %v", err)
}
}
func TestRawChatStreamRequestsUsage(t *testing.T) {
body, err := prepareChatBody([]byte(`{"model":"kimi-k2.6","stream":true,"stream_options":{"foo":"bar"},"messages":[{"role":"user","content":"hello"}]}`))
if err != nil {
t.Fatal(err)
}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
t.Fatal(err)
}
options, ok := req["stream_options"].(map[string]any)
if !ok {
t.Fatalf("missing stream options in %s", string(body))
}
if options["include_usage"] != true || options["foo"] != "bar" {
t.Fatalf("bad stream options: %+v", options)
}
}
func TestConvertedStreamingRequestsAskForUsage(t *testing.T) {
anthropic := convertRequest(AnthropicRequest{Model: "kimi-k2.6", Stream: true, Messages: []AMessage{{Role: "user", Content: []byte(`hello`)}}}, "")
if anthropic.StreamOptions == nil || !anthropic.StreamOptions.IncludeUsage {
t.Fatalf("anthropic conversion should request stream usage: %+v", anthropic.StreamOptions)
}
responses := responsesToChat(ResponsesRequest{Model: "kimi-k2.6", Stream: true, Input: []byte(`"hello"`)}, "")
if responses.StreamOptions == nil || !responses.StreamOptions.IncludeUsage {
t.Fatalf("responses conversion should request stream usage: %+v", responses.StreamOptions)
}
plain := responsesToChat(ResponsesRequest{Model: "kimi-k2.6", Input: []byte(`"hello"`)}, "")
if plain.StreamOptions != nil {
t.Fatalf("non-streaming conversion should not set stream options: %+v", plain.StreamOptions)
}
}
func TestAnthropicContentPreservesImages(t *testing.T) {
messages := contentToOpenAI(AMessage{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/jpeg","data":"abc"}}]`)})
if len(messages) != 1 {
t.Fatalf("got %d messages", len(messages))
}
parts, ok := messages[0].Content.([]OAIContentPart)
if !ok {
t.Fatalf("content should be multimodal parts: %+v", messages[0].Content)
}
if len(parts) != 2 || parts[0].Text != "what is this?" {
t.Fatalf("bad text part: %+v", parts)
}
if parts[1].ImageURL == nil || parts[1].ImageURL.URL != "data:image/jpeg;base64,abc" {
t.Fatalf("bad image part: %+v", parts[1])
}
}
func TestAnthropicImageKeepsKimiModel(t *testing.T) {
out := convertRequest(AnthropicRequest{Model: "kimi-k2.6", Messages: []AMessage{{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]`)}}}, "")
if out.Model != "kimi-k2.6" {
t.Fatalf("image request should keep Kimi model, got %q", out.Model)
}
if err := validateImageSupport(out); err != nil {
t.Fatalf("Kimi image request should validate: %v", err)
}
}
func TestAnthropicImageRejectsUnsupportedModel(t *testing.T) {
out := convertRequest(AnthropicRequest{Model: "deepseek-v4-pro", Messages: []AMessage{{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]`)}}}, "")
if err := validateImageSupport(out); err == nil || !strings.Contains(err.Error(), "deepseek-v4-pro") {
t.Fatalf("DeepSeek image request should be rejected, got %v", err)
}
}
func contentString(v any) string {
s, _ := v.(string)
return s
}
func TestParseWindowsNetstatPID(t *testing.T) {
output := strings.Join([]string{
"Proto Local Address Foreign Address State PID",
"TCP 127.0.0.1:3456 0.0.0.0:0 LISTENING 4321",
"TCP [::1]:9999 [::]:0 LISTENING 8765",
"TCP 127.0.0.1:34560 0.0.0.0:0 LISTENING 1111",
}, "\n")
pid, err := parseWindowsNetstatPID(output, 3456)
if err != nil {
t.Fatal(err)
}
if pid != 4321 {
t.Fatalf("pid = %d, want 4321", pid)
}
}
func TestParseWindowsNetstatPIDMatchesIPv6(t *testing.T) {
output := "TCP [::]:3456 [::]:0 LISTENING 2468\n"
pid, err := parseWindowsNetstatPID(output, 3456)
if err != nil {
t.Fatal(err)
}
if pid != 2468 {
t.Fatalf("pid = %d, want 2468", pid)
}
}
func TestWriteAnthropicResponseIncludesUsage(t *testing.T) {
body := strings.NewReader(`{"choices":[{"message":{"content":"done"}}],"usage":{"prompt_tokens":11,"completion_tokens":5,"total_tokens":16,"prompt_tokens_details":{"cached_tokens":4}}}`)
w := httptest.NewRecorder()
writeAnthropicResponse(w, body, "kimi-k2.6")
var out map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
usage, ok := out["usage"].(map[string]any)
if !ok {
t.Fatalf("missing usage: %+v", out)
}
if usage["input_tokens"] != float64(11) || usage["output_tokens"] != float64(5) || usage["cache_read_input_tokens"] != float64(4) {
t.Fatalf("bad anthropic usage: %+v", usage)
}
}
func TestWriteResponsesResponseIncludesUsage(t *testing.T) {
body := strings.NewReader(`{"choices":[{"message":{"content":"done"}}],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10,"input_tokens_details":{"cached_tokens":2}}}`)
w := httptest.NewRecorder()
writeResponsesResponse(w, body, "kimi-k2.6")
var out map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
usage, ok := out["usage"].(map[string]any)
if !ok {
t.Fatalf("missing usage: %+v", out)
}
if usage["input_tokens"] != float64(7) || usage["output_tokens"] != float64(3) || usage["total_tokens"] != float64(10) {
t.Fatalf("bad responses usage: %+v", usage)
}
details, ok := usage["input_tokens_details"].(map[string]any)
if !ok || details["cached_tokens"] != float64(2) {
t.Fatalf("bad cached details: %+v", usage["input_tokens_details"])
}
}
func TestParseOpenAIStreamChunkReadsUsageOnly(t *testing.T) {
chunk := parseOpenAIStreamChunk([]byte(`{"choices":[],"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}}`))
if !chunk.Usage.Present || chunk.Usage.InputTokens != 8 || chunk.Usage.OutputTokens != 4 || chunk.Usage.TotalTokens != 12 {
t.Fatalf("bad stream usage: %+v", chunk.Usage)
}
if chunk.Content != "" || len(chunk.ToolCalls) != 0 {
t.Fatalf("usage-only chunk should not include deltas: %+v", chunk)
}
}
func TestStreamAnthropicIncludesFinalUsage(t *testing.T) {
body := strings.NewReader(strings.Join([]string{
`data: {"choices":[{"delta":{"content":"hi"}}]}`,
`data: {"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10}}`,
`data: [DONE]`,
``,
}, "\n\n"))
w := httptest.NewRecorder()
streamAnthropic(w, body, "kimi-k2.6")
out := w.Body.String()
for _, want := range []string{`"input_tokens":7`, `"output_tokens":3`} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
}
func TestStreamResponsesIncludesCompletedUsage(t *testing.T) {
body := strings.NewReader(strings.Join([]string{
`data: {"choices":[{"delta":{"content":"hi"}}]}`,
`data: {"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10}}`,
`data: [DONE]`,
``,
}, "\n\n"))
w := httptest.NewRecorder()
streamResponses(w, body, "kimi-k2.6")
out := w.Body.String()
for _, want := range []string{`event: response.completed`, `"input_tokens":7`, `"output_tokens":3`, `"total_tokens":10`} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
}
func TestStreamAnthropicForwardsToolCalls(t *testing.T) {
reasoningContentCache.Lock()
reasoningContentCache.byCallID = map[string]string{}
reasoningContentCache.Unlock()
body := strings.NewReader(strings.Join([]string{
`data: {"choices":[{"delta":{"reasoning_content":"Need pwd.","tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"Bash","arguments":"{\"command\":"}}]}}]}`,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"pwd\"}"}}]}}]}`,
`data: [DONE]`,
``,
}, "\n\n"))
w := httptest.NewRecorder()
streamAnthropic(w, body, "deepseek-v4-flash")
out := w.Body.String()
for _, want := range []string{
`"type":"tool_use"`,
`"name":"Bash"`,
`"type":"input_json_delta"`,
`"partial_json":"{\"command\":"`,
`"stop_reason":"tool_use"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
messages := responsesInputToMessages([]byte(`[{"type":"function_call","call_id":"call_abc","name":"Bash","arguments":"{\"command\":\"pwd\"}"},{"type":"function_call_output","call_id":"call_abc","output":"/tmp"}]`))
if messages[0].ReasoningContent != "Need pwd." {
t.Fatalf("missing cached reasoning content: %+v", messages[0])
}
}
func TestStreamResponsesForwardsToolCalls(t *testing.T) {
reasoningContentCache.Lock()
reasoningContentCache.byCallID = map[string]string{}
reasoningContentCache.Unlock()
body := strings.NewReader(strings.Join([]string{
`data: {"choices":[{"delta":{"reasoning_content":"I should call the tool.","tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"shell","arguments":"{\"cmd\":"}}]}}]}`,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"pwd\"}"}}]}}]}`,
`data: [DONE]`,
``,
}, "\n\n"))
w := httptest.NewRecorder()
streamResponses(w, body, "deepseek-v4-flash")
out := w.Body.String()
for _, want := range []string{
"event: response.output_item.added",
`"type":"function_call"`,
"event: response.function_call_arguments.delta",
"event: response.function_call_arguments.done",
`"arguments":"{\"cmd\":\"pwd\"}"`,
"event: response.completed",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "response.output_text.delta") {
t.Fatalf("tool-only stream should not emit text deltas:\n%s", out)
}
messages := responsesInputToMessages([]byte(`[{"type":"function_call","call_id":"call_abc","name":"shell","arguments":"{\"cmd\":\"pwd\"}"},{"type":"function_call_output","call_id":"call_abc","output":"/tmp"}]`))
if messages[0].ReasoningContent != "I should call the tool." {
t.Fatalf("missing cached reasoning content: %+v", messages[0])
}
}