diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f910114..8e9ccc65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - CLI cache: include local media `fileMtime` when writing transcript cache entries so repeated unchanged audio/video extraction can hit cache (#240, #241, thanks @alfozan). - CLI: pass Codex image attachments to `codex exec` so local image summaries no longer fail before starting (#242, #243, thanks @alfozan). +- OpenAI-compatible gateways: honor `OPENAI_USE_CHAT_COMPLETIONS=false` and `openai.useChatCompletions=false` so custom base URLs can use the Responses API (#235, #236, thanks @mzbgf). - Chrome extension: abort stale side-panel summary streams on tab changes so delayed output from a closed or replaced tab cannot render under the new page title. - Core: extract video IDs from YouTube `/live/` URLs so live and premiere links no longer abort summarization (#232, thanks @devYRPauli). - Chrome extension: keep YouTube slide cards on the shared slide-summary path so local browser thumbnails receive the same summary text shape as CLI `--slides`. diff --git a/src/daemon/agent-model.ts b/src/daemon/agent-model.ts index 037d451c..72f1698d 100644 --- a/src/daemon/agent-model.ts +++ b/src/daemon/agent-model.ts @@ -58,7 +58,7 @@ function overrideModelGatewaySettings({ provider: string; model: Model; baseUrl: string | null; - forceOpenAiChatCompletions: boolean; + forceOpenAiChatCompletions: boolean | undefined; }) { const nextModel = baseUrl ? ({ ...model, baseUrl } as Model) : model; if (provider !== "openai") return nextModel; @@ -66,19 +66,20 @@ function overrideModelGatewaySettings({ typeof nextModel.baseUrl === "string" && nextModel.baseUrl.trim().length > 0 ? nextModel.baseUrl.trim() : null; - const shouldUseChatCompletions = - forceOpenAiChatCompletions || - isCustomOpenAiBaseUrl(effectiveBaseUrl) || - (effectiveBaseUrl !== null && isOpenRouterBaseUrl(effectiveBaseUrl)); + const isOpenRouterBase = effectiveBaseUrl !== null && isOpenRouterBaseUrl(effectiveBaseUrl); + const shouldUseChatCompletions = isOpenRouterBase + ? true + : typeof forceOpenAiChatCompletions === "boolean" + ? forceOpenAiChatCompletions + : isCustomOpenAiBaseUrl(effectiveBaseUrl); if (!shouldUseChatCompletions) return nextModel; - const headers = - effectiveBaseUrl !== null && isOpenRouterBaseUrl(effectiveBaseUrl) - ? { - ...((nextModel as Model & { headers?: Record }).headers ?? {}), - "HTTP-Referer": "https://github.com/steipete/summarize", - "X-Title": "summarize", - } - : (nextModel as Model & { headers?: Record }).headers; + const headers = isOpenRouterBase + ? { + ...((nextModel as Model & { headers?: Record }).headers ?? {}), + "HTTP-Referer": "https://github.com/steipete/summarize", + "X-Title": "summarize", + } + : (nextModel as Model & { headers?: Record }).headers; return { ...nextModel, api: "openai-completions", @@ -95,7 +96,7 @@ function resolveModelWithFallback({ provider: string; modelId: string; baseUrl: string | null; - forceOpenAiChatCompletions: boolean; + forceOpenAiChatCompletions: boolean | undefined; }): Model { try { const model = getModel(provider as never, modelId as never); @@ -108,10 +109,15 @@ function resolveModelWithFallback({ }); } catch (error) { if (baseUrl) { + const isOpenRouterBase = isOpenRouterBaseUrl(baseUrl); + const api = + provider === "openai" && forceOpenAiChatCompletions === false && !isOpenRouterBase + ? "openai-responses" + : "openai-completions"; return createSyntheticModel({ provider: provider as never, modelId, - api: "openai-completions", + api, baseUrl, allowImages: false, }); @@ -299,13 +305,19 @@ export async function resolveAgentModel({ const applyBaseUrlOverride = (provider: string, modelId: string) => { const baseUrl = providerBaseUrlMap[provider] ?? null; const providerForPiAi = provider === "nvidia" || provider === "ollama" ? "openai" : provider; + const forceOpenAiChatCompletions = + provider === "nvidia" || provider === "ollama" + ? true + : provider === "openai" + ? openaiUseChatCompletions + : undefined; return { provider, model: resolveModelWithFallback({ provider: providerForPiAi, modelId, baseUrl, - forceOpenAiChatCompletions: provider === "openai" && openaiUseChatCompletions, + forceOpenAiChatCompletions, }), }; }; diff --git a/src/daemon/chat.ts b/src/daemon/chat.ts index 601d04c7..5e4c1512 100644 --- a/src/daemon/chat.ts +++ b/src/daemon/chat.ts @@ -111,10 +111,12 @@ function resolveOpenAiUseChatCompletions({ }: { env: Record; configForCli: SummarizeConfig | null; -}): boolean { +}): boolean | undefined { const envValue = parseBooleanEnv(env.OPENAI_USE_CHAT_COMPLETIONS); if (envValue !== null) return envValue; - return configForCli?.openai?.useChatCompletions === true; + return typeof configForCli?.openai?.useChatCompletions === "boolean" + ? configForCli.openai.useChatCompletions + : undefined; } export async function streamChatResponse({ @@ -182,7 +184,7 @@ export async function streamChatResponse({ transport: "native" as const, openaiApiKeyOverride: null, openaiBaseUrlOverride: null, - forceChatCompletions: false, + forceChatCompletions: undefined, }; } return { @@ -205,11 +207,17 @@ export async function streamChatResponse({ ? envState.nvidiaBaseUrl : requested.requiredEnv === "OLLAMA_BASE_URL" ? envState.ollamaBaseUrl - : (requested.openaiBaseUrlOverride ?? null), + : requested.provider === "openai" + ? (requested.openaiBaseUrlOverride ?? envState.providerBaseUrls.openai) + : (requested.openaiBaseUrlOverride ?? null), forceChatCompletions: - Boolean(requested.forceChatCompletions) || - requested.requiredEnv === "OLLAMA_BASE_URL" || - (requested.provider === "openai" && openaiUseChatCompletions), + typeof requested.forceChatCompletions === "boolean" + ? requested.forceChatCompletions + : requested.requiredEnv === "OLLAMA_BASE_URL" + ? true + : requested.provider === "openai" + ? openaiUseChatCompletions + : undefined, requestOptions: requested.requestOptions, }; } @@ -312,7 +320,18 @@ export async function streamChatResponse({ timeoutMs: 30_000, fetchImpl, forceOpenRouter: attempt.forceOpenRouter, - forceChatCompletions: attempt.requiredEnv === "OPENAI_API_KEY" && openaiUseChatCompletions, + openaiBaseUrlOverride: + attempt.transport === "openrouter" + ? undefined + : attempt.requiredEnv === "OPENAI_API_KEY" + ? envState.providerBaseUrls.openai + : undefined, + forceChatCompletions: + attempt.transport === "openrouter" + ? undefined + : attempt.requiredEnv === "OPENAI_API_KEY" + ? openaiUseChatCompletions + : undefined, requestOptions: mergeModelRequestOptions(openaiRequestOptions, attempt.requestOptions), }); for await (const chunk of result.textStream) { diff --git a/src/llm/providers/openai.ts b/src/llm/providers/openai.ts index 0c5725d5..e2d754af 100644 --- a/src/llm/providers/openai.ts +++ b/src/llm/providers/openai.ts @@ -95,7 +95,11 @@ export function resolveOpenAiClientConfig({ } })(); - const useChatCompletions = Boolean(forceChatCompletions) || isOpenRouter || isCustomBaseURL; + const useChatCompletions = isOpenRouter + ? true + : typeof forceChatCompletions === "boolean" + ? forceChatCompletions + : isCustomBaseURL; return { apiKey, baseURL: baseURL ?? undefined, diff --git a/src/run/flows/url/types.ts b/src/run/flows/url/types.ts index 562e6eb3..2969c1cc 100644 --- a/src/run/flows/url/types.ts +++ b/src/run/flows/url/types.ts @@ -85,7 +85,7 @@ export type UrlFlowModel = { configForModelSelection: SummarizeConfig | null; envForAuto: Record; cliAvailability: Partial>; - openaiUseChatCompletions: boolean; + openaiUseChatCompletions: boolean | undefined; openaiRequestOptions?: ModelRequestOptions; openaiRequestOptionsOverride?: ModelRequestOptions; openaiWhisperUsdPerMinute: number; diff --git a/src/run/run-config.ts b/src/run/run-config.ts index 41db7067..ba38e2a5 100644 --- a/src/run/run-config.ts +++ b/src/run/run-config.ts @@ -14,7 +14,7 @@ export type ConfigState = { videoMode: ReturnType; cliConfigForRun: SummarizeConfig["cli"] | undefined; configForCli: SummarizeConfig | null; - openaiUseChatCompletions: boolean; + openaiUseChatCompletions: boolean | undefined; openaiRequestOptions: ModelRequestOptions | undefined; openaiRequestOptionsOverride: ModelRequestOptions | undefined; configModelLabel: string | null; @@ -79,7 +79,7 @@ export function resolveConfigState({ ); if (envValue !== null) return envValue; const configValue = config?.openai?.useChatCompletions; - return typeof configValue === "boolean" ? configValue : false; + return typeof configValue === "boolean" ? configValue : undefined; })(); const openaiRequestOptions: ModelRequestOptions | undefined = (() => { diff --git a/src/run/summary-engine.ts b/src/run/summary-engine.ts index fb7b565e..e35cac77 100644 --- a/src/run/summary-engine.ts +++ b/src/run/summary-engine.ts @@ -36,7 +36,7 @@ export type SummaryEngineDeps = { plain: boolean; verbose: boolean; verboseColor: boolean; - openaiUseChatCompletions: boolean; + openaiUseChatCompletions: boolean | undefined; openaiRequestOptions?: ModelRequestOptions; openaiRequestOptionsOverride?: ModelRequestOptions; cliConfigForRun: Parameters[0]["config"]; @@ -337,8 +337,13 @@ export function createSummaryEngine(deps: SummaryEngineDeps) { transport: attempt.transport === "openrouter" ? "openrouter" : "native", }); const forceChatCompletions = - Boolean(attempt.forceChatCompletions) || - (deps.openaiUseChatCompletions && parsedModelEffective.provider === "openai"); + typeof attempt.forceChatCompletions === "boolean" + ? attempt.forceChatCompletions + : attempt.transport === "openrouter" + ? undefined + : parsedModelEffective.provider === "openai" + ? deps.openaiUseChatCompletions + : undefined; const maxOutputTokensForCall = await deps.resolveMaxOutputTokensForCall( parsedModelEffective.canonical, diff --git a/tests/daemon.agent-model.test.ts b/tests/daemon.agent-model.test.ts index fac6de84..338256d1 100644 --- a/tests/daemon.agent-model.test.ts +++ b/tests/daemon.agent-model.test.ts @@ -1,5 +1,8 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveApiKeyForModel } from "../src/daemon/agent-model.js"; +import { resolveAgentModel, resolveApiKeyForModel } from "../src/daemon/agent-model.js"; const emptyApiKeys = { openaiApiKey: null, @@ -24,4 +27,78 @@ describe("daemon agent model resolution", () => { }), ).toBe("proxy-secret"); }); + + it("honors explicit OpenAI Responses routing for agent custom base URLs", async () => { + const home = mkdtempSync(join(tmpdir(), "summarize-agent-openai-responses-")); + + const resolved = await resolveAgentModel({ + env: { + HOME: home, + OPENAI_API_KEY: "sk-openai", + OPENAI_BASE_URL: "https://gateway.example/v1", + OPENAI_USE_CHAT_COMPLETIONS: "false", + }, + pageContent: "Hello", + modelOverride: "openai/gpt-5.4", + }); + + expect(resolved.provider).toBe("openai"); + expect(resolved.model?.api).toBe("openai-responses"); + expect(resolved.model?.baseUrl).toBe("https://gateway.example/v1"); + }); + + it("keeps OpenRouter base URLs on chat completions for agents", async () => { + const home = mkdtempSync(join(tmpdir(), "summarize-agent-openrouter-base-")); + + const resolved = await resolveAgentModel({ + env: { + HOME: home, + OPENAI_API_KEY: "sk-openrouter-via-openai", + OPENAI_BASE_URL: "https://openrouter.ai/api/v1", + OPENAI_USE_CHAT_COMPLETIONS: "false", + }, + pageContent: "Hello", + modelOverride: "openai/openai/gpt-5-mini", + }); + + expect(resolved.provider).toBe("openai"); + expect(resolved.model?.api).toBe("openai-completions"); + expect(resolved.model?.baseUrl).toBe("https://openrouter.ai/api/v1"); + }); + + it("keeps NVIDIA agent models on chat completions", async () => { + const home = mkdtempSync(join(tmpdir(), "summarize-agent-nvidia-")); + + const resolved = await resolveAgentModel({ + env: { + HOME: home, + NVIDIA_API_KEY: "sk-nvidia", + OPENAI_USE_CHAT_COMPLETIONS: "false", + }, + pageContent: "Hello", + modelOverride: "nvidia/z-ai/glm5", + }); + + expect(resolved.provider).toBe("nvidia"); + expect(resolved.model?.api).toBe("openai-completions"); + expect(resolved.model?.baseUrl).toBe("https://integrate.api.nvidia.com/v1"); + }); + + it("keeps Ollama agent models on chat completions", async () => { + const home = mkdtempSync(join(tmpdir(), "summarize-agent-ollama-")); + + const resolved = await resolveAgentModel({ + env: { + HOME: home, + OLLAMA_BASE_URL: "http://ollama-box:11434/v1", + OPENAI_USE_CHAT_COMPLETIONS: "false", + }, + pageContent: "Hello", + modelOverride: "ollama/qwen3:14b", + }); + + expect(resolved.provider).toBe("ollama"); + expect(resolved.model?.api).toBe("openai-completions"); + expect(resolved.model?.baseUrl).toBe("http://ollama-box:11434/v1"); + }); }); diff --git a/tests/daemon.chat.test.ts b/tests/daemon.chat.test.ts index 05d4b3eb..53402b4b 100644 --- a/tests/daemon.chat.test.ts +++ b/tests/daemon.chat.test.ts @@ -100,6 +100,40 @@ describe("daemon/chat", () => { expect(args.forceChatCompletions).toBe(true); }); + it("passes through openai.useChatCompletions=false for fixed sidepanel chat models", async () => { + const home = mkdtempSync(join(tmpdir(), "summarize-daemon-chat-openai-responses-")); + + await streamChatResponse({ + env: { HOME: home, OPENAI_API_KEY: "sk-openai" }, + fetchImpl: fetch, + configForCli: { + openai: { + baseUrl: "https://gateway.example/v1", + useChatCompletions: false, + }, + }, + session: { + id: "s-openai-responses", + lastMeta: { model: null, modelLabel: null, inputSummary: null, summaryFromCache: null }, + }, + pageUrl: "https://example.com", + pageTitle: "Example", + pageContent: "Hello world", + messages: [{ role: "user", content: "Hi" }], + modelOverride: "openai/gpt-5.4", + pushToSession: () => {}, + emitMeta: () => {}, + }); + + const calls = (streamTextWithContext as unknown as { mock: { calls: unknown[][] } }).mock.calls; + const args = calls[0]?.[0] as { + forceChatCompletions?: boolean; + openaiBaseUrlOverride?: string | null; + }; + expect(args.forceChatCompletions).toBe(false); + expect(args.openaiBaseUrlOverride).toBe("https://gateway.example/v1"); + }); + it("routes github-copilot overrides through the GitHub Models gateway", async () => { const home = mkdtempSync(join(tmpdir(), "summarize-daemon-chat-github-models-")); const meta: Array<{ model?: string | null }> = []; @@ -263,9 +297,14 @@ describe("daemon/chat", () => { }); const calls = (streamTextWithContext as unknown as { mock: { calls: unknown[][] } }).mock.calls; - const args = calls[calls.length - 1]?.[0] as { modelId: string; forceOpenRouter?: boolean }; + const args = calls[calls.length - 1]?.[0] as { + modelId: string; + forceOpenRouter?: boolean; + forceChatCompletions?: boolean; + }; expect(args.modelId).toBe("openai/anthropic/claude-sonnet-4-5"); expect(args.forceOpenRouter).toBe(true); + expect(args.forceChatCompletions).toBeUndefined(); expect(meta[0]?.model).toBe("openrouter/anthropic/claude-sonnet-4-5"); }); @@ -347,6 +386,52 @@ describe("daemon/chat", () => { expect(args.forceChatCompletions).toBe(true); }); + it("passes configured OpenAI base URL for auto-selected sidepanel chat models", async () => { + const home = mkdtempSync(join(tmpdir(), "summarize-daemon-chat-auto-openai-base-")); + + vi.mocked(buildAutoModelAttempts).mockReturnValue([ + { + transport: "native" as const, + userModelId: "openai/gpt-5-mini", + llmModelId: "openai/gpt-5-mini", + openrouterProviders: null, + forceOpenRouter: false, + requiredEnv: "OPENAI_API_KEY" as const, + debug: "test", + }, + ]); + + await streamChatResponse({ + env: { HOME: home, OPENAI_API_KEY: "sk-openai" }, + fetchImpl: fetch, + configForCli: { + openai: { + baseUrl: "https://gateway.example/v1", + useChatCompletions: false, + }, + }, + session: { + id: "s-auto-openai-base", + lastMeta: { model: null, modelLabel: null, inputSummary: null, summaryFromCache: null }, + }, + pageUrl: "https://example.com", + pageTitle: null, + pageContent: "Hello world", + messages: [{ role: "user", content: "Hi" }], + modelOverride: null, + pushToSession: () => {}, + emitMeta: () => {}, + }); + + const calls = (streamTextWithContext as unknown as { mock: { calls: unknown[][] } }).mock.calls; + const args = calls[calls.length - 1]?.[0] as { + forceChatCompletions?: boolean; + openaiBaseUrlOverride?: string | null; + }; + expect(args.forceChatCompletions).toBe(false); + expect(args.openaiBaseUrlOverride).toBe("https://gateway.example/v1"); + }); + it("accepts legacy OpenRouter env mapping for auto attempts", async () => { const home = mkdtempSync(join(tmpdir(), "summarize-daemon-chat-auto-openrouter-")); const meta: Array<{ model?: string | null }> = []; @@ -386,9 +471,14 @@ describe("daemon/chat", () => { }); const calls = (streamTextWithContext as unknown as { mock: { calls: unknown[][] } }).mock.calls; - const args = calls[calls.length - 1]?.[0] as { modelId: string; forceOpenRouter?: boolean }; + const args = calls[calls.length - 1]?.[0] as { + modelId: string; + forceOpenRouter?: boolean; + forceChatCompletions?: boolean; + }; expect(args.modelId).toBe("openai/openai/gpt-5-mini"); expect(args.forceOpenRouter).toBe(true); + expect(args.forceChatCompletions).toBeUndefined(); expect(meta[0]?.model).toBe("openrouter/openai/gpt-5-mini"); }); diff --git a/tests/llm.openai-provider.test.ts b/tests/llm.openai-provider.test.ts index e58aa992..74fd728d 100644 --- a/tests/llm.openai-provider.test.ts +++ b/tests/llm.openai-provider.test.ts @@ -45,6 +45,22 @@ describe("openai provider helpers", () => { useChatCompletions: true, isOpenRouter: true, }); + + expect( + resolveOpenAiClientConfig({ + apiKeys: { + openaiApiKey: "oa-key", + openrouterApiKey: null, + }, + forceOpenRouter: true, + forceChatCompletions: false, + }), + ).toEqual({ + apiKey: "oa-key", + baseURL: "https://openrouter.ai/api/v1", + useChatCompletions: true, + isOpenRouter: true, + }); }); it("handles custom and invalid base URLs", () => { @@ -79,6 +95,24 @@ describe("openai provider helpers", () => { }); }); + it("respects forceChatCompletions=false for custom base URLs", () => { + expect( + resolveOpenAiClientConfig({ + apiKeys: { + openaiApiKey: "oa-key", + openrouterApiKey: null, + }, + openaiBaseUrlOverride: "https://gateway.example/v1", + forceChatCompletions: false, + }), + ).toEqual({ + apiKey: "oa-key", + baseURL: "https://gateway.example/v1", + useChatCompletions: false, + isOpenRouter: false, + }); + }); + it("raises missing key errors for OpenAI and OpenRouter modes", () => { expect(() => resolveOpenAiClientConfig({ diff --git a/tests/run.config.test.ts b/tests/run.config.test.ts index 83c10e35..b296e275 100644 --- a/tests/run.config.test.ts +++ b/tests/run.config.test.ts @@ -15,6 +15,23 @@ function resolveTestConfigState(programOpts: Record) { }); } +function resolveTestConfigStateWithEnv( + envForRun: Record, + programOpts: Record = {}, +) { + return resolveConfigState({ + envForRun: { + HOME: mkdtempSync(join(tmpdir(), "summarize-run-config-")), + ...envForRun, + }, + programOpts: { videoMode: "auto", ...programOpts }, + languageExplicitlySet: false, + videoModeExplicitlySet: false, + cliFlagPresent: false, + cliProviderArg: null, + }); +} + describe("run config", () => { it("maps --fast and --thinking to OpenAI request overrides", () => { expect( @@ -44,4 +61,15 @@ describe("run config", () => { /Use either --fast or --service-tier/, ); }); + + it("keeps OPENAI_USE_CHAT_COMPLETIONS=false as an explicit false value", () => { + expect( + resolveTestConfigStateWithEnv({ OPENAI_USE_CHAT_COMPLETIONS: "false" }) + .openaiUseChatCompletions, + ).toBe(false); + }); + + it("leaves openaiUseChatCompletions unset when there is no env or config override", () => { + expect(resolveTestConfigState({}).openaiUseChatCompletions).toBeUndefined(); + }); }); diff --git a/tests/run.summary-engine.test.ts b/tests/run.summary-engine.test.ts new file mode 100644 index 00000000..26069c75 --- /dev/null +++ b/tests/run.summary-engine.test.ts @@ -0,0 +1,148 @@ +import { Writable } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Prompt } from "../src/llm/prompt.js"; +import { createSummaryEngine } from "../src/run/summary-engine.js"; +import type { ModelAttempt } from "../src/run/types.js"; + +const mocks = vi.hoisted(() => ({ + resolveModelIdForLlmCall: vi.fn(), + summarizeWithModelId: vi.fn(), +})); + +vi.mock("../src/run/summary-llm.js", () => ({ + resolveModelIdForLlmCall: mocks.resolveModelIdForLlmCall, + summarizeWithModelId: mocks.summarizeWithModelId, +})); + +function collectStream(): Writable { + return new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); +} + +function createTestSummaryEngine(openaiUseChatCompletions: boolean | undefined) { + return createSummaryEngine({ + env: {}, + envForRun: {}, + stdout: collectStream(), + stderr: collectStream(), + execFileImpl: vi.fn(), + timeoutMs: 1000, + retries: 0, + streamingEnabled: false, + plain: true, + verbose: false, + verboseColor: false, + openaiUseChatCompletions, + cliConfigForRun: null, + cliAvailability: {}, + trackedFetch: globalThis.fetch.bind(globalThis), + resolveMaxOutputTokensForCall: async () => null, + resolveMaxInputTokensForCall: async () => null, + llmCalls: [], + clearProgressForStdout: () => {}, + apiKeys: { + xaiApiKey: null, + openaiApiKey: "oa-key", + googleApiKey: null, + anthropicApiKey: null, + openrouterApiKey: "or-key", + }, + keyFlags: { + googleConfigured: false, + anthropicConfigured: false, + openrouterConfigured: true, + }, + zai: { + apiKey: null, + baseUrl: "https://api.z.ai/api/paas/v4", + }, + nvidia: { + apiKey: null, + baseUrl: "https://integrate.api.nvidia.com/v1", + }, + ollama: { + baseUrl: "http://localhost:11434/v1", + }, + providerBaseUrls: { + openai: null, + anthropic: null, + google: null, + xai: null, + }, + }); +} + +async function runAttempt(attempt: ModelAttempt, openaiUseChatCompletions: boolean | undefined) { + const engine = createTestSummaryEngine(openaiUseChatCompletions); + return engine.runSummaryAttempt({ + attempt, + prompt: { userText: "Summarize this." } as Prompt, + allowStreaming: false, + }); +} + +beforeEach(() => { + mocks.resolveModelIdForLlmCall.mockReset(); + mocks.summarizeWithModelId.mockReset(); + mocks.resolveModelIdForLlmCall.mockImplementation( + async ({ parsedModel }: { parsedModel: { canonical: string } }) => ({ + modelId: parsedModel.canonical, + note: null, + forceStreamOff: false, + }), + ); + mocks.summarizeWithModelId.mockResolvedValue({ + text: "Summary.", + provider: "openai", + canonicalModelId: "openai/gpt-5.4", + usage: null, + }); +}); + +describe("summary engine OpenAI chat-completions routing", () => { + it("passes explicit false through for native OpenAI-compatible gateways", async () => { + await runAttempt( + { + transport: "native", + userModelId: "openai/gpt-5.4", + llmModelId: "openai/gpt-5.4", + openrouterProviders: null, + forceOpenRouter: false, + requiredEnv: "OPENAI_API_KEY", + openaiBaseUrlOverride: "https://gateway.example/v1", + }, + false, + ); + + const call = mocks.summarizeWithModelId.mock.calls[0]?.[0] as { + forceChatCompletions?: boolean; + openaiBaseUrlOverride?: string | null; + }; + expect(call.openaiBaseUrlOverride).toBe("https://gateway.example/v1"); + expect(call.forceChatCompletions).toBe(false); + }); + + it("does not apply the OpenAI chat-completions toggle to OpenRouter attempts", async () => { + await runAttempt( + { + transport: "openrouter", + userModelId: "openrouter/openai/gpt-5.4", + llmModelId: "openai/openai/gpt-5.4", + openrouterProviders: null, + forceOpenRouter: true, + requiredEnv: "OPENROUTER_API_KEY", + }, + false, + ); + + const call = mocks.summarizeWithModelId.mock.calls[0]?.[0] as { + forceChatCompletions?: boolean; + forceOpenRouter?: boolean; + }; + expect(call.forceOpenRouter).toBe(true); + expect(call.forceChatCompletions).toBeUndefined(); + }); +});