diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 918ae23..25d0704 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,7 +92,7 @@ jobs: mkdir -p dist CGO_ENABLED=1 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \ go build -trimpath -buildmode=c-shared \ - -ldflags "-s -w" \ + -ldflags "-s -w -X opencode-go-cliproxyapi/internal/plugin.pluginVersion=${VERSION}" \ -o "dist/${LIB_NAME}" . rm -f "dist/${PLUGIN_ID}.h" @@ -154,7 +154,7 @@ jobs: targets: windows-arm64 out-dir: dist/windows-arm64 output: ${{ env.PLUGIN_ID }}.dll - flags: -ldflags=-s -w + flags: -ldflags=-s -w -X opencode-go-cliproxyapi/internal/plugin.pluginVersion=${{ steps.release_metadata.outputs.version }} - name: Package plugin run: | @@ -226,7 +226,7 @@ jobs: targets: freebsd-${{ matrix.goarch }} out-dir: dist/freebsd-${{ matrix.goarch }} output: ${{ env.PLUGIN_ID }}.so - flags: -ldflags=-s -w + flags: -ldflags=-s -w -X opencode-go-cliproxyapi/internal/plugin.pluginVersion=${{ steps.release_metadata.outputs.version }} - name: Package plugin run: | diff --git a/README.md b/README.md index b62a393..aee8a85 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ go build -buildmode=c-shared -o plugins/linux/amd64/opencode-go-cliproxyapi.so . go build -buildmode=c-shared -o plugins/darwin/arm64/opencode-go-cliproxyapi.dylib . ``` +Tagged release builds set the registered plugin version from the tag. For a custom +build, add `-ldflags "-X opencode-go-cliproxyapi/internal/plugin.pluginVersion=0.1.7-review."` +to the build command and use that same version in the release archive and plugin +store manifest. Use a new version for each build so an update does not replace a +loaded library with different bytes under the same name. + Place the compiled binary into your CLIProxyAPI plugin directory (e.g. `/plugins///`). ## Configuration @@ -114,6 +120,7 @@ plugins: | `api-keys` | `[]object` | *(Required)* | List of API keys (`- value: "..."`). Supports `${ENV_VAR}` expansion. Duplicates and empty values are rejected. | | `base-url` | `string` | `https://opencode.ai/zen/go/v1` | Upstream base URL. Must be valid HTTPS (or HTTP if `allow-http: true`) without query parameters, fragments, or userinfo. | | `catalog-url` | `string` | `{base-url}/models` | Full URL for catalog discovery. Defaults to `{base-url}/models`. | +| `catalog-file` | `string` | unset | Absolute path to a local JSON catalog, using the same `{"data":[...]}` shape and metadata fields as remote discovery. Mutually exclusive with `catalog-url`. Reads the file at startup and each catalog refresh; makes no catalog HTTP requests. The usual size limit and stale policy apply. | | `model-prefix.enabled` | `bool` | `true` | When `true`, client-facing model names use `/`. When `false`, uses bare model IDs. | | `model-prefix.value` | `string` | `opencode-go` | Provider prefix string when prefixing is enabled. | | `catalog.refresh-interval` | `duration` | `15m` | Interval between catalog polling refreshes (e.g. `15m`, `1h`). Minimum is `1m`. | diff --git a/internal/adapter/chatcompletions/convert.go b/internal/adapter/chatcompletions/convert.go index 7aa32bc..bf137a6 100644 --- a/internal/adapter/chatcompletions/convert.go +++ b/internal/adapter/chatcompletions/convert.go @@ -11,8 +11,9 @@ import ( // ---- upstream Chat Completions response shapes (FR-006) ---- type ccRespMessage struct { - Content json.RawMessage `json:"content"` // JSON string or part array - ToolCalls []shared.CCToolCall `json:"tool_calls"` + ReasoningContent string `json:"reasoning_content"` + Content json.RawMessage `json:"content"` // JSON string or part array + ToolCalls []shared.CCToolCall `json:"tool_calls"` } type ccChoice struct { @@ -192,6 +193,9 @@ func chatToResponses(body []byte) ([]byte, *errclass.Error) { oa.AppendFunctionCall(tc.ID, tc.Function.Name, shared.DefaultArgs(tc.Function.Arguments)) } out.Output = oa.Render() + if choice.Message.ReasoningContent != "" { + out.Output = append([]any{reasoningItem(resp.ID, choice.Message.ReasoningContent)}, out.Output...) + } if resp.Usage != nil { var details shared.UsageDetails if resp.Usage.PromptDetails != nil { diff --git a/internal/adapter/chatcompletions/reasoning.go b/internal/adapter/chatcompletions/reasoning.go new file mode 100644 index 0000000..da65d83 --- /dev/null +++ b/internal/adapter/chatcompletions/reasoning.go @@ -0,0 +1,11 @@ +package chatcompletions + +// The summary carrier survives Responses clients that intentionally omit raw +// reasoning content on replay. This marker distinguishes full upstream text +// from foreign reasoning summaries; no encrypted payload is decoded or forged. +const reasoningIDPrefix = "rs_cpa_chat_" + +func reasoningItem(id, text string) map[string]any { + return map[string]any{"type": "reasoning", "id": reasoningIDPrefix + id, + "summary": []any{map[string]any{"type": "summary_text", "text": text}}} +} diff --git a/internal/adapter/chatcompletions/reasoning_test.go b/internal/adapter/chatcompletions/reasoning_test.go new file mode 100644 index 0000000..1f238b4 --- /dev/null +++ b/internal/adapter/chatcompletions/reasoning_test.go @@ -0,0 +1,102 @@ +package chatcompletions + +import ( + "encoding/json" + "reflect" + "testing" +) + +func replayReasoning(t *testing.T, output []any) []map[string]any { + t.Helper() + input := append([]any{map[string]any{"type": "message", "role": "user", "content": "Use the test tool."}}, output...) + input = append(input, map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "42"}) + body, _ := json.Marshal(map[string]any{"input": input}) + got, e := BuildRequest("deepseek-v4.1-flash", "openai-response", body, nil) + if e != nil { + t.Fatal(e) + } + var req struct { + Messages []map[string]any `json:"messages"` + } + if err := json.Unmarshal(got, &req); err != nil { + t.Fatal(err) + } + return req.Messages +} + +func TestReasoningToolRoundTrip(t *testing.T) { + const response = `{"id":"chat1","model":"deepseek-v4.1-flash","choices":[{"message":{"reasoning_content":"Inspect the test value.","content":"Reading it.","tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_value","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}` + raw, e := ConvertNonStreamResponse("openai-response", 200, []byte(response)) + if e != nil { + t.Fatal(e) + } + var converted struct { + Output []any `json:"output"` + } + json.Unmarshal(raw, &converted) + messages := replayReasoning(t, converted.Output) + if len(messages) != 3 || messages[1]["reasoning_content"] != "Inspect the test value." || messages[1]["content"] != "Reading it." || len(messages[1]["tool_calls"].([]any)) != 1 { + t.Fatalf("reasoning/tool turn lost: %#v", messages) + } + + sc := NewStreamConverter("openai-response") + events := feedAll(t, sc, + `data: {"id":"chat1","model":"deepseek-v4.1-flash","choices":[{"delta":{"reasoning_content":"Inspect "}}]}`, + `data: {"choices":[{"delta":{"reasoning_content":"the test value."}}]}`, + `data: {"choices":[{"delta":{"content":"Reading it."}}]}`, + `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_value","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}`, + `data: [DONE]`) + var done []any + var terminal []any + var indexes []int + for _, event := range events { + if event.Name == "response.output_item.done" { + done = append(done, event.Data["item"]) + indexes = append(indexes, int(event.Data["output_index"].(float64))) + } + if event.Name == "response.completed" { + terminal = event.Data["response"].(map[string]any)["output"].([]any) + } + } + if !reflect.DeepEqual(indexes, []int{0, 1, 2}) || !reflect.DeepEqual(done, terminal) || !reflect.DeepEqual(done, converted.Output) { + t.Fatalf("stream/nonstream mismatch: %#v / %#v", done, converted.Output) + } + if got := replayReasoning(t, done); !reflect.DeepEqual(got, messages) { + t.Fatalf("stream replay differs: %#v", got) + } +} + +func TestReasoningDoesNotLeakAcrossUserTurns(t *testing.T) { + body := []byte(`{"input":[{"type":"reasoning","id":"rs_cpa_chat_old","summary":[{"text":"old thought"}]},{"role":"assistant","content":"old answer"},{"role":"user","content":"new question"},{"type":"function_call","call_id":"new","name":"read","arguments":"{}"}]}`) + raw, e := BuildRequest("deepseek-v4.1-flash", "openai-response", body, nil) + if e != nil { + t.Fatal(e) + } + var req struct { + Messages []map[string]any `json:"messages"` + } + json.Unmarshal(raw, &req) + if req.Messages[0]["reasoning_content"] != "old thought" || req.Messages[2]["reasoning_content"] != "" { + t.Fatalf("wrong reasoning association: %#v", req.Messages) + } +} + +func TestMissingAndForeignReasoningCompatibility(t *testing.T) { + for _, model := range []string{"deepseek-v4.1-flash", "other-model"} { + raw, e := BuildRequest(model, "openai-response", []byte(`{"input":[{"type":"reasoning","id":"rs_foreign","summary":[{"text":"only a summary"}]},{"type":"function_call","name":"read","call_id":"1","arguments":"{}"}]}`), nil) + if e != nil { + t.Fatal(e) + } + var req struct { + Messages []map[string]any `json:"messages"` + } + json.Unmarshal(raw, &req) + value, present := req.Messages[0]["reasoning_content"] + if model == "other-model" && present { + t.Fatal("foreign summary replayed as full reasoning") + } + if model != "other-model" && (!present || value != "") { + t.Fatal("older DeepSeek history cannot resume") + } + } +} diff --git a/internal/adapter/chatcompletions/request.go b/internal/adapter/chatcompletions/request.go index 4c7b851..4ca005e 100644 --- a/internal/adapter/chatcompletions/request.go +++ b/internal/adapter/chatcompletions/request.go @@ -103,10 +103,11 @@ type ccContentPart struct { } type ccMessage struct { - Role string `json:"role"` - Content any `json:"content"` // string, []ccContentPart, or nil - ToolCalls []shared.CCToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` + Role string `json:"role"` + Content any `json:"content"` // string, []ccContentPart, or nil + ToolCalls []shared.CCToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ReasoningContent *string `json:"reasoning_content,omitempty"` } type ccRequest struct { @@ -129,6 +130,17 @@ func encode(req *ccRequest) []byte { if req.Messages == nil { req.Messages = []ccMessage{} } + if strings.HasPrefix(strings.ToLower(req.Model), "deepseek-") { + for i := range req.Messages { + m := &req.Messages[i] + if m.Role == "assistant" && m.ReasoningContent == nil { + // Older histories and other providers may have no recoverable reasoning. + // DeepSeek requires the field even for those assistant turns. + empty := "" + m.ReasoningContent = &empty + } + } + } b, _ := json.Marshal(req) return b } @@ -161,7 +173,12 @@ func claudeToChat(upstreamModel string, body []byte, ts *pluginapi.ThinkingSuppo if len(src.StopSequences) > 0 { out.Stop = src.StopSequences } - if shared.ThinkingEnabled(src.Thinking) { + if src.OutputConfig != nil && src.OutputConfig.Effort != "" { + if err := thinking.ValidateEffort(src.OutputConfig.Effort, ts); err != nil { + return nil, err + } + out.ReasoningEffort = strings.ToLower(strings.TrimSpace(src.OutputConfig.Effort)) + } else if shared.ThinkingEnabled(src.Thinking) { out.ReasoningEffort = thinking.EffortFromBudget(src.Thinking.BudgetTokens, ts) } applyToolChoiceCC(out, src.ToolChoiceKind, src.ToolChoiceName) @@ -171,6 +188,12 @@ func claudeToChat(upstreamModel string, body []byte, ts *pluginapi.ThinkingSuppo for i := range src.Messages { m := &src.Messages[i] switch m.Role { + case "system", "developer": + text, eErr := shared.ClaudeSystemMessageText(*m, EndpointPath) + if eErr != nil { + return nil, eErr + } + out.Messages = append(out.Messages, ccMessage{Role: m.Role, Content: text}) case "user": msgs, eErr := claudeUserMessages(m) if eErr != nil { @@ -302,8 +325,8 @@ func claudeAssistantMessage(m *shared.ClaudeMessageRecord) (*ccMessage, *errclas // messages, reasoning.effort maps to reasoning_effort, // parallel_tool_calls passes through as-is (the reverse leg forwards the // same field), and max_output_tokens maps to max_tokens. Historical -// reasoning items are omitted (no CC equivalent; FR-005 explicit omission -// policy). +// reasoning emitted by this adapter is restored to reasoning_content. +// Foreign summaries and encrypted reasoning remain opaque. func responsesToChat(upstreamModel string, body []byte, ts *pluginapi.ThinkingSupport) ([]byte, *errclass.Error) { var src shared.ResponsesRequest if err := json.Unmarshal(body, &src); err != nil { @@ -346,6 +369,7 @@ func responsesToChat(upstreamModel string, body []byte, ts *pluginapi.ThinkingSu if eErr != nil { return nil, eErr } + var pendingReasoning *string for _, item := range items { switch item.Type { case "message": @@ -373,7 +397,13 @@ func responsesToChat(upstreamModel string, body []byte, ts *pluginapi.ThinkingSu addSystem(text) case "user", "assistant": if content != nil { - out.Messages = append(out.Messages, ccMessage{Role: item.Role, Content: content}) + msg := ccMessage{Role: item.Role, Content: content} + if item.Role == "assistant" { + msg.ReasoningContent = pendingReasoning + } else { + pendingReasoning = nil + } + out.Messages = append(out.Messages, msg) } default: return nil, shared.ValidateRole(item.Role, EndpointPath) @@ -386,20 +416,30 @@ func responsesToChat(upstreamModel string, body []byte, ts *pluginapi.ThinkingSu // assistant message so multi-call turns round-trip. if n := len(out.Messages); n > 0 { last := &out.Messages[n-1] - if last.Role == "assistant" && last.Content == nil { + if last.Role == "assistant" && (last.Content == nil || last.ReasoningContent != nil) { last.ToolCalls = append(last.ToolCalls, tc) continue } } out.Messages = append(out.Messages, ccMessage{ - Role: "assistant", ToolCalls: []shared.CCToolCall{tc}, + Role: "assistant", ToolCalls: []shared.CCToolCall{tc}, ReasoningContent: pendingReasoning, }) case "function_call_output": + pendingReasoning = nil out.Messages = append(out.Messages, ccMessage{ Role: "tool", Content: item.Output, ToolCallID: item.CallID, }) case "reasoning": - // omitted: no Chat Completions equivalent (FR-005 policy) + // Only our own full-text carrier is replayable; foreign summaries are not full reasoning. + pendingReasoning = nil + if strings.HasPrefix(item.ID, reasoningIDPrefix) { + var b strings.Builder + for _, part := range item.Summary { + b.WriteString(part.Text) + } + value := b.String() + pendingReasoning = &value + } default: return nil, shared.UnsupportedInputItemType(item.Type) } diff --git a/internal/adapter/chatcompletions/stream.go b/internal/adapter/chatcompletions/stream.go index 13cb619..f452f32 100644 --- a/internal/adapter/chatcompletions/stream.go +++ b/internal/adapter/chatcompletions/stream.go @@ -25,23 +25,25 @@ type StreamConverter struct { lineBuf []byte // partial SSE line carried across Feed calls done bool - started bool // message_start / first chunk seen - id string - model string - claudeEm shared.ClaudeEventEmitter // canonical Messages frames bound to first-chunk identity - textOpen bool // claude text content_block open - textIndex int // claude index of the currently-open text block - nextIndex int // next output/block index - msgIndex int // announced assistant message item index (-1 until text) - tools map[int64]*streamTool - toolOrder []int64 // upstream tool_call indices in first-arrival order - toolsSeen bool // any tool_calls entry observed (terminal-reason precedence) - usage *ccUsage - finished bool // finish_reason processed - heldFinish string // finish_reason awaiting terminal emission (flushed on the next data line or [DONE], so the standard include_usage trailer lands in the terminal event; Flush covers close-without-[DONE]) - terminalSent bool // claudeTerminal already emitted message_delta (Flush must still close with message_stop) - flushed bool // Flush already ran (one-shot guard) - respText strings.Builder // openai-response accumulated output_text + started bool // message_start / first chunk seen + id string + model string + claudeEm shared.ClaudeEventEmitter // canonical Messages frames bound to first-chunk identity + textOpen bool // claude text content_block open + textIndex int // claude index of the currently-open text block + nextIndex int // next output/block index + msgIndex int // announced assistant message item index (-1 until text) + tools map[int64]*streamTool + toolOrder []int64 // upstream tool_call indices in first-arrival order + toolsSeen bool // any tool_calls entry observed (terminal-reason precedence) + usage *ccUsage + finished bool // finish_reason processed + heldFinish string // finish_reason awaiting terminal emission (flushed on the next data line or [DONE], so the standard include_usage trailer lands in the terminal event; Flush covers close-without-[DONE]) + terminalSent bool // claudeTerminal already emitted message_delta (Flush must still close with message_stop) + flushed bool // Flush already ran (one-shot guard) + reasoningIndex int + respReasoning strings.Builder + respText strings.Builder // openai-response accumulated output_text } // streamTool accumulates one upstream tool_calls index; args collects @@ -59,9 +61,10 @@ type streamTool struct { // SSE into sourceFormat's stream shape. func NewStreamConverter(sourceFormat string) *StreamConverter { return &StreamConverter{ - sourceFormat: sourceFormat, - msgIndex: -1, - tools: map[int64]*streamTool{}, + sourceFormat: sourceFormat, + msgIndex: -1, + reasoningIndex: -1, + tools: map[int64]*streamTool{}, } } @@ -184,8 +187,9 @@ type ccToolCallDelta struct { } type ccDelta struct { - Content string `json:"content"` - ToolCalls []ccToolCallDelta `json:"tool_calls"` + ReasoningContent string `json:"reasoning_content"` + Content string `json:"content"` + ToolCalls []ccToolCallDelta `json:"tool_calls"` } type ccChunkChoice struct { @@ -444,6 +448,16 @@ func (sc *StreamConverter) responsesLine(line string) ([][]byte, *errclass.Error return events, nil } choice := chunk.Choices[0] + if text := choice.Delta.ReasoningContent; text != "" { + if sc.reasoningIndex < 0 { + sc.reasoningIndex = sc.nextIndex + sc.nextIndex++ + events = append(events, sc.responsesEm().ItemAdded(sc.reasoningIndex, reasoningItem(sc.id, ""))) + events = append(events, shared.SSEEvent("response.reasoning_summary_part.added", map[string]any{"type": "response.reasoning_summary_part.added", "item_id": reasoningIDPrefix + sc.id, "output_index": sc.reasoningIndex, "summary_index": 0, "part": map[string]any{"type": "summary_text", "text": ""}})) + } + sc.respReasoning.WriteString(text) + events = append(events, shared.SSEEvent("response.reasoning_summary_text.delta", map[string]any{"type": "response.reasoning_summary_text.delta", "item_id": reasoningIDPrefix + sc.id, "output_index": sc.reasoningIndex, "summary_index": 0, "delta": text})) + } if choice.Delta.Content != "" { if sc.msgIndex < 0 { sc.msgIndex = sc.nextIndex @@ -539,5 +553,12 @@ func (sc *StreamConverter) responsesTerminal() [][]byte { } } usage := shared.NewResponsesUsageFrom(input, outputTokens, details) - return [][]byte{sc.responsesEm().Completed(status, usage, oa.Render())} + output := oa.Render() + if sc.reasoningIndex >= 0 { + index := min(sc.reasoningIndex, len(output)) + output = append(output, nil) + copy(output[index+1:], output[index:]) + output[index] = reasoningItem(sc.id, sc.respReasoning.String()) + } + return sc.responsesEm().CompletedEvents(status, usage, output) } diff --git a/internal/adapter/chatcompletions/stream_test.go b/internal/adapter/chatcompletions/stream_test.go index 0ab9449..26b1df1 100644 --- a/internal/adapter/chatcompletions/stream_test.go +++ b/internal/adapter/chatcompletions/stream_test.go @@ -537,10 +537,10 @@ func TestStreamConverterResponses(t *testing.T) { t.Fatalf("response.completed must be deferred past finish_reason: %v", evs) } evs = feedAll(t, sc, `data: {"choices":[{"delta":{},"finish_reason":"length"}]}`) - if len(evs) != 1 || evs[0].Name != "response.completed" { + if len(evs) != 3 || evs[0].Name != "response.output_item.done" || evs[1].Name != "response.output_item.done" || evs[2].Name != "response.completed" { t.Fatalf("completed event wrong: %v", evs) } - resp := evs[0].Data["response"].(map[string]any) + resp := evs[2].Data["response"].(map[string]any) if resp["id"] != "r1" || resp["object"] != "response" || resp["status"] != "completed" { t.Fatalf("completed response wrong: %v", resp) } @@ -944,10 +944,10 @@ func TestStreamConverterFlushAfterFinishWithoutDONE(t *testing.T) { `data: {"id":"r2","choices":[{"delta":{"role":"assistant","content":"x"}}]}`, `data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":4}}`) flushed := parseEvents(t, sc.Flush()) - if len(flushed) != 1 || flushed[0].Name != "response.completed" { + if len(flushed) != 2 || flushed[0].Name != "response.output_item.done" || flushed[1].Name != "response.completed" { t.Fatalf("flush = %v", flushed) } - resp := flushed[0].Data["response"].(map[string]any) + resp := flushed[1].Data["response"].(map[string]any) u := resp["usage"].(map[string]any) if u["input_tokens"] != float64(3) || u["output_tokens"] != float64(4) { t.Fatalf("flushed completed usage wrong: %v", resp) diff --git a/internal/adapter/claude_effort_test.go b/internal/adapter/claude_effort_test.go new file mode 100644 index 0000000..6e45e0b --- /dev/null +++ b/internal/adapter/claude_effort_test.go @@ -0,0 +1,86 @@ +package adapters + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + "opencode-go-cliproxyapi/internal/adapter/chatcompletions" + "opencode-go-cliproxyapi/internal/adapter/responses" + "opencode-go-cliproxyapi/internal/errclass" +) + +func TestClaudeExplicitEffortAcrossTargets(t *testing.T) { + for _, target := range []struct { + name string + build func(string, string, []byte, *pluginapi.ThinkingSupport) ([]byte, *errclass.Error) + }{ + {"chat", chatcompletions.BuildRequest}, + {"responses", responses.BuildRequest}, + } { + for _, control := range []string{`"thinking":{"type":"adaptive"},`, `"thinking":{"type":"enabled","budget_tokens":1024},`, ""} { + for _, effort := range []string{"low", "high", "medium"} { + t.Run(target.name+"/"+control+effort, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"lab","max_tokens":123,%s"output_config":{"effort":%q},"messages":[{"role":"user","content":"hello"}]}`, control, effort)) + out, err := target.build("lab", "claude", body, &pluginapi.ThinkingSupport{Levels: []string{"low", "high"}}) + if effort == "medium" { + if err == nil || err.Class != errclass.ClassUnsupported { + t.Fatalf("unsupported explicit effort must fail, got %s, %v", out, err) + } + return + } + if err != nil { + t.Fatal(err) + } + var result map[string]any + if err := json.Unmarshal(out, &result); err != nil { + t.Fatal(err) + } + actual := result["reasoning_effort"] + if target.name == "responses" { + if reasoning, ok := result["reasoning"].(map[string]any); ok { + actual = reasoning["effort"] + } + } + if actual != effort { + t.Fatalf("explicit effort lost or rewritten: got %v, want %s; %s", actual, effort, out) + } + }) + } + } + } +} + +func TestClaudeExplicitEffortUsesResponsesWirePolicy(t *testing.T) { + for _, test := range []struct { + name, effort, want string + thinking pluginapi.ThinkingSupport + }{ + {"dynamic auto", " AUTO ", "", pluginapi.ThinkingSupport{DynamicAllowed: true, Levels: []string{"low", "high"}}}, + {"off without named none", "none", "", pluginapi.ThinkingSupport{ZeroAllowed: true, Levels: []string{"low", "high"}}}, + {"declared none", "none", "none", pluginapi.ThinkingSupport{Levels: []string{"none", "high"}}}, + {"declared effort", " HIGH ", "high", pluginapi.ThinkingSupport{Levels: []string{"low", "high"}}}, + } { + t.Run(test.name, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"messages":[{"role":"user","content":"hello"}],"output_config":{"effort":%q}}`, test.effort)) + out, err := responses.BuildRequest("lab", "claude", body, &test.thinking) + if err != nil { + t.Fatal(err) + } + var result struct { + Reasoning *struct{ Effort string } + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatal(err) + } + if test.want == "" { + if result.Reasoning != nil { + t.Fatalf("unsupported wire sentinel was forwarded: %s", out) + } + } else if result.Reasoning == nil || result.Reasoning.Effort != test.want { + t.Fatalf("declared effort lost: %s", out) + } + }) + } +} diff --git a/internal/adapter/harness_messages_test.go b/internal/adapter/harness_messages_test.go new file mode 100644 index 0000000..94088ca --- /dev/null +++ b/internal/adapter/harness_messages_test.go @@ -0,0 +1,55 @@ +package adapters + +import ( + "encoding/json" + "testing" + + "opencode-go-cliproxyapi/internal/adapter/chatcompletions" + "opencode-go-cliproxyapi/internal/adapter/messages" + "opencode-go-cliproxyapi/internal/adapter/responses" +) + +func TestPiEasyInputMessage(t *testing.T) { + body := []byte(`{"input":[{"role":"user","content":[{"type":"input_text","text":"hello"}]}]}`) + if _, err := chatcompletions.BuildRequest("lab", "openai-response", body, nil); err != nil { + t.Fatal(err) + } + if _, err := messages.BuildRequest("lab", "openai-response", body, nil); err != nil { + t.Fatal(err) + } + if _, err := chatcompletions.BuildRequest("lab", "openai-response", []byte(`{"input":[{}]}`), nil); err == nil { + t.Fatal("empty item must still fail") + } +} + +func TestClaudeEnvironmentMessagePreservesPosition(t *testing.T) { + body := []byte(`{"system":"initial","messages":[{"role":"user","content":"hello"},{"role":"system","content":[{"type":"text","text":"environment"}]}]}`) + out, err := chatcompletions.BuildRequest("lab", "claude", body, nil) + if err != nil { + t.Fatal(err) + } + var cc struct { + Messages []struct{ Role, Content string } + } + if err := json.Unmarshal(out, &cc); err != nil { + t.Fatal(err) + } + if len(cc.Messages) != 3 || cc.Messages[2].Role != "system" || cc.Messages[2].Content != "environment" { + t.Fatalf("%s", out) + } + out, err = responses.BuildRequest("lab", "claude", body, nil) + if err != nil { + t.Fatal(err) + } + var resp struct{ Input []struct{ Role string } } + if err := json.Unmarshal(out, &resp); err != nil { + t.Fatal(err) + } + if len(resp.Input) != 2 || resp.Input[1].Role != "system" { + t.Fatalf("%s", out) + } + bad := []byte(`{"messages":[{"role":"system","content":[{"type":"image","source":{"type":"url","url":"https://example.com/a.png"}}]}]}`) + if _, err := chatcompletions.BuildRequest("lab", "claude", bad, nil); err == nil { + t.Fatal("system images cannot silently disappear") + } +} diff --git a/internal/adapter/messages/stream.go b/internal/adapter/messages/stream.go index 850b86c..c57d0d7 100644 --- a/internal/adapter/messages/stream.go +++ b/internal/adapter/messages/stream.go @@ -314,7 +314,7 @@ func (sc *StreamConverter) dispatchResponses(etype string, ev *sseEvent, events sc.captureCache(ev.Usage) sc.stopReason = ev.Delta.StopReason case "message_stop": - *events = append(*events, sc.responsesCompleted()) + *events = append(*events, sc.responsesCompleted()...) return true, nil case "error": return false, sseError(ev.Error) @@ -330,11 +330,11 @@ func (sc *StreamConverter) dispatchResponses(etype string, ev *sseEvent, events // represented by their function_call output items and never override the // status (FR-006). Shared by message_stop and Flush so an early upstream // close cannot diverge from the normal-path shape. -func (sc *StreamConverter) responsesCompleted() []byte { +func (sc *StreamConverter) responsesCompleted() [][]byte { status := shared.ResponseStatusFromClaudeStop(sc.stopReason) usage := shared.NewResponsesUsageFrom(sc.promptTokens+valueOrZero(sc.cacheRead)+valueOrZero(sc.cacheCreation), sc.completionTokens, shared.UsageDetails{CachedTokens: sc.cacheRead, CacheWriteTokens: sc.cacheCreation}) - return sc.responsesEm().Completed(status, usage, sc.outputItems()) + return sc.responsesEm().CompletedEvents(status, usage, sc.outputItems()) } // responsesEm binds the shared Responses emitter kernel to the captured @@ -360,7 +360,7 @@ func (sc *StreamConverter) Flush() [][]byte { } switch sc.sourceFormat { case "openai-response": - return [][]byte{sc.responsesCompleted()} + return sc.responsesCompleted() case "openai": return nil default: // claude passthrough forwards verbatim; nothing deferred diff --git a/internal/adapter/responses/request.go b/internal/adapter/responses/request.go index c5843d8..9b890ed 100644 --- a/internal/adapter/responses/request.go +++ b/internal/adapter/responses/request.go @@ -316,7 +316,14 @@ func fromClaudeMessages(upstreamModel string, body []byte, ts *pluginapi.Thinkin TopP: src.TopP, } req.ToolChoice = respToolChoice(src.ToolChoiceKind, src.ToolChoiceName) - if shared.ThinkingEnabled(src.Thinking) { + if src.OutputConfig != nil && src.OutputConfig.Effort != "" { + if err := thinking.ValidateEffort(src.OutputConfig.Effort, ts); err != nil { + return nil, err + } + if effort, ok := reasoningEffortFor(strings.ToLower(strings.TrimSpace(src.OutputConfig.Effort)), ts); ok { + req.Reasoning = map[string]any{"effort": effort} + } + } else if shared.ThinkingEnabled(src.Thinking) { if effort, ok := reasoningEffortFor(thinking.EffortFromBudget(src.Thinking.BudgetTokens, ts), ts); ok { req.Reasoning = map[string]any{"effort": effort} } @@ -325,6 +332,13 @@ func fromClaudeMessages(upstreamModel string, body []byte, ts *pluginapi.Thinkin for _, m := range src.Messages { switch m.Role { + case "system", "developer": + text, eErr := shared.ClaudeSystemMessageText(m, EndpointPath) + if eErr != nil { + return nil, eErr + } + req.Input = append(req.Input, msgItem(m.Role, []map[string]any{textPart(m.Role, text)})) + continue case "user", "assistant": default: return nil, shared.ValidateRole(m.Role, EndpointPath) diff --git a/internal/adapter/responses/request_test.go b/internal/adapter/responses/request_test.go index dbea2fc..4da5f79 100644 --- a/internal/adapter/responses/request_test.go +++ b/internal/adapter/responses/request_test.go @@ -499,10 +499,9 @@ func TestFromClaudeMessagesAbsentSystemAndNullContent(t *testing.T) { func TestClaudeUnknownRoleRejected(t *testing.T) { body := []byte(`{"max_tokens":10,"messages":[` + `{"role":"user","content":"hi"},{"role":"system","content":"mid-history"}]}`) - _, eErr := BuildRequest("m", "claude", body, nil) - if eErr == nil || eErr.Class != errclass.ClassUnsupported || - eErr.Message != `unsupported message role "system" for /v1/responses` { - t.Fatalf("mid-history system = %+v", eErr) + out, eErr := BuildRequest("m", "claude", body, nil) + if eErr != nil || !strings.Contains(string(out), `"role":"system"`) { + t.Fatalf("mid-history system must remain in place: %s, %+v", out, eErr) } _, eErr = BuildRequest("m", "claude", diff --git a/internal/adapter/responses_parity_test.go b/internal/adapter/responses_parity_test.go index d3b1fd6..0857308 100644 --- a/internal/adapter/responses_parity_test.go +++ b/internal/adapter/responses_parity_test.go @@ -130,8 +130,8 @@ func TestResponsesSynthesisRouteParity(t *testing.T) { t.Errorf("synthesized stream missing %s", want) } } - if strings.Count(a, "data: ") != 7 { - t.Errorf("event count = %d, want 7 (created, 2 added, text delta, 2 args deltas, completed)", + if strings.Count(a, "data: ") != 9 { + t.Errorf("event count = %d, want 9 (created, 2 added, text delta, 2 args deltas, 2 item done, completed)", strings.Count(a, "data: ")) } } diff --git a/internal/adapter/shared/shared.go b/internal/adapter/shared/shared.go index deff86c..1b5d289 100644 --- a/internal/adapter/shared/shared.go +++ b/internal/adapter/shared/shared.go @@ -679,6 +679,19 @@ func (e ResponsesEventEmitter) Completed(status string, usage ResponsesUsage, ou }) } +// CompletedEvents closes each announced item before terminating the response. +// Codex commits assistant text and executes calls from output_item.done; the +// aggregate output on response.completed alone does not complete those items. +func (e ResponsesEventEmitter) CompletedEvents(status string, usage ResponsesUsage, output []any) [][]byte { + events := make([][]byte, 0, len(output)+1) + for index, item := range output { + events = append(events, SSEEvent("response.output_item.done", map[string]any{ + "type": "response.output_item.done", "output_index": index, "item": item, + })) + } + return append(events, e.Completed(status, usage, output)) +} + // RedactedSnippet bearer-redacts and truncates a payload snippet for error // messages — never an upstream body echo (FR-011). func RedactedSnippet(s string) string { @@ -930,6 +943,12 @@ func (r *ResponsesRequest) DecodeInputItems() ([]RespItem, *errclass.Error) { if err := json.Unmarshal(r.Input, &items); err != nil { return nil, errclass.Translation("input must be a string or an array of items: " + err.Error()) } + // Responses easy input messages omit type (for example Pi's first user turn). + for i := range items { + if items[i].Type == "" && items[i].Role != "" { + items[i].Type = "message" + } + } return items, nil } @@ -1118,6 +1137,11 @@ type ClaudeThinking struct { BudgetTokens int64 `json:"budget_tokens"` } +// ClaudeOutputConfig carries named effort independently of a thinking budget. +type ClaudeOutputConfig struct { + Effort string `json:"effort"` +} + // ThinkingEnabled reports whether an Anthropic thinking control requests // extended thinking: present with type "enabled" (FR-005). One kernel // serves every adapter gating on the control so the check cannot diverge. @@ -1150,6 +1174,19 @@ type ClaudeMessageRecord struct { Blocks []ClaudeBlock } +// ClaudeSystemMessageText preserves Claude Code's in-history environment +// messages without moving them ahead of previous turns or dropping blocks. +func ClaudeSystemMessageText(message ClaudeMessageRecord, target string) (string, *errclass.Error) { + parts := []string{message.Content} + for _, block := range message.Blocks { + if block.Kind != "text" { + return "", UnsupportedPartType(block.Kind, target) + } + parts = append(parts, block.Text) + } + return strings.Join(parts, ""), nil +} + // ClaudeRequestRecord is the normalized decode of an inbound Anthropic // Messages request body (FR-005): envelope fields resolved once — max // tokens defaulted per ClaudeMaxTokens, system flattened per @@ -1165,6 +1202,7 @@ type ClaudeRequestRecord struct { ToolChoiceKind string ToolChoiceName string Thinking *ClaudeThinking + OutputConfig *ClaudeOutputConfig Stream bool Temperature *float64 TopP *float64 @@ -1211,6 +1249,7 @@ func DecodeClaudeMessages(body json.RawMessage) (*ClaudeRequestRecord, *errclass Tools []ClaudeTool `json:"tools"` ToolChoice json.RawMessage `json:"tool_choice"` Thinking *ClaudeThinking `json:"thinking"` + OutputConfig *ClaudeOutputConfig `json:"output_config"` Stream bool `json:"stream"` Temperature *float64 `json:"temperature"` TopP *float64 `json:"top_p"` @@ -1234,6 +1273,7 @@ func DecodeClaudeMessages(body json.RawMessage) (*ClaudeRequestRecord, *errclass ToolChoiceKind: kind, ToolChoiceName: name, Thinking: env.Thinking, + OutputConfig: env.OutputConfig, Stream: env.Stream, Temperature: env.Temperature, TopP: env.TopP, diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 3118a98..8a8bb46 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -13,8 +13,10 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" + "os" "strings" "sync" @@ -184,11 +186,52 @@ func (m *Manager) SeedFrom(prev *Manager) { // catalog.stale-while-unavailable is enabled, otherwise the routable set // is cleared until the next success (FR-002). func (m *Manager) Refresh(ctx context.Context, apiKey string) error { + body, err := m.readCatalog(ctx, apiKey) + if err != nil { + return m.fail(err.Error()) + } + var env struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &env); err != nil { + return m.fail("invalid json") + } + var entries []rawModel + var warns []string + if env.Data == nil { + warns = append(warns, `upstream catalog response missing "data" field`) + } else if err := json.Unmarshal(env.Data, &entries); err != nil { + return m.fail("invalid json") + } + m.swap(entries, warns...) + return nil +} + +// readCatalog keeps local snapshots on the same validation and stale-policy +// path as remote discovery. A configured file is the source, not a fallback +// that could silently change the advertised catalog after a failed request. +func (m *Manager) readCatalog(ctx context.Context, apiKey string) ([]byte, error) { + budget := max(m.cfg.MaxResponseBytes, catalogBudgetFloor) + if m.cfg.CatalogFile != "" { + file, err := os.Open(m.cfg.CatalogFile) + if err != nil { + return nil, fmt.Errorf("cannot open catalog-file") + } + defer file.Close() + body, err := io.ReadAll(io.LimitReader(file, budget+1)) + if err != nil { + return nil, fmt.Errorf("cannot read catalog-file") + } + if int64(len(body)) > budget { + return nil, fmt.Errorf("response exceeds max-response-bytes") + } + return body, nil + } if m.client == nil { // Only direct construction can produce a nil host client // (production always wires the bridge); fail loudly through the // classified path instead of panicking inside Do. - return m.fail("host client unavailable") + return nil, fmt.Errorf("host client unavailable") } req := pluginapi.HTTPRequest{ Method: http.MethodGet, @@ -200,33 +243,15 @@ func (m *Manager) Refresh(ctx context.Context, apiKey string) error { } resp, err := m.client.Do(ctx, req) if err != nil { - return m.fail("network error") + return nil, fmt.Errorf("network error") } - budget := max(m.cfg.MaxResponseBytes, catalogBudgetFloor) if int64(len(resp.Body)) > budget { - return m.fail("response exceeds max-response-bytes") + return nil, fmt.Errorf("response exceeds max-response-bytes") } if resp.StatusCode != http.StatusOK { - return m.fail(fmt.Sprintf("http %d", resp.StatusCode)) - } - var env struct { - Data json.RawMessage `json:"data"` - } - if err := json.Unmarshal(resp.Body, &env); err != nil { - return m.fail("invalid json") + return nil, fmt.Errorf("http %d", resp.StatusCode) } - var entries []rawModel - var warns []string - if env.Data == nil { - // Key ABSENCE is upstream shape drift, never an intended clear: - // clear like an empty plan (swap(nil)) but leave a diagnostic. - // A present-but-empty array stays silent (tested-intended state). - warns = append(warns, `upstream catalog response missing "data" field`) - } else if err := json.Unmarshal(env.Data, &entries); err != nil { - return m.fail("invalid json") - } - m.swap(entries, warns...) - return nil + return resp.Body, nil } // fail applies the stale policy and returns the classified error. diff --git a/internal/catalog/catalog_file_test.go b/internal/catalog/catalog_file_test.go new file mode 100644 index 0000000..71c66f5 --- /dev/null +++ b/internal/catalog/catalog_file_test.go @@ -0,0 +1,78 @@ +package catalog + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCatalogFileStartsWithoutHTTPAndRetainsExactMetadata(t *testing.T) { + path := filepath.Join(t.TempDir(), "models.json") + if err := os.WriteFile(path, []byte(`{"data":[{"id":"glm-test","context_length":123456,"max_output_tokens":8192,"thinking":{"levels":["low","max"]},"input_modalities":["text","image"],"output_modalities":["text"]}]}`), 0600); err != nil { + t.Fatal(err) + } + cfg := testCfg() + cfg.CatalogFile = path + fc := &fakeClient{} + m := newManager(cfg, fc) + mustRefresh(t, m) + if fc.gotReq != nil { + t.Fatal("file discovery must not issue HTTP requests") + } + record, ok := m.Lookup("opencode-go/glm-test") + if !ok { + t.Fatal("file model not routable") + } + if record.ContextLimit != 123456 || record.OutputLimit != 8192 { + t.Fatalf("metadata lost: %+v", record) + } + if record.Thinking == nil || strings.Join(record.Thinking.Levels, ",") != "low,max" { + t.Fatal("efforts changed") + } + if strings.Join(record.InputModes, ",") != "text,image" { + t.Fatal("modalities changed") + } + if err := os.WriteFile(path, []byte(`invalid private content`), 0600); err != nil { + t.Fatal(err) + } + if err := m.Refresh(context.Background(), testKey); err == nil || strings.Contains(err.Error(), "private content") { + t.Fatalf("unsafe/missing parse error: %v", err) + } + if _, ok := m.Lookup("opencode-go/glm-test"); !ok { + t.Fatal("last valid snapshot lost during file error") + } + if err := os.WriteFile(path, []byte(`{"data":[]}`), 0600); err != nil { + t.Fatal(err) + } + mustRefresh(t, m) + if _, ok := m.Lookup("opencode-go/glm-test"); ok { + t.Fatal("valid empty file did not clear models") + } +} + +func TestCatalogFileErrorsHonorStalePolicy(t *testing.T) { + cfg := testCfg() + cfg.CatalogFile = filepath.Join(t.TempDir(), "sensitive-name.json") + m := New(cfg, nil) + err := m.Refresh(context.Background(), testKey) + if err == nil || strings.Contains(err.Error(), cfg.CatalogFile) { + t.Fatalf("file error includes path or is missing: %v", err) + } + if err := os.WriteFile(cfg.CatalogFile, []byte(`{"data":[{"id":"glm-test"}]}`), 0600); err != nil { + t.Fatal(err) + } + cfg.Catalog.StaleWhileUnavailable = false + m = New(cfg, nil) + mustRefresh(t, m) + if err := os.Remove(cfg.CatalogFile); err != nil { + t.Fatal(err) + } + if m.Refresh(context.Background(), testKey) == nil { + t.Fatal("missing file accepted") + } + if _, ok := m.Lookup("opencode-go/glm-test"); ok { + t.Fatal("stale model retained with stale policy disabled") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index dec6629..550d014 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "os" + "path/filepath" "regexp" "strings" "time" @@ -50,6 +51,7 @@ type RouteOverride struct { type Config struct { BaseURL string CatalogURL string + CatalogFile string ModelPrefix ModelPrefix APIKeys []APIKey Catalog Catalog @@ -66,6 +68,7 @@ type Config struct { type rawConfig struct { BaseURL *string `yaml:"base-url"` CatalogURL *string `yaml:"catalog-url"` + CatalogFile *string `yaml:"catalog-file"` ModelPrefix rawPrefix `yaml:"model-prefix"` APIKeys []rawKey `yaml:"api-keys"` Catalog rawCatalog `yaml:"catalog"` @@ -128,7 +131,8 @@ func Load(yamlBytes []byte) (Config, error) { return Config{}, fmt.Errorf("request-timeout: must be positive") } c := Config{ - BaseURL: orDefault(raw.BaseURL, DefaultBaseURL), + BaseURL: orDefault(raw.BaseURL, DefaultBaseURL), + CatalogFile: orDefault(raw.CatalogFile, ""), ModelPrefix: ModelPrefix{ Enabled: orDefault(raw.ModelPrefix.Enabled, true), Value: orDefault(raw.ModelPrefix.Value, DefaultModelPrefix), @@ -148,6 +152,14 @@ func Load(yamlBytes []byte) (Config, error) { RequestTimeout: requestTimeout, MaxResponseBytes: orDefault(raw.MaxResponseBytes, DefaultMaxResponseBytes), } + if raw.CatalogFile != nil { + if !filepath.IsAbs(c.CatalogFile) { + return Config{}, fmt.Errorf("catalog-file: must be an absolute path") + } + if raw.CatalogURL != nil { + return Config{}, fmt.Errorf("catalog-file and catalog-url are mutually exclusive") + } + } if raw.CatalogURL != nil { // Mirror the derived-default trim so an explicit trailing-slash // catalog-url cannot double up separators downstream. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 30bfcdf..1663334 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "path/filepath" "strings" "testing" "time" @@ -20,6 +21,19 @@ func requireErrContains(t *testing.T, err error, want string) { // that target a later validation check. const withKey = "api-keys:\n - value: sk-dummy\n" +func TestCatalogFileConfiguration(t *testing.T) { + path := filepath.Join(t.TempDir(), "models.json") + cfg, err := Load([]byte(withKey + "catalog-file: '" + path + "'\n")) + if err != nil || cfg.CatalogFile != path { + t.Fatalf("file config failed: %+v %v", cfg, err) + } + for _, extra := range []string{"catalog-file: ''\n", "catalog-file: relative.json\n", "catalog-file: '" + path + "'\ncatalog-url: https://example.com/models\n"} { + if _, err := Load([]byte(withKey + extra)); err == nil { + t.Fatalf("invalid catalog-file configuration accepted: %s", extra) + } + } +} + func TestLoadMinimalAppliesAllDefaults(t *testing.T) { c, err := Load([]byte(withKey)) if err != nil { diff --git a/internal/plugin/executor.go b/internal/plugin/executor.go index 8c15095..34202a0 100644 --- a/internal/plugin/executor.go +++ b/internal/plugin/executor.go @@ -97,7 +97,11 @@ func (m *Manager) handleExecute(request []byte) ([]byte, error) { return classEnvelope(eErr), nil } debugTrace("executor session mode=%s source_format=%s x_opencode_session=%s fallback=%t", "non-stream", req.SourceFormat, sessionID, sessionID == emptyOpenCodeSessionID) - upstreamBody, eErr := buildUpstreamRequest(res.rec.Protocol, res.rec.UpstreamID, req.SourceFormat, req.OriginalRequest, res.rec.Thinking) + sourceBody, names, eErr := prepareToolNamespaces(res.rec.Protocol, req.SourceFormat, req.OriginalRequest) + if eErr != nil { + return classEnvelope(eErr), nil + } + upstreamBody, eErr := buildUpstreamRequest(res.rec.Protocol, res.rec.UpstreamID, req.SourceFormat, sourceBody, res.rec.Thinking) if eErr != nil { return classEnvelope(eErr), nil } @@ -129,7 +133,7 @@ func (m *Manager) handleExecute(request []byte) ([]byte, error) { if eErr != nil { return classEnvelope(eErr), nil } - return okEnvelope(pluginapi.ExecutorResponse{Payload: converted, Headers: resp.Headers}), nil + return okEnvelope(pluginapi.ExecutorResponse{Payload: names.restoreJSON(converted), Headers: resp.Headers}), nil } func buildUpstreamRequest(route catalog.Route, upstreamModel, sourceFormat string, sourceBody []byte, ts *pluginapi.ThinkingSupport) ([]byte, *errclass.Error) { @@ -361,7 +365,11 @@ func (m *Manager) executeStream(req executorRequest) ([]byte, error) { return classEnvelope(eErr), nil } debugTrace("executor session mode=%s source_format=%s x_opencode_session=%s fallback=%t", "stream", req.SourceFormat, sessionID, sessionID == emptyOpenCodeSessionID) - upstreamBody, eErr := buildUpstreamRequest(res.rec.Protocol, res.rec.UpstreamID, req.SourceFormat, req.OriginalRequest, res.rec.Thinking) + sourceBody, names, eErr := prepareToolNamespaces(res.rec.Protocol, req.SourceFormat, req.OriginalRequest) + if eErr != nil { + return classEnvelope(eErr), nil + } + upstreamBody, eErr := buildUpstreamRequest(res.rec.Protocol, res.rec.UpstreamID, req.SourceFormat, sourceBody, res.rec.Thinking) if eErr != nil { return classEnvelope(eErr), nil } @@ -393,12 +401,12 @@ func (m *Manager) executeStream(req executorRequest) ([]byte, error) { if m.bridge != nil { defer m.bridge.inFlight.Done() } - m.pumpStream(downID, id, res, req.SourceFormat) + m.pumpStream(downID, id, res, req.SourceFormat, names) }() return okEnvelope(struct{}{}), nil } -func (m *Manager) pumpStream(downID, upstreamID string, res *resolvedExecution, sourceFormat string) { +func (m *Manager) pumpStream(downID, upstreamID string, res *resolvedExecution, sourceFormat string, names toolNamespaces) { var closeOnce sync.Once closeStreams := func(downErrMsg string) { closeOnce.Do(func() { @@ -452,7 +460,7 @@ func (m *Manager) pumpStream(downID, upstreamID string, res *resolvedExecution, closeStreams(errclass.Redact(convErr.Message)) return } - if emitErr := m.emitAll(downID, events); emitErr != nil { + if emitErr := m.emitAll(downID, names.restoreEvents(events)); emitErr != nil { closeStreams(errclass.Redact(emitErr.Error())) return } @@ -465,7 +473,7 @@ func (m *Manager) pumpStream(downID, upstreamID string, res *resolvedExecution, if !convDone && upstreamClosed { if flusher, ok := conv.(interface{ Flush() [][]byte }); ok { flushed := flusher.Flush() - if emitErr := m.emitAll(downID, flushed); emitErr != nil { + if emitErr := m.emitAll(downID, names.restoreEvents(flushed)); emitErr != nil { closeStreams(errclass.Redact(emitErr.Error())) return } diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 07b4809..12a04fd 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -22,10 +22,11 @@ import ( const ProviderID = "opencode-go" // pluginName / pluginVersion are reported in registration metadata. -const ( - pluginName = "opencode-go-cliproxyapi" - pluginVersion = "0.1.7" -) +const pluginName = "opencode-go-cliproxyapi" + +// pluginVersion is overridden at release time with -ldflags -X so the +// registered version matches the release archive and store manifest. +var pluginVersion = "0.1.7" // githubRepoURL satisfies the host's validPlugin gate (host.go // validPlugin rejects empty Metadata.GitHubRepository). diff --git a/internal/plugin/tool_namespaces.go b/internal/plugin/tool_namespaces.go new file mode 100644 index 0000000..8132d9e --- /dev/null +++ b/internal/plugin/tool_namespaces.go @@ -0,0 +1,208 @@ +package plugin + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + + "opencode-go-cliproxyapi/internal/catalog" + "opencode-go-cliproxyapi/internal/errclass" +) + +type toolIdentity struct { + Name string + Namespace string +} + +// A namespace is a client-side grouping, not an upstream hosted tool. Flatten +// function definitions for Chat/Messages and restore their identity on return. +// Unsupported hosted/custom tools still reach the adapters' explicit rejection. +type toolNamespaces map[string]toolIdentity + +func prepareToolNamespaces(route catalog.Route, source string, body []byte) ([]byte, toolNamespaces, *errclass.Error) { + if source != "openai-response" || route == catalog.RouteResponses { + return body, nil, nil + } + var request map[string]json.RawMessage + if json.Unmarshal(body, &request) != nil { + return body, nil, nil // The adapter owns malformed-request errors. + } + var tools []map[string]json.RawMessage + if raw, present := request["tools"]; present && json.Unmarshal(raw, &tools) != nil { + return body, nil, nil + } + var input []map[string]json.RawMessage + inputIsArray := json.Unmarshal(request["input"], &input) == nil + identities := toolNamespaces{} + declared := map[string]bool{} + flat := make([]map[string]json.RawMessage, 0, len(tools)) + used := map[string]bool{} + for _, tool := range tools { + if rawString(tool["type"]) != "namespace" { + used[rawString(tool["name"])] = true + } + } + // Historical unqualified calls can collide even after their definitions + // have been removed. Reserve those names before flattening any namespace. + for _, item := range input { + if rawString(item["type"]) == "function_call" && rawString(item["namespace"]) == "" { + used[rawString(item["name"])] = true + } + } + register := func(identity toolIdentity, definition bool) (string, *errclass.Error) { + wireName := namespaceWireName(identity) + previous, exists := identities[wireName] + if used[wireName] && (definition || !exists || previous != identity) { + return "", errclass.Translation("flattened namespace tool name collision") + } + used[wireName] = true + identities[wireName] = identity + if definition { + declared[wireName] = true + } + return wireName, nil + } + flattenedTools := false + for _, tool := range tools { + if rawString(tool["type"]) != "namespace" { + flat = append(flat, tool) + continue + } + flattenedTools = true + namespace := rawString(tool["name"]) + var children []map[string]json.RawMessage + if namespace == "" || json.Unmarshal(tool["tools"], &children) != nil || len(children) == 0 { + return nil, nil, errclass.Translation("namespace requires a name and function tools") + } + for _, child := range children { + name := rawString(child["name"]) + if rawString(child["type"]) != "function" || name == "" { + return nil, nil, &errclass.Error{Class: errclass.ClassUnsupported, Message: "only named function tools can be flattened from namespaces"} + } + wireName, eErr := register(toolIdentity{Name: name, Namespace: namespace}, true) + if eErr != nil { + return nil, nil, eErr + } + child["name"], _ = json.Marshal(wireName) + flat = append(flat, child) + } + } + resolve := func(item map[string]json.RawMessage, requireDefinition bool) *errclass.Error { + namespace := rawString(item["namespace"]) + if namespace == "" { + return nil + } + identity := toolIdentity{Name: rawString(item["name"]), Namespace: namespace} + if identity.Name == "" { + return errclass.Translation("namespaced function reference requires a name") + } + wireName := namespaceWireName(identity) + if requireDefinition { + if !declared[wireName] || identities[wireName] != identity { + return errclass.Translation("namespaced function reference has no matching definition") + } + } else { + // Completed calls remain replayable when tools are removed from the + // current definitions. The wire name depends only on their identity. + if _, eErr := register(identity, false); eErr != nil { + return eErr + } + } + item["name"], _ = json.Marshal(wireName) + delete(item, "namespace") + return nil + } + if inputIsArray { + for _, item := range input { + if rawString(item["type"]) == "function_call" { + if eErr := resolve(item, false); eErr != nil { + return nil, nil, eErr + } + } + } + request["input"], _ = json.Marshal(input) + } + var choice map[string]json.RawMessage + if json.Unmarshal(request["tool_choice"], &choice) == nil && choice != nil { + if eErr := resolve(choice, true); eErr != nil { + return nil, nil, eErr + } + request["tool_choice"], _ = json.Marshal(choice) + } + if len(identities) == 0 { + return body, nil, nil + } + if flattenedTools { + request["tools"], _ = json.Marshal(flat) + } + out, _ := json.Marshal(request) + return out, identities, nil +} + +func namespaceWireName(identity toolIdentity) string { + wireName := identity.Namespace + "__" + identity.Name + if len(wireName) > 64 { + digest := sha256.Sum256([]byte(identity.Namespace + "\x00" + identity.Name)) + wireName = "cpa_ns_" + hex.EncodeToString(digest[:24]) + } + return wireName +} + +func rawString(raw json.RawMessage) string { + var value string + _ = json.Unmarshal(raw, &value) + return value +} + +// Restore only protocol-owned function-call items. Tool argument strings and +// arbitrary application data must never be traversed or rewritten. +func (names toolNamespaces) restoreJSON(body []byte) []byte { + if len(names) == 0 { + return body + } + var object map[string]json.RawMessage + if json.Unmarshal(body, &object) != nil || object == nil { + return body + } + if rawString(object["type"]) == "function_call" { + if identity, ok := names[rawString(object["name"])]; ok { + object["name"], _ = json.Marshal(identity.Name) + object["namespace"], _ = json.Marshal(identity.Namespace) + } + } + for _, key := range []string{"item", "response"} { + if value, ok := object[key]; ok { + object[key] = names.restoreJSON(value) + } + } + var output []json.RawMessage + if json.Unmarshal(object["output"], &output) == nil && output != nil { + for i := range output { + output[i] = names.restoreJSON(output[i]) + } + object["output"], _ = json.Marshal(output) + } + result, _ := json.Marshal(object) + return result +} + +func (names toolNamespaces) restoreEvents(events [][]byte) [][]byte { + if len(names) == 0 { + return events + } + for i, event := range events { + lines := bytes.Split(event, []byte("\n")) + for j, line := range lines { + if bytes.HasPrefix(line, []byte("data:")) { + payload := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:"))) + if strings.HasPrefix(string(payload), "{") { + lines[j] = append([]byte("data: "), names.restoreJSON(payload)...) + } + } + } + events[i] = bytes.Join(lines, []byte("\n")) + } + return events +} diff --git a/internal/plugin/tool_namespaces_test.go b/internal/plugin/tool_namespaces_test.go new file mode 100644 index 0000000..803635b --- /dev/null +++ b/internal/plugin/tool_namespaces_test.go @@ -0,0 +1,144 @@ +package plugin + +import ( + "encoding/json" + "strings" + "testing" + + "opencode-go-cliproxyapi/internal/catalog" +) + +const namespacedRequest = `{"tools":[{"type":"function","name":"lookup"},{"type":"namespace","name":"files","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]}],"tool_choice":{"type":"function","namespace":"files","name":"lookup"},"input":[{"type":"function_call","namespace":"files","name":"lookup","call_id":"c1","arguments":"{}"},{"type":"function_call_output","call_id":"c1","output":"found"}]}` + +func TestNamespaceRoundTripAcrossRoutes(t *testing.T) { + for _, route := range []catalog.Route{catalog.RouteChatCompletions, catalog.RouteMessages} { + t.Run(string(route), func(t *testing.T) { + body, names, err := prepareToolNamespaces(route, "openai-response", []byte(namespacedRequest)) + if err != nil { + t.Fatal(err) + } + translated, err := buildUpstreamRequest(route, "lab", "openai-response", body, nil) + if err != nil || !strings.Contains(string(translated), "files__lookup") || strings.Contains(string(translated), `"namespace"`) { + t.Fatalf("flattened request = %s, %v", translated, err) + } + response := []byte(`{"output":[{"type":"function_call","name":"files__lookup","arguments":"{\"name\":\"files__lookup\"}","call_id":"c2"}]}`) + var decoded struct { + Output []struct{ Name, Namespace, Arguments, CallID string } + } + if err := json.Unmarshal(names.restoreJSON(response), &decoded); err != nil { + t.Fatal(err) + } + if item := decoded.Output[0]; item.Name != "lookup" || item.Namespace != "files" || item.Arguments != `{"name":"files__lookup"}` { + t.Fatalf("identity or opaque arguments changed: %+v", item) + } + for _, envelope := range []string{ + `{"type":"response.output_item.added","item":{"type":"function_call","name":"files__lookup"}}`, + `{"type":"response.output_item.done","item":{"type":"function_call","name":"files__lookup"}}`, + `{"type":"response.completed","response":` + string(response) + `}`, + } { + event := names.restoreEvents([][]byte{[]byte("event: fixture\ndata: " + envelope + "\n\n")})[0] + if !strings.Contains(string(event), `"namespace":"files"`) || !strings.HasSuffix(string(event), "\n\n") { + t.Fatalf("stream identity or framing lost: %s", event) + } + } + }) + } +} + +func TestNamespaceRejectsAmbiguousAndUnsupportedTools(t *testing.T) { + for _, body := range []string{ + strings.Replace(namespacedRequest, `"name":"lookup"`, `"name":"files__lookup"`, 1), + strings.Replace(namespacedRequest, `"name":"files"`, `"name":""`, 1), + strings.Replace(namespacedRequest, `"tools":[{"type":"function","name":"lookup","parameters"`, `"tools":[{"type":"custom","name":"lookup","parameters"`, 1), + strings.Replace(namespacedRequest, `"namespace":"files"`, `"namespace":"missing"`, 1), + } { + if _, _, err := prepareToolNamespaces(catalog.RouteChatCompletions, "openai-response", []byte(body)); err == nil { + t.Fatalf("expected explicit rejection: %s", body) + } + } +} + +func TestNativeResponsesNamespaceUntouched(t *testing.T) { + body, names, err := prepareToolNamespaces(catalog.RouteResponses, "openai-response", []byte(namespacedRequest)) + if err != nil || len(names) != 0 || string(body) != namespacedRequest { + t.Fatal("native Responses namespace must pass through") + } +} + +func TestNamespaceHistorySurvivesChangingToolDefinitions(t *testing.T) { + for _, toolField := range []string{ + ``, + `"tools":null,`, + `"tools":[],`, + `"tools":[{"type":"function","name":"current","parameters":{"type":"object"}}],`, + `"tools":[{"type":"namespace","name":"files","tools":[{"type":"function","name":"current","parameters":{"type":"object"}}]}],`, + } { + for _, route := range []catalog.Route{catalog.RouteChatCompletions, catalog.RouteMessages} { + t.Run(string(route)+"/"+toolField, func(t *testing.T) { + body := []byte(`{` + toolField + `"input":[{"type":"function_call","namespace":"files","name":"retired","call_id":"c1","arguments":"{}"},{"type":"function_call_output","call_id":"c1","output":"done"},{"type":"function_call","namespace":"files","name":"retired","call_id":"c2","arguments":"{}"},{"type":"function_call_output","call_id":"c2","output":"again"},{"role":"user","content":"continue"}]}`) + flat, names, err := prepareToolNamespaces(route, "openai-response", body) + if err != nil { + t.Fatal(err) + } + translated, err := buildUpstreamRequest(route, "lab", "openai-response", flat, nil) + if err != nil || strings.Count(string(translated), "files__retired") != 2 { + t.Fatalf("historical identity lost: %s %v", translated, err) + } + if names["files__retired"] != (toolIdentity{Name: "retired", Namespace: "files"}) { + t.Fatal("historical identity is not restorable") + } + var decoded map[string]json.RawMessage + if err := json.Unmarshal(flat, &decoded); err != nil { + t.Fatal(err) + } + if strings.Contains(string(decoded["tools"]), "retired") { + t.Fatal("replaying history re-enabled a removed tool") + } + if toolField == "" && decoded["tools"] != nil { + t.Fatal("absent tools gained definitions") + } + }) + } + } +} + +func TestNamespaceHistoryStillRejectsCollisionsAndForcedMissingTools(t *testing.T) { + history := `{"type":"function_call","namespace":"files","name":"retired","call_id":"c1","arguments":"{}"}` + for _, body := range []string{ + `{"tools":[{"type":"function","name":"files__retired"}],"input":[` + history + `]}`, + `{"input":[` + history + `,{"type":"function_call","name":"files__retired","call_id":"c2","arguments":"{}"}]}`, + `{"input":[{"type":"function_call","namespace":"a__b","name":"c"},{"type":"function_call","namespace":"a","name":"b__c"}]}`, + `{"input":[` + history + `],"tool_choice":{"type":"function","namespace":"files","name":"retired"}}`, + `{"tools":[],"tool_choice":{"type":"function","namespace":"files","name":"retired"}}`, + } { + for _, route := range []catalog.Route{catalog.RouteChatCompletions, catalog.RouteMessages} { + if _, _, err := prepareToolNamespaces(route, "openai-response", []byte(body)); err == nil { + t.Fatalf("ambiguous or unavailable choice accepted: %s", body) + } + } + } +} + +func TestLongNamespaceHistoryKeepsDefinitionWireName(t *testing.T) { + identity := toolIdentity{Name: "lookup", Namespace: strings.Repeat("files", 20)} + tool, _ := json.Marshal(map[string]any{"type": "namespace", "name": identity.Namespace, "tools": []any{map[string]any{"type": "function", "name": identity.Name}}}) + call, _ := json.Marshal(map[string]any{"type": "function_call", "namespace": identity.Namespace, "name": identity.Name, "call_id": "c1", "arguments": "{}"}) + withTools := []byte(`{"tools":[` + string(tool) + `],"input":[` + string(call) + `]}`) + withoutTools := []byte(`{"input":[` + string(call) + `]}`) + var previous string + for _, body := range [][]byte{withTools, withoutTools} { + flat, names, err := prepareToolNamespaces(catalog.RouteMessages, "openai-response", body) + if err != nil { + t.Fatal(err) + } + var decoded struct{ Input []struct{ Name string } } + if err := json.Unmarshal(flat, &decoded); err != nil { + t.Fatal(err) + } + wire := decoded.Input[0].Name + if len(wire) > 64 || !strings.HasPrefix(wire, "cpa_ns_") || names[wire] != identity || (previous != "" && wire != previous) { + t.Fatalf("long identity changed with definitions: %s", flat) + } + previous = wire + } +}