mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
fix(aibridge): correlate Codex custom tool results to their AI Gateway thread (#30667)
Codex sends the results of custom (freeform) tools such as `apply_patch` back as `custom_tool_call_output` input items. `RequestPayload.correlatingToolCallID` only matched `function_call_output`, so these follow-up interceptions had no correlating tool call ID. They were not linked to the interception that issued the tool call, which left blank AI Gateway sessions. [Example session](https://dogfood.cdr.dev/ai-gateway/sessions/01a1184a-08a4-7d50-8e15-a12368302a2f) <img width="996" height="576" alt="image" src="https://github.com/user-attachments/assets/5bce6035-963b-4438-897b-bee36ead5a47" /> `custom_tool_call_output` is now handled the same way as `function_call_output`. The earlier `custom_tool_call` is already recorded as a tool usage with its `call_id`, and `GetAIBridgeInterceptionLineageByToolCallID` does not check the tool type, so lineage now resolves without any other change. Fixes AIGOV-707 <details> <summary>Implementation notes</summary> - **Reproduction first:** the first commit adds blocking and streaming `custom_tool_result.txtar` fixtures. Each one is a follow-up turn that replays a `custom_tool_call` and ends with its `custom_tool_call_output`. `TestResponsesOutputMatchesUpstream` now checks `CorrelatingToolCallID` on every case, and `TestCorrelatingToolCallID` gains a `custom_tool_call_output` case plus a case where the last item is the tool call itself rather than its result. On that commit, both fixture cases and the unit case fail with `nil` instead of the custom tool's call ID. The second commit makes them pass. - **Out of scope:** other client-executed result types (`computer_call_output`, `shell_call_output`, `apply_patch_call_output`, `local_shell_call_output`) are not correlated either. Codex's `apply_patch` arrives as a custom tool, so it is covered here. Those other types were left out to keep this change tied to the reported bug. </details> > Generated by Coder Agents on behalf of @dannykopping.
This commit is contained in:
@@ -106,6 +106,9 @@ var (
|
||||
//go:embed openai/responses/blocking/custom_tool.txtar
|
||||
OaiResponsesBlockingCustomTool []byte
|
||||
|
||||
//go:embed openai/responses/blocking/custom_tool_result.txtar
|
||||
OaiResponsesBlockingCustomToolResult []byte
|
||||
|
||||
//go:embed openai/responses/blocking/web_search.txtar
|
||||
OaiResponsesBlockingWebSearch []byte
|
||||
|
||||
@@ -159,6 +162,9 @@ var (
|
||||
//go:embed openai/responses/streaming/custom_tool.txtar
|
||||
OaiResponsesStreamingCustomTool []byte
|
||||
|
||||
//go:embed openai/responses/streaming/custom_tool_result.txtar
|
||||
OaiResponsesStreamingCustomToolResult []byte
|
||||
|
||||
//go:embed openai/responses/streaming/conversation.txtar
|
||||
OaiResponsesStreamingConversation []byte
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
Follow-up turn after a custom (freeform) tool call. The last input item is a
|
||||
custom_tool_call_output, as sent by Codex after it executes a custom tool such
|
||||
as apply_patch.
|
||||
|
||||
-- request --
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": "Use the code_exec tool to print hello world to the console."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_haf8njtwrVZ1754Gm6fjAtuA",
|
||||
"name": "code_exec",
|
||||
"input": "print(\"hello world\")"
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_haf8njtwrVZ1754Gm6fjAtuA",
|
||||
"output": "hello world"
|
||||
}
|
||||
],
|
||||
"model": "gpt-5",
|
||||
"tools": [
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "code_exec",
|
||||
"description": "Executes arbitrary Python code."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
-- non-streaming --
|
||||
{
|
||||
"id": "resp_09c614364030cdf00069694260a1b281a0b3c2d1e0f9a8b7c6",
|
||||
"object": "response",
|
||||
"created_at": 1768505950,
|
||||
"status": "completed",
|
||||
"background": false,
|
||||
"billing": {
|
||||
"payer": "developer"
|
||||
},
|
||||
"completed_at": 1768505952,
|
||||
"error": null,
|
||||
"frequency_penalty": 0.0,
|
||||
"incomplete_details": null,
|
||||
"instructions": null,
|
||||
"max_output_tokens": null,
|
||||
"max_tool_calls": null,
|
||||
"model": "gpt-5-2025-08-07",
|
||||
"output": [
|
||||
{
|
||||
"id": "msg_09c614364030cdf00069694261c3d481a0a1b2c3d4e5f6a7b8",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"annotations": [],
|
||||
"logprobs": [],
|
||||
"text": "The code printed \"hello world\" to the console."
|
||||
}
|
||||
],
|
||||
"role": "assistant"
|
||||
}
|
||||
],
|
||||
"parallel_tool_calls": true,
|
||||
"presence_penalty": 0.0,
|
||||
"previous_response_id": null,
|
||||
"prompt_cache_key": null,
|
||||
"prompt_cache_retention": null,
|
||||
"reasoning": {
|
||||
"effort": "medium",
|
||||
"summary": null
|
||||
},
|
||||
"safety_identifier": null,
|
||||
"service_tier": "default",
|
||||
"store": true,
|
||||
"temperature": 1.0,
|
||||
"text": {
|
||||
"format": {
|
||||
"type": "text"
|
||||
},
|
||||
"verbosity": "medium"
|
||||
},
|
||||
"tool_choice": "auto",
|
||||
"tools": [
|
||||
{
|
||||
"type": "custom",
|
||||
"description": "Executes arbitrary Python code.",
|
||||
"format": {
|
||||
"type": "text"
|
||||
},
|
||||
"name": "code_exec"
|
||||
}
|
||||
],
|
||||
"top_logprobs": 0,
|
||||
"top_p": 1.0,
|
||||
"truncation": "disabled",
|
||||
"usage": {
|
||||
"input_tokens": 112,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
},
|
||||
"output_tokens": 14,
|
||||
"output_tokens_details": {
|
||||
"reasoning_tokens": 0
|
||||
},
|
||||
"total_tokens": 126
|
||||
},
|
||||
"user": null,
|
||||
"metadata": {}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
Follow-up turn after a custom (freeform) tool call. The last input item is a
|
||||
custom_tool_call_output, as sent by Codex after it executes a custom tool such
|
||||
as apply_patch.
|
||||
|
||||
-- request --
|
||||
{
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": "Use the code_exec tool to print hello world to the console."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_2gSnF58IEhXLwlbnqbm5XKMd",
|
||||
"name": "code_exec",
|
||||
"input": "print(\"hello world\")"
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_2gSnF58IEhXLwlbnqbm5XKMd",
|
||||
"output": "hello world"
|
||||
}
|
||||
],
|
||||
"model": "gpt-5",
|
||||
"stream": true,
|
||||
"tools": [
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "code_exec",
|
||||
"description": "Executes arbitrary Python code."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
-- streaming --
|
||||
event: response.created
|
||||
data: {"type":"response.created","response":{"id":"resp_0c26996bc41c2a0500696942f1a2b3819fc4d5e6f7a8b9c0d1","object":"response","created_at":1768506100,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-2025-08-07","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"custom","description":"Executes arbitrary Python code.","format":{"type":"text"},"name":"code_exec"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0}
|
||||
|
||||
event: response.in_progress
|
||||
data: {"type":"response.in_progress","response":{"id":"resp_0c26996bc41c2a0500696942f1a2b3819fc4d5e6f7a8b9c0d1","object":"response","created_at":1768506100,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-2025-08-07","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"custom","description":"Executes arbitrary Python code.","format":{"type":"text"},"name":"code_exec"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1}
|
||||
|
||||
event: response.output_item.added
|
||||
data: {"type":"response.output_item.added","item":{"id":"msg_0c26996bc41c2a0500696942f2b3c4819fd5e6f7a8b9c0d1e2","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2}
|
||||
|
||||
event: response.content_part.added
|
||||
data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0c26996bc41c2a0500696942f2b3c4819fd5e6f7a8b9c0d1e2","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3}
|
||||
|
||||
event: response.output_text.delta
|
||||
data: {"type":"response.output_text.delta","content_index":0,"delta":"The code printed","item_id":"msg_0c26996bc41c2a0500696942f2b3c4819fd5e6f7a8b9c0d1e2","logprobs":[],"output_index":0,"sequence_number":4}
|
||||
|
||||
event: response.output_text.delta
|
||||
data: {"type":"response.output_text.delta","content_index":0,"delta":" \"hello world\"","item_id":"msg_0c26996bc41c2a0500696942f2b3c4819fd5e6f7a8b9c0d1e2","logprobs":[],"output_index":0,"sequence_number":5}
|
||||
|
||||
event: response.output_text.delta
|
||||
data: {"type":"response.output_text.delta","content_index":0,"delta":" to the console.","item_id":"msg_0c26996bc41c2a0500696942f2b3c4819fd5e6f7a8b9c0d1e2","logprobs":[],"output_index":0,"sequence_number":6}
|
||||
|
||||
event: response.output_text.done
|
||||
data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0c26996bc41c2a0500696942f2b3c4819fd5e6f7a8b9c0d1e2","logprobs":[],"output_index":0,"text":"The code printed \"hello world\" to the console.","sequence_number":7}
|
||||
|
||||
event: response.content_part.done
|
||||
data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0c26996bc41c2a0500696942f2b3c4819fd5e6f7a8b9c0d1e2","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"The code printed \"hello world\" to the console."},"sequence_number":8}
|
||||
|
||||
event: response.output_item.done
|
||||
data: {"type":"response.output_item.done","item":{"id":"msg_0c26996bc41c2a0500696942f2b3c4819fd5e6f7a8b9c0d1e2","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The code printed \"hello world\" to the console."}],"role":"assistant"},"output_index":0,"sequence_number":9}
|
||||
|
||||
event: response.completed
|
||||
data: {"type":"response.completed","response":{"id":"resp_0c26996bc41c2a0500696942f1a2b3819fc4d5e6f7a8b9c0d1","object":"response","created_at":1768506100,"status":"completed","background":false,"completed_at":1768506102,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-2025-08-07","output":[{"id":"msg_0c26996bc41c2a0500696942f2b3c4819fd5e6f7a8b9c0d1e2","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The code printed \"hello world\" to the console."}],"role":"assistant"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"custom","description":"Executes arbitrary Python code.","format":{"type":"text"},"name":"code_exec"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":112,"input_tokens_details":{"cached_tokens":0},"output_tokens":14,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":126},"user":null,"metadata":{}},"sequence_number":10}
|
||||
|
||||
@@ -27,9 +27,10 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
constFunctionCallOutput = string(constant.ValueOf[constant.FunctionCallOutput]())
|
||||
constInputText = string(constant.ValueOf[constant.InputText]())
|
||||
constUser = string(constant.ValueOf[constant.User]())
|
||||
constCustomToolCallOutput = string(constant.ValueOf[constant.CustomToolCallOutput]())
|
||||
constFunctionCallOutput = string(constant.ValueOf[constant.FunctionCallOutput]())
|
||||
constInputText = string(constant.ValueOf[constant.InputText]())
|
||||
constUser = string(constant.ValueOf[constant.User]())
|
||||
|
||||
reqPathContent = string(constant.ValueOf[constant.Content]())
|
||||
reqPathModel = string(constant.ValueOf[constant.Model]())
|
||||
@@ -78,8 +79,13 @@ func (p RequestPayload) correlatingToolCallID() *string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Both function and custom (freeform) tool results reference the
|
||||
// originating tool call by call_id. Codex sends custom_tool_call_output
|
||||
// for custom tools such as apply_patch.
|
||||
last := arr[len(arr)-1]
|
||||
if last.Get(reqPathType).String() != constFunctionCallOutput {
|
||||
switch last.Get(reqPathType).String() {
|
||||
case constFunctionCallOutput, constCustomToolCallOutput:
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,15 @@ func TestCorrelatingToolCallID(t *testing.T) {
|
||||
payload: []byte(`{"model":"gpt-4o","input":[{"type":"function_call_output","call_id":"call_first","output":"r1"},{"role":"user","content":"hi"},{"type":"function_call_output","call_id":"call_second","output":"r2"}]}`),
|
||||
wantCall: new("call_second"),
|
||||
},
|
||||
{
|
||||
name: "custom_tool_call_output",
|
||||
payload: []byte(`{"model":"gpt-5","input":[{"role":"user","content":"hi"},{"type":"custom_tool_call","call_id":"call_custom","name":"apply_patch","input":"*** Begin Patch"},{"type":"custom_tool_call_output","call_id":"call_custom","output":"Done!"}]}`),
|
||||
wantCall: new("call_custom"),
|
||||
},
|
||||
{
|
||||
name: "last input is a tool call, not a tool result",
|
||||
payload: []byte(`{"model":"gpt-5","input":[{"type":"custom_tool_call","call_id":"call_custom","name":"apply_patch","input":"*** Begin Patch"}]}`),
|
||||
},
|
||||
{
|
||||
name: "last input is not a tool result",
|
||||
payload: []byte(`{"model":"gpt-4o","input":[{"type":"function_call_output","call_id":"call_first","output":"r1"},{"role":"user","content":"hi"}]}`),
|
||||
|
||||
@@ -45,8 +45,11 @@ func TestResponsesOutputMatchesUpstream(t *testing.T) {
|
||||
expectPromptRecorded string
|
||||
expectToolRecorded *recorder.ToolUsageRecord
|
||||
expectTokenUsage *recorder.TokenUsageRecord
|
||||
userAgent string
|
||||
expectedClient aibclient.Type
|
||||
// expectCorrelatingToolCallID is the call_id of the tool result that
|
||||
// links this interception to the one that issued the tool call.
|
||||
expectCorrelatingToolCallID *string
|
||||
userAgent string
|
||||
expectedClient aibclient.Type
|
||||
}{
|
||||
{
|
||||
name: "blocking_simple",
|
||||
@@ -139,6 +142,26 @@ func TestResponsesOutputMatchesUpstream(t *testing.T) {
|
||||
},
|
||||
expectedClient: aibclient.Unknown,
|
||||
},
|
||||
{
|
||||
// Follow-up turn carrying a custom_tool_call_output must be
|
||||
// correlated to the custom_tool_call recorded in the prior turn.
|
||||
name: "blocking_custom_tool_result",
|
||||
fixture: fixtures.OaiResponsesBlockingCustomToolResult,
|
||||
expectModel: "gpt-5",
|
||||
expectCorrelatingToolCallID: new("call_haf8njtwrVZ1754Gm6fjAtuA"),
|
||||
expectTokenUsage: &recorder.TokenUsageRecord{
|
||||
MsgID: "resp_09c614364030cdf00069694260a1b281a0b3c2d1e0f9a8b7c6",
|
||||
ProviderModel: "gpt-5-2025-08-07",
|
||||
Input: 112,
|
||||
Output: 14,
|
||||
ExtraTokenTypes: map[string]int64{
|
||||
"output_reasoning": 0,
|
||||
"total_tokens": 126,
|
||||
},
|
||||
Metadata: recorder.Metadata{recorder.MetadataKeyServiceTier: "default"},
|
||||
},
|
||||
expectedClient: aibclient.Unknown,
|
||||
},
|
||||
{
|
||||
// web_search_call is a hosted tool executed server-side by the
|
||||
// provider. It carries an item id but no call_id, so the recorded
|
||||
@@ -319,6 +342,27 @@ func TestResponsesOutputMatchesUpstream(t *testing.T) {
|
||||
},
|
||||
expectedClient: aibclient.Unknown,
|
||||
},
|
||||
{
|
||||
// Follow-up turn carrying a custom_tool_call_output must be
|
||||
// correlated to the custom_tool_call recorded in the prior turn.
|
||||
name: "streaming_custom_tool_result",
|
||||
fixture: fixtures.OaiResponsesStreamingCustomToolResult,
|
||||
streaming: true,
|
||||
expectModel: "gpt-5",
|
||||
expectCorrelatingToolCallID: new("call_2gSnF58IEhXLwlbnqbm5XKMd"),
|
||||
expectTokenUsage: &recorder.TokenUsageRecord{
|
||||
MsgID: "resp_0c26996bc41c2a0500696942f1a2b3819fc4d5e6f7a8b9c0d1",
|
||||
ProviderModel: "gpt-5-2025-08-07",
|
||||
Input: 112,
|
||||
Output: 14,
|
||||
ExtraTokenTypes: map[string]int64{
|
||||
"output_reasoning": 0,
|
||||
"total_tokens": 126,
|
||||
},
|
||||
Metadata: recorder.Metadata{recorder.MetadataKeyServiceTier: "default"},
|
||||
},
|
||||
expectedClient: aibclient.Unknown,
|
||||
},
|
||||
{
|
||||
// web_search_call is a hosted tool executed server-side by the
|
||||
// provider. It carries an item id but no call_id, so the recorded
|
||||
@@ -454,6 +498,7 @@ func TestResponsesOutputMatchesUpstream(t *testing.T) {
|
||||
require.Equal(t, intc.Model, tc.expectModel)
|
||||
require.Equal(t, tc.userAgent, intc.UserAgent)
|
||||
require.Equal(t, string(tc.expectedClient), intc.Client)
|
||||
require.Equal(t, tc.expectCorrelatingToolCallID, intc.CorrelatingToolCallID)
|
||||
|
||||
recordedPrompts := bridgeServer.Recorder.RecordedPromptUsages()
|
||||
if tc.expectPromptRecorded != "" {
|
||||
|
||||
Reference in New Issue
Block a user