Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c415ebaa | |||
|
|
643efdabb6 | ||
| 1b685002b8 | |||
| db1dd25a9b | |||
|
|
7fdcf01534 | ||
|
|
582913dfcc | ||
|
|
23ebb2b09f | ||
|
|
ce6eef602e | ||
|
|
cc7e1e8921 | ||
|
|
e2d9bb5038 | ||
|
|
1ecb921068 | ||
|
|
6e004b8eff | ||
|
|
311ca26d76 | ||
|
|
22c0fd3571 |
7 changed files with 1370 additions and 119 deletions
10
Makefile
10
Makefile
|
|
@ -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)
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
984
cmd/ocgo/main.go
984
cmd/ocgo/main.go
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,8 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -23,6 +25,8 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||||
`forced_login_method = "api"`,
|
`forced_login_method = "api"`,
|
||||||
`model_provider = "ocgo-launch"`,
|
`model_provider = "ocgo-launch"`,
|
||||||
`model_catalog_json = `,
|
`model_catalog_json = `,
|
||||||
|
`model_reasoning_effort = "minimal"`,
|
||||||
|
`model_reasoning_summary = "none"`,
|
||||||
"[model_providers.ocgo-launch]",
|
"[model_providers.ocgo-launch]",
|
||||||
`name = "OpenCode Go"`,
|
`name = "OpenCode Go"`,
|
||||||
`base_url = "http://127.0.0.1:3456/v1/"`,
|
`base_url = "http://127.0.0.1:3456/v1/"`,
|
||||||
|
|
@ -56,6 +60,36 @@ func TestWriteCodexProfileReplacesExistingSections(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestWriteCodexModelCatalog(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "ocgo-models.json")
|
path := filepath.Join(t.TempDir(), "ocgo-models.json")
|
||||||
if err := writeCodexModelCatalog(path); err != nil {
|
if err := writeCodexModelCatalog(path); err != nil {
|
||||||
|
|
@ -66,13 +100,34 @@ func TestWriteCodexModelCatalog(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
content := string(b)
|
content := string(b)
|
||||||
for _, want := range []string{`"models"`, `"slug": "deepseek-v4-pro"`, `"context_window": 128000`, `"truncation_policy"`} {
|
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) {
|
if !strings.Contains(content, want) {
|
||||||
t.Fatalf("missing %q in:\n%s", want, content)
|
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) {
|
func TestCompareVersions(t *testing.T) {
|
||||||
if compareVersions("0.80.9", "0.81.0") >= 0 {
|
if compareVersions("0.80.9", "0.81.0") >= 0 {
|
||||||
t.Fatal("0.80.9 should be older")
|
t.Fatal("0.80.9 should be older")
|
||||||
|
|
@ -100,3 +155,346 @@ func TestResponsesInputToMessages(t *testing.T) {
|
||||||
t.Fatalf("bad user conversion: %+v", messages[1])
|
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