Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Restore Responses function namespaces in JSON and SSE output, and preserve qualified tool identities when replaying calls or selecting a function.
- Preserve Qoder user images and image-bearing tool results, emitting tool-result images after their complete ordered tool batch.
- Bridge Responses custom tools through function calls, restoring custom output/events and replaying tool results. Format rules are descriptive, not grammar-enforced; custom input events are emitted after argument collection.

Expand All @@ -22,6 +23,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### 中文

- Responses 的 JSON 和 SSE 输出会还原 function 的命名空间,历史调用回放与指定函数选择也会保留完整工具身份。
- 保留 Qoder 用户消息及工具结果中的图片,并在完整、有序的工具结果批次之后发送工具图片。
- 通过 function 调用桥接 Responses custom 工具,还原 custom 输出与事件并回放工具结果。格式规则仅作为描述传递,不强制执行语法约束;custom 输入事件在参数收集后发送。

Expand Down
14 changes: 11 additions & 3 deletions internal/api/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
CachedTokens: result.CachedTokens, UsageSource: result.UsageSource, Credits: result.Credits,
ConsumedCredits: result.ConsumedCredits, Model: result.Model,
}, nil, result.AttemptCount)
writeJSON(w, http.StatusOK, responsesResponse(execution.requestID, firstNonEmpty(result.Model, execution.publicModel), result.Content, result.Reasoning, decodeOpenAIToolCalls(result.ToolCalls), result.PromptTokens, result.CompletionTokens))
response := responsesResponse(execution.requestID, firstNonEmpty(result.Model, execution.publicModel), result.Content, result.Reasoning, decodeOpenAIToolCalls(result.ToolCalls), result.PromptTokens, result.CompletionTokens)
translate.RestoreResponseToolNames(response, execution.request.ResponseToolNames)
writeJSON(w, http.StatusOK, response)
}

func (s *Server) handleResponsesStream(w http.ResponseWriter, r *http.Request, execution compatibilityExecution) {
Expand All @@ -162,7 +164,7 @@ func (s *Server) handleResponsesStream(w http.ResponseWriter, r *http.Request, e
flusher.Flush()
}
writer := compatibilityStreamWriter(w)
stats, relayErr := relayResponsesStream(writer, upstream.Response.Body, execution.requestID, firstNonEmpty(execution.publicModel, execution.request.Model))
stats, relayErr := relayResponsesStreamWithNames(writer, upstream.Response.Body, execution.requestID, firstNonEmpty(execution.publicModel, execution.request.Model), execution.request.ResponseToolNames)
status := streamRequestStatus(relayErr)
if r.Context().Err() != nil || errors.Is(relayErr, context.Canceled) || errors.Is(relayErr, context.DeadlineExceeded) {
status = accounts.RequestStatusCanceled
Expand Down Expand Up @@ -708,9 +710,11 @@ func relayAnthropicStream(writer io.Writer, body io.Reader, requestID, model str
type responsesEventWriter struct {
writer io.Writer
sequenceNumber int
toolNames map[string]translate.ResponseToolName
}

func (w *responsesEventWriter) write(event string, payload any) error {
translate.RestoreResponseToolNames(payload, w.toolNames)
if object, ok := payload.(map[string]any); ok {
object["sequence_number"] = w.sequenceNumber
w.sequenceNumber++
Expand All @@ -719,7 +723,11 @@ func (w *responsesEventWriter) write(event string, payload any) error {
}

func relayResponsesStream(writer io.Writer, body io.Reader, requestID, model string) (streamRelayStats, error) {
eventWriter := responsesEventWriter{writer: writer}
return relayResponsesStreamWithNames(writer, body, requestID, model, nil)
}

func relayResponsesStreamWithNames(writer io.Writer, body io.Reader, requestID, model string, names map[string]translate.ResponseToolName) (streamRelayStats, error) {
eventWriter := responsesEventWriter{writer: writer, toolNames: names}
responseID := "resp_" + requestID
created := time.Now().Unix()
inProgress := map[string]any{"id": responseID, "object": "response", "created_at": created, "status": "in_progress", "model": model, "output": []any{}}
Expand Down
108 changes: 108 additions & 0 deletions internal/api/compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,114 @@ import (
"github.com/caigee-cmd/cli2api/internal/translate"
)

func TestResponsesNamespaceHandlerRoundTrip(t *testing.T) {
for _, stream := range []bool{false, true} {
t.Run(fmt.Sprint(stream), func(t *testing.T) {
calls := 0
server, closeServer := newCompatibilityServer(t, func(w http.ResponseWriter, r *http.Request) {
calls++
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if calls == 2 {
messages := body["messages"].([]any)
found := false
for _, raw := range messages {
message := raw.(map[string]any)
if tools, ok := message["tool_calls"].([]any); ok {
for _, rawCall := range tools {
call := rawCall.(map[string]any)
if call["id"] == "call_probe" && call["function"].(map[string]any)["name"] == "mcp__fastctx__glob" {
found = true
}
}
}
}
if !found {
t.Errorf("history name/id missing: %v", messages)
}
_, _ = io.WriteString(w, `{"choices":[{"message":{"content":"ROUNDTRIP_OK"},"finish_reason":"stop"}]}`)
return
}
if stream {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_probe\",\"function\":{\"name\":\"mcp__fastctx__glob\",\"arguments\":\"{\"}}]}}]}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n")
} else {
_, _ = io.WriteString(w, `{"choices":[{"message":{"tool_calls":[{"id":"call_probe","type":"function","function":{"name":"mcp__fastctx__glob","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}`)
}
})
defer closeServer()
tools := []any{map[string]any{"type": "namespace", "name": "mcp__fastctx", "tools": []any{map[string]any{"type": "function", "name": "glob", "parameters": map[string]any{"type": "object", "properties": map[string]any{}}}}}}
request := map[string]any{"model": "qoder/glm-5.2", "input": "find", "tools": tools, "stream": stream}
send := func() *httptest.ResponseRecorder {
data, _ := json.Marshal(request)
recorder := httptest.NewRecorder()
server.handleResponses(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(data))))
if recorder.Code != 200 {
t.Fatalf("status=%d %s", recorder.Code, recorder.Body.String())
}
return recorder
}
recorder := send()
var completed map[string]any
check := func(item map[string]any) {
if item["name"] != "glob" || item["namespace"] != "mcp__fastctx" || item["call_id"] != "call_probe" {
t.Fatalf("wrong tool identity: %v", item)
}
}
if stream {
seen := map[string]bool{}
for _, line := range strings.Split(recorder.Body.String(), "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
var event map[string]any
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event); err != nil {
t.Fatal(err)
}
typ, _ := event["type"].(string)
if item, ok := event["item"].(map[string]any); ok && item["type"] == "function_call" {
check(item)
seen[typ] = true
}
if typ == "response.function_call_arguments.delta" || typ == "response.function_call_arguments.done" {
check(event)
seen[typ] = true
}
if typ == "response.completed" {
completed = event["response"].(map[string]any)
}
}
for _, typ := range []string{"response.output_item.added", "response.output_item.done", "response.function_call_arguments.delta", "response.function_call_arguments.done"} {
if !seen[typ] {
t.Fatalf("missing %s", typ)
}
}
} else if err := json.Unmarshal(recorder.Body.Bytes(), &completed); err != nil {
t.Fatal(err)
}
if completed == nil {
t.Fatal("no completed response")
}
output := completed["output"].([]any)
item := output[len(output)-1].(map[string]any)
check(item)
if item["arguments"] != "{}" {
t.Fatal(item)
}
request["stream"] = false
request["input"] = []any{map[string]any{"role": "user", "content": "find"}, item, map[string]any{"type": "function_call_output", "call_id": "call_probe", "output": "found"}}
if result := send(); !strings.Contains(result.Body.String(), "ROUNDTRIP_OK") {
t.Fatal(result.Body.String())
}
if calls != 2 {
t.Fatalf("upstream calls=%d", calls)
}
})
}
}

func TestCompatibilityStreamsPreserveTypedReadError(t *testing.T) {
failover := false
want := &providers.Error{
Expand Down
50 changes: 49 additions & 1 deletion internal/translate/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@
"strings"
)

// RestoreResponseToolNames only visits protocol containers, never user content
// or tool arguments. The same operation serves JSON responses and SSE events.
func RestoreResponseToolNames(value any, names map[string]ResponseToolName) {
if len(names) == 0 {
return
}
switch item := value.(type) {
case []any:
for _, child := range item {
RestoreResponseToolNames(child, names)
}
case map[string]any:
typ, _ := item["type"].(string)
if typ == "function_call" || typ == "response.function_call_arguments.delta" || typ == "response.function_call_arguments.done" {
name, _ := item["name"].(string)
if identity, ok := names[name]; ok && item["namespace"] == nil {
item["name"] = identity.Name
item["namespace"] = identity.Namespace
}
}
for _, key := range []string{"response", "output", "item"} {
RestoreResponseToolNames(item[key], names)
}
}
}

// AnthropicMessagesRequest is the supported subset of Anthropic's Messages API.
type AnthropicMessagesRequest struct {
Model string `json:"model"`
Expand Down Expand Up @@ -129,6 +155,12 @@
if effort := anthropicReasoningEffort(request.OutputConfig); len(effort) > 0 {
chat.ReasoningEffort = effort
}
}
chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice)

Check failure on line 159 in internal/translate/compat.go

View workflow job for this annotation

GitHub Actions / windows-host

syntax error: non-declaration statement outside function body

Check failure on line 159 in internal/translate/compat.go

View workflow job for this annotation

GitHub Actions / go

syntax error: non-declaration statement outside function body
chat.ParallelToolCalls = anthropicParallelToolCalls(request.ToolChoice)
if effort := anthropicReasoningEffort(request.OutputConfig); len(effort) > 0 {
chat.ReasoningEffort = effort
}
if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil {
return ChatRequest{}, err
}
Expand Down Expand Up @@ -171,7 +203,12 @@
if err != nil {
return ChatRequest{}, err
}
tools, err := translateResponsesTools(mergeJSONArray(request.Tools, additionalTools))
mergedTools := mergeJSONArray(request.Tools, additionalTools)
chat.ResponseToolNames, err = responseToolNames(mergedTools)
if err != nil {
return ChatRequest{}, err
}
tools, err := translateResponsesTools(mergedTools)
if err != nil {
return ChatRequest{}, err
}
Expand Down Expand Up @@ -483,6 +520,9 @@
return nil, fmt.Errorf("input[%d] file inputs are not supported by the Qoder upstream", itemIndex)
case "function_call":
name := rawMapString(source, "name")
if namespace := rawMapString(source, "namespace"); namespace != "" {
name = qualifyNamespaceToolName(namespace, name)
}
callID := firstRawMapString(source, "call_id", "id")
if name == "" || callID == "" {
return nil, fmt.Errorf("input[%d] function_call requires name and call_id", itemIndex)
Expand Down Expand Up @@ -622,6 +662,14 @@
default:
return nil, fmt.Errorf("tool_choice type %q is not supported", rawMapString(source, "type"))
}
name := rawMapString(source, "name")
if name == "" {
return nil, fmt.Errorf("tool_choice.name required")
}
if namespace := rawMapString(source, "namespace"); namespace != "" {
name = qualifyNamespaceToolName(namespace, name)
}
return json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": name}})
}

func responseReasoningEffort(raw json.RawMessage) json.RawMessage {
Expand Down
2 changes: 2 additions & 0 deletions internal/translate/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
)

type ChatRequest struct {
ResponseToolNames map[string]ResponseToolName `json:"-"`

Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
Expand Down
57 changes: 56 additions & 1 deletion internal/translate/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,59 @@

const defaultToolParameters = `{"type":"object","properties":{}}`

// ResponseToolName is request-local metadata; it must never reach the provider.
type ResponseToolName struct {
Namespace string
Name string
}

// responseToolNames records actual declarations, rather than guessing identity
// by splitting names (both namespaces and tool names can contain underscores).
func responseToolNames(raw json.RawMessage) (map[string]ResponseToolName, error) {
if emptyJSON(raw) {
return nil, nil
}
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return nil, err
}
all := map[string]ResponseToolName{}
names := map[string]ResponseToolName{}
register := func(flat string, identity ResponseToolName) error {
if flat == "" {
return nil
}
if previous, exists := all[flat]; exists && previous != identity {
return fmt.Errorf("tool name collision for %q", flat)
}
all[flat] = identity
return nil
}
for _, rawItem := range items {
var item map[string]json.RawMessage
if json.Unmarshal(rawItem, &item) != nil {
continue
}
typ := strings.ToLower(strings.TrimSpace(rawMapString(item, "type")))
switch typ {
case "namespace":
for _, tool := range expandNamespaceToolItems(rawItem, strings.TrimSpace(rawMapString(item, "name"))) {
if err := register(tool.name, tool.identity); err != nil {
return nil, err
}
if tool.identity.Namespace != "" {
names[tool.name] = tool.identity
}
}
case "function", "":
name, _, _ := toolFields(item)
flat := name
if err := register(flat, ResponseToolName{Name: name}); err != nil {
return nil, err
}
}
}
return names, nil
const (
customToolMarker = "__codex_custom__"
customToolParameters = `{"type":"object","properties":{"input":{"type":"string","description":"Raw freeform input for the custom tool."}},"required":["input"],"additionalProperties":false}`
Expand All @@ -16,13 +69,13 @@
// EncodeCustomToolName maps a Codex custom/freeform tool onto an upstream
// function name. The marker keeps the round trip reversible without changing
// the namespace/name identity carried by CustomToolCall.
func EncodeCustomToolName(namespace, name string) string {

Check failure on line 72 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / windows-host

syntax error: unexpected name EncodeCustomToolName, expected (

Check failure on line 72 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / go

syntax error: unexpected name EncodeCustomToolName, expected (
return customToolName(namespace, name)
}

// DecodeCustomToolName reverses EncodeCustomToolName. It returns ok=false for
// ordinary function tools.
func DecodeCustomToolName(upstreamName string) (namespace, name string, ok bool) {

Check failure on line 78 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / windows-host

syntax error: unexpected name DecodeCustomToolName, expected (

Check failure on line 78 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / go

syntax error: unexpected name DecodeCustomToolName, expected (
upstreamName = strings.TrimSpace(upstreamName)
index := strings.Index(upstreamName, customToolMarker)
if index < 0 {
Expand All @@ -35,7 +88,7 @@
return strings.TrimSpace(upstreamName[:index]), name, true
}

func customToolName(namespace, name string) string {

Check failure on line 91 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / windows-host

syntax error: unexpected name customToolName, expected (

Check failure on line 91 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / go

syntax error: unexpected name customToolName, expected (
name = strings.TrimSpace(name)
if name == "" {
return ""
Expand All @@ -47,7 +100,7 @@
return strings.TrimRight(namespace, "_") + customToolMarker + name
}

func customToolDescription(description string, format json.RawMessage) string {

Check failure on line 103 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / windows-host

syntax error: unexpected name customToolDescription, expected (

Check failure on line 103 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / go

syntax error: unexpected name customToolDescription, expected (
description = strings.TrimSpace(description)
if len(format) == 0 || string(format) == "null" {
return description
Expand All @@ -67,7 +120,7 @@
// Nested tools may be Responses-flat or Chat Completions shaped. Short nested
// names are qualified as namespace__name; already-qualified mcp__* names stay
// unchanged. This does not apply Devin's mcp__ upstream aliasing.
func NormalizeOpenAITools(raw json.RawMessage) (json.RawMessage, error) {

Check failure on line 123 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / windows-host

syntax error: unexpected name NormalizeOpenAITools, expected (

Check failure on line 123 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / go

syntax error: unexpected name NormalizeOpenAITools, expected (
if emptyJSON(raw) {
return nil, nil
}
Expand Down Expand Up @@ -129,12 +182,13 @@

type normalizedTool struct {
name string
identity ResponseToolName
description string
parameters json.RawMessage
custom bool
}

func expandNamespaceToolItems(raw json.RawMessage, namespace string) []normalizedTool {

Check failure on line 191 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / windows-host

syntax error: unexpected name expandNamespaceToolItems, expected (

Check failure on line 191 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / go

syntax error: unexpected name expandNamespaceToolItems, expected (
var wrapper struct {
Tools []json.RawMessage `json:"tools"`
}
Expand Down Expand Up @@ -165,16 +219,17 @@
continue
}
name, description, parameters := toolFields(probe)
identity := ResponseToolName{Namespace: namespace, Name: name}
name = qualifyNamespaceToolName(namespace, name)
if name == "" {
continue
}
out = append(out, normalizedTool{name: name, description: description, parameters: parameters})
out = append(out, normalizedTool{name: name, identity: identity, description: description, parameters: parameters})
}
return out
}

func toolFields(source map[string]json.RawMessage) (name, description string, parameters json.RawMessage) {

Check failure on line 232 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / windows-host

syntax error: unexpected name toolFields, expected (

Check failure on line 232 in internal/translate/tools.go

View workflow job for this annotation

GitHub Actions / go

syntax error: unexpected name toolFields, expected (
var function map[string]json.RawMessage
if raw, ok := rawMapJSONValue(source, "function"); ok {
_ = json.Unmarshal(raw, &function)
Expand Down
Loading
Loading