From 5ec3385991f639dfb131e3668ac767132d8e7abf Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Fri, 10 Apr 2026 03:04:00 -0500 Subject: [PATCH 1/3] fix: make reasoning effort opt-in, disable by default Previously every request sent reasoning: { effort: "medium" } to the Codex API due to modelInfo.defaultReasoningEffort always falling back to "medium", causing 100K+ token consumption for simple conversations. - Remove modelInfo?.defaultReasoningEffort from fallback chain in all translation files (openai/anthropic/gemini) and responses.ts - Change default_reasoning_effort schema default from "medium" to null - Update config/default.yaml to default_reasoning_effort: null - Allow null in settings API validation and hook type - Add "Disabled (no reasoning)" option to Dashboard dropdown - Update tests to reflect new opt-in behavior Reasoning is still activated when: client sends reasoning_effort, model name suffix specifies effort, or user sets default in config. --- CHANGELOG.md | 2 ++ config/default.yaml | 2 +- shared/hooks/use-general-settings.ts | 2 +- src/__tests__/config-local-override.test.ts | 2 +- src/__tests__/config-schema.test.ts | 2 +- src/config-schema.ts | 2 +- src/routes/__tests__/general-settings.test.ts | 28 +++++++++++++++++-- src/routes/admin/settings.ts | 9 ++++-- src/routes/responses.ts | 3 +- src/translation/anthropic-to-codex.ts | 3 +- src/translation/gemini-to-codex.ts | 3 +- src/translation/openai-to-codex.ts | 3 +- tests/integration/account-routing.test.ts | 6 ++-- web/src/components/GeneralSettings.tsx | 5 ++-- 14 files changed, 49 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab35de38..de2f4d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- 默认不再发送 `reasoning.effort`:移除 `modelInfo.defaultReasoningEffort` 自动兜底,`default_reasoning_effort` 默认改为 `null`,彻底消除简单对话触发 medium 推理导致的 token 暴涨;Dashboard 新增 "Disabled (no reasoning)" 选项,用户可按需开启 + - 上游 401 时立即触发 RT→AT 刷新,而非等待定时器(修复 token 被提前作废后账号一直显示 expired 的问题) - Dashboard session 滑动窗口续期:每次有效请求自动延长过期时间,不再固定 TTL 后断连 - Dashboard 前端全局 401 拦截:session 过期后自动跳回登录页,不再卡死在空白页 diff --git a/config/default.yaml b/config/default.yaml index 3d18f662..5b007a31 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -10,7 +10,7 @@ client: chromium_version: "144" model: default: gpt-5.2-codex - default_reasoning_effort: medium + default_reasoning_effort: null default_service_tier: null inject_desktop_context: false suppress_desktop_directives: false diff --git a/shared/hooks/use-general-settings.ts b/shared/hooks/use-general-settings.ts index 469f7d92..815c44ea 100644 --- a/shared/hooks/use-general-settings.ts +++ b/shared/hooks/use-general-settings.ts @@ -8,7 +8,7 @@ export interface GeneralSettingsData { inject_desktop_context: boolean; suppress_desktop_directives: boolean; default_model: string; - default_reasoning_effort: string; + default_reasoning_effort: string | null; refresh_enabled: boolean; refresh_margin_seconds: number; refresh_concurrency: number; diff --git a/src/__tests__/config-local-override.test.ts b/src/__tests__/config-local-override.test.ts index d3702143..e18fd978 100644 --- a/src/__tests__/config-local-override.test.ts +++ b/src/__tests__/config-local-override.test.ts @@ -44,7 +44,7 @@ client: chromium_version: "136" model: default: test-model - default_reasoning_effort: medium + default_reasoning_effort: null default_service_tier: null inject_desktop_context: false suppress_desktop_directives: false diff --git a/src/__tests__/config-schema.test.ts b/src/__tests__/config-schema.test.ts index a338160b..f4bec5b2 100644 --- a/src/__tests__/config-schema.test.ts +++ b/src/__tests__/config-schema.test.ts @@ -37,7 +37,7 @@ describe("ConfigSchema", () => { expect(result.auth.max_concurrent_per_account).toBe(3); expect(result.auth.request_interval_ms).toBe(50); expect(result.model.default).toBe("gpt-5.2-codex"); - expect(result.model.default_reasoning_effort).toBe("medium"); + expect(result.model.default_reasoning_effort).toBeNull(); expect(result.tls.force_http11).toBe(false); expect(result.quota.refresh_interval_minutes).toBe(5); expect(result.quota.warning_thresholds.primary).toEqual([80, 90]); diff --git a/src/config-schema.ts b/src/config-schema.ts index 1c4f59a9..c16b2618 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -17,7 +17,7 @@ export const ConfigSchema = z.object({ }), model: z.object({ default: z.string().default("gpt-5.2-codex"), - default_reasoning_effort: z.string().default("medium"), + default_reasoning_effort: z.string().nullable().default(null), default_service_tier: z.string().nullable().default(null), inject_desktop_context: z.boolean().default(false), suppress_desktop_directives: z.boolean().default(true), diff --git a/src/routes/__tests__/general-settings.test.ts b/src/routes/__tests__/general-settings.test.ts index 62508263..b05021c8 100644 --- a/src/routes/__tests__/general-settings.test.ts +++ b/src/routes/__tests__/general-settings.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const mockConfig = { server: { port: 8080, proxy_api_key: null as string | null }, tls: { proxy_url: null as string | null, force_http11: false }, - model: { default: "gpt-5.2-codex", default_reasoning_effort: "medium", inject_desktop_context: false, suppress_desktop_directives: true }, + model: { default: "gpt-5.2-codex", default_reasoning_effort: null as string | null, inject_desktop_context: false, suppress_desktop_directives: true }, quota: { refresh_interval_minutes: 5, warning_thresholds: { primary: [80, 90], secondary: [80, 90] }, @@ -114,7 +114,7 @@ describe("GET /admin/general-settings", () => { inject_desktop_context: false, suppress_desktop_directives: true, default_model: "gpt-5.2-codex", - default_reasoning_effort: "medium", + default_reasoning_effort: null, refresh_enabled: true, refresh_margin_seconds: 300, refresh_concurrency: 2, @@ -325,6 +325,30 @@ describe("POST /admin/general-settings", () => { expect(res.status).toBe(400); }); + it("accepts null default_reasoning_effort to disable reasoning", async () => { + mockConfig.model.default_reasoning_effort = "medium"; + const app = makeApp(); + const res = await app.request("/admin/general-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ default_reasoning_effort: null }), + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(mutateYaml).toHaveBeenCalledOnce(); + }); + + it("rejects invalid default_reasoning_effort", async () => { + const app = makeApp(); + const res = await app.request("/admin/general-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ default_reasoning_effort: "ultra" }), + }); + expect(res.status).toBe(400); + }); + it("requires auth when proxy_api_key is set", async () => { mockConfig.server.proxy_api_key = "my-secret"; const app = makeApp(); diff --git a/src/routes/admin/settings.ts b/src/routes/admin/settings.ts index 5a3a88bb..fc050d94 100644 --- a/src/routes/admin/settings.ts +++ b/src/routes/admin/settings.ts @@ -132,7 +132,7 @@ export function createSettingsRoutes(): Hono { inject_desktop_context?: boolean; suppress_desktop_directives?: boolean; default_model?: string; - default_reasoning_effort?: string; + default_reasoning_effort?: string | null; refresh_enabled?: boolean; refresh_margin_seconds?: number; refresh_concurrency?: number; @@ -161,9 +161,12 @@ export function createSettingsRoutes(): Hono { if (body.default_reasoning_effort !== undefined) { const validEfforts = ["low", "medium", "high", "xhigh"]; - if (!validEfforts.includes(body.default_reasoning_effort)) { + if ( + body.default_reasoning_effort !== null && + !validEfforts.includes(body.default_reasoning_effort) + ) { c.status(400); - return c.json({ error: `default_reasoning_effort must be one of: ${validEfforts.join(", ")}` }); + return c.json({ error: `default_reasoning_effort must be one of: ${validEfforts.join(", ")} or null` }); } } diff --git a/src/routes/responses.ts b/src/routes/responses.ts index 107fd120..13fcfae7 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -486,13 +486,12 @@ export function createResponsesRoutes( codexRequest.previous_response_id = body.previous_response_id; } - // Reasoning effort: explicit body > suffix > model default > config default + // Reasoning effort: explicit body > suffix > config default const effort = (isRecord(body.reasoning) && typeof body.reasoning.effort === "string" ? body.reasoning.effort : null) ?? parsed.reasoningEffort ?? - modelInfo?.defaultReasoningEffort ?? config.model.default_reasoning_effort; const summary = isRecord(body.reasoning) && typeof body.reasoning.summary === "string" diff --git a/src/translation/anthropic-to-codex.ts b/src/translation/anthropic-to-codex.ts index d38ab723..f59e5836 100644 --- a/src/translation/anthropic-to-codex.ts +++ b/src/translation/anthropic-to-codex.ts @@ -228,12 +228,11 @@ export function translateAnthropicToCodexRequest( request.tool_choice = codexToolChoice; } - // Reasoning effort: thinking config > suffix > model default > config default + // Reasoning effort: thinking config > suffix > config default const thinkingEffort = mapThinkingToEffort(req.thinking); const effort = thinkingEffort ?? parsed.reasoningEffort ?? - modelInfo?.defaultReasoningEffort ?? cfg.default_reasoning_effort; request.reasoning = { summary: "auto", ...(effort ? { effort } : {}) }; diff --git a/src/translation/gemini-to-codex.ts b/src/translation/gemini-to-codex.ts index 94bd10e5..73dba4ee 100644 --- a/src/translation/gemini-to-codex.ts +++ b/src/translation/gemini-to-codex.ts @@ -219,14 +219,13 @@ export function translateGeminiToCodexRequest( request.tool_choice = codexToolChoice; } - // Reasoning effort: thinking config > suffix > model default > config default + // Reasoning effort: thinking config > suffix > config default const thinkingEffort = budgetToEffort( req.generationConfig?.thinkingConfig?.thinkingBudget, ); const effort = thinkingEffort ?? parsed.reasoningEffort ?? - modelInfo?.defaultReasoningEffort ?? cfg.default_reasoning_effort; request.reasoning = { summary: "auto", ...(effort ? { effort } : {}) }; diff --git a/src/translation/openai-to-codex.ts b/src/translation/openai-to-codex.ts index 45b4811f..057004b7 100644 --- a/src/translation/openai-to-codex.ts +++ b/src/translation/openai-to-codex.ts @@ -182,11 +182,10 @@ export function translateToCodexRequest( request.tool_choice = codexToolChoice; } - // Reasoning effort: explicit API field > suffix > model default > config default + // Reasoning effort: explicit API field > suffix > config default const effort = req.reasoning_effort ?? parsed.reasoningEffort ?? - modelInfo?.defaultReasoningEffort ?? cfg.default_reasoning_effort; request.reasoning = { summary: "auto", ...(effort ? { effort } : {}) }; diff --git a/tests/integration/account-routing.test.ts b/tests/integration/account-routing.test.ts index d58ded9c..78ffbc59 100644 --- a/tests/integration/account-routing.test.ts +++ b/tests/integration/account-routing.test.ts @@ -32,7 +32,7 @@ vi.mock("@src/config.js", () => ({ }, model: { default: "gpt-5.2-codex", - default_reasoning_effort: "medium", + default_reasoning_effort: null, default_service_tier: null, }, server: { proxy_api_key: null }, @@ -73,7 +73,7 @@ describe("account-routing integration", () => { }, model: { default: "gpt-5.2-codex", - default_reasoning_effort: "medium", + default_reasoning_effort: null, default_service_tier: null, }, server: { proxy_api_key: null }, @@ -166,7 +166,7 @@ describe("account-routing integration", () => { }, model: { default: "gpt-5.2-codex", - default_reasoning_effort: "medium", + default_reasoning_effort: null, default_service_tier: null, }, server: { proxy_api_key: null }, diff --git a/web/src/components/GeneralSettings.tsx b/web/src/components/GeneralSettings.tsx index eb146f2b..25886edf 100644 --- a/web/src/components/GeneralSettings.tsx +++ b/web/src/components/GeneralSettings.tsx @@ -30,7 +30,7 @@ export function GeneralSettings() { const currentInjectContext = gs.data?.inject_desktop_context ?? false; const currentSuppressDirectives = gs.data?.suppress_desktop_directives ?? false; const currentDefaultModel = gs.data?.default_model ?? ""; - const currentReasoningEffort = gs.data?.default_reasoning_effort ?? "medium"; + const currentReasoningEffort = gs.data?.default_reasoning_effort ?? ""; const currentRefreshEnabled = gs.data?.refresh_enabled ?? true; const currentRefreshMargin = gs.data?.refresh_margin_seconds ?? 300; const currentRefreshConcurrency = gs.data?.refresh_concurrency ?? 2; @@ -100,7 +100,7 @@ export function GeneralSettings() { } if (draftReasoningEffort !== null) { - patch.default_reasoning_effort = draftReasoningEffort; + patch.default_reasoning_effort = draftReasoningEffort === "" ? null : draftReasoningEffort; } if (draftRefreshEnabled !== null) { @@ -265,6 +265,7 @@ export function GeneralSettings() { value={displayReasoningEffort} onChange={(e) => setDraftReasoningEffort((e.target as HTMLSelectElement).value)} > + From 6b18b543e25039adc68a20a7831e1bf91c18b346 Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Fri, 10 Apr 2026 03:33:50 -0500 Subject: [PATCH 2/3] debug: log per-request token breakdown to diagnose input inflation --- src/routes/shared/proxy-handler.ts | 42 +++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/routes/shared/proxy-handler.ts b/src/routes/shared/proxy-handler.ts index b2431ee9..0303b60b 100644 --- a/src/routes/shared/proxy-handler.ts +++ b/src/routes/shared/proxy-handler.ts @@ -136,13 +136,24 @@ export async function handleProxyRequest( const inputItems = req.codexRequest.input?.length ?? 0; const instrLen = req.codexRequest.instructions?.length ?? 0; const affinityHit = preferredEntryId && entryId === preferredEntryId; + const reasoningField = req.codexRequest.reasoning + ? `effort=${req.codexRequest.reasoning.effort ?? "none"} summary=${req.codexRequest.reasoning.summary ?? "none"}` + : "off"; console.log( - `[${fmt.tag}] Account ${entryId} | model=${req.model} | input_items=${inputItems} instr=${instrLen}B payload=${reqJson.length}B` + + `[${fmt.tag}] Account ${entryId} | model=${req.model} | input_items=${inputItems} instr=${instrLen}B payload=${reqJson.length}B reasoning=[${reasoningField}]` + (prevRespId ? ` | affinity=${affinityHit ? "hit" : "miss"}` : ""), ); if (reqJson.length > 50_000) { + // Log per-item size breakdown to diagnose large payload origin + const itemSizes = (req.codexRequest.input ?? []).map((item, i) => { + const sz = JSON.stringify(item).length; + const role = typeof item === "object" && item !== null && "role" in item ? (item as Record).role : (item as Record).type; + return ` [${i}] ${role} ${sz}B`; + }); console.warn( - `[${fmt.tag}] ⚠ Large payload (${(reqJson.length / 1024).toFixed(1)}KB) — input_items=${inputItems} instr=${instrLen}B`, + `[${fmt.tag}] ⚠ Large payload (${(reqJson.length / 1024).toFixed(1)}KB) — input_items=${inputItems} instr=${instrLen}B\n` + + ` instructions: ${instrLen}B\n` + + itemSizes.join("\n"), ); } } @@ -208,11 +219,21 @@ export async function handleProxyRequest( affinityMap.record(capturedResponseId, capturedEntryId, conversationId, upstreamTurnState); } if (usageInfo) { + const uncached = usageInfo.cached_tokens + ? usageInfo.input_tokens - usageInfo.cached_tokens + : usageInfo.input_tokens; console.log( - `[${fmt.tag}] Account ${capturedEntryId} | Usage: in=${usageInfo.input_tokens} out=${usageInfo.output_tokens}` + - (usageInfo.cached_tokens ? ` cached=${usageInfo.cached_tokens}` : "") + + `[${fmt.tag}] Account ${capturedEntryId} | Usage: in=${usageInfo.input_tokens}` + + (usageInfo.cached_tokens ? ` (cached=${usageInfo.cached_tokens} uncached=${uncached})` : "") + + ` out=${usageInfo.output_tokens}` + (usageInfo.reasoning_tokens ? ` reasoning=${usageInfo.reasoning_tokens}` : ""), ); + if (usageInfo.input_tokens > 10_000) { + console.warn( + `[${fmt.tag}] ⚠ High input token count: ${usageInfo.input_tokens} tokens` + + (usageInfo.reasoning_tokens ? ` (reasoning=${usageInfo.reasoning_tokens})` : ""), + ); + } } releaseAccount(accountPool, capturedEntryId, usageInfo, released); } @@ -295,6 +316,19 @@ async function handleNonStreaming( if (result.responseId && affinityMap && conversationId) { affinityMap.record(result.responseId, currentEntryId, conversationId, turnState); } + if (result.usage) { + const u = result.usage; + const uncached = u.cached_tokens ? u.input_tokens - u.cached_tokens : u.input_tokens; + console.log( + `[${fmt.tag}] Account ${currentEntryId} | Usage: in=${u.input_tokens}` + + (u.cached_tokens ? ` (cached=${u.cached_tokens} uncached=${uncached})` : "") + + ` out=${u.output_tokens}` + + (u.reasoning_tokens ? ` reasoning=${u.reasoning_tokens}` : ""), + ); + if (u.input_tokens > 10_000) { + console.warn(`[${fmt.tag}] ⚠ High input token count: ${u.input_tokens} tokens`); + } + } releaseAccount(accountPool, currentEntryId, result.usage, released); return c.json(result.response); } catch (collectErr) { From 0c1cf9bd3e1e87f3f8758677151a10f01bbc59b5 Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Fri, 10 Apr 2026 04:10:43 -0500 Subject: [PATCH 3/3] fix: only send reasoning field when effort is explicitly set Previously reasoning: { summary: "auto" } was sent on every request even without an effort, which caused include: ["reasoning.encrypted_content"] to be added unconditionally. This could trigger default-effort reasoning on models that interpret the presence of the reasoning object as opt-in. Now reasoning is omitted entirely when no effort is configured or requested. The responses.ts route preserves client-supplied reasoning objects (e.g. explicit summary override) when present. --- src/routes/responses.ts | 13 +- src/translation/anthropic-to-codex.ts | 4 +- src/translation/gemini-to-codex.ts | 4 +- src/translation/openai-to-codex.ts | 4 +- .../translation/anthropic-to-codex.test.ts | 685 ++++++++++++++++++ .../unit/translation/gemini-to-codex.test.ts | 479 ++++++++++++ .../unit/translation/openai-to-codex.test.ts | 406 +++++++++++ 7 files changed, 1587 insertions(+), 8 deletions(-) create mode 100644 tests/unit/translation/anthropic-to-codex.test.ts create mode 100644 tests/unit/translation/gemini-to-codex.test.ts create mode 100644 tests/unit/translation/openai-to-codex.test.ts diff --git a/src/routes/responses.ts b/src/routes/responses.ts index 13fcfae7..23a0daf0 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -493,11 +493,14 @@ export function createResponsesRoutes( : null) ?? parsed.reasoningEffort ?? config.model.default_reasoning_effort; - const summary = - isRecord(body.reasoning) && typeof body.reasoning.summary === "string" - ? body.reasoning.summary - : "auto"; - codexRequest.reasoning = { summary, ...(effort ? { effort } : {}) }; + const clientReasoningRecord = isRecord(body.reasoning) ? body.reasoning : null; + if (effort || clientReasoningRecord) { + const summary = + clientReasoningRecord && typeof clientReasoningRecord.summary === "string" + ? clientReasoningRecord.summary + : "auto"; + codexRequest.reasoning = { summary, ...(effort ? { effort } : {}) }; + } // Service tier const serviceTier = diff --git a/src/translation/anthropic-to-codex.ts b/src/translation/anthropic-to-codex.ts index f59e5836..43b02f78 100644 --- a/src/translation/anthropic-to-codex.ts +++ b/src/translation/anthropic-to-codex.ts @@ -234,7 +234,9 @@ export function translateAnthropicToCodexRequest( thinkingEffort ?? parsed.reasoningEffort ?? cfg.default_reasoning_effort; - request.reasoning = { summary: "auto", ...(effort ? { effort } : {}) }; + if (effort) { + request.reasoning = { effort, summary: "auto" }; + } // Service tier: suffix > config default const serviceTier = diff --git a/src/translation/gemini-to-codex.ts b/src/translation/gemini-to-codex.ts index 73dba4ee..10027bc5 100644 --- a/src/translation/gemini-to-codex.ts +++ b/src/translation/gemini-to-codex.ts @@ -227,7 +227,9 @@ export function translateGeminiToCodexRequest( thinkingEffort ?? parsed.reasoningEffort ?? cfg.default_reasoning_effort; - request.reasoning = { summary: "auto", ...(effort ? { effort } : {}) }; + if (effort) { + request.reasoning = { effort, summary: "auto" }; + } // Service tier: suffix > config default const serviceTier = diff --git a/src/translation/openai-to-codex.ts b/src/translation/openai-to-codex.ts index 057004b7..9cb8d747 100644 --- a/src/translation/openai-to-codex.ts +++ b/src/translation/openai-to-codex.ts @@ -187,7 +187,9 @@ export function translateToCodexRequest( req.reasoning_effort ?? parsed.reasoningEffort ?? cfg.default_reasoning_effort; - request.reasoning = { summary: "auto", ...(effort ? { effort } : {}) }; + if (effort) { + request.reasoning = { effort, summary: "auto" }; + } // Service tier: explicit API field > suffix > config default const serviceTier = diff --git a/tests/unit/translation/anthropic-to-codex.test.ts b/tests/unit/translation/anthropic-to-codex.test.ts new file mode 100644 index 00000000..5d36c01d --- /dev/null +++ b/tests/unit/translation/anthropic-to-codex.test.ts @@ -0,0 +1,685 @@ +/** + * Tests for translateAnthropicToCodexRequest — Anthropic Messages → Codex format. + */ + +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@src/config.js", () => ({ + getConfig: vi.fn(() => ({ + model: { + default: "gpt-5.2-codex", + default_reasoning_effort: null, + default_service_tier: null, + suppress_desktop_directives: false, + }, + })), +})); + +vi.mock("@src/paths.js", () => ({ + getConfigDir: vi.fn(() => "/tmp/test-config"), +})); + +vi.mock("@src/translation/shared-utils.js", () => ({ + buildInstructions: vi.fn((text: string) => text), + budgetToEffort: vi.fn((budget: number | undefined) => { + if (!budget || budget <= 0) return undefined; + if (budget < 2000) return "low"; + if (budget < 8000) return "medium"; + if (budget < 20000) return "high"; + return "xhigh"; + }), +})); + +vi.mock("@src/translation/tool-format.js", () => ({ + anthropicToolsToCodex: vi.fn((tools: unknown[]) => tools), + anthropicToolChoiceToCodex: vi.fn(() => undefined), +})); + +vi.mock("@src/models/model-store.js", () => ({ + parseModelName: vi.fn((input: string) => { + if (input === "codex") return { modelId: "gpt-5.4", serviceTier: null, reasoningEffort: null }; + if (input === "gpt-5.4-fast") return { modelId: "gpt-5.4", serviceTier: "fast", reasoningEffort: null }; + if (input === "gpt-5.4-high") return { modelId: "gpt-5.4", serviceTier: null, reasoningEffort: "high" }; + return { modelId: input, serviceTier: null, reasoningEffort: null }; + }), + getModelInfo: vi.fn((id: string) => { + if (id === "gpt-5.4") return { defaultReasoningEffort: "medium" }; + return undefined; + }), +})); + +import { translateAnthropicToCodexRequest } from "@src/translation/anthropic-to-codex.js"; +import { anthropicToolsToCodex, anthropicToolChoiceToCodex } from "@src/translation/tool-format.js"; +import type { AnthropicMessagesRequest } from "@src/types/anthropic.js"; + +function makeRequest(overrides: Partial = {}): AnthropicMessagesRequest { + return { + model: "gpt-5.4", + max_tokens: 4096, + messages: [{ role: "user", content: "Hello" }], + ...overrides, + } as AnthropicMessagesRequest; +} + +describe("translateAnthropicToCodexRequest", () => { + // ── System instructions ────────────────────────────────────────────── + + describe("system instructions", () => { + it("uses string system as instructions", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ system: "Be concise." }), + ); + expect(result.instructions).toBe("Be concise."); + }); + + it("joins text block array system into instructions", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + system: [ + { type: "text" as const, text: "First paragraph." }, + { type: "text" as const, text: "Second paragraph." }, + ], + }), + ); + expect(result.instructions).toBe("First paragraph.\n\nSecond paragraph."); + }); + + it("falls back to default instructions when no system provided", () => { + const result = translateAnthropicToCodexRequest(makeRequest()); + expect(result.instructions).toBe("You are a helpful assistant."); + }); + }); + + // ── Messages ───────────────────────────────────────────────────────── + + describe("messages", () => { + it("converts user text string to input item", () => { + const result = translateAnthropicToCodexRequest(makeRequest()); + expect(result.input).toHaveLength(1); + expect(result.input[0]).toEqual({ role: "user", content: "Hello" }); + }); + + it("converts user with array content (text blocks) to text string", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "user", + content: [ + { type: "text" as const, text: "Line one" }, + { type: "text" as const, text: "Line two" }, + ], + }, + ], + }), + ); + expect(result.input).toHaveLength(1); + expect(result.input[0]).toEqual({ role: "user", content: "Line one\nLine two" }); + }); + + it("converts image block to input_image content part", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "user", + content: [ + { type: "text" as const, text: "Describe this" }, + { + type: "image" as const, + source: { + type: "base64" as const, + media_type: "image/png", + data: "iVBOR...", + }, + }, + ], + }, + ], + }), + ); + expect(result.input).toHaveLength(1); + const item = result.input[0]; + expect(Array.isArray(item.content)).toBe(true); + const parts = item.content as Array>; + expect(parts).toHaveLength(2); + expect(parts[0]).toEqual({ type: "input_text", text: "Describe this" }); + expect(parts[1]).toEqual({ + type: "input_image", + image_url: "data:image/png;base64,iVBOR...", + }); + }); + + it("converts tool_use block to function_call input item", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use" as const, + id: "toolu_01", + name: "search", + input: { query: "test" }, + }, + ], + }, + ], + }), + ); + const fcItem = result.input.find( + (i) => "type" in i && i.type === "function_call", + ); + expect(fcItem).toBeDefined(); + expect(fcItem).toMatchObject({ + type: "function_call", + call_id: "toolu_01", + name: "search", + arguments: '{"query":"test"}', + }); + }); + + it("converts tool_result block to function_call_output input item", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "toolu_01", + content: "result data", + }, + ], + }, + ], + }), + ); + const outputItem = result.input.find( + (i) => "type" in i && i.type === "function_call_output", + ); + expect(outputItem).toBeDefined(); + expect(outputItem).toMatchObject({ + type: "function_call_output", + call_id: "toolu_01", + output: "result data", + }); + }); + + it("prepends 'Error: ' to tool_result output when is_error is true", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "toolu_02", + content: "something went wrong", + is_error: true, + }, + ], + }, + ], + }), + ); + const outputItem = result.input.find( + (i) => "type" in i && i.type === "function_call_output", + ); + expect(outputItem).toBeDefined(); + expect((outputItem as Record).output).toBe( + "Error: something went wrong", + ); + }); + }); + + // ── Thinking → reasoning effort ────────────────────────────────────── + + describe("thinking to reasoning effort", () => { + it("maps enabled thinking with budget_tokens to effort", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + thinking: { type: "enabled", budget_tokens: 5000 }, + }), + ); + // budgetToEffort(5000) → "medium" + expect(result.reasoning?.effort).toBe("medium"); + }); + + it("maps enabled thinking with small budget to low effort", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + thinking: { type: "enabled", budget_tokens: 500 }, + }), + ); + expect(result.reasoning?.effort).toBe("low"); + }); + + it("maps disabled thinking to undefined effort", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + thinking: { type: "disabled" }, + }), + ); + // disabled → undefined, no config default → no effort set + expect(result.reasoning?.effort).toBeUndefined(); + }); + + it("maps adaptive thinking with budget_tokens to effort", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + thinking: { type: "adaptive", budget_tokens: 15000 }, + }), + ); + // budgetToEffort(15000) → "high" + expect(result.reasoning?.effort).toBe("high"); + }); + + it("maps adaptive thinking without budget_tokens to undefined", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + thinking: { type: "adaptive" }, + }), + ); + // adaptive without budget → undefined, no config default → no effort set + expect(result.reasoning?.effort).toBeUndefined(); + }); + }); + + // ── Model parsing ──────────────────────────────────────────────────── + + describe("model parsing", () => { + it("resolves 'codex' alias via parseModelName", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ model: "codex" }), + ); + expect(result.model).toBe("gpt-5.4"); + }); + + it("extracts service_tier from -fast suffix", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ model: "gpt-5.4-fast" }), + ); + expect(result.service_tier).toBe("fast"); + }); + + it("extracts reasoning effort from -high suffix", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ model: "gpt-5.4-high" }), + ); + expect(result.reasoning?.effort).toBe("high"); + }); + }); + + // ── Tools ──────────────────────────────────────────────────────────── + + describe("tools", () => { + it("delegates tools array to anthropicToolsToCodex", () => { + const tools = [ + { name: "search", description: "Search the web", input_schema: {} }, + ]; + translateAnthropicToCodexRequest(makeRequest({ tools })); + + expect(anthropicToolsToCodex).toHaveBeenCalledWith(tools); + }); + + it("delegates tool_choice to anthropicToolChoiceToCodex", () => { + const toolChoice = { type: "auto" as const }; + translateAnthropicToCodexRequest(makeRequest({ tool_choice: toolChoice })); + + expect(anthropicToolChoiceToCodex).toHaveBeenCalledWith(toolChoice); + }); + }); + + // ── Fixed fields ───────────────────────────────────────────────────── + + describe("fixed fields", () => { + it("always sets stream to true", () => { + const result = translateAnthropicToCodexRequest(makeRequest()); + expect(result.stream).toBe(true); + }); + + it("always sets store to false", () => { + const result = translateAnthropicToCodexRequest(makeRequest()); + expect(result.store).toBe(false); + }); + + it("does not set reasoning when no effort is configured or requested", () => { + const result = translateAnthropicToCodexRequest(makeRequest()); + expect(result.reasoning).toBeUndefined(); + }); + }); + + // ── Empty messages ─────────────────────────────────────────────────── + + describe("empty messages", () => { + it("ensures at least one input item when messages produce no items", () => { + // All thinking blocks get filtered out, producing no items + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "assistant", + content: [ + { type: "thinking" as const, thinking: "internal thought" }, + ], + }, + ], + }), + ); + expect(result.input.length).toBeGreaterThanOrEqual(1); + }); + }); + + // ── tool_result with array content ───────────────────────────────── + + describe("tool_result with array content", () => { + it("converts tool_result with array text content to joined string", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "toolu_arr", + content: [ + { type: "text" as const, text: "Line 1" }, + { type: "text" as const, text: "Line 2" }, + ], + }, + ], + }, + ], + }), + ); + const outputItem = result.input.find( + (i) => "type" in i && i.type === "function_call_output", + ); + expect(outputItem).toBeDefined(); + expect((outputItem as Record).output).toBe("Line 1\nLine 2"); + }); + }); + + // ── tool_result with image content (screenshot scenario) ─────────── + + describe("tool_result with image content", () => { + it("extracts images from tool_result into a following user message", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "toolu_img", + content: [ + { type: "text" as const, text: "Screenshot captured" }, + { + type: "image" as const, + source: { + type: "base64" as const, + media_type: "image/png", + data: "iVBORw0KGgo=", + }, + }, + ], + }, + ], + }, + ], + }), + ); + + // Should produce function_call_output with text only + const outputItem = result.input.find( + (i) => "type" in i && i.type === "function_call_output", + ); + expect(outputItem).toBeDefined(); + expect((outputItem as Record).output).toBe("Screenshot captured"); + + // Should produce a follow-up user message with the image + const userItem = result.input.find( + (i) => "role" in i && i.role === "user" && Array.isArray(i.content), + ); + expect(userItem).toBeDefined(); + const parts = (userItem as { content: Array> }).content; + expect(parts).toHaveLength(1); + expect(parts[0].type).toBe("input_image"); + expect(parts[0].image_url).toBe("data:image/png;base64,iVBORw0KGgo="); + }); + + it("handles tool_result with image-only content (no text)", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "toolu_img2", + content: [ + { + type: "image" as const, + source: { + type: "base64" as const, + media_type: "image/jpeg", + data: "/9j/4AAQ", + }, + }, + ], + }, + ], + }, + ], + }), + ); + + const outputItem = result.input.find( + (i) => "type" in i && i.type === "function_call_output", + ); + expect(outputItem).toBeDefined(); + expect((outputItem as Record).output).toBe(""); + + const userItem = result.input.find( + (i) => "role" in i && i.role === "user" && Array.isArray(i.content), + ); + expect(userItem).toBeDefined(); + const parts = (userItem as { content: Array> }).content; + expect(parts[0].image_url).toBe("data:image/jpeg;base64,/9j/4AAQ"); + }); + + it("handles tool_result with multiple images", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "toolu_multi", + content: [ + { type: "text" as const, text: "Two screenshots" }, + { + type: "image" as const, + source: { type: "base64" as const, media_type: "image/png", data: "img1" }, + }, + { + type: "image" as const, + source: { type: "base64" as const, media_type: "image/png", data: "img2" }, + }, + ], + }, + ], + }, + ], + }), + ); + + const userItem = result.input.find( + (i) => "role" in i && i.role === "user" && Array.isArray(i.content), + ); + expect(userItem).toBeDefined(); + const parts = (userItem as { content: Array> }).content; + expect(parts).toHaveLength(2); + expect(parts[0].image_url).toBe("data:image/png;base64,img1"); + expect(parts[1].image_url).toBe("data:image/png;base64,img2"); + }); + }); + + // ── Mixed assistant content ──────────────────────────────────────── + + describe("mixed assistant content", () => { + it("converts assistant text block to assistant input item", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "assistant", + content: [ + { type: "text" as const, text: "Here is the result" }, + ], + }, + ], + }), + ); + const assistantItem = result.input.find( + (i) => "role" in i && i.role === "assistant", + ); + expect(assistantItem).toBeDefined(); + expect((assistantItem as Record).content).toBe("Here is the result"); + }); + + it("handles assistant with both text and tool_use blocks", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "assistant", + content: [ + { type: "text" as const, text: "Let me search" }, + { + type: "tool_use" as const, + id: "toolu_mixed", + name: "search", + input: { query: "test" }, + }, + ], + }, + ], + }), + ); + const assistantItem = result.input.find( + (i) => "role" in i && i.role === "assistant", + ); + const fcItem = result.input.find( + (i) => "type" in i && i.type === "function_call", + ); + expect(assistantItem).toBeDefined(); + expect(fcItem).toBeDefined(); + }); + + it("converts multiple tool_use blocks in single assistant message", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use" as const, + id: "toolu_1", + name: "search", + input: { query: "a" }, + }, + { + type: "tool_use" as const, + id: "toolu_2", + name: "fetch", + input: { url: "https://example.com" }, + }, + ], + }, + ], + }), + ); + const fcItems = result.input.filter( + (i) => "type" in i && i.type === "function_call", + ); + expect(fcItems).toHaveLength(2); + }); + }); + + // ── Thinking block filtering ────────────────────────────────────── + + describe("thinking block handling", () => { + it("filters out thinking blocks from assistant text content", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "assistant", + content: [ + { type: "thinking" as const, thinking: "internal thought" }, + { type: "text" as const, text: "visible answer" }, + ], + }, + ], + }), + ); + const assistantItem = result.input.find( + (i) => "role" in i && i.role === "assistant", + ); + expect(assistantItem).toBeDefined(); + expect((assistantItem as Record).content).toBe("visible answer"); + }); + + it("filters out redacted_thinking blocks from assistant content", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + messages: [ + { + role: "assistant", + content: [ + { type: "redacted_thinking" as const, data: "encrypted" }, + { type: "text" as const, text: "answer" }, + ], + }, + ], + }), + ); + const assistantItem = result.input.find( + (i) => "role" in i && i.role === "assistant", + ); + expect(assistantItem).toBeDefined(); + expect((assistantItem as Record).content).toBe("answer"); + }); + }); + + // ── System instruction edge cases ───────────────────────────────── + + describe("system instruction edge cases", () => { + it("uses default instructions for empty system string", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ system: "" }), + ); + expect(result.instructions).toBe("You are a helpful assistant."); + }); + + it("handles single text block system", () => { + const result = translateAnthropicToCodexRequest( + makeRequest({ + system: [{ type: "text" as const, text: "Only one block." }], + }), + ); + expect(result.instructions).toBe("Only one block."); + }); + }); +}); diff --git a/tests/unit/translation/gemini-to-codex.test.ts b/tests/unit/translation/gemini-to-codex.test.ts new file mode 100644 index 00000000..813502cd --- /dev/null +++ b/tests/unit/translation/gemini-to-codex.test.ts @@ -0,0 +1,479 @@ +/** + * Tests for translateGeminiToCodexRequest / geminiContentsToMessages + * — Google Gemini generateContent → Codex Responses API format. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@src/config.js", () => ({ + getConfig: vi.fn(() => ({ + model: { + default: "gpt-5.2-codex", + default_reasoning_effort: null, + default_service_tier: null, + suppress_desktop_directives: false, + }, + })), +})); + +vi.mock("@src/paths.js", () => ({ + getConfigDir: vi.fn(() => "/tmp/test-config"), +})); + +vi.mock("@src/translation/shared-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + buildInstructions: vi.fn((text: string) => text), + budgetToEffort: vi.fn((budget: number | undefined) => { + if (!budget || budget <= 0) return undefined; + if (budget < 2000) return "low"; + if (budget < 8000) return "medium"; + if (budget < 20000) return "high"; + return "xhigh"; + }), + injectAdditionalProperties: actual.injectAdditionalProperties, + prepareSchema: actual.prepareSchema, + }; +}); + +vi.mock("@src/translation/tool-format.js", () => ({ + geminiToolsToCodex: vi.fn((tools: unknown[]) => []), + geminiToolConfigToCodex: vi.fn(() => undefined), +})); + +vi.mock("@src/models/model-store.js", () => ({ + parseModelName: vi.fn((input: string) => { + if (input === "codex") return { modelId: "gpt-5.4", serviceTier: null, reasoningEffort: null }; + if (input === "gpt-5.4-fast") return { modelId: "gpt-5.4", serviceTier: "fast", reasoningEffort: null }; + if (input === "gpt-5.4-high") return { modelId: "gpt-5.4", serviceTier: null, reasoningEffort: "high" }; + return { modelId: input, serviceTier: null, reasoningEffort: null }; + }), + getModelInfo: vi.fn((id: string) => { + if (id === "gpt-5.4") return { defaultReasoningEffort: "medium" }; + return undefined; + }), +})); + +import { + translateGeminiToCodexRequest as _translateGeminiToCodexRequest, + geminiContentsToMessages, +} from "@src/translation/gemini-to-codex.js"; +import type { GeminiGenerateContentRequest } from "@src/types/gemini.js"; + +/** Unwrap the new GeminiTranslationResult — existing tests only check codexRequest fields. */ +const translateGeminiToCodexRequest = (req: GeminiGenerateContentRequest, model: string) => + _translateGeminiToCodexRequest(req, model).codexRequest; +import { geminiToolsToCodex, geminiToolConfigToCodex } from "@src/translation/tool-format.js"; + +function makeRequest( + overrides: Partial = {}, +): GeminiGenerateContentRequest { + return { + contents: [{ role: "user", parts: [{ text: "Hello" }] }], + ...overrides, + } as GeminiGenerateContentRequest; +} + +describe("translateGeminiToCodexRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("converts basic user text content", () => { + const result = translateGeminiToCodexRequest(makeRequest(), "gpt-5.4"); + expect(result.model).toBe("gpt-5.4"); + expect(result.input).toHaveLength(1); + expect(result.input[0]).toEqual({ role: "user", content: "Hello" }); + }); + + it("extracts systemInstruction parts as instructions", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + systemInstruction: { parts: [{ text: "Be concise." }] }, + }), + "gpt-5.4", + ); + expect(result.instructions).toBe("Be concise."); + }); + + it("defaults instructions to 'You are a helpful assistant.' when no systemInstruction", () => { + const result = translateGeminiToCodexRequest(makeRequest(), "gpt-5.4"); + expect(result.instructions).toBe("You are a helpful assistant."); + }); + + it("maps model role to assistant", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + contents: [ + { role: "user", parts: [{ text: "Hi" }] }, + { role: "model", parts: [{ text: "Hello!" }] }, + ], + }), + "gpt-5.4", + ); + expect(result.input).toHaveLength(2); + expect(result.input[1]).toEqual({ role: "assistant", content: "Hello!" }); + }); + + it("filters out thought parts from text content", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + contents: [ + { + role: "model", + parts: [ + { text: "thinking...", thought: true }, + { text: "Visible answer" }, + ], + }, + ], + }), + "gpt-5.4", + ); + const assistant = result.input.find( + (i) => "role" in i && i.role === "assistant", + ); + expect(assistant).toBeDefined(); + expect((assistant as { content: string }).content).toBe("Visible answer"); + expect((assistant as { content: string }).content).not.toContain("thinking"); + }); + + it("converts inlineData to input_image content parts", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + contents: [ + { + role: "user", + parts: [ + { text: "Describe this" }, + { inlineData: { mimeType: "image/png", data: "abc123" } }, + ], + }, + ], + }), + "gpt-5.4", + ); + const userItem = result.input[0]; + expect(userItem).toHaveProperty("content"); + const content = (userItem as { content: unknown }).content; + expect(Array.isArray(content)).toBe(true); + const parts = content as Array<{ type: string; text?: string; image_url?: string }>; + expect(parts).toHaveLength(2); + expect(parts[0]).toEqual({ type: "input_text", text: "Describe this" }); + expect(parts[1]).toEqual({ + type: "input_image", + image_url: "data:image/png;base64,abc123", + }); + }); + + it("converts functionCall to function_call with generated call_id", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + contents: [ + { + role: "model", + parts: [ + { functionCall: { name: "search", args: { q: "test" } } }, + ], + }, + ], + }), + "gpt-5.4", + ); + const fcItem = result.input.find( + (i) => "type" in i && i.type === "function_call", + ); + expect(fcItem).toBeDefined(); + expect(fcItem).toMatchObject({ + type: "function_call", + call_id: "fc_0", + name: "search", + arguments: '{"q":"test"}', + }); + }); + + it("converts functionResponse to function_call_output matching call_id by name", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + contents: [ + { + role: "model", + parts: [ + { functionCall: { name: "search", args: { q: "test" } } }, + ], + }, + { + role: "user", + parts: [ + { functionResponse: { name: "search", response: { result: "found" } } }, + ], + }, + ], + }), + "gpt-5.4", + ); + const fcItem = result.input.find( + (i) => "type" in i && i.type === "function_call", + ); + const fcOutput = result.input.find( + (i) => "type" in i && i.type === "function_call_output", + ); + expect(fcItem).toBeDefined(); + expect(fcOutput).toBeDefined(); + // The functionResponse for "search" should match the call_id from the functionCall + expect((fcOutput as { call_id: string }).call_id).toBe( + (fcItem as { call_id: string }).call_id, + ); + expect(fcOutput).toMatchObject({ + type: "function_call_output", + output: '{"result":"found"}', + }); + }); + + it("converts thinkingBudget to reasoning effort via budgetToEffort", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + generationConfig: { + thinkingConfig: { thinkingBudget: 5000 }, + }, + }), + "gpt-5.4", + ); + expect(result.reasoning?.effort).toBe("medium"); + }); + + it("maps model suffix -fast to service_tier", () => { + const result = translateGeminiToCodexRequest(makeRequest(), "gpt-5.4-fast"); + expect(result.service_tier).toBe("fast"); + }); + + it("maps model suffix -high to reasoning effort", () => { + const result = translateGeminiToCodexRequest(makeRequest(), "gpt-5.4-high"); + expect(result.reasoning?.effort).toBe("high"); + }); + + it("delegates tools to geminiToolsToCodex", () => { + const tools = [ + { functionDeclarations: [{ name: "fn1", description: "desc" }] }, + ]; + translateGeminiToCodexRequest(makeRequest({ tools }), "gpt-5.4"); + expect(geminiToolsToCodex).toHaveBeenCalledWith(tools); + }); + + it("delegates toolConfig to geminiToolConfigToCodex", () => { + const toolConfig = { + functionCallingConfig: { mode: "AUTO" as const }, + }; + translateGeminiToCodexRequest(makeRequest({ toolConfig }), "gpt-5.4"); + expect(geminiToolConfigToCodex).toHaveBeenCalledWith(toolConfig); + }); + + it("always sets stream: true and store: false", () => { + const result = translateGeminiToCodexRequest(makeRequest(), "gpt-5.4"); + expect(result.stream).toBe(true); + expect(result.store).toBe(false); + }); + + it("does not set reasoning when no effort is configured or requested", () => { + const result = translateGeminiToCodexRequest(makeRequest(), "gpt-5.4"); + expect(result.reasoning).toBeUndefined(); + }); + + it("ensures at least one input item when contents produce no items", () => { + // Even with empty parts, there should be at least one input + const result = translateGeminiToCodexRequest( + makeRequest({ + contents: [{ role: "user", parts: [{ text: "" }] }], + }), + "gpt-5.4", + ); + expect(result.input.length).toBeGreaterThanOrEqual(1); + }); + + it("does not set reasoning when no override is given", () => { + // no client request, no suffix, config default is null → reasoning field absent + const result = translateGeminiToCodexRequest(makeRequest(), "gpt-5.4"); + expect(result.reasoning).toBeUndefined(); + }); + + it("does not set service_tier when suffix has none and config default is null", () => { + const result = translateGeminiToCodexRequest(makeRequest(), "gpt-5.4"); + expect(result.service_tier).toBeUndefined(); + }); + + // ── Response format (Structured Outputs) ────────────────────────── + + describe("response_format via responseMimeType", () => { + it("converts application/json without responseSchema to json_object", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + generationConfig: { + responseMimeType: "application/json", + }, + }), + "gpt-5.4", + ); + expect(result.text).toEqual({ format: { type: "json_object" } }); + }); + + it("converts application/json with responseSchema to json_schema", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + generationConfig: { + responseMimeType: "application/json", + responseSchema: { + type: "object", + properties: { name: { type: "string" } }, + }, + }, + }), + "gpt-5.4", + ); + expect(result.text?.format).toMatchObject({ + type: "json_schema", + name: "gemini_schema", + strict: true, + }); + // Should auto-inject additionalProperties: false + expect(result.text?.format.schema).toHaveProperty("additionalProperties", false); + }); + + it("does not set text.format when responseMimeType is not application/json", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + generationConfig: { + responseMimeType: "text/plain", + }, + }), + "gpt-5.4", + ); + expect(result.text).toBeUndefined(); + }); + + it("does not set text.format when no generationConfig", () => { + const result = translateGeminiToCodexRequest(makeRequest(), "gpt-5.4"); + expect(result.text).toBeUndefined(); + }); + }); + + // ── Multiple function calls ─────────────────────────────────────── + + describe("multiple function calls", () => { + it("generates incremental call_ids for multiple functionCalls", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + contents: [ + { + role: "model", + parts: [ + { functionCall: { name: "search", args: { q: "a" } } }, + { functionCall: { name: "fetch", args: { url: "b" } } }, + ], + }, + ], + }), + "gpt-5.4", + ); + const fcItems = result.input.filter( + (i) => "type" in i && i.type === "function_call", + ); + expect(fcItems).toHaveLength(2); + expect(fcItems[0]).toMatchObject({ call_id: "fc_0", name: "search" }); + expect(fcItems[1]).toMatchObject({ call_id: "fc_1", name: "fetch" }); + }); + }); + + // ── systemInstruction edge cases ────────────────────────────────── + + describe("systemInstruction edge cases", () => { + it("joins multiple systemInstruction parts", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + systemInstruction: { + parts: [ + { text: "First part." }, + { text: "Second part." }, + ], + }, + }), + "gpt-5.4", + ); + expect(result.instructions).toContain("First part."); + expect(result.instructions).toContain("Second part."); + }); + }); + + // ── thought parts edge cases ────────────────────────────────────── + + describe("thought parts edge cases", () => { + it("returns empty string for model turn with only thought parts", () => { + const result = translateGeminiToCodexRequest( + makeRequest({ + contents: [ + { + role: "model", + parts: [ + { text: "thinking...", thought: true }, + ], + }, + ], + }), + "gpt-5.4", + ); + const assistant = result.input.find( + (i) => "role" in i && i.role === "assistant", + ); + expect(assistant).toBeDefined(); + expect((assistant as { content: string }).content).toBe(""); + }); + }); +}); + +describe("geminiContentsToMessages", () => { + it("converts basic contents to role/content pairs", () => { + const contents = [ + { role: "user" as const, parts: [{ text: "Hello" }] }, + { role: "model" as const, parts: [{ text: "Hi there" }] }, + ]; + const messages = geminiContentsToMessages(contents); + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual({ role: "user", content: "Hello" }); + expect(messages[1]).toEqual({ role: "assistant", content: "Hi there" }); + }); + + it("maps 'model' role to 'assistant'", () => { + const contents = [{ role: "model" as const, parts: [{ text: "Response" }] }]; + const messages = geminiContentsToMessages(contents); + expect(messages[0].role).toBe("assistant"); + }); + + it("prepends systemInstruction as system message", () => { + const contents = [{ role: "user" as const, parts: [{ text: "Hi" }] }]; + const systemInstruction = { parts: [{ text: "You are a coding assistant." }] }; + const messages = geminiContentsToMessages(contents, systemInstruction); + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual({ + role: "system", + content: "You are a coding assistant.", + }); + expect(messages[1]).toEqual({ role: "user", content: "Hi" }); + }); + + it("does not add system message when no systemInstruction provided", () => { + const contents = [{ role: "user" as const, parts: [{ text: "Hi" }] }]; + const messages = geminiContentsToMessages(contents); + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe("user"); + }); + + it("extracts text from multiple parts joined by newline", () => { + const contents = [ + { + role: "user" as const, + parts: [{ text: "Line 1" }, { text: "Line 2" }], + }, + ]; + const messages = geminiContentsToMessages(contents); + expect(messages[0].content).toBe("Line 1\nLine 2"); + }); +}); diff --git a/tests/unit/translation/openai-to-codex.test.ts b/tests/unit/translation/openai-to-codex.test.ts new file mode 100644 index 00000000..c780591e --- /dev/null +++ b/tests/unit/translation/openai-to-codex.test.ts @@ -0,0 +1,406 @@ +/** + * Tests for translateToCodexRequest — OpenAI Chat Completions → Codex format. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@src/config.js", () => ({ + getConfig: vi.fn(() => ({ + model: { + default: "gpt-5.2-codex", + default_reasoning_effort: null, + default_service_tier: null, + suppress_desktop_directives: false, + }, + })), +})); + +vi.mock("@src/paths.js", () => ({ + getConfigDir: vi.fn(() => "/tmp/test-config"), +})); + +vi.mock("@src/translation/shared-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + buildInstructions: vi.fn((text: string) => text), + injectAdditionalProperties: actual.injectAdditionalProperties, + prepareSchema: actual.prepareSchema, + }; +}); + +vi.mock("@src/translation/tool-format.js", () => ({ + openAIToolsToCodex: vi.fn((tools: unknown[]) => tools), + openAIToolChoiceToCodex: vi.fn(() => undefined), + openAIFunctionsToCodex: vi.fn((fns: unknown[]) => fns), +})); + +vi.mock("@src/models/model-store.js", () => ({ + parseModelName: vi.fn((input: string) => { + if (input === "codex") return { modelId: "gpt-5.4", serviceTier: null, reasoningEffort: null }; + if (input === "gpt-5.4-fast") return { modelId: "gpt-5.4", serviceTier: "fast", reasoningEffort: null }; + if (input === "gpt-5.4-high") return { modelId: "gpt-5.4", serviceTier: null, reasoningEffort: "high" }; + if (input === "gpt-5.4-high-fast") return { modelId: "gpt-5.4", serviceTier: "fast", reasoningEffort: "high" }; + return { modelId: input, serviceTier: null, reasoningEffort: null }; + }), + getModelInfo: vi.fn((id: string) => { + if (id === "gpt-5.4") return { defaultReasoningEffort: "medium" }; + return undefined; + }), +})); + +import { translateToCodexRequest as _translateToCodexRequest } from "@src/translation/openai-to-codex.js"; +import type { ChatCompletionRequest } from "@src/types/openai.js"; + +/** Unwrap the new TranslationResult — existing tests only check codexRequest fields. */ +const translateToCodexRequest = (req: ChatCompletionRequest) => _translateToCodexRequest(req).codexRequest; + +function makeRequest(overrides: Partial = {}): ChatCompletionRequest { + return { + model: "gpt-5.4", + messages: [{ role: "user", content: "Hello" }], + stream: false, + n: 1, + ...overrides, + } as ChatCompletionRequest; +} + +describe("translateToCodexRequest", () => { + it("converts basic user message", () => { + const result = translateToCodexRequest(makeRequest()); + expect(result.model).toBe("gpt-5.4"); + expect(result.input).toHaveLength(1); + expect(result.input[0]).toEqual({ role: "user", content: "Hello" }); + expect(result.stream).toBe(true); + expect(result.store).toBe(false); + }); + + it("extracts system messages as instructions", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "Hi" }, + ], + })); + expect(result.instructions).toBe("You are helpful."); + expect(result.input).toHaveLength(1); + }); + + it("combines multiple system/developer messages", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { role: "system", content: "Be concise." }, + { role: "developer", content: "Use JSON." }, + { role: "user", content: "Hi" }, + ], + })); + expect(result.instructions).toContain("Be concise."); + expect(result.instructions).toContain("Use JSON."); + }); + + it("default instructions when no system messages", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [{ role: "user", content: "Hi" }], + })); + expect(result.instructions).toBe("You are a helpful assistant."); + }); + + it("converts assistant messages with tool_calls", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { role: "user", content: "Hi" }, + { + role: "assistant", + content: "", + tool_calls: [{ + id: "call_1", + type: "function" as const, + function: { name: "search", arguments: '{"q":"test"}' }, + }], + }, + { role: "tool", content: "result data", tool_call_id: "call_1" }, + ], + })); + const fcItem = result.input.find((i) => "type" in i && i.type === "function_call"); + expect(fcItem).toBeDefined(); + const fcOutput = result.input.find((i) => "type" in i && i.type === "function_call_output"); + expect(fcOutput).toBeDefined(); + }); + + it("resolves model alias via parseModelName", () => { + const result = translateToCodexRequest(makeRequest({ model: "codex" })); + expect(result.model).toBe("gpt-5.4"); + }); + + it("uses suffix-parsed service_tier", () => { + const result = translateToCodexRequest(makeRequest({ model: "gpt-5.4-fast" })); + expect(result.service_tier).toBe("fast"); + }); + + it("uses suffix-parsed reasoning_effort", () => { + const result = translateToCodexRequest(makeRequest({ model: "gpt-5.4-high" })); + expect(result.reasoning?.effort).toBe("high"); + }); + + it("explicit reasoning_effort overrides suffix", () => { + const result = translateToCodexRequest(makeRequest({ + model: "gpt-5.4-high", + reasoning_effort: "low", + })); + expect(result.reasoning?.effort).toBe("low"); + }); + + it("does not set reasoning when no effort is configured or requested", () => { + const result = translateToCodexRequest(makeRequest()); + expect(result.reasoning).toBeUndefined(); + }); + + it("sets reasoning with summary: auto when effort is present", () => { + const result = translateToCodexRequest(makeRequest({ reasoning_effort: "medium" })); + expect(result.reasoning).toEqual({ effort: "medium", summary: "auto" }); + }); + + it("ensures at least one input item", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [{ role: "system", content: "System only" }], + })); + expect(result.input.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ── Multimodal image_url ────────────────────────────────────────────── + +describe("translateToCodexRequest — multimodal content", () => { + it("converts image_url object format to input_image part", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe this" }, + { type: "image_url", image_url: { url: "data:image/png;base64,abc123" } }, + ], + }, + ], + })); + const item = result.input[0]; + expect(Array.isArray(item.content)).toBe(true); + const parts = item.content as Array>; + expect(parts).toHaveLength(2); + expect(parts[0]).toEqual({ type: "input_text", text: "Describe this" }); + expect(parts[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,abc123" }); + }); + + it("converts image_url string format to input_image part", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { type: "image_url", image_url: "https://example.com/img.jpg" }, + ], + }, + ], + })); + const item = result.input[0]; + expect(Array.isArray(item.content)).toBe(true); + const parts = item.content as Array>; + expect(parts[1]).toEqual({ type: "input_image", image_url: "https://example.com/img.jpg" }); + }); + + it("converts text-only array content to plain string", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Hello" }, + { type: "text", text: "World" }, + ], + }, + ], + })); + expect(result.input[0]).toEqual({ role: "user", content: "Hello\nWorld" }); + }); +}); + +// ── Legacy function format ──────────────────────────────────────────── + +describe("translateToCodexRequest — legacy function format", () => { + it("converts assistant function_call to function_call input item", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { role: "user", content: "Search" }, + { + role: "assistant", + content: null, + function_call: { name: "search", arguments: '{"q":"test"}' }, + }, + ], + })); + const fcItem = result.input.find((i) => "type" in i && i.type === "function_call"); + expect(fcItem).toBeDefined(); + expect(fcItem).toMatchObject({ + type: "function_call", + call_id: "fc_search", + name: "search", + arguments: '{"q":"test"}', + }); + }); + + it("converts function role message to function_call_output", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { role: "user", content: "Search" }, + { + role: "assistant", + content: null, + function_call: { name: "search", arguments: '{"q":"test"}' }, + }, + { role: "function", name: "search", content: "result data" }, + ], + })); + const fcOutput = result.input.find((i) => "type" in i && i.type === "function_call_output"); + expect(fcOutput).toBeDefined(); + expect(fcOutput).toMatchObject({ + type: "function_call_output", + call_id: "fc_search", + output: "result data", + }); + }); +}); + +// ── Response format (Structured Outputs) ────────────────────────────── + +describe("translateToCodexRequest — response_format", () => { + it("converts json_object to text.format", () => { + const result = translateToCodexRequest(makeRequest({ + response_format: { type: "json_object" }, + })); + expect(result.text).toEqual({ format: { type: "json_object" } }); + }); + + it("converts json_schema to text.format with schema details", () => { + const result = translateToCodexRequest(makeRequest({ + response_format: { + type: "json_schema", + json_schema: { + name: "my_schema", + schema: { type: "object", properties: { result: { type: "string" } } }, + strict: true, + }, + }, + })); + expect(result.text).toEqual({ + format: { + type: "json_schema", + name: "my_schema", + schema: { type: "object", properties: { result: { type: "string" } }, additionalProperties: false }, + strict: true, + }, + }); + }); + + it("does not set text.format for type 'text'", () => { + const result = translateToCodexRequest(makeRequest({ + response_format: { type: "text" }, + })); + expect(result.text).toBeUndefined(); + }); + + it("converts json_schema without strict field", () => { + const result = translateToCodexRequest(makeRequest({ + response_format: { + type: "json_schema", + json_schema: { + name: "schema_no_strict", + schema: { type: "object" }, + }, + }, + })); + expect(result.text?.format).toMatchObject({ + type: "json_schema", + name: "schema_no_strict", + }); + expect(result.text?.format).not.toHaveProperty("strict"); + }); +}); + +// ── Branch coverage: content edge cases ─────────────────────────────── + +describe("translateToCodexRequest — content edge cases", () => { + it("converts assistant message with both text and tool_calls", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { role: "user", content: "Search and explain" }, + { + role: "assistant", + content: "I'll search for that.", + tool_calls: [{ + id: "call_1", + type: "function" as const, + function: { name: "search", arguments: '{"q":"test"}' }, + }], + }, + ], + })); + // Both text content and function_call should be in the input + const assistantItem = result.input.find((i) => "role" in i && i.role === "assistant"); + expect(assistantItem).toBeDefined(); + expect(assistantItem!.content).toBe("I'll search for that."); + const fcItem = result.input.find((i) => "type" in i && i.type === "function_call"); + expect(fcItem).toBeDefined(); + }); + + it("skips image_url part when image_url is null/undefined", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe" }, + { type: "image_url", image_url: null }, + ], + }, + ], + })); + // Should not crash; the null image_url part should be skipped + const item = result.input[0]; + if (Array.isArray(item.content)) { + // Only text part should remain + expect(item.content).toHaveLength(1); + expect(item.content[0]).toEqual({ type: "input_text", text: "Describe" }); + } else { + // Or collapsed to string if only text + expect(typeof item.content).toBe("string"); + } + }); + + it("converts function role message without name to fc_unknown", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { role: "user", content: "Run function" }, + { + role: "assistant", + content: null, + function_call: { name: "do_thing", arguments: "{}" }, + }, + { role: "function", content: "done" }, // no name field + ], + })); + const fcOutput = result.input.find( + (i) => "type" in i && i.type === "function_call_output" && "call_id" in i && i.call_id === "fc_unknown", + ); + expect(fcOutput).toBeDefined(); + }); + + it("converts null content to empty string", () => { + const result = translateToCodexRequest(makeRequest({ + messages: [ + { role: "user", content: null }, + ], + })); + // null content → extractContent returns "" + expect(result.input[0]).toEqual({ role: "user", content: "" }); + }); +});