Spaces:
Paused
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 +5 -1
- src/routes/chat.ts +0 -1
- src/routes/gemini.ts +7 -1
- src/routes/messages.ts +9 -1
- src/translation/anthropic-to-codex.ts +69 -24
- src/translation/codex-event-extractor.ts +59 -0
- src/translation/codex-to-anthropic.ts +109 -10
- src/translation/codex-to-gemini.ts +49 -1
- src/translation/codex-to-openai.ts +115 -6
- src/translation/gemini-to-codex.ts +79 -28
- src/translation/openai-to-codex.ts +50 -41
- src/translation/tool-format.ts +135 -0
- src/types/codex-events.ts +89 -0
- src/types/gemini.ts +18 -1
- src/types/openai.ts +21 -0
|
@@ -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 {
|
|
@@ -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);
|
|
@@ -127,7 +127,13 @@ export function createGeminiRoutes(
|
|
| 127 |
}
|
| 128 |
|
| 129 |
// Parse request
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
|
@@ -86,7 +86,15 @@ export function createMessagesRoutes(
|
|
| 86 |
}
|
| 87 |
|
| 88 |
// Parse request
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
|
@@ -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
|
| 26 |
-
* Flattens tool_use/tool_result blocks into readable text for Codex.
|
| 27 |
*/
|
| 28 |
-
function
|
| 29 |
content: string | Array<Record<string, unknown>>,
|
| 30 |
): string {
|
| 31 |
if (typeof content === "string") return content;
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
for (const block of content) {
|
| 34 |
-
if (block.type === "
|
| 35 |
-
parts.push(block.text);
|
| 36 |
-
} else if (block.type === "tool_use") {
|
| 37 |
const name = typeof block.name === "string" ? block.name : "unknown";
|
| 38 |
-
|
|
|
|
| 39 |
try {
|
| 40 |
-
|
| 41 |
} catch {
|
| 42 |
-
|
| 43 |
}
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
} else if (block.type === "tool_result") {
|
| 46 |
-
const
|
| 47 |
-
|
| 48 |
-
let text = "";
|
| 49 |
if (typeof block.content === "string") {
|
| 50 |
-
|
| 51 |
} else if (Array.isArray(block.content)) {
|
| 52 |
-
|
| 53 |
.filter((b) => typeof b.text === "string")
|
| 54 |
.map((b) => b.text!)
|
| 55 |
.join("\n");
|
| 56 |
}
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
}
|
| 61 |
-
|
|
|
|
| 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 |
-
|
| 94 |
-
|
| 95 |
-
|
| 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;
|
|
@@ -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;
|
|
@@ -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:
|
| 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:
|
| 77 |
delta: { type: "text_delta", text: evt.textDelta },
|
| 78 |
});
|
| 79 |
}
|
|
@@ -91,16 +162,18 @@ export async function* streamCodexToAnthropic(
|
|
| 91 |
}
|
| 92 |
}
|
| 93 |
|
| 94 |
-
// 4.
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 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
|
| 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 |
},
|
|
@@ -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
|
| 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",
|
|
@@ -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 |
-
|
| 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: {
|
|
@@ -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
|
| 19 |
-
* Flattens functionCall/functionResponse parts into readable text for Codex.
|
| 20 |
*/
|
| 21 |
-
function
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
for (const p of parts) {
|
| 31 |
-
if (p.
|
| 32 |
-
|
| 33 |
-
textParts.push(p.text);
|
| 34 |
-
} else if (p.functionCall) {
|
| 35 |
let args: string;
|
| 36 |
try {
|
| 37 |
-
args = JSON.stringify(p.functionCall.args ?? {}
|
| 38 |
} catch {
|
| 39 |
-
args =
|
| 40 |
}
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
} else if (p.functionResponse) {
|
| 43 |
-
let
|
| 44 |
try {
|
| 45 |
-
|
| 46 |
} catch {
|
| 47 |
-
|
| 48 |
}
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
}
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 107 |
-
role
|
| 108 |
-
content
|
| 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;
|
|
@@ -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 |
-
|
| 84 |
const text = extractText(msg.content);
|
| 85 |
-
if (text
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
} else if (msg.role === "tool") {
|
| 90 |
-
|
| 91 |
input.push({
|
| 92 |
-
|
| 93 |
-
|
|
|
|
| 94 |
});
|
| 95 |
} else if (msg.role === "function") {
|
| 96 |
-
|
| 97 |
input.push({
|
| 98 |
-
|
| 99 |
-
|
|
|
|
| 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;
|
|
@@ -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 |
+
}
|
|
@@ -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 |
}
|
|
@@ -64,9 +64,26 @@ export type GeminiContent = z.infer<typeof GeminiContentSchema>;
|
|
| 64 |
|
| 65 |
// --- Response ---
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
export interface GeminiCandidate {
|
| 68 |
content: {
|
| 69 |
-
parts:
|
| 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";
|
|
@@ -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 {
|