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:
Danny Kopping
2026-10-13 13:36:04 +02:00
committed by GitHub
parent ad85646164
commit 71e8e526fa
6 changed files with 266 additions and 6 deletions
+6
View File
@@ -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}
+10 -4
View File
@@ -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 != "" {