diff --git a/electron/ai-edition/deep-agent/agent-provider-capabilities.ts b/electron/ai-edition/deep-agent/agent-provider-capabilities.ts deleted file mode 100644 index fde62ca7e..000000000 --- a/electron/ai-edition/deep-agent/agent-provider-capabilities.ts +++ /dev/null @@ -1,257 +0,0 @@ -// ponytail: port of axcut's agent-provider-capabilities.ts. Drives the -// per-provider reasoning-effort wiring (OpenAI uses `reasoning.effort`, -// Anthropic uses `thinking` blocks, OpenRouter uses `modelKwargs.reasoning`, -// Google uses `thinkingConfig`). Lives behind createOpenScreenChatModel. - -import { getProviderDefinition, type ProviderDefinition } from "../provider-registry"; - -export type AgentReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; - -export const AGENT_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", -]; - -export interface ReasoningCapability { - supported: boolean; - efforts: readonly AgentReasoningEffort[]; - defaultEffort?: AgentReasoningEffort; - strategy?: - | "custom-openai-account" - | "openai-responses" - | "anthropic-thinking" - | "minimax-thinking" - | "openrouter-reasoning" - | "google-thinking"; -} - -export interface LangChainReasoningOptions { - reasoning?: { effort: "low" | "medium" | "high" }; - thinking?: Record; - outputConfig?: Record; - thinkingConfig?: Record; - modelKwargs?: Record; - useResponsesApi?: boolean; -} - -const OPENAI_REASONING_EFFORTS: readonly AgentReasoningEffort[] = ["none", "low", "medium", "high"]; -const ANTHROPIC_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ - "none", - "low", - "medium", - "high", - "xhigh", -]; -const OPENROUTER_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ - "none", - "low", - "medium", - "high", -]; -const GOOGLE_REASONING_EFFORTS: readonly AgentReasoningEffort[] = ["none", "low", "medium", "high"]; - -// Every branch here compares against canonical registry ids — -// createOpenScreenChatModel normalizes the provider before calling in. -export function getReasoningCapability(provider: string, model?: string): ReasoningCapability { - const def: ProviderDefinition | undefined = getProviderDefinition(provider); - const normalizedModel = normalizeModelName(model); - - if ( - (provider === "openai" || provider === "openai-compatible") && - isOpenAIReasoningModel(normalizedModel) - ) { - return { - supported: true, - efforts: OPENAI_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "openai-responses", - }; - } - if (def?.id === "anthropic" && isAnthropicReasoningModel(normalizedModel)) { - return { - supported: true, - efforts: ANTHROPIC_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "anthropic-thinking", - }; - } - if (provider === "minimax" || provider === "minimax-token-plan") { - // MiniMax's thinking block is binary — `{type: "adaptive"}` (on) or - // `{type: "disabled"}` (off, ignored on M2.x which is always-on) — no - // budget_tokens tiers like native Anthropic. Any non-"none" effort - // just turns it on; see buildLangChainReasoningOptions below. - return { - supported: true, - efforts: ANTHROPIC_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "minimax-thinking", - }; - } - if (provider === "openrouter" && isOpenRouterReasoningModel(normalizedModel)) { - return { - supported: true, - efforts: OPENROUTER_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "openrouter-reasoning", - }; - } - if (provider === "google" && isGeminiThinkingModel(normalizedModel)) { - return { - supported: true, - efforts: GOOGLE_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "google-thinking", - }; - } - return { supported: false, efforts: ["none"] }; -} - -export function normalizeReasoningEffortForCapability( - effort: AgentReasoningEffort | undefined, - capability: ReasoningCapability, -): AgentReasoningEffort | undefined { - if (!capability.supported) return undefined; - if (!effort) return capability.defaultEffort; - if (capability.efforts.includes(effort)) return effort; - if (effort === "minimal" && capability.efforts.includes("low")) return "low"; - if (effort === "xhigh" && capability.efforts.includes("high")) return "high"; - return capability.defaultEffort; -} - -export function buildLangChainReasoningOptions( - provider: string, - model: string | undefined, - effort: AgentReasoningEffort | undefined, -): LangChainReasoningOptions { - const capability = getReasoningCapability(provider, model); - const normalizedEffort = normalizeReasoningEffortForCapability(effort, capability); - if (!capability.supported || !normalizedEffort || normalizedEffort === "none") { - return {}; - } - - switch (capability.strategy) { - case "openai-responses": - return { - reasoning: { effort: toOpenAIReasoningEffort(normalizedEffort) }, - useResponsesApi: true, - }; - case "anthropic-thinking": - return buildAnthropicReasoningOptions(model, normalizedEffort); - case "minimax-thinking": - // No "none" case needed — normalizedEffort === "none" already - // short-circuits to {} above, which omits `thinking` (= off). - return { thinking: { type: "adaptive" } }; - case "openrouter-reasoning": - return { - modelKwargs: { - reasoning: { effort: toOpenAIReasoningEffort(normalizedEffort) }, - include_reasoning: true, - }, - }; - case "google-thinking": - return { - thinkingConfig: { - includeThoughts: true, - thinkingLevel: toGoogleThinkingLevel(normalizedEffort), - thinkingBudget: toGoogleThinkingBudget(normalizedEffort), - }, - }; - default: - return {}; - } -} - -export function shouldDisableModelStreamingForToolCalling( - provider: string, - model?: string, -): boolean { - return provider === "google" && normalizeModelName(model).startsWith("gemini-3"); -} - -function buildAnthropicReasoningOptions( - model: string | undefined, - effort: AgentReasoningEffort, -): LangChainReasoningOptions { - if (isAnthropicAdaptiveThinkingModel(model)) { - return { - thinking: { type: "adaptive", display: "summarized" }, - outputConfig: { effort: toAnthropicEffort(effort) }, - }; - } - return { - thinking: { - type: "enabled", - budget_tokens: toAnthropicBudgetTokens(effort), - display: "summarized", - }, - }; -} - -function isOpenAIReasoningModel(model: string): boolean { - return /^(o\d|o\d-|o\d\.|gpt-5|gpt-5-|gpt-5\.)/.test(model); -} - -function isAnthropicReasoningModel(model: string): boolean { - return /^claude-(opus|sonnet|haiku)-4/.test(model); -} - -function isAnthropicAdaptiveThinkingModel(model: string | undefined): boolean { - if (!model) return false; - return /^claude-(opus|sonnet)-4-[67]/.test(model); -} - -function isGeminiThinkingModel(model: string): boolean { - return model.startsWith("gemini-2.5") || model.startsWith("gemini-3"); -} - -function isOpenRouterReasoningModel(model: string): boolean { - // OpenRouter slugs are `vendor/model`, and both vendor matchers are - // anchored at the start — so the prefix has to come off first or - // `openai/gpt-5` never matches. - if (isOpenAIReasoningModel(stripVendorPrefix(model, "openai/"))) return true; - if (isAnthropicReasoningModel(stripVendorPrefix(model, "anthropic/"))) return true; - return /deepseek-r1/i.test(model) || /qwen.*thinking/i.test(model) || /grok-4/i.test(model); -} - -function stripVendorPrefix(model: string, prefix: string): string { - return model.startsWith(prefix) ? model.slice(prefix.length) : model; -} - -function toOpenAIReasoningEffort(effort: AgentReasoningEffort): "low" | "medium" | "high" { - if (effort === "high" || effort === "xhigh") return "high"; - if (effort === "medium") return "medium"; - return "low"; -} - -function toAnthropicEffort(effort: AgentReasoningEffort): "low" | "medium" | "high" | "xhigh" { - if (effort === "high" || effort === "xhigh") return effort; - if (effort === "medium") return "medium"; - return "low"; -} - -function toAnthropicBudgetTokens(effort: AgentReasoningEffort): number { - if (effort === "xhigh") return 16_000; - if (effort === "high") return 10_000; - if (effort === "medium") return 4_000; - return 1_024; -} - -function toGoogleThinkingLevel(effort: AgentReasoningEffort): "LOW" | "MEDIUM" | "HIGH" { - if (effort === "high" || effort === "xhigh") return "HIGH"; - if (effort === "medium") return "MEDIUM"; - return "LOW"; -} - -function toGoogleThinkingBudget(effort: AgentReasoningEffort): number { - if (effort === "high" || effort === "xhigh") return 8_192; - if (effort === "medium") return 4_096; - return 1_024; -} - -function normalizeModelName(model?: string): string { - return model?.trim().toLowerCase() || ""; -} diff --git a/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts b/electron/ai-edition/deep-agent/chat-model-capabilities.test.ts similarity index 95% rename from electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts rename to electron/ai-edition/deep-agent/chat-model-capabilities.test.ts index 0b6581ed4..d9a6c306e 100644 --- a/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts +++ b/electron/ai-edition/deep-agent/chat-model-capabilities.test.ts @@ -1,13 +1,13 @@ // Covers the reasoning-effort matrix that survived the llm-call.ts deletion. -// The deleted top-level agent-provider-capabilities.ts had the only tests for -// this logic; these keep the per-provider wiring pinned. +// The capability helpers now live in chat-model.ts (folded from +// agent-provider-capabilities.ts); these keep the per-provider wiring pinned. import { describe, expect, it } from "vitest"; import { buildLangChainReasoningOptions, getReasoningCapability, normalizeReasoningEffortForCapability, -} from "./agent-provider-capabilities"; +} from "./chat-model"; describe("getReasoningCapability", () => { it("turns reasoning off for non-reasoning OpenAI models", () => { diff --git a/electron/ai-edition/deep-agent/chat-model.ts b/electron/ai-edition/deep-agent/chat-model.ts index 5845905b3..037343262 100644 --- a/electron/ai-edition/deep-agent/chat-model.ts +++ b/electron/ai-edition/deep-agent/chat-model.ts @@ -5,16 +5,277 @@ // // The openai-oauth (Codex) and copilot-proxy branches were removed in 1.8.0 // along with their providers — see the note in provider-registry.ts. +// +// ponytail: the per-provider reasoning-effort capability table (was +// ./agent-provider-capabilities.ts) is only consumed by createOpenScreenChatModel, +// so it lives here as a top section. The deep-agent runtime wrapper +// (./service.ts) stays separate because it owns a different concern — the +// LangGraph thread + tool graph, not the chat-model factory. import { ChatAnthropic } from "@langchain/anthropic"; import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; import { ChatMistralAI } from "@langchain/mistralai"; import { ChatOpenAI } from "@langchain/openai"; -import { normalizeProviderId } from "../provider-registry"; import { - buildLangChainReasoningOptions, - shouldDisableModelStreamingForToolCalling, -} from "./agent-provider-capabilities"; + getProviderDefinition, + normalizeProviderId, + type ProviderDefinition, +} from "../provider-registry"; + +// --- per-provider reasoning-effort capability table ----------------------- + +export type AgentReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +export const AGENT_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +]; + +export interface ReasoningCapability { + supported: boolean; + efforts: readonly AgentReasoningEffort[]; + defaultEffort?: AgentReasoningEffort; + strategy?: + | "custom-openai-account" + | "openai-responses" + | "anthropic-thinking" + | "minimax-thinking" + | "openrouter-reasoning" + | "google-thinking"; +} + +export interface LangChainReasoningOptions { + reasoning?: { effort: "low" | "medium" | "high" }; + thinking?: Record; + outputConfig?: Record; + thinkingConfig?: Record; + modelKwargs?: Record; + useResponsesApi?: boolean; +} + +const OPENAI_REASONING_EFFORTS: readonly AgentReasoningEffort[] = ["none", "low", "medium", "high"]; +const ANTHROPIC_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ + "none", + "low", + "medium", + "high", + "xhigh", +]; +const OPENROUTER_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ + "none", + "low", + "medium", + "high", +]; +const GOOGLE_REASONING_EFFORTS: readonly AgentReasoningEffort[] = ["none", "low", "medium", "high"]; + +// Every branch here compares against canonical registry ids — +// createOpenScreenChatModel normalizes the provider before calling in. +export function getReasoningCapability(provider: string, model?: string): ReasoningCapability { + const def: ProviderDefinition | undefined = getProviderDefinition(provider); + const normalizedModel = normalizeModelName(model); + + if ( + (provider === "openai" || provider === "openai-compatible") && + isOpenAIReasoningModel(normalizedModel) + ) { + return { + supported: true, + efforts: OPENAI_REASONING_EFFORTS, + defaultEffort: "medium", + strategy: "openai-responses", + }; + } + if (def?.id === "anthropic" && isAnthropicReasoningModel(normalizedModel)) { + return { + supported: true, + efforts: ANTHROPIC_REASONING_EFFORTS, + defaultEffort: "medium", + strategy: "anthropic-thinking", + }; + } + if (provider === "minimax" || provider === "minimax-token-plan") { + // MiniMax's thinking block is binary — `{type: "adaptive"}` (on) or + // `{type: "disabled"}` (off, ignored on M2.x which is always-on) — no + // budget_tokens tiers like native Anthropic. Any non-"none" effort + // just turns it on; see buildLangChainReasoningOptions below. + return { + supported: true, + efforts: ANTHROPIC_REASONING_EFFORTS, + defaultEffort: "medium", + strategy: "minimax-thinking", + }; + } + if (provider === "openrouter" && isOpenRouterReasoningModel(normalizedModel)) { + return { + supported: true, + efforts: OPENROUTER_REASONING_EFFORTS, + defaultEffort: "medium", + strategy: "openrouter-reasoning", + }; + } + if (provider === "google" && isGeminiThinkingModel(normalizedModel)) { + return { + supported: true, + efforts: GOOGLE_REASONING_EFFORTS, + defaultEffort: "medium", + strategy: "google-thinking", + }; + } + return { supported: false, efforts: ["none"] }; +} + +export function normalizeReasoningEffortForCapability( + effort: AgentReasoningEffort | undefined, + capability: ReasoningCapability, +): AgentReasoningEffort | undefined { + if (!capability.supported) return undefined; + if (!effort) return capability.defaultEffort; + if (capability.efforts.includes(effort)) return effort; + if (effort === "minimal" && capability.efforts.includes("low")) return "low"; + if (effort === "xhigh" && capability.efforts.includes("high")) return "high"; + return capability.defaultEffort; +} + +export function buildLangChainReasoningOptions( + provider: string, + model: string | undefined, + effort: AgentReasoningEffort | undefined, +): LangChainReasoningOptions { + const capability = getReasoningCapability(provider, model); + const normalizedEffort = normalizeReasoningEffortForCapability(effort, capability); + if (!capability.supported || !normalizedEffort || normalizedEffort === "none") { + return {}; + } + + switch (capability.strategy) { + case "openai-responses": + return { + reasoning: { effort: toOpenAIReasoningEffort(normalizedEffort) }, + useResponsesApi: true, + }; + case "anthropic-thinking": + return buildAnthropicReasoningOptions(model, normalizedEffort); + case "minimax-thinking": + // No "none" case needed — normalizedEffort === "none" already + // short-circuits to {} above, which omits `thinking` (= off). + return { thinking: { type: "adaptive" } }; + case "openrouter-reasoning": + return { + modelKwargs: { + reasoning: { effort: toOpenAIReasoningEffort(normalizedEffort) }, + include_reasoning: true, + }, + }; + case "google-thinking": + return { + thinkingConfig: { + includeThoughts: true, + thinkingLevel: toGoogleThinkingLevel(normalizedEffort), + thinkingBudget: toGoogleThinkingBudget(normalizedEffort), + }, + }; + default: + return {}; + } +} + +export function shouldDisableModelStreamingForToolCalling( + provider: string, + model?: string, +): boolean { + return provider === "google" && normalizeModelName(model).startsWith("gemini-3"); +} + +function buildAnthropicReasoningOptions( + model: string | undefined, + effort: AgentReasoningEffort, +): LangChainReasoningOptions { + if (isAnthropicAdaptiveThinkingModel(model)) { + return { + thinking: { type: "adaptive", display: "summarized" }, + outputConfig: { effort: toAnthropicEffort(effort) }, + }; + } + return { + thinking: { + type: "enabled", + budget_tokens: toAnthropicBudgetTokens(effort), + display: "summarized", + }, + }; +} + +function isOpenAIReasoningModel(model: string): boolean { + return /^(o\d|o\d-|o\d\.|gpt-5|gpt-5-|gpt-5\.)/.test(model); +} + +function isAnthropicReasoningModel(model: string): boolean { + return /^claude-(opus|sonnet|haiku)-4/.test(model); +} + +function isAnthropicAdaptiveThinkingModel(model: string | undefined): boolean { + if (!model) return false; + return /^claude-(opus|sonnet)-4-[67]/.test(model); +} + +function isGeminiThinkingModel(model: string): boolean { + return model.startsWith("gemini-2.5") || model.startsWith("gemini-3"); +} + +function isOpenRouterReasoningModel(model: string): boolean { + // OpenRouter slugs are `vendor/model`, and both vendor matchers are + // anchored at the start — so the prefix has to come off first or + // `openai/gpt-5` never matches. + if (isOpenAIReasoningModel(stripVendorPrefix(model, "openai/"))) return true; + if (isAnthropicReasoningModel(stripVendorPrefix(model, "anthropic/"))) return true; + return /deepseek-r1/i.test(model) || /qwen.*thinking/i.test(model) || /grok-4/i.test(model); +} + +function stripVendorPrefix(model: string, prefix: string): string { + return model.startsWith(prefix) ? model.slice(prefix.length) : model; +} + +function toOpenAIReasoningEffort(effort: AgentReasoningEffort): "low" | "medium" | "high" { + if (effort === "high" || effort === "xhigh") return "high"; + if (effort === "medium") return "medium"; + return "low"; +} + +function toAnthropicEffort(effort: AgentReasoningEffort): "low" | "medium" | "high" | "xhigh" { + if (effort === "high" || effort === "xhigh") return effort; + if (effort === "medium") return "medium"; + return "low"; +} + +function toAnthropicBudgetTokens(effort: AgentReasoningEffort): number { + if (effort === "xhigh") return 16_000; + if (effort === "high") return 10_000; + if (effort === "medium") return 4_000; + return 1_024; +} + +function toGoogleThinkingLevel(effort: AgentReasoningEffort): "LOW" | "MEDIUM" | "HIGH" { + if (effort === "high" || effort === "xhigh") return "HIGH"; + if (effort === "medium") return "MEDIUM"; + return "LOW"; +} + +function toGoogleThinkingBudget(effort: AgentReasoningEffort): number { + if (effort === "high" || effort === "xhigh") return 8_192; + if (effort === "medium") return 4_096; + return 1_024; +} + +function normalizeModelName(model?: string): string { + return model?.trim().toLowerCase() || ""; +} + +// --- chat-model factory ---------------------------------------------------- export interface OpenScreenChatModelConfig { provider: string; @@ -107,8 +368,7 @@ export async function createOpenScreenChatModel( ): Promise { // Canonicalise once, here: stored configs can still carry historical // aliases (`claude`, `gemini`, `anthropic-proxy`), and every provider - // comparison below — and in agent-provider-capabilities — is an exact - // match against a registry id. + // comparison below is an exact match against a registry id. const config: OpenScreenChatModelConfig = { ...input, provider: normalizeProviderId(input.provider) ?? input.provider, diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index a10b718ab..5595f5454 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -33,6 +33,20 @@ import { anchorRegionsWithDerivedMs } from "../timeline/timelineMap"; // `AspectRatio` union without a runtime bridge. export const axcutSchemaVersion = 6; +// ponytail: every region schema shares the same monotonicity rule +// (end >= start) with the same error shape. Factor the refine so the +// five call sites stay declarative; the message + path stay tied to +// the field names (e.g. `endSec >= startSec`, `endMs >= startMs`). +const endGteStart = >( + schema: T, + endKey: keyof T["shape"] & string, + startKey: keyof T["shape"] & string, +) => + schema.refine((data) => (data[endKey] as number) >= (data[startKey] as number), { + message: `${endKey} must be greater than or equal to ${startKey}`, + path: [endKey], + }); + export const isoDateSchema = z.string().datetime({ offset: true }); export const wordSchema = z @@ -160,44 +174,41 @@ export const clipSchema = z path: ["sourceEndSec"], }); -export const gapSchema = z - .object({ +export const gapSchema = endGteStart( + z.object({ id: z.string().min(1), timelineStartSec: z.number().nonnegative(), timelineEndSec: z.number().nonnegative(), reason: z.string().default(""), - }) - .refine((data) => data.timelineEndSec >= data.timelineStartSec, { - message: "timelineEndSec must be greater than or equal to timelineStartSec", - path: ["timelineEndSec"], - }); + }), + "timelineEndSec", + "timelineStartSec", +); -export const rangeSchema = z - .object({ +export const rangeSchema = endGteStart( + z.object({ startSec: z.number().nonnegative(), endSec: z.number().nonnegative(), reason: z.string().default(""), - }) - .refine((data) => data.endSec >= data.startSec, { - message: "endSec must be greater than or equal to startSec", - path: ["endSec"], - }); + }), + "endSec", + "startSec", +); // ponytail: trimRanges reference asset source-time (not timeline). trimRegions // in v2 are the inverse — a skip = the region inside the source we DON'T keep. -export const trimRangeSchema = z - .object({ +export const trimRangeSchema = endGteStart( + z.object({ id: z.string().min(1), assetId: z.string().min(1), startSec: z.number().nonnegative(), endSec: z.number().nonnegative(), reason: z.string().default(""), origin: z.enum(["system", "agent", "user"]), - }) - .refine((data) => data.endSec >= data.startSec, { - message: "endSec must be greater than or equal to startSec", - path: ["endSec"], - }); + }), + "endSec", + "startSec", +); export const timelineSchema = z.preprocess( // Back-compat: the field was renamed skipRanges → trimRanges. Old persisted @@ -353,8 +364,8 @@ const clipAnchorShape = { sourceEndSec: z.number().nonnegative().optional(), }; -export const annotationRegionSchema = z - .object({ +export const annotationRegionSchema = endGteStart( + z.object({ id: z.string().min(1), startMs: z.number().nonnegative(), endMs: z.number().nonnegative(), @@ -376,14 +387,13 @@ export const annotationRegionSchema = z annotationSource: z.literal("auto-caption").optional(), figureData: figureDataSchema, blurData: blurDataSchema, - }) - .refine((data) => data.endMs >= data.startMs, { - message: "endMs must be greater than or equal to startMs", - path: ["endMs"], - }); + }), + "endMs", + "startMs", +); -export const zoomRegionSchema = z - .object({ +export const zoomRegionSchema = endGteStart( + z.object({ id: z.string().min(1), startMs: z.number().nonnegative(), endMs: z.number().nonnegative(), @@ -404,11 +414,10 @@ export const zoomRegionSchema = z rotationPreset: z.enum(["iso", "left", "right"]).optional(), customScale: z.number().positive().optional(), source: z.enum(["auto", "manual"]).optional(), - }) - .refine((data) => data.endMs >= data.startMs, { - message: "endMs must be greater than or equal to startMs", - path: ["endMs"], - }); + }), + "endMs", + "startMs", +); // Legacy OpenScreen appearance / export settings that the v3 schema doesn't // normalize into the timeline / assets model. They are applied at export time diff --git a/technical-documentation/architecture/llm-providers.md b/technical-documentation/architecture/llm-providers.md index 236c247e3..f01f02b37 100644 --- a/technical-documentation/architecture/llm-providers.md +++ b/technical-documentation/architecture/llm-providers.md @@ -8,7 +8,7 @@ The provider layer defines model metadata, protects credentials, discovers model | [`electron/ai-edition/llm-config-store.ts`](../../electron/ai-edition/llm-config-store.ts) | `LlmConfigStore` — plain JSON for selection, `safeStorage` blob for credentials. | | [`electron/ai-edition/llm-provider-auth.ts`](../../electron/ai-edition/llm-provider-auth.ts) | Model-list discovery per provider. Despite the filename it performs no authentication any more — see [Known gaps](#known-gaps). | | [`electron/ai-edition/deep-agent/chat-model.ts`](../../electron/ai-edition/deep-agent/chat-model.ts) | `createOpenScreenChatModel` — the single transport. Picks a `@langchain/*` chat model class per provider. | -| [`electron/ai-edition/deep-agent/agent-provider-capabilities.ts`](../../electron/ai-edition/deep-agent/agent-provider-capabilities.ts) | Per-provider reasoning-effort capability and its LangChain wire options. | +| [`electron/ai-edition/deep-agent/chat-model.ts`](../../electron/ai-edition/deep-agent/chat-model.ts) | Per-provider reasoning-effort capability and its LangChain wire options. | | [`electron/native-bridge/services/aiEditionService.ts`](../../electron/native-bridge/services/aiEditionService.ts) | IPC surface: connect / disconnect, snapshot, `llmListProviderModels`. | | [`src/components/ai-edition/ProviderSettings.tsx`](../../src/components/ai-edition/ProviderSettings.tsx) | Renders cards and forms directly from `PROVIDER_DEFINITIONS`. | @@ -113,14 +113,14 @@ Everything returns `{models, error?}` rather than throwing, so the settings UI c 1. Add a complete `ProviderDefinition` in `provider-registry.ts` (auth kind, env keys, default model, base URL, `wireProtocol`, reasoning support). Widen `authKind` if the provider is not API-key-based. 2. Add a branch in `createOpenScreenChatModel` if none of the three existing adapters fits. -3. Add a capability branch in `agent-provider-capabilities.ts` if the provider exposes reasoning, and constrain `getReasoningEffortOptions` if its scale is not the full six tiers. +3. Add a capability branch in `chat-model.ts` if the provider exposes reasoning, and constrain `getReasoningEffortOptions` if its scale is not the full six tiers. 4. Add a discovery branch in `aiEditionService.llmListProviderModels`, plus its fetch helper in `llm-provider-auth.ts`. 5. Extend the native-bridge contracts if the provider needs operations the existing IPC surface doesn't cover. 6. Confirm `ProviderSettings.tsx` renders the right fields from the registry metadata alone, then add registry, transport, and UI tests. ## Known gaps -- **`normalizeReasoningEffort` in `provider-registry.ts` is dead.** It is exported but has no caller anywhere in the repo; `normalizeReasoningEffortForCapability` in `agent-provider-capabilities.ts` is the live one. The two also disagree — the dead copy's strategy union knows `custom-openai-account` but not `minimax-thinking`. Delete it rather than fixing it. +- **`normalizeReasoningEffort` in `provider-registry.ts` is dead.** It is exported but has no caller anywhere in the repo; `normalizeReasoningEffortForCapability` in `chat-model.ts` is the live one. The two also disagree — the dead copy's strategy union knows `custom-openai-account` but not `minimax-thinking`. Delete it rather than fixing it. - **`custom-openai-account` is a phantom strategy.** It appears in `ReasoningCapability["strategy"]` but no branch of `getReasoningCapability` returns it, and no branch of `buildLangChainReasoningOptions` handles it. Left over from the removed ChatGPT provider. - **`llm-provider-auth.ts` is misnamed.** It performs no authentication since the device flows were removed — it is purely model-list discovery. `model-discovery.ts` would say what it does. - **MiniMax discovery spends the user's key.** Nine probe requests per discovery click, uncached, at `max_tokens: 1`. Cache per key if it ever moves to a hot path.