Compare commits

..

14 commits
v0.2.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
7 changed files with 1370 additions and 119 deletions

View file

@ -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)

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

View file

@ -1,6 +1,8 @@
package main
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@ -23,6 +25,8 @@ func TestWriteCodexProfile(t *testing.T) {
`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/"`,
@ -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) {
path := filepath.Join(t.TempDir(), "ocgo-models.json")
if err := writeCodexModelCatalog(path); err != nil {
@ -66,13 +100,34 @@ func TestWriteCodexModelCatalog(t *testing.T) {
t.Fatal(err)
}
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) {
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")
@ -100,3 +155,346 @@ func TestResponsesInputToMessages(t *testing.T) {
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])
}
}