Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<commit>"`
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. `<cliproxyapi_root>/plugins/<os>/<arch>/`).

## Configuration
Expand Down Expand Up @@ -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 `<prefix>/<model>`. 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`. |
Expand Down
8 changes: 6 additions & 2 deletions internal/adapter/chatcompletions/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions internal/adapter/chatcompletions/reasoning.go
Original file line number Diff line number Diff line change
@@ -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}}}
}
102 changes: 102 additions & 0 deletions internal/adapter/chatcompletions/reasoning_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
62 changes: 51 additions & 11 deletions internal/adapter/chatcompletions/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
Loading