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
50 changes: 50 additions & 0 deletions core/providers/openai/tool_search_roundtrip_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package openai

import (
"fmt"
"testing"

"github.com/tidwall/gjson"
Expand Down Expand Up @@ -51,3 +52,52 @@ func TestResponsesInputRoundTripsToolSearchItems(t *testing.T) {
}
t.Logf("round-trip OK:\n%s", out)
}

// TestResponsesInputRoundTripsAdditionalToolsItems reproduces the codex
// first request for code-mode models (e.g. gpt-5.6-sol), whose
// `additional_tools` input item previously had its nested tools re-serialized
// through the mcp_list_tools shape — dropping every `tools[].type`
// discriminator and making OpenAI reject with "Missing required parameter:
// 'input[0].tools[0].type'" — and verifies the item now round-trips unchanged.
func TestResponsesInputRoundTripsAdditionalToolsItems(t *testing.T) {
input := []byte(`[
{"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"apply_patch","description":"Apply a patch"},{"type":"function","name":"shell","description":"Runs a shell command","parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}},{"type":"namespace","name":"repo_tools","description":"Repository helper tools","tools":[{"type":"function","name":"open_file","description":"Open a file","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}},{"type":"function","name":"list_files","description":"List files","parameters":{"type":"object","properties":{"dir":{"type":"string"}},"required":["dir"]}}]}]},
{"role":"user","content":[{"type":"input_text","text":"Reply exactly with OK."}]}
]`)

var in OpenAIResponsesRequestInput
if err := in.UnmarshalJSON(input); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if got := len(in.OpenAIResponsesRequestInputArray); got != 2 {
t.Fatalf("expected 2 input items, got %d", got)
}

out, err := in.MarshalJSON()
if err != nil {
t.Fatalf("marshal failed: %v", err)
}

// Every nested tools[].type must survive (OpenAI requires them).
for i, want := range []string{"custom", "function", "namespace"} {
if got := gjson.GetBytes(out, fmt.Sprintf("0.tools.%d.type", i)).String(); got != want {
t.Fatalf("additional_tools.tools[%d].type lost: %q (full: %s)", i, got, gjson.GetBytes(out, "0.tools").Raw)
}
}
// Function parameters and the namespace's nested tool list must survive.
if !gjson.GetBytes(out, "0.tools.1.parameters").IsObject() {
t.Fatalf("additional_tools function parameters lost: %s", gjson.GetBytes(out, "0.tools.1").Raw)
}
if got := gjson.GetBytes(out, "0.tools.2.tools.#").Int(); got != 2 {
t.Fatalf("additional_tools namespace nested tools lost: %s", gjson.GetBytes(out, "0.tools.2").Raw)
}
// The typed mcp_list_tools shape must not bleed in.
if gjson.GetBytes(out, "0.tools.0.input_schema").Exists() {
t.Fatalf("mcp_list_tools shape leaked into additional_tools: %s", gjson.GetBytes(out, "0.tools.0").Raw)
}
// The ordinary user message must still parse/serialize normally.
if got := gjson.GetBytes(out, "1.role").String(); got != "user" {
t.Fatalf("plain user message broke: %q", got)
}
t.Logf("round-trip OK:\n%s", out)
}
81 changes: 45 additions & 36 deletions core/schemas/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -1047,13 +1047,16 @@ const (
ResponsesMessageTypeItemReference ResponsesMessageType = "item_reference"
ResponsesMessageTypeRefusal ResponsesMessageType = "refusal"
ResponsesMessageTypeCompaction ResponsesMessageType = "compaction"
// Codex deferred-tool discovery (tool_search). OpenAI's Responses API
// supports these item types natively; Bifrost preserves them verbatim
// because its typed schema doesn't model them (the call's `arguments` is a
// JSON object — unlike function_call's string — and the output carries a
// `tools` array). See ResponsesMessage's (Un)MarshalJSON.
// Codex deferred-tool discovery (tool_search) and code-mode tool
// declarations (additional_tools). OpenAI's Responses API supports these
// item types natively; Bifrost preserves them verbatim because its typed
// schema doesn't model them (tool_search_call's `arguments` is a JSON
// object — unlike function_call's string — and tool_search_output /
// additional_tools carry `tools` arrays whose entries don't fit any typed
// tool shape). See ResponsesMessage's (Un)MarshalJSON.
ResponsesMessageTypeToolSearchCall ResponsesMessageType = "tool_search_call"
ResponsesMessageTypeToolSearchOutput ResponsesMessageType = "tool_search_output"
ResponsesMessageTypeAdditionalTools ResponsesMessageType = "additional_tools"
ResponsesMessageTypeAdvisorCall ResponsesMessageType = "advisor_call" // Anthropic advisor server tool (server_tool_use + advisor_tool_result)
)

Expand Down Expand Up @@ -1084,43 +1087,49 @@ type ResponsesMessage struct {
// gpt-oss models include only reasoning_text content blocks in a message, while other openai models include summaries+encrypted_content
*ResponsesReasoning

// rawToolSearch preserves codex `tool_search_call` / `tool_search_output`
// items verbatim. OpenAI's Responses API accepts these natively, but
// Bifrost's typed schema doesn't model them (the call's `arguments` is a
// JSON object — unlike function_call's string — and the output carries a
// `tools` array). Rather than fail to deserialize the whole input array or
// drop/mangle these items, we round-trip the original bytes unchanged.
// rawPreserved preserves codex `tool_search_call` / `tool_search_output` /
// `additional_tools` items verbatim. OpenAI's Responses API accepts these
// natively, but Bifrost's typed schema doesn't model them:
// tool_search_call's `arguments` is a JSON object — unlike function_call's
// string — and tool_search_output / additional_tools carry `tools` arrays
// whose entries (per-entry `type` discriminators, function parameters,
// nested namespace tool lists) don't fit any typed tool shape; a typed
// decode promotes them into the embedded mcp_list_tools fields and strips
// required fields. Rather than fail to deserialize the whole input array
// or drop/mangle these items, we round-trip the original bytes unchanged.
// Set by UnmarshalJSON, emitted by MarshalJSON; nil for every other type.
rawToolSearch []byte
rawPreserved []byte
}

// isToolSearchItem reports whether t is a codex tool_search item type, which
// Bifrost preserves verbatim rather than modelling field-by-field.
func isToolSearchItem(t string) bool {
// isRawPreservedItem reports whether t is an item type that Bifrost preserves
// verbatim rather than modelling field-by-field (see rawPreserved).
func isRawPreservedItem(t string) bool {
return t == string(ResponsesMessageTypeToolSearchCall) ||
t == string(ResponsesMessageTypeToolSearchOutput)
}

// UnmarshalJSON preserves codex tool_search items verbatim (see rawToolSearch)
// and otherwise normalizes function/tool-call arguments before decoding the rest
// of the item. OpenAI's Responses API serializes `function_call` `arguments` as
// a JSON string, but `tool_search_call` items serialize `arguments` as a JSON
// object — e.g. {} while in_progress and {"query":"...","limit":10} when
// completed. The embedded ResponsesToolMessage.Arguments field is a *string, so
// an object value makes a plain decode fail with "Mismatch type string with
// value object", which silently drops the item mid-stream and hangs streaming
// clients. We shadow `arguments` as raw JSON, decode everything else as usual,
// then store the canonical stringified form.
t == string(ResponsesMessageTypeToolSearchOutput) ||
t == string(ResponsesMessageTypeAdditionalTools)
}

// UnmarshalJSON preserves codex tool_search/additional_tools items verbatim
// (see rawPreserved) and otherwise normalizes function/tool-call arguments
// before decoding the rest of the item. OpenAI's Responses API serializes
// `function_call` `arguments` as a JSON string, but `tool_search_call` items
// serialize `arguments` as a JSON object — e.g. {} while in_progress and
// {"query":"...","limit":10} when completed. The embedded
// ResponsesToolMessage.Arguments field is a *string, so an object value makes
// a plain decode fail with "Mismatch type string with value object", which
// silently drops the item mid-stream and hangs streaming clients. We shadow
// `arguments` as raw JSON, decode everything else as usual, then store the
// canonical stringified form.
func (m *ResponsesMessage) UnmarshalJSON(data []byte) error {
// Clear the receiver first so a reused instance never retains a stale
// rawToolSearch (or other fields) from a prior decode — unmarshalling a
// non-tool-search payload must not leave preserved bytes that MarshalJSON
// rawPreserved (or other fields) from a prior decode — unmarshalling a
// non-preserved payload must not leave preserved bytes that MarshalJSON
// would then re-emit.
*m = ResponsesMessage{}
if t := gjson.GetBytes(data, "type").String(); isToolSearchItem(t) {
if t := gjson.GetBytes(data, "type").String(); isRawPreservedItem(t) {
mt := ResponsesMessageType(t)
m.Type = &mt
m.rawToolSearch = append([]byte(nil), data...)
m.rawPreserved = append([]byte(nil), data...)
return nil
}

Expand All @@ -1147,11 +1156,11 @@ func (m *ResponsesMessage) UnmarshalJSON(data []byte) error {
return nil
}

// MarshalJSON re-emits preserved tool_search items verbatim and defers every
// other item type to the default (sorted-key) struct encoding.
// MarshalJSON re-emits preserved items verbatim and defers every other item
// type to the default (sorted-key) struct encoding.
func (m ResponsesMessage) MarshalJSON() ([]byte, error) {
if m.rawToolSearch != nil {
return m.rawToolSearch, nil
if m.rawPreserved != nil {
return m.rawPreserved, nil
}
type alias ResponsesMessage
return MarshalSorted(alias(m))
Expand Down
41 changes: 40 additions & 1 deletion core/schemas/responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func TestResponsesMessageToolCallArguments(t *testing.T) {
// Codex's request (which enables the `tool_search` tool). These are the exact
// frames that triggered the production "Mismatch type string with value
// object" failure. tool_search items are preserved verbatim (see
// rawToolSearch), so the item must decode without error and re-encode
// rawPreserved), so the item must decode without error and re-encode
// byte-identically, object-form arguments included.
t.Run("real tool_search_call frames from openai", func(t *testing.T) {
items := map[string]string{
Expand Down Expand Up @@ -262,6 +262,45 @@ func TestResponsesMessageToolCallArguments(t *testing.T) {
})
}

// TestResponsesMessagePreservesAdditionalTools verifies that codex
// `additional_tools` input items (sent for code-mode models such as
// gpt-5.6-sol) round-trip byte-identically. These items carry a `tools` array
// whose entries have their own `type` discriminators (custom / function /
// namespace with nested tool lists); a typed decode promotes the array into
// the embedded mcp_list_tools fields and strips `type`, making OpenAI reject
// the forwarded request with "Missing required parameter:
// 'input[0].tools[0].type'".
func TestResponsesMessagePreservesAdditionalTools(t *testing.T) {
raw := `{"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"apply_patch","description":"Apply a patch"},{"type":"function","name":"shell","description":"Runs a shell command","parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}},{"type":"namespace","name":"repo_tools","description":"Repository helper tools","tools":[{"type":"function","name":"open_file","description":"Open a file","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}]}]}`

var msg ResponsesMessage
if err := Unmarshal([]byte(raw), &msg); err != nil {
t.Fatalf("unmarshal additional_tools item: %v", err)
}
if msg.Type == nil || *msg.Type != ResponsesMessageTypeAdditionalTools {
t.Fatalf("expected additional_tools item, got %#v", msg.Type)
}
encoded, err := MarshalSorted(msg)
if err != nil {
t.Fatalf("marshal preserved additional_tools item: %v", err)
}
if string(encoded) != raw {
t.Fatalf("expected item to round-trip verbatim\nwant: %s\ngot: %s", raw, encoded)
}

// A reused receiver must not leak preserved bytes into the next decode.
if err := Unmarshal([]byte(`{"type":"message","role":"user","content":"hi"}`), &msg); err != nil {
t.Fatalf("unmarshal follow-up message: %v", err)
}
encoded, err = MarshalSorted(msg)
if err != nil {
t.Fatalf("marshal follow-up message: %v", err)
}
if strings.Contains(string(encoded), "additional_tools") {
t.Fatalf("expected reused receiver to drop preserved bytes, got %s", encoded)
}
}

func TestResponsesMessagePreservesOpenAIPhase(t *testing.T) {
raw := []byte(`{"id":"msg_123","type":"message","status":"in_progress","content":[],"phase":"final_answer","role":"assistant"}`)

Expand Down
77 changes: 68 additions & 9 deletions ui/app/workspace/logs/sheets/logDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ const getResponsesRole = (msg: ResponsesMessage): MessageRole => {
msg.type &&
(msg.type.endsWith("_call") ||
msg.type.endsWith("_call_output") ||
msg.type === "tool_search_output" ||
Comment thread
greptile-apps[bot] marked this conversation as resolved.
msg.type === "additional_tools" ||
msg.type === "mcp_list_tools" ||
msg.type === "mcp_approval_request" ||
msg.type === "mcp_approval_responses")
Expand All @@ -261,6 +263,37 @@ const isReasoningResponsesMessage = (m: ResponsesMessage): boolean => m.type ===
// Streaming providers can emit a single logical assistant turn (or reasoning
// item) as many small messages. Collapse adjacent ones so the UI shows one
// bubble per turn instead of N "1 line" bubbles.
// Expands namespace tool declarations into their callable children
// (`namespace.tool` names), leaving plain declarations untouched.
const flattenDeclaredTools = (tools: any[]): any[] =>
tools.flatMap((tool) =>
tool?.type === "namespace" && Array.isArray(tool.tools)
? (tool.tools as any[]).map((nested) => ({ ...nested, name: `${tool.name ?? "namespace"}.${nested?.name ?? ""}` }))
: [tool],
);

// Later declarations of the same tool replace earlier ones — a conversation
// history can carry multiple `additional_tools` items, each a point-in-time
// update, so the effective tool set is last-write-wins by name. Unnamed
// declarations are kept as-is.
const dedupeDeclaredTools = (tools: any[]): any[] => {
const byName = new Map<string, number>();
const out: any[] = [];
for (const tool of tools) {
const name = tool?.name ?? tool?.function?.name;
if (typeof name === "string" && name) {
const idx = byName.get(name);
if (idx !== undefined) {
out[idx] = tool;
continue;
}
byName.set(name, out.length);
}
out.push(tool);
}
return out;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
};

const coalesceResponsesMessages = (msgs: ResponsesMessage[]): ResponsesMessage[] => {
const out: ResponsesMessage[] = [];
for (const m of msgs) {
Expand Down Expand Up @@ -621,10 +654,18 @@ export function LogDetailView({
})
: null;

// Tools can also be declared inside Responses input items instead of the
// top-level tools param (codex code-mode models send an `additional_tools`
// input item with the tool definitions, including namespace groupings).
const inputDeclaredToolEntries = (log.responses_input_history ?? [])
.filter((m) => m.type === "additional_tools" && Array.isArray(m.tools))
.flatMap((m) => m.tools as any[]);
const inputDeclaredTools = flattenDeclaredTools(inputDeclaredToolEntries);
const declaredTools = dedupeDeclaredTools([...flattenDeclaredTools((log.params?.tools as any[]) ?? []), ...inputDeclaredTools]);
let toolsParameter = null;
if (log.params?.tools) {
if (declaredTools.length) {
try {
toolsParameter = JSON.stringify(log.params.tools, null, 2);
toolsParameter = JSON.stringify(declaredTools, null, 2);
} catch {}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down Expand Up @@ -901,7 +942,7 @@ export function LogDetailView({
) : (
<HeroStat
label="Tools available"
value={(log.params?.tools?.length ?? 0).toString()}
value={declaredTools.length.toString()}
sub={(log.params as any)?.tool_choice != null ? `choice: ${formatToolChoice((log.params as any).tool_choice)}` : ""}
/>
)}
Expand Down Expand Up @@ -1614,9 +1655,9 @@ export function LogDetailView({
{showTabs && !isPassthrough && !log.list_models_output && (
<TabsTrigger value="tools" className="px-3">
Tools
{log.params?.tools?.length ? (
{declaredTools.length ? (
<span className="bg-background text-muted-foreground ml-1.5 rounded-sm border px-2 py-0.5 text-[10px] tabular-nums">
{log.params.tools.length}
{declaredTools.length}
</span>
) : null}
</TabsTrigger>
Expand Down Expand Up @@ -2065,7 +2106,14 @@ export function LogDetailView({
? msg.name
: msg.type === "function_call_output" && msg.call_id
? msg.call_id
: msg.type || undefined;
: Array.isArray(msg.tools)
? (() => {
const callable = flattenDeclaredTools(msg.tools).length;
return callable !== msg.tools.length
? `${msg.type} · ${msg.tools.length} declarations · ${callable} callable tools`
: `${msg.type} · ${msg.tools.length} tool${msg.tools.length === 1 ? "" : "s"}`;
})()
: msg.type || undefined;
}
const usePlainText = role === "user" || role === "assistant";
return (
Expand Down Expand Up @@ -2113,6 +2161,10 @@ export function LogDetailView({
text={typeof msg.output === "string" ? msg.output : JSON.stringify(msg.output, null, 2)}
preview={3}
/>
) : Array.isArray(msg.tools) && msg.tools.length > 0 ? (
<CollapsibleCode text={JSON.stringify(msg.tools, null, 2)} preview={3} />
) : Array.isArray(msg.tools) ? (
<div className="text-muted-foreground text-[12px] italic">No tools declared</div>
) : (
<div className="text-muted-foreground text-[12px] italic">No content</div>
)}
Expand Down Expand Up @@ -2240,7 +2292,14 @@ export function LogDetailView({
{toolsParameter ? (
<div className="bg-card rounded-sm border p-5">
<div className="text-muted-foreground mb-3 text-[12px]">
{log.params?.tools?.length ?? 0} tools exposed to the model
{declaredTools.length} tools exposed to the model
{inputDeclaredTools.length ? (
<>
{" "}
· {inputDeclaredTools.length} via input items
{inputDeclaredTools.length !== inputDeclaredToolEntries.length ? " (namespaces expanded)" : ""}
</>
) : null}
{(log.params as any)?.tool_choice != null ? (
<>
{" "}
Expand All @@ -2250,8 +2309,8 @@ export function LogDetailView({
) : null}
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{(log.params?.tools as any[]).map((tool, i) => {
const name = tool?.function?.name ?? tool?.name ?? `tool_${i}`;
{declaredTools.map((tool, i) => {
const name = tool?.name ?? tool?.function?.name ?? `tool_${i}`;
const description = tool?.function?.description ?? tool?.description ?? "";
const schema = tool?.function?.parameters ?? tool?.input_schema ?? tool?.parameters ?? null;
const schemaJson = schema != null ? JSON.stringify(schema, null, 2) : "";
Expand Down
5 changes: 4 additions & 1 deletion ui/lib/types/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,10 @@ export type ResponsesMessageType =
| "mcp_approval_responses"
| "reasoning"
| "item_reference"
| "refusal";
| "refusal"
| "tool_search_call"
| "tool_search_output"
| "additional_tools";
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Content block types for responses
export type ResponsesMessageContentBlockType =
Expand Down
Loading