Compare commits

..

17 commits
v0.1.0 ... main

Author SHA1 Message Date
39c415ebaa Merge pull request 'feat: add multi-key support with automatic rate-limit rotation' (#11) from renekv/ocgo:feat/multi-key-rotation into main
Reviewed-on: #11
2026-05-26 10:58:06 +00:00
rene
643efdabb6 feat: add multi-key support with automatic rate-limit rotation
Support multiple API keys via `api_keys` array in config or `OCGO_API_KEYS`
env var. On HTTP 429, automatically rotate to the next key and retry.
Key index persisted to `~/.config/ocgo/key-index` for restart safety.
Backward compatible with existing single-key `api_key` configs.

Fixes #115

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:39:33 +08:00
1b685002b8 Merge pull request 'feat: add --model flag, isKnownModelID routing, [1M] context tag' (#10) from renekv/ocgo:main into main
Reviewed-on: #10
2026-05-26 03:50:20 +00:00
db1dd25a9b feat: add --model flag, isKnownModelID routing, [1M] context tag 2026-05-26 11:48:44 +08:00
Rene
7fdcf01534 archon compat 2026-05-25 17:22:32 +08:00
Rene
582913dfcc install to ~/.local/bin 2026-05-25 10:30:06 +08:00
Rene
23ebb2b09f support deepseek 1M 2026-05-23 23:06:07 +08:00
Emanuel Casco
ce6eef602e feat: propagate token usage 2026-05-16 16:13:25 +02:00
Emanuel Casco
cc7e1e8921 feat: improve Windows support 2026-05-16 16:09:51 +02:00
Emanuel Casco
e2d9bb5038 Fix image input support 2026-05-08 21:04:23 +02:00
Emanuel Casco
1ecb921068 fix: preserve tool result follow-up text 2026-05-07 09:40:38 +02:00
Emanuel Casco
6e004b8eff fix: resolve Makefile install conflict 2026-05-07 09:27:49 +02:00
Emanuel Casco
311ca26d76 fix: infer go bin path instead of hardcoding it 2026-05-07 09:24:22 +02:00
Emanuel Casco
22c0fd3571 Fix tool calling through compatibility proxy
Forward streamed tool call deltas for both Claude and Codex launch paths, preserve call IDs across tool results, and replay DeepSeek reasoning_content when required after tool execution.

Fixes https://github.com/emanuelcasco/ocgo/issues/1
2026-05-07 00:21:52 +02:00
Emanuel Casco
1c95a72249 feat: add support for codex cli 2026-05-05 13:53:38 +02:00
Emanuel Casco
502e6d28c7 docs: add cc launch example at the begining of the README 2026-05-05 10:17:16 +02:00
Emanuel Casco
ee8f441a15 docs: update install instructions 2026-05-05 10:15:45 +02:00
8 changed files with 1976 additions and 94 deletions

View file

@ -1,9 +1,14 @@
.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 bin/ocgo ./cmd/ocgo go build -ldflags "-X main.version=$(VERSION)" -o $(OCGO_BIN) ./cmd/ocgo
run: run:
go run ./cmd/ocgo go run ./cmd/ocgo
@ -15,7 +20,8 @@ clean:
rm -rf bin rm -rf bin
install: build install: build
install -m 0755 bin/ocgo $(HOME)/go/bin/ocgo mkdir -p "$(INSTALL_DIR)"
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,30 +1,57 @@
# ocgo # ocgo
`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. `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.
```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 installed and available as `claude` in your `PATH` when using `ocgo launch claude`. - Claude Code or Codex CLI installed and available.
## Installation ## Installation
Homebrew installation is coming soon. Install with Homebrew:
```bash ```bash
# TODO: replace with the published Homebrew tap/formula brew install emanuelcasco/tap/ocgo
```
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:
@ -110,6 +137,55 @@ 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:
@ -235,23 +311,31 @@ The script builds macOS/Linux `amd64` and `arm64` archives, uploads them to GitH
## How it works ## How it works
`ocgo` exposes a local subset of the Anthropic API used by Claude Code: `ocgo` exposes a local compatibility API used by Claude Code and Codex CLI:
- `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 into OpenAI-compatible chat completion requests and forwarded to: Requests sent to `/v1/messages` are converted from Anthropic Messages format into OpenAI-compatible chat completion requests.
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
``` ```
Responses are converted back into Anthropic-compatible responses for Claude Code. 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.
## 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 rather than full API parity. `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.
## License ## License

9
build.bat Normal file
View file

@ -0,0 +1,9 @@
@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

@ -0,0 +1,40 @@
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

@ -0,0 +1,27 @@
//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

@ -0,0 +1,19 @@
//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

500
cmd/ocgo/main_test.go Normal file
View file

@ -0,0 +1,500 @@
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])
}
}