Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c415ebaa | |||
|
|
643efdabb6 | ||
| 1b685002b8 | |||
| db1dd25a9b | |||
|
|
7fdcf01534 | ||
|
|
582913dfcc | ||
|
|
23ebb2b09f | ||
|
|
ce6eef602e | ||
|
|
cc7e1e8921 | ||
|
|
e2d9bb5038 | ||
|
|
1ecb921068 | ||
|
|
6e004b8eff | ||
|
|
311ca26d76 | ||
|
|
22c0fd3571 | ||
|
|
1c95a72249 | ||
|
|
502e6d28c7 | ||
|
|
ee8f441a15 |
8 changed files with 1976 additions and 94 deletions
10
Makefile
10
Makefile
|
|
@ -1,9 +1,14 @@
|
|||
.PHONY: build run test clean install release
|
||||
|
||||
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:
|
||||
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:
|
||||
go run ./cmd/ocgo
|
||||
|
|
@ -15,7 +20,8 @@ clean:
|
|||
rm -rf bin
|
||||
|
||||
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:
|
||||
@[ -n "$(TAG)" ] || (echo "Usage: make release TAG=v0.1.0" && exit 1)
|
||||
|
|
|
|||
100
README.md
100
README.md
|
|
@ -1,30 +1,57 @@
|
|||
# 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
|
||||
|
||||
- Save and reuse your OpenCode Go API key.
|
||||
- List known OpenCode Go model IDs.
|
||||
- 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.
|
||||
- Exposes Anthropic-compatible and OpenAI-compatible local API layers.
|
||||
- Supports streaming text responses and basic tool-call translation.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.22 or newer.
|
||||
- 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
|
||||
|
||||
Homebrew installation is coming soon.
|
||||
Install with Homebrew:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Build from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/emanuelcasco/ocgo.git
|
||||
cd ocgo
|
||||
make install
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
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`.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
`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`
|
||||
- `POST /v1/messages`
|
||||
- `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
|
||||
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
|
||||
|
||||
`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
|
||||
|
||||
|
|
|
|||
9
build.bat
Normal file
9
build.bat
Normal 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%
|
||||
40
cmd/ocgo/listener_pid_netstat.go
Normal file
40
cmd/ocgo/listener_pid_netstat.go
Normal 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
|
||||
}
|
||||
27
cmd/ocgo/listener_pid_unix.go
Normal file
27
cmd/ocgo/listener_pid_unix.go
Normal 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")
|
||||
}
|
||||
19
cmd/ocgo/listener_pid_windows.go
Normal file
19
cmd/ocgo/listener_pid_windows.go
Normal 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)
|
||||
}
|
||||
1365
cmd/ocgo/main.go
1365
cmd/ocgo/main.go
File diff suppressed because it is too large
Load diff
500
cmd/ocgo/main_test.go
Normal file
500
cmd/ocgo/main_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue