icebear icebear0828 Claude Opus 4.6 commited on
Commit
ab2754a
·
unverified ·
1 Parent(s): 0be9bdf

feat: native tool calling support across all protocols (#8)

Browse files

* feat: native tool calling support across all protocols

- Fix 500 errors: wrap c.req.json() in try-catch for all 3 routes,
returning proper 400 responses for malformed JSON
- Forward tool definitions to Codex API instead of sending tools: []
- Parse function call SSE events (output_item.added,
function_call_arguments.delta/done) with item_id→call_id mapping
- Translate tool calls back to each protocol's format:
OpenAI: streaming tool_calls chunks + finish_reason "tool_calls"
Anthropic: tool_use content blocks + stop_reason "tool_use"
Gemini: functionCall parts in candidates
- Convert tool results from each protocol to native
function_call_output input items
- New shared module: tool-format.ts for cross-protocol tool conversion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review issues in native tool calling

- Fix Gemini call_id collision: use counter-based IDs with name→callId
correlation instead of fragile fc_${name} pattern
- Remove dead code: pendingFunctionCalls (streaming Gemini),
toolCallArgsMap (non-streaming OpenAI & Anthropic)
- Fix Anthropic streaming text delta hardcoded index: use contentIndex
and reopen text block if text arrives after tool calls
- Unify GeminiPart type: add functionResponse to types/gemini.ts,
remove duplicate local type from gemini-to-codex.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

src/proxy/codex-api.ts CHANGED
@@ -27,6 +27,8 @@ export interface CodexResponsesRequest {
27
  reasoning?: { effort: string };
28
  /** Optional: tools available to the model */
29
  tools?: unknown[];
 
 
30
  /** Optional: previous response ID for multi-turn */
31
  previous_response_id?: string | null;
32
  }
@@ -34,7 +36,9 @@ export interface CodexResponsesRequest {
34
  export type CodexInputItem =
35
  | { role: "user"; content: string }
36
  | { role: "assistant"; content: string }
37
- | { role: "system"; content: string };
 
 
38
 
39
  /** Parsed SSE event from the Codex Responses stream */
40
  export interface CodexSSEEvent {
 
27
  reasoning?: { effort: string };
28
  /** Optional: tools available to the model */
29
  tools?: unknown[];
30
+ /** Optional: tool choice strategy */
31
+ tool_choice?: string | { type: string; name: string };
32
  /** Optional: previous response ID for multi-turn */
33
  previous_response_id?: string | null;
34
  }
 
36
  export type CodexInputItem =
37
  | { role: "user"; content: string }
38
  | { role: "assistant"; content: string }
39
+ | { role: "system"; content: string }
40
+ | { type: "function_call"; id?: string; call_id: string; name: string; arguments: string }
41
+ | { type: "function_call_output"; call_id: string; output: string };
42
 
43
  /** Parsed SSE event from the Codex Responses stream */
44
  export interface CodexSSEEvent {
src/routes/chat.ts CHANGED
@@ -103,7 +103,6 @@ export function createChatRoutes(
103
  },
104
  });
105
  }
106
-
107
  const parsed = ChatCompletionRequestSchema.safeParse(body);
108
  if (!parsed.success) {
109
  c.status(400);
 
103
  },
104
  });
105
  }
 
106
  const parsed = ChatCompletionRequestSchema.safeParse(body);
107
  if (!parsed.success) {
108
  c.status(400);
src/routes/gemini.ts CHANGED
@@ -127,7 +127,13 @@ export function createGeminiRoutes(
127
  }
128
 
129
  // Parse request
130
- const body = await c.req.json();
 
 
 
 
 
 
131
  const validationResult = GeminiGenerateContentRequestSchema.safeParse(body);
132
  if (!validationResult.success) {
133
  c.status(400);
 
127
  }
128
 
129
  // Parse request
130
+ let body: unknown;
131
+ try {
132
+ body = await c.req.json();
133
+ } catch {
134
+ c.status(400);
135
+ return c.json(makeError(400, "Invalid JSON in request body"));
136
+ }
137
  const validationResult = GeminiGenerateContentRequestSchema.safeParse(body);
138
  if (!validationResult.success) {
139
  c.status(400);
src/routes/messages.ts CHANGED
@@ -86,7 +86,15 @@ export function createMessagesRoutes(
86
  }
87
 
88
  // Parse request
89
- const body = await c.req.json();
 
 
 
 
 
 
 
 
90
  const parsed = AnthropicMessagesRequestSchema.safeParse(body);
91
  if (!parsed.success) {
92
  c.status(400);
 
86
  }
87
 
88
  // Parse request
89
+ let body: unknown;
90
+ try {
91
+ body = await c.req.json();
92
+ } catch {
93
+ c.status(400);
94
+ return c.json(
95
+ makeError("invalid_request_error", "Invalid JSON in request body"),
96
+ );
97
+ }
98
  const parsed = AnthropicMessagesRequestSchema.safeParse(body);
99
  if (!parsed.success) {
100
  c.status(400);
src/translation/anthropic-to-codex.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
  import { resolveModelId, getModelInfo } from "../routes/models.js";
11
  import { getConfig } from "../config.js";
12
  import { buildInstructions, budgetToEffort } from "./shared-utils.js";
 
13
 
14
  /**
15
  * Map Anthropic thinking budget_tokens to Codex reasoning effort.
@@ -22,43 +23,77 @@ function mapThinkingToEffort(
22
  }
23
 
24
  /**
25
- * Extract text from Anthropic content (string or content block array).
26
- * Flattens tool_use/tool_result blocks into readable text for Codex.
27
  */
28
- function flattenContent(
29
  content: string | Array<Record<string, unknown>>,
30
  ): string {
31
  if (typeof content === "string") return content;
32
- const parts: string[] = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  for (const block of content) {
34
- if (block.type === "text" && typeof block.text === "string") {
35
- parts.push(block.text);
36
- } else if (block.type === "tool_use") {
37
  const name = typeof block.name === "string" ? block.name : "unknown";
38
- let inputStr: string;
 
39
  try {
40
- inputStr = JSON.stringify(block.input, null, 2);
41
  } catch {
42
- inputStr = String(block.input);
43
  }
44
- parts.push(`[Tool Call: ${name}(${inputStr})]`);
 
 
 
 
 
45
  } else if (block.type === "tool_result") {
46
- const id =
47
- typeof block.tool_use_id === "string" ? block.tool_use_id : "unknown";
48
- let text = "";
49
  if (typeof block.content === "string") {
50
- text = block.content;
51
  } else if (Array.isArray(block.content)) {
52
- text = (block.content as Array<{ text?: string }>)
53
  .filter((b) => typeof b.text === "string")
54
  .map((b) => b.text!)
55
  .join("\n");
56
  }
57
- const prefix = block.is_error ? "Tool Error" : "Tool Result";
58
- parts.push(`[${prefix} (${id})]: ${text}`);
 
 
 
 
 
 
59
  }
60
  }
61
- return parts.join("\n");
 
62
  }
63
 
64
  /**
@@ -90,10 +125,11 @@ export function translateAnthropicToCodexRequest(
90
  // Build input items from messages
91
  const input: CodexInputItem[] = [];
92
  for (const msg of req.messages) {
93
- input.push({
94
- role: msg.role as "user" | "assistant",
95
- content: flattenContent(msg.content),
96
- });
 
97
  }
98
 
99
  // Ensure at least one input message
@@ -106,6 +142,10 @@ export function translateAnthropicToCodexRequest(
106
  const modelInfo = getModelInfo(modelId);
107
  const config = getConfig();
108
 
 
 
 
 
109
  // Build request
110
  const request: CodexResponsesRequest = {
111
  model: modelId,
@@ -113,9 +153,14 @@ export function translateAnthropicToCodexRequest(
113
  input,
114
  stream: true,
115
  store: false,
116
- tools: [],
117
  };
118
 
 
 
 
 
 
119
  // Add previous response ID for multi-turn conversations
120
  if (previousResponseId) {
121
  request.previous_response_id = previousResponseId;
 
10
  import { resolveModelId, getModelInfo } from "../routes/models.js";
11
  import { getConfig } from "../config.js";
12
  import { buildInstructions, budgetToEffort } from "./shared-utils.js";
13
+ import { anthropicToolsToCodex, anthropicToolChoiceToCodex } from "./tool-format.js";
14
 
15
  /**
16
  * Map Anthropic thinking budget_tokens to Codex reasoning effort.
 
23
  }
24
 
25
  /**
26
+ * Extract text-only content from Anthropic blocks.
 
27
  */
28
+ function extractTextContent(
29
  content: string | Array<Record<string, unknown>>,
30
  ): string {
31
  if (typeof content === "string") return content;
32
+ return content
33
+ .filter((b) => b.type === "text" && typeof b.text === "string")
34
+ .map((b) => b.text as string)
35
+ .join("\n");
36
+ }
37
+
38
+ /**
39
+ * Convert Anthropic message content blocks into native Codex input items.
40
+ * Handles text, tool_use, and tool_result blocks.
41
+ */
42
+ function contentToInputItems(
43
+ role: "user" | "assistant",
44
+ content: string | Array<Record<string, unknown>>,
45
+ ): CodexInputItem[] {
46
+ if (typeof content === "string") {
47
+ return [{ role, content }];
48
+ }
49
+
50
+ const items: CodexInputItem[] = [];
51
+
52
+ // Collect text blocks first
53
+ const text = extractTextContent(content);
54
+ if (text || !content.some((b) => b.type === "tool_use" || b.type === "tool_result")) {
55
+ items.push({ role, content: text });
56
+ }
57
+
58
  for (const block of content) {
59
+ if (block.type === "tool_use") {
 
 
60
  const name = typeof block.name === "string" ? block.name : "unknown";
61
+ const id = typeof block.id === "string" ? block.id : `tc_${name}`;
62
+ let args: string;
63
  try {
64
+ args = JSON.stringify(block.input ?? {});
65
  } catch {
66
+ args = "{}";
67
  }
68
+ items.push({
69
+ type: "function_call",
70
+ call_id: id,
71
+ name,
72
+ arguments: args,
73
+ });
74
  } else if (block.type === "tool_result") {
75
+ const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "unknown";
76
+ let resultText = "";
 
77
  if (typeof block.content === "string") {
78
+ resultText = block.content;
79
  } else if (Array.isArray(block.content)) {
80
+ resultText = (block.content as Array<{ text?: string }>)
81
  .filter((b) => typeof b.text === "string")
82
  .map((b) => b.text!)
83
  .join("\n");
84
  }
85
+ if (block.is_error) {
86
+ resultText = `Error: ${resultText}`;
87
+ }
88
+ items.push({
89
+ type: "function_call_output",
90
+ call_id: toolUseId,
91
+ output: resultText,
92
+ });
93
  }
94
  }
95
+
96
+ return items;
97
  }
98
 
99
  /**
 
125
  // Build input items from messages
126
  const input: CodexInputItem[] = [];
127
  for (const msg of req.messages) {
128
+ const items = contentToInputItems(
129
+ msg.role as "user" | "assistant",
130
+ msg.content as string | Array<Record<string, unknown>>,
131
+ );
132
+ input.push(...items);
133
  }
134
 
135
  // Ensure at least one input message
 
142
  const modelInfo = getModelInfo(modelId);
143
  const config = getConfig();
144
 
145
+ // Convert tools to Codex format
146
+ const codexTools = req.tools?.length ? anthropicToolsToCodex(req.tools) : [];
147
+ const codexToolChoice = anthropicToolChoiceToCodex(req.tool_choice);
148
+
149
  // Build request
150
  const request: CodexResponsesRequest = {
151
  model: modelId,
 
153
  input,
154
  stream: true,
155
  store: false,
156
+ tools: codexTools,
157
  };
158
 
159
+ // Add tool_choice if specified
160
+ if (codexToolChoice) {
161
+ request.tool_choice = codexToolChoice;
162
+ }
163
+
164
  // Add previous response ID for multi-turn conversations
165
  if (previousResponseId) {
166
  request.previous_response_id = previousResponseId;
src/translation/codex-event-extractor.ts CHANGED
@@ -16,11 +16,31 @@ export interface UsageInfo {
16
  output_tokens: number;
17
  }
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  export interface ExtractedEvent {
20
  typed: TypedCodexEvent;
21
  responseId?: string;
22
  textDelta?: string;
23
  usage?: UsageInfo;
 
 
 
24
  }
25
 
26
  /**
@@ -31,6 +51,9 @@ export async function* iterateCodexEvents(
31
  codexApi: CodexApi,
32
  rawResponse: Response,
33
  ): AsyncGenerator<ExtractedEvent> {
 
 
 
34
  for await (const raw of codexApi.parseStream(rawResponse)) {
35
  const typed = parseCodexEvent(raw);
36
  const extracted: ExtractedEvent = { typed };
@@ -45,6 +68,42 @@ export async function* iterateCodexEvents(
45
  extracted.textDelta = typed.delta;
46
  break;
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  case "response.completed":
49
  if (typed.response.id) extracted.responseId = typed.response.id;
50
  if (typed.response.usage) extracted.usage = typed.response.usage;
 
16
  output_tokens: number;
17
  }
18
 
19
+ export interface FunctionCallStart {
20
+ callId: string;
21
+ name: string;
22
+ outputIndex: number;
23
+ }
24
+
25
+ export interface FunctionCallDelta {
26
+ callId: string;
27
+ delta: string;
28
+ }
29
+
30
+ export interface FunctionCallDone {
31
+ callId: string;
32
+ name: string;
33
+ arguments: string;
34
+ }
35
+
36
  export interface ExtractedEvent {
37
  typed: TypedCodexEvent;
38
  responseId?: string;
39
  textDelta?: string;
40
  usage?: UsageInfo;
41
+ functionCallStart?: FunctionCallStart;
42
+ functionCallDelta?: FunctionCallDelta;
43
+ functionCallDone?: FunctionCallDone;
44
  }
45
 
46
  /**
 
51
  codexApi: CodexApi,
52
  rawResponse: Response,
53
  ): AsyncGenerator<ExtractedEvent> {
54
+ // Map item_id → { call_id, name } for resolving delta/done events
55
+ const itemIdToCallInfo = new Map<string, { callId: string; name: string }>();
56
+
57
  for await (const raw of codexApi.parseStream(rawResponse)) {
58
  const typed = parseCodexEvent(raw);
59
  const extracted: ExtractedEvent = { typed };
 
68
  extracted.textDelta = typed.delta;
69
  break;
70
 
71
+ case "response.output_item.added":
72
+ if (typed.item.type === "function_call") {
73
+ // Register item_id → call_id mapping
74
+ itemIdToCallInfo.set(typed.item.id, {
75
+ callId: typed.item.call_id,
76
+ name: typed.item.name,
77
+ });
78
+ extracted.functionCallStart = {
79
+ callId: typed.item.call_id,
80
+ name: typed.item.name,
81
+ outputIndex: typed.outputIndex,
82
+ };
83
+ }
84
+ break;
85
+
86
+ case "response.function_call_arguments.delta": {
87
+ // Resolve item_id to call_id if needed
88
+ const deltaInfo = itemIdToCallInfo.get(typed.call_id);
89
+ extracted.functionCallDelta = {
90
+ callId: deltaInfo?.callId ?? typed.call_id,
91
+ delta: typed.delta,
92
+ };
93
+ break;
94
+ }
95
+
96
+ case "response.function_call_arguments.done": {
97
+ // Resolve item_id to call_id + name if needed
98
+ const doneInfo = itemIdToCallInfo.get(typed.call_id);
99
+ extracted.functionCallDone = {
100
+ callId: doneInfo?.callId ?? typed.call_id,
101
+ name: typed.name || doneInfo?.name || "",
102
+ arguments: typed.arguments,
103
+ };
104
+ break;
105
+ }
106
+
107
  case "response.completed":
108
  if (typed.response.id) extracted.responseId = typed.response.id;
109
  if (typed.response.usage) extracted.usage = typed.response.usage;
src/translation/codex-to-anthropic.ts CHANGED
@@ -12,6 +12,7 @@
12
  import { randomUUID } from "crypto";
13
  import type { CodexApi } from "../proxy/codex-api.js";
14
  import type {
 
15
  AnthropicMessagesResponse,
16
  AnthropicUsage,
17
  } from "../types/anthropic.js";
@@ -41,6 +42,10 @@ export async function* streamCodexToAnthropic(
41
  const msgId = `msg_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
42
  let outputTokens = 0;
43
  let inputTokens = 0;
 
 
 
 
44
 
45
  // 1. message_start
46
  yield formatSSE("message_start", {
@@ -60,20 +65,86 @@ export async function* streamCodexToAnthropic(
60
  // 2. content_block_start for text block at index 0
61
  yield formatSSE("content_block_start", {
62
  type: "content_block_start",
63
- index: 0,
64
  content_block: { type: "text", text: "" },
65
  });
 
66
 
67
  // 3. Process Codex stream events
68
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
69
  if (evt.responseId) onResponseId?.(evt.responseId);
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  switch (evt.typed.type) {
72
  case "response.output_text.delta": {
73
  if (evt.textDelta) {
 
 
 
 
 
 
 
 
 
74
  yield formatSSE("content_block_delta", {
75
  type: "content_block_delta",
76
- index: 0,
77
  delta: { type: "text_delta", text: evt.textDelta },
78
  });
79
  }
@@ -91,16 +162,18 @@ export async function* streamCodexToAnthropic(
91
  }
92
  }
93
 
94
- // 4. content_block_stop
95
- yield formatSSE("content_block_stop", {
96
- type: "content_block_stop",
97
- index: 0,
98
- });
 
 
99
 
100
  // 5. message_delta with stop_reason and usage
101
  yield formatSSE("message_delta", {
102
  type: "message_delta",
103
- delta: { stop_reason: "end_turn" },
104
  usage: { output_tokens: outputTokens },
105
  });
106
 
@@ -129,6 +202,9 @@ export async function collectCodexToAnthropicResponse(
129
  let outputTokens = 0;
130
  let responseId: string | null = null;
131
 
 
 
 
132
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
133
  if (evt.responseId) responseId = evt.responseId;
134
  if (evt.textDelta) fullText += evt.textDelta;
@@ -136,6 +212,29 @@ export async function collectCodexToAnthropicResponse(
136
  inputTokens = evt.usage.input_tokens;
137
  outputTokens = evt.usage.output_tokens;
138
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  }
140
 
141
  const usage: AnthropicUsage = {
@@ -148,9 +247,9 @@ export async function collectCodexToAnthropicResponse(
148
  id,
149
  type: "message",
150
  role: "assistant",
151
- content: [{ type: "text", text: fullText }],
152
  model,
153
- stop_reason: "end_turn",
154
  stop_sequence: null,
155
  usage,
156
  },
 
12
  import { randomUUID } from "crypto";
13
  import type { CodexApi } from "../proxy/codex-api.js";
14
  import type {
15
+ AnthropicContentBlock,
16
  AnthropicMessagesResponse,
17
  AnthropicUsage,
18
  } from "../types/anthropic.js";
 
42
  const msgId = `msg_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
43
  let outputTokens = 0;
44
  let inputTokens = 0;
45
+ let hasToolCalls = false;
46
+ let contentIndex = 0;
47
+ let textBlockStarted = false;
48
+ const callIdsWithDeltas = new Set<string>();
49
 
50
  // 1. message_start
51
  yield formatSSE("message_start", {
 
65
  // 2. content_block_start for text block at index 0
66
  yield formatSSE("content_block_start", {
67
  type: "content_block_start",
68
+ index: contentIndex,
69
  content_block: { type: "text", text: "" },
70
  });
71
+ textBlockStarted = true;
72
 
73
  // 3. Process Codex stream events
74
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
75
  if (evt.responseId) onResponseId?.(evt.responseId);
76
 
77
+ // Handle function call start → close text block, open tool_use block
78
+ if (evt.functionCallStart) {
79
+ hasToolCalls = true;
80
+
81
+ // Close text block if still open
82
+ if (textBlockStarted) {
83
+ yield formatSSE("content_block_stop", {
84
+ type: "content_block_stop",
85
+ index: contentIndex,
86
+ });
87
+ contentIndex++;
88
+ textBlockStarted = false;
89
+ }
90
+
91
+ // Start tool_use block
92
+ yield formatSSE("content_block_start", {
93
+ type: "content_block_start",
94
+ index: contentIndex,
95
+ content_block: {
96
+ type: "tool_use",
97
+ id: evt.functionCallStart.callId,
98
+ name: evt.functionCallStart.name,
99
+ input: {},
100
+ },
101
+ });
102
+ continue;
103
+ }
104
+
105
+ if (evt.functionCallDelta) {
106
+ callIdsWithDeltas.add(evt.functionCallDelta.callId);
107
+ yield formatSSE("content_block_delta", {
108
+ type: "content_block_delta",
109
+ index: contentIndex,
110
+ delta: { type: "input_json_delta", partial_json: evt.functionCallDelta.delta },
111
+ });
112
+ continue;
113
+ }
114
+
115
+ if (evt.functionCallDone) {
116
+ // Emit full arguments if no deltas were streamed
117
+ if (!callIdsWithDeltas.has(evt.functionCallDone.callId)) {
118
+ yield formatSSE("content_block_delta", {
119
+ type: "content_block_delta",
120
+ index: contentIndex,
121
+ delta: { type: "input_json_delta", partial_json: evt.functionCallDone.arguments },
122
+ });
123
+ }
124
+ // Close this tool_use block
125
+ yield formatSSE("content_block_stop", {
126
+ type: "content_block_stop",
127
+ index: contentIndex,
128
+ });
129
+ contentIndex++;
130
+ continue;
131
+ }
132
+
133
  switch (evt.typed.type) {
134
  case "response.output_text.delta": {
135
  if (evt.textDelta) {
136
+ // Reopen a text block if the previous one was closed (e.g. after tool calls)
137
+ if (!textBlockStarted) {
138
+ yield formatSSE("content_block_start", {
139
+ type: "content_block_start",
140
+ index: contentIndex,
141
+ content_block: { type: "text", text: "" },
142
+ });
143
+ textBlockStarted = true;
144
+ }
145
  yield formatSSE("content_block_delta", {
146
  type: "content_block_delta",
147
+ index: contentIndex,
148
  delta: { type: "text_delta", text: evt.textDelta },
149
  });
150
  }
 
162
  }
163
  }
164
 
165
+ // 4. Close text block if still open (no tool calls, or text came before tools)
166
+ if (textBlockStarted) {
167
+ yield formatSSE("content_block_stop", {
168
+ type: "content_block_stop",
169
+ index: contentIndex,
170
+ });
171
+ }
172
 
173
  // 5. message_delta with stop_reason and usage
174
  yield formatSSE("message_delta", {
175
  type: "message_delta",
176
+ delta: { stop_reason: hasToolCalls ? "tool_use" : "end_turn" },
177
  usage: { output_tokens: outputTokens },
178
  });
179
 
 
202
  let outputTokens = 0;
203
  let responseId: string | null = null;
204
 
205
+ // Collect tool calls
206
+ const toolUseBlocks: AnthropicContentBlock[] = [];
207
+
208
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
209
  if (evt.responseId) responseId = evt.responseId;
210
  if (evt.textDelta) fullText += evt.textDelta;
 
212
  inputTokens = evt.usage.input_tokens;
213
  outputTokens = evt.usage.output_tokens;
214
  }
215
+ if (evt.functionCallDone) {
216
+ let parsedInput: Record<string, unknown> = {};
217
+ try {
218
+ parsedInput = JSON.parse(evt.functionCallDone.arguments) as Record<string, unknown>;
219
+ } catch { /* use empty object */ }
220
+ toolUseBlocks.push({
221
+ type: "tool_use",
222
+ id: evt.functionCallDone.callId,
223
+ name: evt.functionCallDone.name,
224
+ input: parsedInput,
225
+ });
226
+ }
227
+ }
228
+
229
+ const hasToolCalls = toolUseBlocks.length > 0;
230
+ const content: AnthropicContentBlock[] = [];
231
+ if (fullText) {
232
+ content.push({ type: "text", text: fullText });
233
+ }
234
+ content.push(...toolUseBlocks);
235
+ // Ensure at least one content block
236
+ if (content.length === 0) {
237
+ content.push({ type: "text", text: "" });
238
  }
239
 
240
  const usage: AnthropicUsage = {
 
247
  id,
248
  type: "message",
249
  role: "assistant",
250
+ content,
251
  model,
252
+ stop_reason: hasToolCalls ? "tool_use" : "end_turn",
253
  stop_sequence: null,
254
  usage,
255
  },
src/translation/codex-to-gemini.ts CHANGED
@@ -13,6 +13,7 @@ import type { CodexApi } from "../proxy/codex-api.js";
13
  import type {
14
  GeminiGenerateContentResponse,
15
  GeminiUsageMetadata,
 
16
  } from "../types/gemini.js";
17
  import { iterateCodexEvents } from "./codex-event-extractor.js";
18
 
@@ -38,6 +39,33 @@ export async function* streamCodexToGemini(
38
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
39
  if (evt.responseId) onResponseId?.(evt.responseId);
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  switch (evt.typed.type) {
42
  case "response.output_text.delta": {
43
  if (evt.textDelta) {
@@ -108,6 +136,7 @@ export async function collectCodexToGeminiResponse(
108
  let inputTokens = 0;
109
  let outputTokens = 0;
110
  let responseId: string | null = null;
 
111
 
112
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
113
  if (evt.responseId) responseId = evt.responseId;
@@ -116,6 +145,15 @@ export async function collectCodexToGeminiResponse(
116
  inputTokens = evt.usage.input_tokens;
117
  outputTokens = evt.usage.output_tokens;
118
  }
 
 
 
 
 
 
 
 
 
119
  }
120
 
121
  const usage: GeminiUsageInfo = {
@@ -129,12 +167,22 @@ export async function collectCodexToGeminiResponse(
129
  totalTokenCount: inputTokens + outputTokens,
130
  };
131
 
 
 
 
 
 
 
 
 
 
 
132
  return {
133
  response: {
134
  candidates: [
135
  {
136
  content: {
137
- parts: [{ text: fullText }],
138
  role: "model",
139
  },
140
  finishReason: "STOP",
 
13
  import type {
14
  GeminiGenerateContentResponse,
15
  GeminiUsageMetadata,
16
+ GeminiPart,
17
  } from "../types/gemini.js";
18
  import { iterateCodexEvents } from "./codex-event-extractor.js";
19
 
 
39
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
40
  if (evt.responseId) onResponseId?.(evt.responseId);
41
 
42
+ // Function call done → emit as a candidate with functionCall part
43
+ if (evt.functionCallDone) {
44
+ let args: Record<string, unknown> = {};
45
+ try {
46
+ args = JSON.parse(evt.functionCallDone.arguments) as Record<string, unknown>;
47
+ } catch { /* use empty args */ }
48
+ const fcChunk: GeminiGenerateContentResponse = {
49
+ candidates: [
50
+ {
51
+ content: {
52
+ parts: [{
53
+ functionCall: {
54
+ name: evt.functionCallDone.name,
55
+ args,
56
+ },
57
+ }],
58
+ role: "model",
59
+ },
60
+ index: 0,
61
+ },
62
+ ],
63
+ modelVersion: model,
64
+ };
65
+ yield `data: ${JSON.stringify(fcChunk)}\n\n`;
66
+ continue;
67
+ }
68
+
69
  switch (evt.typed.type) {
70
  case "response.output_text.delta": {
71
  if (evt.textDelta) {
 
136
  let inputTokens = 0;
137
  let outputTokens = 0;
138
  let responseId: string | null = null;
139
+ const functionCallParts: GeminiPart[] = [];
140
 
141
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
142
  if (evt.responseId) responseId = evt.responseId;
 
145
  inputTokens = evt.usage.input_tokens;
146
  outputTokens = evt.usage.output_tokens;
147
  }
148
+ if (evt.functionCallDone) {
149
+ let args: Record<string, unknown> = {};
150
+ try {
151
+ args = JSON.parse(evt.functionCallDone.arguments) as Record<string, unknown>;
152
+ } catch { /* use empty args */ }
153
+ functionCallParts.push({
154
+ functionCall: { name: evt.functionCallDone.name, args },
155
+ });
156
+ }
157
  }
158
 
159
  const usage: GeminiUsageInfo = {
 
167
  totalTokenCount: inputTokens + outputTokens,
168
  };
169
 
170
+ // Build response parts: text + function calls
171
+ const parts: GeminiPart[] = [];
172
+ if (fullText) {
173
+ parts.push({ text: fullText });
174
+ }
175
+ parts.push(...functionCallParts);
176
+ if (parts.length === 0) {
177
+ parts.push({ text: "" });
178
+ }
179
+
180
  return {
181
  response: {
182
  candidates: [
183
  {
184
  content: {
185
+ parts,
186
  role: "model",
187
  },
188
  finishReason: "STOP",
src/translation/codex-to-openai.ts CHANGED
@@ -15,6 +15,8 @@ import type { CodexApi } from "../proxy/codex-api.js";
15
  import type {
16
  ChatCompletionResponse,
17
  ChatCompletionChunk,
 
 
18
  } from "../types/openai.js";
19
  import { iterateCodexEvents, type UsageInfo } from "./codex-event-extractor.js";
20
 
@@ -39,6 +41,12 @@ export async function* streamCodexToOpenAI(
39
  ): AsyncGenerator<string> {
40
  const chunkId = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
41
  const created = Math.floor(Date.now() / 1000);
 
 
 
 
 
 
42
 
43
  // Send initial role chunk
44
  yield formatSSE({
@@ -58,6 +66,88 @@ export async function* streamCodexToOpenAI(
58
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
59
  if (evt.responseId) onResponseId?.(evt.responseId);
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  switch (evt.typed.type) {
62
  case "response.output_text.delta": {
63
  if (evt.textDelta) {
@@ -89,7 +179,7 @@ export async function* streamCodexToOpenAI(
89
  {
90
  index: 0,
91
  delta: {},
92
- finish_reason: "stop",
93
  },
94
  ],
95
  });
@@ -118,6 +208,9 @@ export async function collectCodexResponse(
118
  let completionTokens = 0;
119
  let responseId: string | null = null;
120
 
 
 
 
121
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
122
  if (evt.responseId) responseId = evt.responseId;
123
  if (evt.textDelta) fullText += evt.textDelta;
@@ -125,6 +218,25 @@ export async function collectCodexResponse(
125
  promptTokens = evt.usage.input_tokens;
126
  completionTokens = evt.usage.output_tokens;
127
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  }
129
 
130
  return {
@@ -136,11 +248,8 @@ export async function collectCodexResponse(
136
  choices: [
137
  {
138
  index: 0,
139
- message: {
140
- role: "assistant",
141
- content: fullText,
142
- },
143
- finish_reason: "stop",
144
  },
145
  ],
146
  usage: {
 
15
  import type {
16
  ChatCompletionResponse,
17
  ChatCompletionChunk,
18
+ ChatCompletionToolCall,
19
+ ChatCompletionChunkToolCall,
20
  } from "../types/openai.js";
21
  import { iterateCodexEvents, type UsageInfo } from "./codex-event-extractor.js";
22
 
 
41
  ): AsyncGenerator<string> {
42
  const chunkId = `chatcmpl-${randomUUID().replace(/-/g, "").slice(0, 24)}`;
43
  const created = Math.floor(Date.now() / 1000);
44
+ let hasToolCalls = false;
45
+ // Track tool call indices by call_id
46
+ const toolCallIndexMap = new Map<string, number>();
47
+ let nextToolCallIndex = 0;
48
+ // Track which call_ids have received argument deltas
49
+ const callIdsWithDeltas = new Set<string>();
50
 
51
  // Send initial role chunk
52
  yield formatSSE({
 
66
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
67
  if (evt.responseId) onResponseId?.(evt.responseId);
68
 
69
+ // Handle function call events
70
+ if (evt.functionCallStart) {
71
+ hasToolCalls = true;
72
+ const idx = nextToolCallIndex++;
73
+ toolCallIndexMap.set(evt.functionCallStart.callId, idx);
74
+ const toolCall: ChatCompletionChunkToolCall = {
75
+ index: idx,
76
+ id: evt.functionCallStart.callId,
77
+ type: "function",
78
+ function: {
79
+ name: evt.functionCallStart.name,
80
+ arguments: "",
81
+ },
82
+ };
83
+ yield formatSSE({
84
+ id: chunkId,
85
+ object: "chat.completion.chunk",
86
+ created,
87
+ model,
88
+ choices: [
89
+ {
90
+ index: 0,
91
+ delta: { tool_calls: [toolCall] },
92
+ finish_reason: null,
93
+ },
94
+ ],
95
+ });
96
+ continue;
97
+ }
98
+
99
+ if (evt.functionCallDelta) {
100
+ callIdsWithDeltas.add(evt.functionCallDelta.callId);
101
+ const idx = toolCallIndexMap.get(evt.functionCallDelta.callId) ?? 0;
102
+ const toolCall: ChatCompletionChunkToolCall = {
103
+ index: idx,
104
+ function: {
105
+ arguments: evt.functionCallDelta.delta,
106
+ },
107
+ };
108
+ yield formatSSE({
109
+ id: chunkId,
110
+ object: "chat.completion.chunk",
111
+ created,
112
+ model,
113
+ choices: [
114
+ {
115
+ index: 0,
116
+ delta: { tool_calls: [toolCall] },
117
+ finish_reason: null,
118
+ },
119
+ ],
120
+ });
121
+ continue;
122
+ }
123
+
124
+ // functionCallDone — emit full arguments if no deltas were streamed
125
+ if (evt.functionCallDone) {
126
+ if (!callIdsWithDeltas.has(evt.functionCallDone.callId)) {
127
+ const idx = toolCallIndexMap.get(evt.functionCallDone.callId) ?? 0;
128
+ const toolCall: ChatCompletionChunkToolCall = {
129
+ index: idx,
130
+ function: {
131
+ arguments: evt.functionCallDone.arguments,
132
+ },
133
+ };
134
+ yield formatSSE({
135
+ id: chunkId,
136
+ object: "chat.completion.chunk",
137
+ created,
138
+ model,
139
+ choices: [
140
+ {
141
+ index: 0,
142
+ delta: { tool_calls: [toolCall] },
143
+ finish_reason: null,
144
+ },
145
+ ],
146
+ });
147
+ }
148
+ continue;
149
+ }
150
+
151
  switch (evt.typed.type) {
152
  case "response.output_text.delta": {
153
  if (evt.textDelta) {
 
179
  {
180
  index: 0,
181
  delta: {},
182
+ finish_reason: hasToolCalls ? "tool_calls" : "stop",
183
  },
184
  ],
185
  });
 
208
  let completionTokens = 0;
209
  let responseId: string | null = null;
210
 
211
+ // Collect tool calls
212
+ const toolCalls: ChatCompletionToolCall[] = [];
213
+
214
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
215
  if (evt.responseId) responseId = evt.responseId;
216
  if (evt.textDelta) fullText += evt.textDelta;
 
218
  promptTokens = evt.usage.input_tokens;
219
  completionTokens = evt.usage.output_tokens;
220
  }
221
+ if (evt.functionCallDone) {
222
+ toolCalls.push({
223
+ id: evt.functionCallDone.callId,
224
+ type: "function",
225
+ function: {
226
+ name: evt.functionCallDone.name,
227
+ arguments: evt.functionCallDone.arguments,
228
+ },
229
+ });
230
+ }
231
+ }
232
+
233
+ const hasToolCalls = toolCalls.length > 0;
234
+ const message: ChatCompletionResponse["choices"][0]["message"] = {
235
+ role: "assistant",
236
+ content: fullText || null,
237
+ };
238
+ if (hasToolCalls) {
239
+ message.tool_calls = toolCalls;
240
  }
241
 
242
  return {
 
248
  choices: [
249
  {
250
  index: 0,
251
+ message,
252
+ finish_reason: hasToolCalls ? "tool_calls" : "stop",
 
 
 
253
  },
254
  ],
255
  usage: {
src/translation/gemini-to-codex.ts CHANGED
@@ -5,6 +5,7 @@
5
  import type {
6
  GeminiGenerateContentRequest,
7
  GeminiContent,
 
8
  } from "../types/gemini.js";
9
  import type {
10
  CodexResponsesRequest,
@@ -13,43 +14,83 @@ import type {
13
  import { resolveModelId, getModelInfo } from "../routes/models.js";
14
  import { getConfig } from "../config.js";
15
  import { buildInstructions, budgetToEffort } from "./shared-utils.js";
 
16
 
17
  /**
18
- * Extract text from Gemini content parts.
19
- * Flattens functionCall/functionResponse parts into readable text for Codex.
20
  */
21
- function flattenParts(
22
- parts: Array<{
23
- text?: string;
24
- thought?: boolean;
25
- functionCall?: { name: string; args?: Record<string, unknown> };
26
- functionResponse?: { name: string; response?: Record<string, unknown> };
27
- }>,
28
- ): string {
29
- const textParts: string[] = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  for (const p of parts) {
31
- if (p.thought) continue;
32
- if (p.text) {
33
- textParts.push(p.text);
34
- } else if (p.functionCall) {
35
  let args: string;
36
  try {
37
- args = JSON.stringify(p.functionCall.args ?? {}, null, 2);
38
  } catch {
39
- args = String(p.functionCall.args);
40
  }
41
- textParts.push(`[Tool Call: ${p.functionCall.name}(${args})]`);
 
 
 
 
 
 
 
 
 
42
  } else if (p.functionResponse) {
43
- let resp: string;
44
  try {
45
- resp = JSON.stringify(p.functionResponse.response ?? {}, null, 2);
46
  } catch {
47
- resp = String(p.functionResponse.response);
48
  }
49
- textParts.push(`[Tool Result (${p.functionResponse.name})]: ${resp}`);
 
 
 
 
 
 
 
50
  }
51
  }
52
- return textParts.join("\n");
 
 
 
 
 
 
 
 
53
  }
54
 
55
  /**
@@ -103,10 +144,11 @@ export function translateGeminiToCodexRequest(
103
  const input: CodexInputItem[] = [];
104
  for (const content of req.contents) {
105
  const role = content.role === "model" ? "assistant" : "user";
106
- input.push({
107
- role: role as "user" | "assistant",
108
- content: flattenParts(content.parts),
109
- });
 
110
  }
111
 
112
  // Ensure at least one input message
@@ -119,6 +161,10 @@ export function translateGeminiToCodexRequest(
119
  const modelInfo = getModelInfo(modelId);
120
  const config = getConfig();
121
 
 
 
 
 
122
  // Build request
123
  const request: CodexResponsesRequest = {
124
  model: modelId,
@@ -126,9 +172,14 @@ export function translateGeminiToCodexRequest(
126
  input,
127
  stream: true,
128
  store: false,
129
- tools: [],
130
  };
131
 
 
 
 
 
 
132
  // Add previous response ID for multi-turn conversations
133
  if (previousResponseId) {
134
  request.previous_response_id = previousResponseId;
 
5
  import type {
6
  GeminiGenerateContentRequest,
7
  GeminiContent,
8
+ GeminiPart,
9
  } from "../types/gemini.js";
10
  import type {
11
  CodexResponsesRequest,
 
14
  import { resolveModelId, getModelInfo } from "../routes/models.js";
15
  import { getConfig } from "../config.js";
16
  import { buildInstructions, budgetToEffort } from "./shared-utils.js";
17
+ import { geminiToolsToCodex, geminiToolConfigToCodex } from "./tool-format.js";
18
 
19
  /**
20
+ * Extract text-only content from Gemini parts.
 
21
  */
22
+ function extractTextFromParts(parts: GeminiPart[]): string {
23
+ return parts
24
+ .filter((p) => !p.thought && p.text)
25
+ .map((p) => p.text!)
26
+ .join("\n");
27
+ }
28
+
29
+ /**
30
+ * Convert Gemini content parts into native Codex input items.
31
+ */
32
+ function partsToInputItems(
33
+ role: "user" | "assistant",
34
+ parts: GeminiPart[],
35
+ ): CodexInputItem[] {
36
+ const items: CodexInputItem[] = [];
37
+ const hasFunctionParts = parts.some((p) => p.functionCall || p.functionResponse);
38
+
39
+ // Collect text content
40
+ const text = extractTextFromParts(parts);
41
+ if (text || !hasFunctionParts) {
42
+ items.push({ role, content: text });
43
+ }
44
+
45
+ // Track call_ids by function name to correlate functionCall → functionResponse
46
+ let callCounter = 0;
47
+ const nameToCallIds = new Map<string, string[]>();
48
+
49
  for (const p of parts) {
50
+ if (p.functionCall) {
51
+ const callId = `fc_${callCounter++}`;
 
 
52
  let args: string;
53
  try {
54
+ args = JSON.stringify(p.functionCall.args ?? {});
55
  } catch {
56
+ args = "{}";
57
  }
58
+ items.push({
59
+ type: "function_call",
60
+ call_id: callId,
61
+ name: p.functionCall.name,
62
+ arguments: args,
63
+ });
64
+ // Record call_id for this function name (for matching responses)
65
+ const ids = nameToCallIds.get(p.functionCall.name) ?? [];
66
+ ids.push(callId);
67
+ nameToCallIds.set(p.functionCall.name, ids);
68
  } else if (p.functionResponse) {
69
+ let output: string;
70
  try {
71
+ output = JSON.stringify(p.functionResponse.response ?? {});
72
  } catch {
73
+ output = String(p.functionResponse.response);
74
  }
75
+ // Match response to the earliest unmatched call with the same name
76
+ const ids = nameToCallIds.get(p.functionResponse.name);
77
+ const callId = ids?.shift() ?? `fc_${callCounter++}`;
78
+ items.push({
79
+ type: "function_call_output",
80
+ call_id: callId,
81
+ output,
82
+ });
83
  }
84
  }
85
+
86
+ return items;
87
+ }
88
+
89
+ /**
90
+ * Extract text from Gemini content parts (for session hashing).
91
+ */
92
+ function flattenParts(parts: GeminiPart[]): string {
93
+ return extractTextFromParts(parts);
94
  }
95
 
96
  /**
 
144
  const input: CodexInputItem[] = [];
145
  for (const content of req.contents) {
146
  const role = content.role === "model" ? "assistant" : "user";
147
+ const items = partsToInputItems(
148
+ role as "user" | "assistant",
149
+ content.parts as GeminiPart[],
150
+ );
151
+ input.push(...items);
152
  }
153
 
154
  // Ensure at least one input message
 
161
  const modelInfo = getModelInfo(modelId);
162
  const config = getConfig();
163
 
164
+ // Convert tools to Codex format
165
+ const codexTools = req.tools?.length ? geminiToolsToCodex(req.tools) : [];
166
+ const codexToolChoice = geminiToolConfigToCodex(req.toolConfig);
167
+
168
  // Build request
169
  const request: CodexResponsesRequest = {
170
  model: modelId,
 
172
  input,
173
  stream: true,
174
  store: false,
175
+ tools: codexTools,
176
  };
177
 
178
+ // Add tool_choice if specified
179
+ if (codexToolChoice) {
180
+ request.tool_choice = codexToolChoice;
181
+ }
182
+
183
  // Add previous response ID for multi-turn conversations
184
  if (previousResponseId) {
185
  request.previous_response_id = previousResponseId;
src/translation/openai-to-codex.ts CHANGED
@@ -10,6 +10,11 @@ import type {
10
  import { resolveModelId, getModelInfo } from "../routes/models.js";
11
  import { getConfig } from "../config.js";
12
  import { buildInstructions } from "./shared-utils.js";
 
 
 
 
 
13
 
14
  /** Extract plain text from content (string, array, null, or undefined). */
15
  function extractText(content: ChatMessage["content"]): string {
@@ -21,35 +26,6 @@ function extractText(content: ChatMessage["content"]): string {
21
  .join("\n");
22
  }
23
 
24
- /** Flatten tool_calls array into human-readable text. */
25
- function flattenToolCalls(
26
- toolCalls: NonNullable<ChatMessage["tool_calls"]>,
27
- ): string {
28
- return toolCalls
29
- .map((tc) => {
30
- let args = tc.function.arguments;
31
- try {
32
- args = JSON.stringify(JSON.parse(args), null, 2);
33
- } catch {
34
- /* keep raw string */
35
- }
36
- return `[Tool Call: ${tc.function.name}(${args})]`;
37
- })
38
- .join("\n");
39
- }
40
-
41
- /** Flatten a legacy function_call into human-readable text. */
42
- function flattenFunctionCall(
43
- fc: NonNullable<ChatMessage["function_call"]>,
44
- ): string {
45
- let args = fc.arguments;
46
- try {
47
- args = JSON.stringify(JSON.parse(args), null, 2);
48
- } catch {
49
- /* keep raw string */
50
- }
51
- return `[Tool Call: ${fc.name}(${args})]`;
52
- }
53
 
54
  /**
55
  * Convert a ChatCompletionRequest to a CodexResponsesRequest.
@@ -80,23 +56,43 @@ export function translateToCodexRequest(
80
  if (msg.role === "system" || msg.role === "developer") continue;
81
 
82
  if (msg.role === "assistant") {
83
- const parts: string[] = [];
84
  const text = extractText(msg.content);
85
- if (text) parts.push(text);
86
- if (msg.tool_calls?.length) parts.push(flattenToolCalls(msg.tool_calls));
87
- if (msg.function_call) parts.push(flattenFunctionCall(msg.function_call));
88
- input.push({ role: "assistant", content: parts.join("\n") });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  } else if (msg.role === "tool") {
90
- const name = msg.name ?? msg.tool_call_id ?? "unknown";
91
  input.push({
92
- role: "user",
93
- content: `[Tool Result (${name})]: ${extractText(msg.content)}`,
 
94
  });
95
  } else if (msg.role === "function") {
96
- const name = msg.name ?? "unknown";
97
  input.push({
98
- role: "user",
99
- content: `[Tool Result (${name})]: ${extractText(msg.content)}`,
 
100
  });
101
  } else {
102
  input.push({ role: "user", content: extractText(msg.content) });
@@ -113,6 +109,14 @@ export function translateToCodexRequest(
113
  const modelInfo = getModelInfo(modelId);
114
  const config = getConfig();
115
 
 
 
 
 
 
 
 
 
116
  // Build request
117
  const request: CodexResponsesRequest = {
118
  model: modelId,
@@ -120,9 +124,14 @@ export function translateToCodexRequest(
120
  input,
121
  stream: true,
122
  store: false,
123
- tools: [],
124
  };
125
 
 
 
 
 
 
126
  // Add previous response ID for multi-turn conversations
127
  if (previousResponseId) {
128
  request.previous_response_id = previousResponseId;
 
10
  import { resolveModelId, getModelInfo } from "../routes/models.js";
11
  import { getConfig } from "../config.js";
12
  import { buildInstructions } from "./shared-utils.js";
13
+ import {
14
+ openAIToolsToCodex,
15
+ openAIToolChoiceToCodex,
16
+ openAIFunctionsToCodex,
17
+ } from "./tool-format.js";
18
 
19
  /** Extract plain text from content (string, array, null, or undefined). */
20
  function extractText(content: ChatMessage["content"]): string {
 
26
  .join("\n");
27
  }
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  /**
31
  * Convert a ChatCompletionRequest to a CodexResponsesRequest.
 
56
  if (msg.role === "system" || msg.role === "developer") continue;
57
 
58
  if (msg.role === "assistant") {
59
+ // First push the text content
60
  const text = extractText(msg.content);
61
+ if (text || (!msg.tool_calls?.length && !msg.function_call)) {
62
+ input.push({ role: "assistant", content: text });
63
+ }
64
+ // Then push tool calls as native function_call items
65
+ if (msg.tool_calls?.length) {
66
+ for (const tc of msg.tool_calls) {
67
+ input.push({
68
+ type: "function_call",
69
+ call_id: tc.id,
70
+ name: tc.function.name,
71
+ arguments: tc.function.arguments,
72
+ });
73
+ }
74
+ }
75
+ if (msg.function_call) {
76
+ input.push({
77
+ type: "function_call",
78
+ call_id: `fc_${msg.function_call.name}`,
79
+ name: msg.function_call.name,
80
+ arguments: msg.function_call.arguments,
81
+ });
82
+ }
83
  } else if (msg.role === "tool") {
84
+ // Native tool result
85
  input.push({
86
+ type: "function_call_output",
87
+ call_id: msg.tool_call_id ?? "unknown",
88
+ output: extractText(msg.content),
89
  });
90
  } else if (msg.role === "function") {
91
+ // Legacy function result native format
92
  input.push({
93
+ type: "function_call_output",
94
+ call_id: `fc_${msg.name ?? "unknown"}`,
95
+ output: extractText(msg.content),
96
  });
97
  } else {
98
  input.push({ role: "user", content: extractText(msg.content) });
 
109
  const modelInfo = getModelInfo(modelId);
110
  const config = getConfig();
111
 
112
+ // Convert tools to Codex format
113
+ const codexTools = req.tools?.length
114
+ ? openAIToolsToCodex(req.tools)
115
+ : req.functions?.length
116
+ ? openAIFunctionsToCodex(req.functions)
117
+ : [];
118
+ const codexToolChoice = openAIToolChoiceToCodex(req.tool_choice);
119
+
120
  // Build request
121
  const request: CodexResponsesRequest = {
122
  model: modelId,
 
124
  input,
125
  stream: true,
126
  store: false,
127
+ tools: codexTools,
128
  };
129
 
130
+ // Add tool_choice if specified
131
+ if (codexToolChoice) {
132
+ request.tool_choice = codexToolChoice;
133
+ }
134
+
135
  // Add previous response ID for multi-turn conversations
136
  if (previousResponseId) {
137
  request.previous_response_id = previousResponseId;
src/translation/tool-format.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared tool format conversion utilities.
3
+ *
4
+ * Converts tool definitions and tool_choice from each protocol
5
+ * (OpenAI, Anthropic, Gemini) into the Codex Responses API format.
6
+ */
7
+
8
+ import type { ChatCompletionRequest } from "../types/openai.js";
9
+ import type { AnthropicMessagesRequest } from "../types/anthropic.js";
10
+ import type { GeminiGenerateContentRequest } from "../types/gemini.js";
11
+
12
+ // ── Codex Responses API tool format ─────────────────────────────
13
+
14
+ export interface CodexToolDefinition {
15
+ type: "function";
16
+ name: string;
17
+ description?: string;
18
+ parameters?: Record<string, unknown>;
19
+ strict?: boolean;
20
+ }
21
+
22
+ // ── OpenAI → Codex ──────────────────────────────────────────────
23
+
24
+ export function openAIToolsToCodex(
25
+ tools: NonNullable<ChatCompletionRequest["tools"]>,
26
+ ): CodexToolDefinition[] {
27
+ return tools.map((t) => {
28
+ const def: CodexToolDefinition = {
29
+ type: "function",
30
+ name: t.function.name,
31
+ };
32
+ if (t.function.description) def.description = t.function.description;
33
+ if (t.function.parameters) def.parameters = t.function.parameters;
34
+ return def;
35
+ });
36
+ }
37
+
38
+ export function openAIToolChoiceToCodex(
39
+ choice: ChatCompletionRequest["tool_choice"],
40
+ ): string | { type: "function"; name: string } | undefined {
41
+ if (!choice) return undefined;
42
+ if (typeof choice === "string") {
43
+ // "none" | "auto" | "required" → pass through
44
+ return choice;
45
+ }
46
+ // { type: "function", function: { name } } → { type: "function", name }
47
+ return { type: "function", name: choice.function.name };
48
+ }
49
+
50
+ /**
51
+ * Convert legacy OpenAI `functions` array to Codex tool definitions.
52
+ */
53
+ export function openAIFunctionsToCodex(
54
+ functions: NonNullable<ChatCompletionRequest["functions"]>,
55
+ ): CodexToolDefinition[] {
56
+ return functions.map((f) => {
57
+ const def: CodexToolDefinition = {
58
+ type: "function",
59
+ name: f.name,
60
+ };
61
+ if (f.description) def.description = f.description;
62
+ if (f.parameters) def.parameters = f.parameters;
63
+ return def;
64
+ });
65
+ }
66
+
67
+ // ── Anthropic → Codex ───────────────────────────────────────────
68
+
69
+ export function anthropicToolsToCodex(
70
+ tools: NonNullable<AnthropicMessagesRequest["tools"]>,
71
+ ): CodexToolDefinition[] {
72
+ return tools.map((t) => {
73
+ const def: CodexToolDefinition = {
74
+ type: "function",
75
+ name: t.name,
76
+ };
77
+ if (t.description) def.description = t.description;
78
+ if (t.input_schema) def.parameters = t.input_schema;
79
+ return def;
80
+ });
81
+ }
82
+
83
+ export function anthropicToolChoiceToCodex(
84
+ choice: AnthropicMessagesRequest["tool_choice"],
85
+ ): string | { type: "function"; name: string } | undefined {
86
+ if (!choice) return undefined;
87
+ switch (choice.type) {
88
+ case "auto":
89
+ return "auto";
90
+ case "any":
91
+ return "required";
92
+ case "tool":
93
+ return { type: "function", name: choice.name };
94
+ default:
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ // ── Gemini → Codex ──────────────────────────────────────────────
100
+
101
+ export function geminiToolsToCodex(
102
+ tools: NonNullable<GeminiGenerateContentRequest["tools"]>,
103
+ ): CodexToolDefinition[] {
104
+ const defs: CodexToolDefinition[] = [];
105
+ for (const toolGroup of tools) {
106
+ if (toolGroup.functionDeclarations) {
107
+ for (const fd of toolGroup.functionDeclarations) {
108
+ const def: CodexToolDefinition = {
109
+ type: "function",
110
+ name: fd.name,
111
+ };
112
+ if (fd.description) def.description = fd.description;
113
+ if (fd.parameters) def.parameters = fd.parameters;
114
+ defs.push(def);
115
+ }
116
+ }
117
+ }
118
+ return defs;
119
+ }
120
+
121
+ export function geminiToolConfigToCodex(
122
+ config: GeminiGenerateContentRequest["toolConfig"],
123
+ ): string | undefined {
124
+ if (!config?.functionCallingConfig?.mode) return undefined;
125
+ switch (config.functionCallingConfig.mode) {
126
+ case "AUTO":
127
+ return "auto";
128
+ case "NONE":
129
+ return "none";
130
+ case "ANY":
131
+ return "required";
132
+ default:
133
+ return undefined;
134
+ }
135
+ }
src/types/codex-events.ts CHANGED
@@ -43,6 +43,33 @@ export interface CodexCompletedEvent {
43
  response: CodexResponseData;
44
  }
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  export interface CodexUnknownEvent {
47
  type: "unknown";
48
  raw: unknown;
@@ -54,6 +81,9 @@ export type TypedCodexEvent =
54
  | CodexTextDeltaEvent
55
  | CodexTextDoneEvent
56
  | CodexCompletedEvent
 
 
 
57
  | CodexUnknownEvent;
58
 
59
  // ── Type guard / parser ──────────────────────────────────────────
@@ -115,6 +145,65 @@ export function parseCodexEvent(evt: CodexSSEEvent): TypedCodexEvent {
115
  ? { type: "response.completed", response: resp }
116
  : { type: "unknown", raw: data };
117
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  default:
119
  return { type: "unknown", raw: data };
120
  }
 
43
  response: CodexResponseData;
44
  }
45
 
46
+ // ── Function call event data shapes ─────────────────────────────
47
+
48
+ export interface CodexOutputItemAddedEvent {
49
+ type: "response.output_item.added";
50
+ outputIndex: number;
51
+ item: {
52
+ type: "function_call";
53
+ id: string;
54
+ call_id: string;
55
+ name: string;
56
+ };
57
+ }
58
+
59
+ export interface CodexFunctionCallArgsDeltaEvent {
60
+ type: "response.function_call_arguments.delta";
61
+ delta: string;
62
+ outputIndex: number;
63
+ call_id: string;
64
+ }
65
+
66
+ export interface CodexFunctionCallArgsDoneEvent {
67
+ type: "response.function_call_arguments.done";
68
+ arguments: string;
69
+ call_id: string;
70
+ name: string;
71
+ }
72
+
73
  export interface CodexUnknownEvent {
74
  type: "unknown";
75
  raw: unknown;
 
81
  | CodexTextDeltaEvent
82
  | CodexTextDoneEvent
83
  | CodexCompletedEvent
84
+ | CodexOutputItemAddedEvent
85
+ | CodexFunctionCallArgsDeltaEvent
86
+ | CodexFunctionCallArgsDoneEvent
87
  | CodexUnknownEvent;
88
 
89
  // ── Type guard / parser ──────────────────────────────────────────
 
145
  ? { type: "response.completed", response: resp }
146
  : { type: "unknown", raw: data };
147
  }
148
+ case "response.output_item.added": {
149
+ if (
150
+ isRecord(data) &&
151
+ isRecord(data.item) &&
152
+ data.item.type === "function_call" &&
153
+ typeof data.item.call_id === "string" &&
154
+ typeof data.item.name === "string"
155
+ ) {
156
+ return {
157
+ type: "response.output_item.added",
158
+ outputIndex: typeof data.output_index === "number" ? data.output_index : 0,
159
+ item: {
160
+ type: "function_call",
161
+ id: typeof data.item.id === "string" ? data.item.id : "",
162
+ call_id: data.item.call_id,
163
+ name: data.item.name,
164
+ },
165
+ };
166
+ }
167
+ return { type: "unknown", raw: data };
168
+ }
169
+ case "response.function_call_arguments.delta": {
170
+ // Codex uses item_id (not call_id) on delta events
171
+ const deltaCallId = isRecord(data)
172
+ ? (typeof data.call_id === "string" ? data.call_id : typeof data.item_id === "string" ? data.item_id : "")
173
+ : "";
174
+ if (
175
+ isRecord(data) &&
176
+ typeof data.delta === "string" &&
177
+ deltaCallId
178
+ ) {
179
+ return {
180
+ type: "response.function_call_arguments.delta",
181
+ delta: data.delta,
182
+ outputIndex: typeof data.output_index === "number" ? data.output_index : 0,
183
+ call_id: deltaCallId,
184
+ };
185
+ }
186
+ return { type: "unknown", raw: data };
187
+ }
188
+ case "response.function_call_arguments.done": {
189
+ // Codex uses item_id (not call_id); name may be absent
190
+ const doneCallId = isRecord(data)
191
+ ? (typeof data.call_id === "string" ? data.call_id : typeof data.item_id === "string" ? data.item_id : "")
192
+ : "";
193
+ if (
194
+ isRecord(data) &&
195
+ typeof data.arguments === "string" &&
196
+ doneCallId
197
+ ) {
198
+ return {
199
+ type: "response.function_call_arguments.done",
200
+ arguments: data.arguments,
201
+ call_id: doneCallId,
202
+ name: typeof data.name === "string" ? data.name : "",
203
+ };
204
+ }
205
+ return { type: "unknown", raw: data };
206
+ }
207
  default:
208
  return { type: "unknown", raw: data };
209
  }
src/types/gemini.ts CHANGED
@@ -64,9 +64,26 @@ export type GeminiContent = z.infer<typeof GeminiContentSchema>;
64
 
65
  // --- Response ---
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  export interface GeminiCandidate {
68
  content: {
69
- parts: Array<{ text: string; thought?: boolean }>;
70
  role: "model";
71
  };
72
  finishReason?: "STOP" | "MAX_TOKENS" | "SAFETY" | "OTHER";
 
64
 
65
  // --- Response ---
66
 
67
+ export interface GeminiFunctionCall {
68
+ name: string;
69
+ args?: Record<string, unknown>;
70
+ }
71
+
72
+ export interface GeminiFunctionResponse {
73
+ name: string;
74
+ response?: Record<string, unknown>;
75
+ }
76
+
77
+ export interface GeminiPart {
78
+ text?: string;
79
+ thought?: boolean;
80
+ functionCall?: GeminiFunctionCall;
81
+ functionResponse?: GeminiFunctionResponse;
82
+ }
83
+
84
  export interface GeminiCandidate {
85
  content: {
86
+ parts: GeminiPart[];
87
  role: "model";
88
  };
89
  finishReason?: "STOP" | "MAX_TOKENS" | "SAFETY" | "OTHER";
src/types/openai.ts CHANGED
@@ -76,11 +76,21 @@ export type ChatCompletionRequest = z.infer<typeof ChatCompletionRequestSchema>;
76
 
77
  // --- Response (non-streaming) ---
78
 
 
 
 
 
 
 
 
 
 
79
  export interface ChatCompletionChoice {
80
  index: number;
81
  message: {
82
  role: "assistant";
83
  content: string | null;
 
84
  };
85
  finish_reason: "stop" | "length" | "tool_calls" | "function_call" | null;
86
  }
@@ -102,9 +112,20 @@ export interface ChatCompletionResponse {
102
 
103
  // --- Response (streaming) ---
104
 
 
 
 
 
 
 
 
 
 
 
105
  export interface ChatCompletionChunkDelta {
106
  role?: "assistant";
107
  content?: string | null;
 
108
  }
109
 
110
  export interface ChatCompletionChunkChoice {
 
76
 
77
  // --- Response (non-streaming) ---
78
 
79
+ export interface ChatCompletionToolCall {
80
+ id: string;
81
+ type: "function";
82
+ function: {
83
+ name: string;
84
+ arguments: string;
85
+ };
86
+ }
87
+
88
  export interface ChatCompletionChoice {
89
  index: number;
90
  message: {
91
  role: "assistant";
92
  content: string | null;
93
+ tool_calls?: ChatCompletionToolCall[];
94
  };
95
  finish_reason: "stop" | "length" | "tool_calls" | "function_call" | null;
96
  }
 
112
 
113
  // --- Response (streaming) ---
114
 
115
+ export interface ChatCompletionChunkToolCall {
116
+ index: number;
117
+ id?: string;
118
+ type?: "function";
119
+ function?: {
120
+ name?: string;
121
+ arguments?: string;
122
+ };
123
+ }
124
+
125
  export interface ChatCompletionChunkDelta {
126
  role?: "assistant";
127
  content?: string | null;
128
+ tool_calls?: ChatCompletionChunkToolCall[];
129
  }
130
 
131
  export interface ChatCompletionChunkChoice {