Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
44 changes: 28 additions & 16 deletions src/daemon/agent-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,27 +58,28 @@ function overrideModelGatewaySettings({
provider: string;
model: Model<Api>;
baseUrl: string | null;
forceOpenAiChatCompletions: boolean;
forceOpenAiChatCompletions: boolean | undefined;
}) {
const nextModel = baseUrl ? ({ ...model, baseUrl } as Model<Api>) : model;
if (provider !== "openai") return nextModel;
const effectiveBaseUrl =
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<Api> & { headers?: Record<string, string> }).headers ?? {}),
"HTTP-Referer": "https://github.com/steipete/summarize",
"X-Title": "summarize",
}
: (nextModel as Model<Api> & { headers?: Record<string, string> }).headers;
const headers = isOpenRouterBase
? {
...((nextModel as Model<Api> & { headers?: Record<string, string> }).headers ?? {}),
"HTTP-Referer": "https://github.com/steipete/summarize",
"X-Title": "summarize",
}
: (nextModel as Model<Api> & { headers?: Record<string, string> }).headers;
return {
...nextModel,
api: "openai-completions",
Expand All @@ -95,7 +96,7 @@ function resolveModelWithFallback({
provider: string;
modelId: string;
baseUrl: string | null;
forceOpenAiChatCompletions: boolean;
forceOpenAiChatCompletions: boolean | undefined;
}): Model<Api> {
try {
const model = getModel(provider as never, modelId as never);
Expand All @@ -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,
});
Expand Down Expand Up @@ -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,
}),
};
};
Expand Down
35 changes: 27 additions & 8 deletions src/daemon/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,12 @@ function resolveOpenAiUseChatCompletions({
}: {
env: Record<string, string | undefined>;
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({
Expand Down Expand Up @@ -182,7 +184,7 @@ export async function streamChatResponse({
transport: "native" as const,
openaiApiKeyOverride: null,
openaiBaseUrlOverride: null,
forceChatCompletions: false,
forceChatCompletions: undefined,
};
}
return {
Expand All @@ -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,
};
}
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion src/llm/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/run/flows/url/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export type UrlFlowModel = {
configForModelSelection: SummarizeConfig | null;
envForAuto: Record<string, string | undefined>;
cliAvailability: Partial<Record<CliProvider, boolean>>;
openaiUseChatCompletions: boolean;
openaiUseChatCompletions: boolean | undefined;
openaiRequestOptions?: ModelRequestOptions;
openaiRequestOptionsOverride?: ModelRequestOptions;
openaiWhisperUsdPerMinute: number;
Expand Down
4 changes: 2 additions & 2 deletions src/run/run-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type ConfigState = {
videoMode: ReturnType<typeof parseVideoMode>;
cliConfigForRun: SummarizeConfig["cli"] | undefined;
configForCli: SummarizeConfig | null;
openaiUseChatCompletions: boolean;
openaiUseChatCompletions: boolean | undefined;
openaiRequestOptions: ModelRequestOptions | undefined;
openaiRequestOptionsOverride: ModelRequestOptions | undefined;
configModelLabel: string | null;
Expand Down Expand Up @@ -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 = (() => {
Expand Down
11 changes: 8 additions & 3 deletions src/run/summary-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export type SummaryEngineDeps = {
plain: boolean;
verbose: boolean;
verboseColor: boolean;
openaiUseChatCompletions: boolean;
openaiUseChatCompletions: boolean | undefined;
openaiRequestOptions?: ModelRequestOptions;
openaiRequestOptionsOverride?: ModelRequestOptions;
cliConfigForRun: Parameters<typeof runCliModel>[0]["config"];
Expand Down Expand Up @@ -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,
Expand Down
79 changes: 78 additions & 1 deletion tests/daemon.agent-model.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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");
});
});
Loading