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

### English

- 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.

- Preserve typed upstream stream errors through the OpenAI, Anthropic, and Responses relays so invalid Devin requests do not falsely cool accounts, while transport interruptions remain retryable
- Report Devin cache reads and writes in OpenAI-compatible usage, with prompt
totals including all upstream input tokens
Expand All @@ -19,6 +21,8 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### 中文

- 通过 function 调用桥接 Responses custom 工具,还原 custom 输出与事件并回放工具结果。格式规则仅作为描述传递,不强制执行语法约束;custom 输入事件在参数收集后发送。

- OpenAI、Anthropic 与 Responses 流式转发会保留上游的类型化错误,避免无效的 Devin 请求被错误地冷却账号,同时传输中断仍可重试
- Devin 的缓存读取与写入会显示在 OpenAI 兼容 usage 中,prompt 总数包含全部上游输入 token
- 新增手动更新命令,提取 descriptor 并生成 Devin 聊天与账号状态 protobuf 类型;构建与 CI 直接使用已提交的 Go 文件,不下载发行包或重新生成 schema
Expand Down
83 changes: 75 additions & 8 deletions internal/api/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,32 @@ type proxyToolCall struct {
Arguments string
}

func proxyToolCallItem(requestID string, callIndex int, call proxyToolCall) map[string]any {
namespace, name, custom := translate.DecodeCustomToolName(call.Name)
if !custom {
return responseFunctionCallItem(requestID, callIndex, call)
}
input := ""
if raw := strings.TrimSpace(call.Arguments); raw != "" {
var payload struct {
Input string `json:"input"`
}
if json.Unmarshal([]byte(raw), &payload) == nil {
input = payload.Input
} else {
input = call.Arguments
}
}
item := map[string]any{
"id": fmt.Sprintf("ctc_%s_%d", requestID, callIndex), "type": "custom_tool_call", "status": "completed",
"call_id": call.ID, "name": name, "input": input,
}
if namespace != "" {
item["namespace"] = namespace
}
return item
}

func decodeOpenAIToolCalls(raw json.RawMessage) []proxyToolCall {
if len(raw) == 0 || string(raw) == "null" {
return nil
Expand Down Expand Up @@ -356,7 +382,7 @@ func responsesOutputItems(requestID, content, reasoning string, toolCalls []prox
})
}
for callIndex, call := range toolCalls {
items = append(items, responseFunctionCallItem(requestID, callIndex, call))
items = append(items, proxyToolCallItem(requestID, callIndex, call))
}
return items
}
Expand Down Expand Up @@ -806,6 +832,12 @@ func relayResponsesStream(writer io.Writer, body io.Reader, requestID, model str
toolOutputIndexes[index] = nextOutputIndex
nextOutputIndex++
item := map[string]any{"id": fmt.Sprintf("fc_%s_%d", requestID, index), "type": "function_call", "status": "in_progress", "call_id": toolCallIDs[index], "name": toolCallNames[index], "arguments": ""}
if namespace, name, custom := translate.DecodeCustomToolName(toolCallNames[index]); custom {
item = map[string]any{"id": fmt.Sprintf("ctc_%s_%d", requestID, index), "type": "custom_tool_call", "status": "in_progress", "call_id": toolCallIDs[index], "name": name, "input": ""}
if namespace != "" {
item["namespace"] = namespace
}
}
if err := eventWriter.write("response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": toolOutputIndexes[index], "item": item}); err != nil {
return err
}
Expand All @@ -818,7 +850,13 @@ func relayResponsesStream(writer io.Writer, body io.Reader, requestID, model str
}
emitted := toolArgumentLengths[index]
if len(accumulated) > emitted {
if err := eventWriter.write("response.function_call_arguments.delta", map[string]any{"type": "response.function_call_arguments.delta", "output_index": toolOutputIndexes[index], "item_id": fmt.Sprintf("fc_%s_%d", requestID, index), "call_id": toolCallIDs[index], "name": toolCallNames[index], "delta": accumulated[emitted:]}); err != nil {
deltaPayload := map[string]any{"type": "response.function_call_arguments.delta", "output_index": toolOutputIndexes[index], "item_id": fmt.Sprintf("fc_%s_%d", requestID, index), "call_id": toolCallIDs[index], "name": toolCallNames[index], "delta": accumulated[emitted:]}
if _, _, custom := translate.DecodeCustomToolName(toolCallNames[index]); custom {
toolArgumentLengths[index] = len(accumulated)
continue
}
eventName, _ := deltaPayload["type"].(string)
if err := eventWriter.write(eventName, deltaPayload); err != nil {
return err
}
toolArgumentLengths[index] = len(accumulated)
Expand Down Expand Up @@ -854,19 +892,48 @@ func relayResponsesStream(writer io.Writer, body io.Reader, requestID, model str
call.ID = fmt.Sprintf("call_%s_%d", requestID, callIndex)
}
}
item := responseFunctionCallItem(requestID, callIndex, call)
item := proxyToolCallItem(requestID, callIndex, call)
outputIndex, ok := toolOutputIndexes[callIndex]
if !ok {
outputIndex = nextOutputIndex
nextOutputIndex++
}
if !toolAnnounced[callIndex] {
if err := eventWriter.write("response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": outputIndex, "item": map[string]any{"id": item["id"], "type": "function_call", "status": "in_progress", "call_id": call.ID, "name": call.Name, "arguments": ""}}); err != nil {
if namespace, name, custom := translate.DecodeCustomToolName(call.Name); custom {
input := ""
var payload struct {
Input string `json:"input"`
}
if json.Unmarshal([]byte(call.Arguments), &payload) == nil {
input = payload.Input
} else {
input = call.Arguments
}
if !toolAnnounced[callIndex] {
added := map[string]any{"id": item["id"], "type": "custom_tool_call", "status": "in_progress", "call_id": call.ID, "name": name, "input": ""}
if namespace != "" {
added["namespace"] = namespace
}
if err := eventWriter.write("response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": outputIndex, "item": added}); err != nil {
return stats, err
}
}
if input != "" {
if err := eventWriter.write("response.custom_tool_call_input.delta", map[string]any{"type": "response.custom_tool_call_input.delta", "output_index": outputIndex, "item_id": item["id"], "call_id": call.ID, "delta": input}); err != nil {
return stats, err
}
}
if err := eventWriter.write("response.custom_tool_call_input.done", map[string]any{"type": "response.custom_tool_call_input.done", "output_index": outputIndex, "item_id": item["id"], "call_id": call.ID, "name": name, "input": input}); err != nil {
return stats, err
}
} else {
if !toolAnnounced[callIndex] {
if err := eventWriter.write("response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": outputIndex, "item": map[string]any{"id": item["id"], "type": "function_call", "status": "in_progress", "call_id": call.ID, "name": call.Name, "arguments": ""}}); err != nil {
return stats, err
}
}
if err := eventWriter.write("response.function_call_arguments.done", map[string]any{"type": "response.function_call_arguments.done", "output_index": outputIndex, "item_id": item["id"], "call_id": call.ID, "name": call.Name, "arguments": call.Arguments}); err != nil {
return stats, err
}
}
if err := eventWriter.write("response.function_call_arguments.done", map[string]any{"type": "response.function_call_arguments.done", "output_index": outputIndex, "item_id": item["id"], "call_id": call.ID, "name": call.Name, "arguments": call.Arguments}); err != nil {
return stats, err
}
if err := eventWriter.write("response.output_item.done", map[string]any{"type": "response.output_item.done", "output_index": outputIndex, "item": item}); err != nil {
return stats, err
Expand Down
56 changes: 56 additions & 0 deletions internal/api/compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,62 @@ func TestResponsesStreamKeepsDistinctFunctionCallIDs(t *testing.T) {
}
}

func TestResponsesNonStreamRestoresCustomToolCall(t *testing.T) {
server, closeServer := newCompatibilityServer(t, func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"model": "glm-5.2", "choices": []any{map[string]any{"message": map[string]any{
"content": "", "tool_calls": []any{map[string]any{"id": "call_custom", "type": "function", "function": map[string]string{"name": "__codex_custom__exec", "arguments": `{"input":"patch text"}`}}},
}, "finish_reason": "tool_calls"}},
"usage": map[string]any{"prompt_tokens": 8, "completion_tokens": 3},
})
})
defer closeServer()

request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"qoder/glm-5.2","input":"hi"}`))
recorder := httptest.NewRecorder()
server.handleResponses(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var response struct {
Output []struct {
Type string `json:"type"`
Name string `json:"name"`
Input string `json:"input"`
} `json:"output"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Output) != 1 || response.Output[0].Type != "custom_tool_call" || response.Output[0].Name != "exec" || response.Output[0].Input != "patch text" {
t.Fatalf("output=%+v", response.Output)
}
}

func TestResponsesStreamWritesCustomToolCallEvents(t *testing.T) {
server, closeServer := newCompatibilityServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_custom\",\"function\":{\"name\":\"__codex_custom__exec\",\"arguments\":\"{\\\"input\\\":\\\"patch\"}}]}}]}\n\n")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\" text\\\"}\"}}]}}]}\n\n")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"finish_reason\":\"tool_calls\"}]}\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
})
defer closeServer()

request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"qoder/glm-5.2","stream":true,"input":"hi"}`))
recorder := httptest.NewRecorder()
server.handleResponses(recorder, request)
body := recorder.Body.String()
for _, expected := range []string{"event: response.custom_tool_call_input.delta", `"delta":"patch text"`, "event: response.custom_tool_call_input.done", `"type":"custom_tool_call"`, `"name":"exec"`, `"input":"patch text"`} {
if !strings.Contains(body, expected) {
t.Fatalf("missing %q in %s", expected, body)
}
}
if strings.Contains(body, "__codex_custom__") {
t.Fatalf("custom marker leaked into response: %s", body)
}
}

func TestResponsesRejectsStatefulPreviousResponseID(t *testing.T) {
server, closeServer := newCompatibilityServer(t, func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("worker should not receive unsupported stateful response request")
Expand Down
22 changes: 22 additions & 0 deletions internal/providers/workbuddy/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1287,6 +1287,28 @@ func TestPrepareBodyExpandsNamespaceTools(t *testing.T) {
}
}

func TestPrepareBodyKeepsEncodedCustomFunctionTools(t *testing.T) {
const encodedName = "functions__codex_custom__apply_patch"
out := PrepareBody([]byte(`{"model":"m","tools":[{"type":"function","function":{"name":"functions__codex_custom__apply_patch","parameters":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}}],"tool_choice":"functions__codex_custom__apply_patch"}`))
var body map[string]any
if err := json.Unmarshal(out, &body); err != nil {
t.Fatal(err)
}
tools, _ := body["tools"].([]any)
if len(tools) != 1 {
t.Fatalf("tools=%v", body["tools"])
}
tool, _ := tools[0].(map[string]any)
fn, _ := tool["function"].(map[string]any)
if fn["name"] != encodedName {
t.Fatalf("encoded custom function name changed: %v", fn["name"])
}
choice, _ := body["tool_choice"].(string)
if choice != encodedName {
t.Fatalf("encoded custom tool choice changed: %v", body["tool_choice"])
}
}

func TestCatalogHeadersUseDesktopUAOnlyForGlobal(t *testing.T) {
global := http.Header{}
SetCatalogHeaders(global, Credential{AccessToken: "at", UID: "u1", Domain: "www.workbuddy.ai"})
Expand Down
109 changes: 72 additions & 37 deletions internal/translate/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,21 +119,21 @@ func TranslateAnthropicMessages(request AnthropicMessagesRequest) (ChatRequest,
if err != nil {
return ChatRequest{}, err
}
chat.Tools = tools
toolChoice, err := translateAnthropicToolChoice(request.ToolChoice)
if err != nil {
return ChatRequest{}, err
}
chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice)
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
}
return chat, nil
chat.Tools = tools
toolChoice, err := translateAnthropicToolChoice(request.ToolChoice)
if err != nil {
return ChatRequest{}, err
}
chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice)
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
}
return chat, nil
}

func TranslateResponses(request ResponsesRequest) (ChatRequest, error) {
if strings.TrimSpace(request.PreviousID) != "" || !emptyJSON(request.Conversation) {
Expand Down Expand Up @@ -175,24 +175,24 @@ func TranslateResponses(request ResponsesRequest) (ChatRequest, error) {
if err != nil {
return ChatRequest{}, err
}
chat.Tools = tools
toolChoice, err := translateResponsesToolChoice(request.ToolChoice)
if err != nil {
return ChatRequest{}, err
}
// Codex Desktop compact / recovery turns can keep a tool_choice while
// tools normalize to empty (hosted shells dropped, etc). Drop the
// orphan choice instead of failing the whole turn.
chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice)
chat.ResponseFormat, err = translateResponsesTextFormat(request.Text)
if err != nil {
return ChatRequest{}, err
}
if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil {
return ChatRequest{}, err
}
return chat, nil
chat.Tools = tools
toolChoice, err := translateResponsesToolChoice(request.ToolChoice)
if err != nil {
return ChatRequest{}, err
}
// Codex Desktop compact / recovery turns can keep a tool_choice while
// tools normalize to empty (hosted shells dropped, etc). Drop the
// orphan choice instead of failing the whole turn.
chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice)
chat.ResponseFormat, err = translateResponsesTextFormat(request.Text)
if err != nil {
return ChatRequest{}, err
}
if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil {
return ChatRequest{}, err
}
return chat, nil
}

func anthropicMessageParts(raw json.RawMessage) (any, []compatibilityToolCall, []compatibilityToolResult, error) {
if rawText, ok := rawJSONString(raw); ok {
Expand Down Expand Up @@ -465,6 +465,20 @@ func translateResponsesInput(raw json.RawMessage) ([]ChatMessage, error) {
if len(images) > 0 {
messages = append(messages, ChatMessage{Role: "user", Content: images})
}
case "custom_tool_call_output":
callID := rawMapString(source, "call_id")
if callID == "" {
return nil, fmt.Errorf("input[%d].call_id required", itemIndex)
}
content, images, err := responsesFunctionCallOutput(rawMapJSON(source, "output"))
if err != nil {
return nil, fmt.Errorf("input[%d].output: %w", itemIndex, err)
}
flushPendingReasoning()
messages = append(messages, ChatMessage{Role: "tool", ToolCallID: callID, Content: content})
if len(images) > 0 {
messages = append(messages, ChatMessage{Role: "user", Content: images})
}
case "input_file":
return nil, fmt.Errorf("input[%d] file inputs are not supported by the Qoder upstream", itemIndex)
case "function_call":
Expand All @@ -484,6 +498,19 @@ func translateResponsesInput(raw json.RawMessage) ([]ChatMessage, error) {
return nil, fmt.Errorf("input[%d].arguments must be valid JSON", itemIndex)
}
appendAssistant(ChatMessage{Role: "assistant", Content: "", ToolCalls: marshalToolCalls([]compatibilityToolCall{{ID: callID, Name: name, Arguments: arguments}})})
case "custom_tool_call":
name := rawMapString(source, "name")
namespace := rawMapString(source, "namespace")
callID := firstRawMapString(source, "call_id", "id")
if name == "" || callID == "" {
return nil, fmt.Errorf("input[%d] custom_tool_call requires name and call_id", itemIndex)
}
input := rawMapString(source, "input")
arguments, err := json.Marshal(map[string]string{"input": input})
if err != nil {
return nil, fmt.Errorf("input[%d] custom_tool_call input: %w", itemIndex, err)
}
appendAssistant(ChatMessage{Role: "assistant", Content: "", ToolCalls: marshalToolCalls([]compatibilityToolCall{{ID: callID, Name: EncodeCustomToolName(namespace, name), Arguments: arguments}})})
case "reasoning":
// Codex/Desktop replays prior Responses reasoning items. WorkBuddy
// thinking mode requires that text back on the assistant turn as
Expand Down Expand Up @@ -579,14 +606,22 @@ func translateResponsesToolChoice(raw json.RawMessage) (json.RawMessage, error)
if err := json.Unmarshal(raw, &source); err != nil {
return nil, fmt.Errorf("tool_choice must be a string or an object")
}
if rawMapString(source, "type") != "function" {
switch rawMapString(source, "type") {
case "function":
name := rawMapString(source, "name")
if name == "" {
return nil, fmt.Errorf("tool_choice.name required")
}
return json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": name}})
case "custom":
name := rawMapString(source, "name")
if name == "" {
return nil, fmt.Errorf("tool_choice.name required")
}
return json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": EncodeCustomToolName(rawMapString(source, "namespace"), name)}})
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")
}
return json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": name}})
}

func responseReasoningEffort(raw json.RawMessage) json.RawMessage {
Expand Down
Loading
Loading