codex-proxy / src /translation /anthropic-to-codex.ts
icebear
fix: remove unsupported previous_response_id parameter (#30)
3c0eaf7 unverified
raw
history blame
6.77 kB
/**
* Translate Anthropic Messages API request β†’ Codex Responses API request.
*/
import type { AnthropicMessagesRequest } from "../types/anthropic.js";
import type {
CodexResponsesRequest,
CodexInputItem,
CodexContentPart,
} from "../proxy/codex-api.js";
import { resolveModelId, getModelInfo } from "../models/model-store.js";
import { getConfig } from "../config.js";
import { buildInstructions, budgetToEffort } from "./shared-utils.js";
import { anthropicToolsToCodex, anthropicToolChoiceToCodex } from "./tool-format.js";
/**
* Map Anthropic thinking budget_tokens to Codex reasoning effort.
*/
function mapThinkingToEffort(
thinking: AnthropicMessagesRequest["thinking"],
): string | undefined {
if (!thinking || thinking.type === "disabled") return undefined;
if (thinking.type === "adaptive") {
// adaptive: use budget_tokens if provided, otherwise let Codex decide
return thinking.budget_tokens ? budgetToEffort(thinking.budget_tokens) : undefined;
}
return budgetToEffort(thinking.budget_tokens);
}
/**
* Extract text-only content from Anthropic blocks.
*/
function extractTextContent(
content: string | Array<Record<string, unknown>>,
): string {
if (typeof content === "string") return content;
return content
.filter((b) => b.type === "text" && typeof b.text === "string")
.map((b) => b.text as string)
.join("\n");
}
/**
* Build multimodal content (text + images) from Anthropic blocks.
* Returns plain string if text-only, or CodexContentPart[] if images present.
*/
function extractMultimodalContent(
content: Array<Record<string, unknown>>,
): string | CodexContentPart[] {
const hasImage = content.some((b) => b.type === "image");
if (!hasImage) return extractTextContent(content);
const parts: CodexContentPart[] = [];
for (const block of content) {
if (block.type === "text" && typeof block.text === "string") {
parts.push({ type: "input_text", text: block.text });
} else if (block.type === "image") {
// Anthropic format: source: { type: "base64", media_type: "image/png", data: "..." }
const source = block.source as
| { type: string; media_type: string; data: string }
| undefined;
if (source?.type === "base64" && source.media_type && source.data) {
parts.push({
type: "input_image",
image_url: `data:${source.media_type};base64,${source.data}`,
});
}
}
}
return parts.length > 0 ? parts : "";
}
/**
* Convert Anthropic message content blocks into native Codex input items.
* Handles text, image, tool_use, and tool_result blocks.
*/
function contentToInputItems(
role: "user" | "assistant",
content: string | Array<Record<string, unknown>>,
): CodexInputItem[] {
if (typeof content === "string") {
return [{ role, content }];
}
const items: CodexInputItem[] = [];
// Build content (text or multimodal) for the message itself
const hasToolBlocks = content.some((b) => b.type === "tool_use" || b.type === "tool_result");
if (role === "user") {
const extracted = extractMultimodalContent(content);
if (extracted || !hasToolBlocks) {
items.push({ role: "user", content: extracted || "" });
}
} else {
// Assistant messages: text-only (Codex doesn't support structured assistant content)
const text = extractTextContent(content);
if (text || !hasToolBlocks) {
items.push({ role: "assistant", content: text });
}
}
for (const block of content) {
if (block.type === "tool_use") {
const name = typeof block.name === "string" ? block.name : "unknown";
const id = typeof block.id === "string" ? block.id : `tc_${name}`;
let args: string;
try {
args = JSON.stringify(block.input ?? {});
} catch {
args = "{}";
}
items.push({
type: "function_call",
call_id: id,
name,
arguments: args,
});
} else if (block.type === "tool_result") {
const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "unknown";
let resultText = "";
if (typeof block.content === "string") {
resultText = block.content;
} else if (Array.isArray(block.content)) {
resultText = (block.content as Array<{ text?: string }>)
.filter((b) => typeof b.text === "string")
.map((b) => b.text!)
.join("\n");
}
if (block.is_error) {
resultText = `Error: ${resultText}`;
}
items.push({
type: "function_call_output",
call_id: toolUseId,
output: resultText,
});
}
}
return items;
}
/**
* Convert an AnthropicMessagesRequest to a CodexResponsesRequest.
*
* Mapping:
* - system (top-level) β†’ instructions field
* - messages β†’ input array
* - model β†’ resolved model ID
* - thinking β†’ reasoning.effort
*/
export function translateAnthropicToCodexRequest(
req: AnthropicMessagesRequest,
): CodexResponsesRequest {
// Extract system instructions
let userInstructions: string;
if (req.system) {
if (typeof req.system === "string") {
userInstructions = req.system;
} else {
userInstructions = req.system.map((b) => b.text).join("\n\n");
}
} else {
userInstructions = "You are a helpful assistant.";
}
const instructions = buildInstructions(userInstructions);
// Build input items from messages
const input: CodexInputItem[] = [];
for (const msg of req.messages) {
const items = contentToInputItems(
msg.role as "user" | "assistant",
msg.content as string | Array<Record<string, unknown>>,
);
input.push(...items);
}
// Ensure at least one input message
if (input.length === 0) {
input.push({ role: "user", content: "" });
}
// Resolve model
const modelId = resolveModelId(req.model);
const modelInfo = getModelInfo(modelId);
const config = getConfig();
// Convert tools to Codex format
const codexTools = req.tools?.length ? anthropicToolsToCodex(req.tools) : [];
const codexToolChoice = anthropicToolChoiceToCodex(req.tool_choice);
// Build request
const request: CodexResponsesRequest = {
model: modelId,
instructions,
input,
stream: true,
store: false,
tools: codexTools,
};
// Add tool_choice if specified
if (codexToolChoice) {
request.tool_choice = codexToolChoice;
}
// Always request reasoning summary (translation layer filters output on demand)
const thinkingEffort = mapThinkingToEffort(req.thinking);
const effort =
thinkingEffort ??
modelInfo?.defaultReasoningEffort ??
config.model.default_reasoning_effort;
request.reasoning = { summary: "auto", ...(effort ? { effort } : {}) };
return request;
}