diff --git a/backend/src/lib/chat/modelCapabilities.test.ts b/backend/src/lib/chat/modelCapabilities.test.ts index 6a8dad5c01..ba9216e99c 100644 --- a/backend/src/lib/chat/modelCapabilities.test.ts +++ b/backend/src/lib/chat/modelCapabilities.test.ts @@ -10,52 +10,145 @@ import { discoverCompatibleModels } from "../llm/modelDiscovery"; test("GPT-5.6 is a ROSS-compatible main model", () => { assert.equal(resolveModel("gpt-5.6", "fallback"), "gpt-5.6"); + assert.equal(resolveModel("gpt-5.6-terra", "fallback"), "gpt-5.6-terra"); assert.equal(modelCapability("gpt-5.6")?.tier, "main"); }); test("reasoning effort is model-specific", () => { assert.equal(supportsReasoningEffort("gpt-5.6", "max"), true); + assert.equal(supportsReasoningEffort("gpt-5.6-luna", "max"), true); assert.equal(supportsReasoningEffort("gpt-5.5", "max"), false); - assert.equal( - supportsReasoningEffort("gemini-3-flash-preview", "high"), - false, - ); + assert.equal(supportsReasoningEffort("gemini-3.6-flash", "minimal"), true); + assert.equal(supportsReasoningEffort("claude-opus-5", "max"), true); + assert.equal(supportsReasoningEffort("kimi-k3", "max"), true); }); test("unsupported reasoning effort falls back to the model default", () => { assert.equal(resolveReasoningEffort("gpt-5.5", "max"), "medium"); - assert.equal( - resolveReasoningEffort("gemini-3-flash-preview", "high"), - undefined, - ); + assert.equal(resolveReasoningEffort("gemini-2-flash", "high"), undefined); }); -test("key-scoped discovery exposes availability but never the API key", async () => { +test("key-scoped discovery exposes live compatible models for every provider", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - new Response( - JSON.stringify({ - data: [{ id: "gpt-5.6" }, { id: "text-embedding-3-large" }], - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); + const calls: Array<{ url: string; headers: Headers }> = []; + globalThis.fetch = async (input, init) => { + const url = String(input); + calls.push({ url, headers: new Headers(init?.headers) }); + const body = url.includes("api.openai.com") + ? { + data: [ + { id: "gpt-5.6-sol" }, + { id: "gpt-5.6-terra" }, + { id: "gpt-5.6-luna" }, + { id: "gpt-6-preview" }, + { id: "text-embedding-3-large" }, + ], + } + : url.includes("api.anthropic.com") + ? { + data: [{ id: "claude-opus-5", display_name: "Claude Opus 5" }], + has_more: false, + } + : url.includes("generativelanguage.googleapis.com") + ? { + models: [ + { + name: "models/gemini-3.6-flash", + baseModelId: "gemini-3.6-flash", + displayName: "Gemini 3.6 Flash", + supportedGenerationMethods: ["generateContent"], + }, + { + name: "models/text-embedding-999", + supportedGenerationMethods: ["embedContent"], + }, + ], + } + : url.includes("api.x.ai") + ? { data: [{ id: "grok-4.6" }, { id: "grok-image-1" }] } + : { data: [{ id: "kimi-k3" }, { id: "embedding-v1" }] }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; try { - const secret = "sk-test-secret-that-must-not-leak"; - const result = await discoverCompatibleModels({ openai: secret }); + const secrets = { + openai: "sk-openai-secret", + claude: "sk-claude-secret", + gemini: "gemini-secret", + xai: "xai-secret", + moonshot: "moonshot-secret", + }; + const result = await discoverCompatibleModels(secrets); + for (const id of [ + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-6-preview", + "claude-opus-5", + "gemini-3.6-flash", + "grok-4.6", + "kimi-k3", + ]) { + assert.equal( + result.models.find((model) => model.id === id)?.available, + true, + id, + ); + } + for (const id of [ + "text-embedding-3-large", + "text-embedding-999", + "grok-image-1", + "embedding-v1", + ]) { + assert.equal( + result.models.some((model) => model.id === id), + false, + id, + ); + } + for (const secret of Object.values(secrets)) { + assert.equal(JSON.stringify(result).includes(secret), false); + } + assert.equal(calls.length, 5); assert.equal( - result.models.find((model) => model.id === "gpt-5.6")?.available, - true, + calls + .find((call) => call.url.includes("api.anthropic.com")) + ?.headers.get("x-api-key"), + secrets.claude, ); assert.equal( - result.models.some((model) => model.id === "text-embedding-3-large"), - false, + calls + .find((call) => call.url.includes("generativelanguage.googleapis.com")) + ?.headers.get("x-goog-api-key"), + secrets.gemini, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("successful discovery with no compatible chat model fails closed", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ data: [{ id: "text-embedding-3-large" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + try { + const result = await discoverCompatibleModels({ openai: "sk-test" }); + const openAIModels = result.models.filter( + (model) => model.provider === "openai", ); - assert.match( - result.models.find((model) => model.id === "gpt-5.5") - ?.availabilityReason ?? "", - /does not currently list this model/i, + assert.ok(openAIModels.length > 0); + assert.equal( + openAIModels.every((model) => !model.available), + true, ); - assert.equal(JSON.stringify(result).includes(secret), false); + assert.match(openAIModels[0]?.availabilityReason ?? "", /does not list/i); } finally { globalThis.fetch = originalFetch; } diff --git a/backend/src/lib/llm/claude.ts b/backend/src/lib/llm/claude.ts index 18c0f624de..df972a1ace 100644 --- a/backend/src/lib/llm/claude.ts +++ b/backend/src/lib/llm/claude.ts @@ -8,6 +8,7 @@ import type { } from "./types"; import { toClaudeTools } from "./tools"; import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog"; +import { modelCapability } from "./models"; type ContentBlock = | { type: "text"; text: string } @@ -112,6 +113,7 @@ export async function streamClaude( runTools, apiKeys, enableThinking, + reasoningEffort, } = params; const maxIter = params.maxIterations ?? 10; const anthropic = client(apiKeys?.claude); @@ -134,12 +136,11 @@ export async function streamClaude( model, system: systemPrompt, messages: messages as Anthropic.MessageParam[], - tools: toolsEnabled && claudeTools.length - ? (claudeTools as unknown as Tool[]) - : undefined, - ...(iter === 0 && - toolsEnabled && - params.requiredFirstToolName + tools: + toolsEnabled && claudeTools.length + ? (claudeTools as unknown as Tool[]) + : undefined, + ...(iter === 0 && toolsEnabled && params.requiredFirstToolName ? { tool_choice: { type: "tool", @@ -152,10 +153,10 @@ export async function streamClaude( // Claude 4.x models require `thinking.type: "adaptive"` and // drive effort via `output_config.effort` rather than a fixed // token budget. We only opt in when the caller requested it. - ...(enableThinking + ...(enableThinking && modelCapability(model)?.reasoningEfforts.length ? ({ thinking: { type: "adaptive" }, - output_config: { effort: "high" }, + output_config: { effort: reasoningEffort ?? "high" }, } as unknown as Record) : {}), // Extended thinking requires temperature to be default (omitted). diff --git a/backend/src/lib/llm/gemini.ts b/backend/src/lib/llm/gemini.ts index 266378a64a..8f005dd525 100644 --- a/backend/src/lib/llm/gemini.ts +++ b/backend/src/lib/llm/gemini.ts @@ -6,6 +6,7 @@ import type { } from "./types"; import { toGeminiTools } from "./tools"; import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog"; +import { modelCapability } from "./models"; type GeminiPart = { text?: string; @@ -168,6 +169,7 @@ export async function streamGemini( runTools, apiKeys, enableThinking, + reasoningEffort, } = params; const maxIter = params.maxIterations ?? 10; const ai = client(apiKeys?.gemini); @@ -193,12 +195,11 @@ export async function streamGemini( contents: contents as never, config: { systemInstruction: systemPrompt, - tools: toolsEnabled && functionDeclarations.length - ? [{ functionDeclarations } as never] - : undefined, - ...(iter === 0 && - toolsEnabled && - params.requiredFirstToolName + tools: + toolsEnabled && functionDeclarations.length + ? [{ functionDeclarations } as never] + : undefined, + ...(iter === 0 && toolsEnabled && params.requiredFirstToolName ? { toolConfig: { functionCallingConfig: { @@ -212,9 +213,11 @@ export async function streamGemini( // When disabled, explicitly zero the thinking budget so the // model skips thinking entirely (saves tokens and latency // for bulk extraction jobs). - thinkingConfig: enableThinking - ? { includeThoughts: true } - : { thinkingBudget: 0 }, + thinkingConfig: geminiThinkingConfig( + model, + !!enableThinking, + reasoningEffort, + ), }, }); } catch (error) { @@ -343,6 +346,21 @@ export async function streamGemini( } } +function geminiThinkingConfig( + model: string, + enableThinking: boolean, + reasoningEffort?: StreamChatParams["reasoningEffort"], +) { + if (!modelCapability(model)?.reasoningEfforts.length) return undefined; + if (!enableThinking) return { thinkingBudget: 0 }; + const effort = reasoningEffort ?? "medium"; + if (effort === "none") return { thinkingBudget: 0 }; + return { + includeThoughts: true, + thinkingLevel: effort.toUpperCase(), + } as unknown as Record; +} + export async function completeGeminiText(params: { model: string; systemPrompt?: string; diff --git a/backend/src/lib/llm/modelDiscovery.ts b/backend/src/lib/llm/modelDiscovery.ts index 963de57c52..1a6de1cd69 100644 --- a/backend/src/lib/llm/modelDiscovery.ts +++ b/backend/src/lib/llm/modelDiscovery.ts @@ -1,18 +1,28 @@ -import { MODEL_CAPABILITIES, type ModelCapability } from "./models"; +import { + MODEL_CAPABILITIES, + modelCapability, + modelLabel, + type ModelCapability, +} from "./models"; import { approvedModelProviders } from "./runtimeModels"; import { loadRuntimeConfig } from "../../config/runtime"; import type { Provider, UserApiKeys } from "./types"; -const MODEL_ENDPOINTS: Partial> = { - openai: "https://api.openai.com/v1/models", - xai: "https://api.x.ai/v1/models", - moonshot: "https://api.moonshot.ai/v1/models", -}; - const MODEL_DISCOVERY_ALIASES: Readonly> = { "gpt-5.6": ["gpt-5.6-sol"], }; +type ProviderModel = { + id: string; + label?: string; + supportedGenerationMethods?: string[]; +}; + +type DiscoveryAdapter = { + list: (apiKey: string) => Promise; + compatible: (model: ProviderModel) => boolean; +}; + export type DiscoveredModel = ModelCapability & { available: boolean; availability: "live" | "configured" | "unavailable" | "fallback"; @@ -27,90 +37,272 @@ export type ModelDiscoveryResult = { warning?: string; }; -async function discoverModelIds( - provider: Provider, - apiKey: string, -): Promise> { - const url = MODEL_ENDPOINTS[provider]; - if (!url) return new Set(); +async function fetchJson(url: string, init: RequestInit) { const response = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` }, + ...init, signal: AbortSignal.timeout(10_000), }); if (!response.ok) { - throw new Error(`${provider} model discovery failed (${response.status})`); + throw new Error(`model discovery failed (${response.status})`); } - const body = (await response.json()) as { data?: Array<{ id?: unknown }> }; - return new Set( - (body.data ?? []) - .map((entry) => (typeof entry.id === "string" ? entry.id.trim() : "")) - .filter(Boolean), - ); + return (await response.json()) as Record; +} + +function openAIStyleAdapter( + baseUrl: string, + compatible: DiscoveryAdapter["compatible"], +): DiscoveryAdapter { + return { + compatible, + async list(apiKey) { + const body = await fetchJson(`${baseUrl}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + const data = Array.isArray(body.data) ? body.data : []; + return data.flatMap((value): ProviderModel[] => { + if (!value || typeof value !== "object") return []; + const id = (value as { id?: unknown }).id; + return typeof id === "string" && id.trim() ? [{ id: id.trim() }] : []; + }); + }, + }; } +const anthropicAdapter: DiscoveryAdapter = { + compatible: (model) => /^claude-/.test(model.id), + async list(apiKey) { + const models: ProviderModel[] = []; + let afterId: string | undefined; + for (let page = 0; page < 20; page++) { + const url = new URL("https://api.anthropic.com/v1/models"); + url.searchParams.set("limit", "1000"); + if (afterId) url.searchParams.set("after_id", afterId); + const body = await fetchJson(url.toString(), { + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + }); + const data = Array.isArray(body.data) ? body.data : []; + for (const value of data) { + if (!value || typeof value !== "object") continue; + const entry = value as { + id?: unknown; + display_name?: unknown; + }; + if (typeof entry.id !== "string" || !entry.id.trim()) continue; + models.push({ + id: entry.id.trim(), + ...(typeof entry.display_name === "string" && + entry.display_name.trim() + ? { label: entry.display_name.trim() } + : {}), + }); + } + if (body.has_more !== true || typeof body.last_id !== "string") break; + afterId = body.last_id; + } + return models; + }, +}; + +const geminiAdapter: DiscoveryAdapter = { + compatible: (model) => + /^gemini-/.test(model.id) && + !/(?:image|tts|embedding|aqa)/i.test(model.id) && + (model.supportedGenerationMethods ?? []).some( + (method) => method.toLowerCase() === "generatecontent", + ), + async list(apiKey) { + const models: ProviderModel[] = []; + let pageToken: string | undefined; + for (let page = 0; page < 20; page++) { + const url = new URL( + "https://generativelanguage.googleapis.com/v1beta/models", + ); + url.searchParams.set("pageSize", "1000"); + if (pageToken) url.searchParams.set("pageToken", pageToken); + const body = await fetchJson(url.toString(), { + headers: { "x-goog-api-key": apiKey }, + }); + const data = Array.isArray(body.models) ? body.models : []; + for (const value of data) { + if (!value || typeof value !== "object") continue; + const entry = value as { + name?: unknown; + baseModelId?: unknown; + displayName?: unknown; + supportedGenerationMethods?: unknown; + }; + const resourceId = + typeof entry.name === "string" + ? entry.name.replace(/^models\//, "").trim() + : ""; + const id = + typeof entry.baseModelId === "string" && entry.baseModelId.trim() + ? entry.baseModelId.trim() + : resourceId; + if (!id) continue; + models.push({ + id, + ...(typeof entry.displayName === "string" && entry.displayName.trim() + ? { label: entry.displayName.trim() } + : {}), + supportedGenerationMethods: Array.isArray( + entry.supportedGenerationMethods, + ) + ? entry.supportedGenerationMethods.filter( + (method): method is string => typeof method === "string", + ) + : [], + }); + } + if (typeof body.nextPageToken !== "string" || !body.nextPageToken) break; + pageToken = body.nextPageToken; + } + return models; + }, +}; + +const textModel = + (prefix: RegExp, excluded: RegExp) => (model: ProviderModel) => + prefix.test(model.id) && !excluded.test(model.id); + +/** + * Exhaustive by design: adding a Provider makes TypeScript require a model + * discovery adapter before that provider can be built into ROSS. + */ +const MODEL_DISCOVERY_ADAPTERS = { + openai: openAIStyleAdapter( + "https://api.openai.com/v1", + textModel( + /^(?:gpt-|o\d|ft:(?:gpt-|o\d))/, + /(?:embedding|moderation|transcribe|tts|audio|realtime|image|search)/i, + ), + ), + claude: anthropicAdapter, + gemini: geminiAdapter, + xai: openAIStyleAdapter( + "https://api.x.ai/v1", + textModel(/^grok-/, /(?:image|video|vision|embedding)/i), + ), + moonshot: openAIStyleAdapter( + "https://api.moonshot.ai/v1", + textModel(/^kimi-/, /(?:embedding|image|video)/i), + ), +} satisfies Record; + function isModelListed(modelId: string, ids: ReadonlySet): boolean { if (ids.has(modelId)) return true; return (MODEL_DISCOVERY_ALIASES[modelId] ?? []).some((id) => ids.has(id)); } +function curatedModels(provider: Provider) { + return MODEL_CAPABILITIES.filter( + (capability) => + capability.tier === "main" && capability.provider === provider, + ); +} + +function liveModel(model: ProviderModel, provider: Provider): DiscoveredModel { + const capability = modelCapability(model.id) ?? { + id: model.id, + label: modelLabel(model.id), + provider, + tier: "main" as const, + reasoningEfforts: [], + }; + return { + ...capability, + label: model.label || capability.label, + available: true, + availability: "live", + }; +} + +function missingKeyModel(capability: ModelCapability): DiscoveredModel { + return { + ...capability, + available: false, + availability: "unavailable", + availabilityReason: `Add an API key for ${providerLabel(capability.provider)} to use this model.`, + }; +} + +function unlistedModel(capability: ModelCapability): DiscoveredModel { + return { + ...capability, + available: false, + availability: "unavailable", + availabilityReason: `This ${providerLabel(capability.provider)} key does not list a chat-compatible model ROSS can use.`, + }; +} + /** - * Key-scoped provider availability is combined with ROSS's compatibility - * registry. Credentials are used only by the backend and are never returned. + * Return every chat-compatible model exposed to the user's configured key. + * Credentials remain backend-only. A curated list is used only when no key is + * configured or a provider's discovery endpoint is temporarily unavailable. */ export async function discoverCompatibleModels( apiKeys: UserApiKeys, ): Promise { const approvedProviders = approvedModelProviders(); - const discovered = new Map | null>(); - const warnings: string[] = []; - - for (const provider of ["openai", "xai", "moonshot"] as const) { - const key = apiKeys[provider]?.trim(); - if (!key) continue; - try { - discovered.set(provider, await discoverModelIds(provider, key)); - } catch { - discovered.set(provider, null); - warnings.push(`${providerLabel(provider)} model availability could not be refreshed.`); - } - } + const providerResults = await Promise.all( + approvedProviders.map(async (provider) => { + const key = apiKeys[provider]?.trim(); + const curated = curatedModels(provider); + if (!key) { + return { models: curated.map(missingKeyModel) }; + } - const models = MODEL_CAPABILITIES.filter( - (capability) => - capability.tier === "main" && - approvedProviders.includes(capability.provider), - ).map((capability): DiscoveredModel => { - const configured = Boolean(apiKeys[capability.provider]?.trim()); - if (!configured) { - return { - ...capability, - available: false, - availability: "unavailable", - availabilityReason: `Add an API key for ${providerLabel(capability.provider)} to use this model.`, - }; - } + try { + const adapter = MODEL_DISCOVERY_ADAPTERS[provider]; + const listed = await adapter.list(key); + const compatible = listed.filter(adapter.compatible); + if (!compatible.length) { + return { models: curated.map(unlistedModel) }; + } + const ids = new Set(compatible.map((model) => model.id)); + const seen = new Set(); + const models: DiscoveredModel[] = []; - const ids = discovered.get(capability.provider); - if (ids === undefined || ids === null) { - return { - ...capability, - available: true, - availability: ids === null ? "fallback" : "configured", - }; - } + for (const model of compatible) { + if (seen.has(model.id)) continue; + seen.add(model.id); + models.push(liveModel(model, provider)); + } - const available = isModelListed(capability.id, ids); - return { - ...capability, - available, - availability: available ? "live" : "unavailable", - ...(available - ? {} - : { - availabilityReason: `This ${providerLabel(capability.provider)} project does not currently list this model as available.`, - }), - }; - }); + // Preserve provider aliases that ROSS supports even when the Models + // API returns only their concrete target, such as gpt-5.6-sol. + for (const capability of curated) { + if (!seen.has(capability.id) && isModelListed(capability.id, ids)) { + seen.add(capability.id); + models.push({ + ...capability, + available: true, + availability: "live", + }); + } + } + return { models }; + } catch { + return { + models: curated.map( + (capability): DiscoveredModel => ({ + ...capability, + available: true, + availability: "fallback", + }), + ), + warning: `${providerLabel(provider)} model availability could not be refreshed.`, + }; + } + }), + ); + const models = providerResults.flatMap((result) => result.models); + const warnings = providerResults.flatMap((result) => + result.warning ? [result.warning] : [], + ); return { models, diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index 812c81dc47..51aa356978 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -15,7 +15,14 @@ export const GEMINI_MAIN_MODELS = [ "gemini-3.1-pro-preview", "gemini-3-flash-preview", ] as const; -export const OPENAI_MAIN_MODELS = ["gpt-5.6", "gpt-5.5", "gpt-5.4"] as const; +export const OPENAI_MAIN_MODELS = [ + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", +] as const; export const XAI_MAIN_MODELS = ["grok-4.5"] as const; export const MOONSHOT_MAIN_MODELS = ["kimi-k2.5"] as const; @@ -61,6 +68,14 @@ export type ModelCapability = { }; const NO_EFFORTS = [] as const; +const GPT_5_6_EFFORTS = [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; /** * ROSS compatibility registry. Provider discovery establishes whether the @@ -96,12 +111,36 @@ export const MODEL_CAPABILITIES: readonly ModelCapability[] = [ tier: "main" as const, reasoningEfforts: NO_EFFORTS, })), + { + id: "gpt-5.6-sol", + label: "GPT-5.6 Sol", + provider: "openai", + tier: "main", + reasoningEfforts: GPT_5_6_EFFORTS, + defaultReasoningEffort: "medium", + }, + { + id: "gpt-5.6-terra", + label: "GPT-5.6 Terra", + provider: "openai", + tier: "main", + reasoningEfforts: GPT_5_6_EFFORTS, + defaultReasoningEffort: "medium", + }, + { + id: "gpt-5.6-luna", + label: "GPT-5.6 Luna", + provider: "openai", + tier: "main", + reasoningEfforts: GPT_5_6_EFFORTS, + defaultReasoningEffort: "medium", + }, { id: "gpt-5.6", - label: "GPT-5.6", + label: "GPT-5.6 (Sol alias)", provider: "openai", tier: "main", - reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], + reasoningEfforts: GPT_5_6_EFFORTS, defaultReasoningEffort: "medium", }, { @@ -127,7 +166,75 @@ const CAPABILITY_BY_ID = new Map( ); export function modelCapability(model: string): ModelCapability | null { - return CAPABILITY_BY_ID.get(model) ?? null; + const registered = CAPABILITY_BY_ID.get(model); + if (registered?.reasoningEfforts.length) return registered; + return inferredModelCapability(model) ?? registered ?? null; +} + +function inferredModelCapability(model: string): ModelCapability | null { + let provider: Provider; + try { + provider = providerForModel(model); + } catch { + return null; + } + + const base = { + id: model, + label: modelLabel(model), + provider, + tier: "main" as const, + }; + + if (/^claude-opus-5(?:-|$)/.test(model)) { + return { + ...base, + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + defaultReasoningEffort: "high", + }; + } + if ( + /^claude-(?:fable|mythos|sonnet)-5(?:-|$)/.test(model) || + /^claude-(?:opus|sonnet)-4-(?:6|7|8)(?:-|$)/.test(model) + ) { + return { + ...base, + reasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "high", + }; + } + if (/^gemini-3(?:\.|-|$)/.test(model)) { + return { + ...base, + reasoningEfforts: ["minimal", "low", "medium", "high"], + defaultReasoningEffort: "medium", + }; + } + if (/^kimi-k3(?:-|$)/.test(model)) { + return { + ...base, + reasoningEfforts: ["low", "high", "max"], + defaultReasoningEffort: "max", + }; + } + return { ...base, reasoningEfforts: NO_EFFORTS }; +} + +export function modelLabel(model: string): string { + const prefixes: readonly [string, string][] = [ + ["claude-", "Claude "], + ["gemini-", "Gemini "], + ["gpt-", "GPT-"], + ["grok-", "Grok "], + ["kimi-", "Kimi "], + ]; + const [prefix, labelPrefix] = + prefixes.find(([candidate]) => model.startsWith(candidate)) ?? ["", ""]; + const suffix = model + .slice(prefix.length) + .replaceAll("-", " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); + return `${labelPrefix}${suffix}`; } export function resolveReasoningEffort( @@ -154,11 +261,23 @@ export function supportsReasoningEffort( // --------------------------------------------------------------------------- export function providerForModel(model: string): Provider { - if (model.startsWith("claude")) return "claude"; - if (model.startsWith("gemini")) return "gemini"; - if (model.startsWith("gpt-")) return "openai"; - if (model.startsWith("grok-")) return "xai"; - if (model.startsWith("kimi-")) return "moonshot"; + if (/^claude-[A-Za-z0-9][A-Za-z0-9._:-]{0,152}$/.test(model)) { + return "claude"; + } + if (/^gemini-[A-Za-z0-9][A-Za-z0-9._:-]{0,152}$/.test(model)) { + return "gemini"; + } + if ( + /^(?:gpt-[A-Za-z0-9]|o\d|ft:(?:gpt-[A-Za-z0-9]|o\d))[A-Za-z0-9._:-]{0,155}$/.test( + model, + ) + ) { + return "openai"; + } + if (/^grok-[A-Za-z0-9][A-Za-z0-9._:-]{0,154}$/.test(model)) return "xai"; + if (/^kimi-[A-Za-z0-9][A-Za-z0-9._:-]{0,154}$/.test(model)) { + return "moonshot"; + } throw new Error(`Unknown model id: ${model}`); } @@ -166,6 +285,6 @@ export function resolveModel( id: string | null | undefined, fallback: string, ): string { - if (id && ALL_MODELS.has(id)) return id; + if (id && (ALL_MODELS.has(id) || modelCapability(id))) return id; return fallback; } diff --git a/backend/src/lib/llm/openaiCompatible.ts b/backend/src/lib/llm/openaiCompatible.ts index d32a1ef1b7..6601ba948b 100644 --- a/backend/src/lib/llm/openaiCompatible.ts +++ b/backend/src/lib/llm/openaiCompatible.ts @@ -103,6 +103,7 @@ async function createChatCompletion(params: { requiredToolName?: string; stream: boolean; maxTokens?: number; + reasoningEffort?: StreamChatParams["reasoningEffort"]; apiKey: string; signal?: AbortSignal; }) { @@ -127,6 +128,7 @@ async function createChatCompletion(params: { : undefined, stream: params.stream, max_tokens: params.maxTokens ?? 16_384, + reasoning_effort: params.reasoningEffort, }), signal: params.signal, }); @@ -163,6 +165,7 @@ export async function streamOpenAICompatible( ? params.requiredFirstToolName : undefined, stream: true, + reasoningEffort: params.reasoningEffort, apiKey: key, signal: params.abortSignal, }); @@ -229,7 +232,8 @@ export async function streamOpenAICompatible( if (!normalizedCalls.length || !params.runTools) break; messages.push({ role: "assistant", content: "" }); - const results: NormalizedToolResult[] = await params.runTools(normalizedCalls); + const results: NormalizedToolResult[] = + await params.runTools(normalizedCalls); for (const result of results) { messages.push({ role: "tool", diff --git a/backend/src/lib/llm/types.ts b/backend/src/lib/llm/types.ts index 82afc4c061..a9c9cfb959 100644 --- a/backend/src/lib/llm/types.ts +++ b/backend/src/lib/llm/types.ts @@ -6,6 +6,7 @@ export type Provider = "claude" | "gemini" | "openai" | "xai" | "moonshot"; export type ReasoningEffort = | "none" + | "minimal" | "low" | "medium" | "high" diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index b748d854be..8fe6d2c42f 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -119,6 +119,7 @@ function parseOptionalModel( const REASONING_EFFORTS = new Set([ "none", + "minimal", "low", "medium", "high", diff --git a/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx b/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx index df7e7f68a5..8580daf9d4 100644 --- a/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx +++ b/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx @@ -1268,6 +1268,7 @@ export default function ProjectAssistantChatPage({ params }: Props) { projectName={project?.name} projectCmNumber={project?.cm_number} defaultJurisdictions={project?.jurisdictions} + jurisdictionPersistenceScope={`project:${projectId}`} /> diff --git a/frontend/src/app/components/assistant/ChatInput.tsx b/frontend/src/app/components/assistant/ChatInput.tsx index d98dbbe1ab..49dab6e25d 100644 --- a/frontend/src/app/components/assistant/ChatInput.tsx +++ b/frontend/src/app/components/assistant/ChatInput.tsx @@ -23,6 +23,10 @@ import { AssistantWorkflowModal } from "./AssistantWorkflowModal"; import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup"; import { ModelToggle } from "./ModelToggle"; import { useSelectedModel } from "@/app/hooks/useSelectedModel"; +import { + jurisdictionCodes, + useSelectedJurisdiction, +} from "@/app/hooks/useSelectedJurisdiction"; import { useModelCatalog } from "@/app/hooks/useModelCatalog"; import { useUserProfile } from "@/app/contexts/UserProfileContext"; import { @@ -48,6 +52,7 @@ interface Props { projectName?: string; projectCmNumber?: string | null; defaultJurisdictions?: Array<"CA-ON" | "CA" | "US">; + jurisdictionPersistenceScope?: string; } export const ChatInput = forwardRef(function ChatInput( @@ -61,6 +66,7 @@ export const ChatInput = forwardRef(function ChatInput( projectName, projectCmNumber, defaultJurisdictions, + jurisdictionPersistenceScope, }: Props, ref, ) { @@ -86,22 +92,16 @@ export const ChatInput = forwardRef(function ChatInput( : ((selectedModel?.defaultReasoningEffort as ReasoningEffort | undefined) ?? reasoningEfforts[0]); const { profile } = useUserProfile(); - const [jurisdictionOverride, setJurisdictionOverride] = useState< - "CA-ON" | "CA" | "US" | null - >(null); const profileJurisdictions = profile?.legalResearch.enabledJurisdictions; const baseJurisdictions = defaultJurisdictions?.length ? defaultJurisdictions : profileJurisdictions?.length ? profileJurisdictions : (["CA-ON", "CA"] as Array<"CA-ON" | "CA" | "US">); - const jurisdiction = - jurisdictionOverride ?? - (baseJurisdictions.includes("CA-ON") - ? "CA-ON" - : baseJurisdictions.includes("CA") - ? "CA" - : "US"); + const [jurisdiction, setJurisdiction] = useSelectedJurisdiction( + baseJurisdictions, + jurisdictionPersistenceScope, + ); const apiKeys = profile?.apiKeys; const textareaRef = useRef(null); const controlsRef = useRef(null); @@ -117,12 +117,14 @@ export const ChatInput = forwardRef(function ChatInput( } const selected = modelCatalog.models.find((item) => item.id === model); const selectedAvailable = - selected?.available !== false && isModelAvailable(model, apiKeys); + selected?.available !== false && + isModelAvailable(model, apiKeys, selected?.provider); if (selected && selectedAvailable) return; const replacement = modelCatalog.models.find( (item) => - item.available !== false && isModelAvailable(item.id, apiKeys), + item.available !== false && + isModelAvailable(item.id, apiKeys, item.provider), ) ?? modelCatalog.models[0]; if (replacement && replacement.id !== model) setModel(replacement.id); }, [apiKeys, model, modelCatalog.loading, modelCatalog.models, setModel]); @@ -167,8 +169,10 @@ export const ChatInput = forwardRef(function ChatInput( const handleSubmit = () => { const query = value.trim(); if (!query || isLoading) return; - if (apiKeys && !isModelAvailable(model, apiKeys)) { - setApiKeyModalProvider(getModelProvider(model)); + if (apiKeys && !isModelAvailable(model, apiKeys, selectedModel?.provider)) { + setApiKeyModalProvider( + selectedModel?.provider ?? getModelProvider(model), + ); return; } setValue(""); @@ -191,11 +195,7 @@ export const ChatInput = forwardRef(function ChatInput( workflow: wf ?? undefined, model, reasoningEffort, - jurisdictions: jurisdictionOverride - ? jurisdiction === "CA-ON" - ? ["CA-ON", "CA"] - : [jurisdiction] - : baseJurisdictions, + jurisdictions: jurisdictionCodes(jurisdiction), }); }; @@ -292,9 +292,7 @@ export const ChatInput = forwardRef(function ChatInput( id="chat-jurisdiction" value={jurisdiction} onChange={(event) => - setJurisdictionOverride( - event.target.value as "CA-ON" | "CA" | "US", - ) + setJurisdiction(event.target.value as "CA-ON" | "CA" | "US") } className="h-8 rounded-lg border-0 bg-white/45 px-2 text-xs text-gray-600 outline-none hover:bg-white/65 focus:ring-2 focus:ring-gray-400" > diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index 417004beff..e49d9d8fea 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -17,6 +17,7 @@ export interface ModelOption { id: string; label: string; group: "Anthropic" | "Google" | "OpenAI" | "xAI" | "Moonshot"; + provider: "claude" | "gemini" | "openai" | "xai" | "moonshot"; available?: boolean; availabilityReason?: string; reasoningEfforts?: string[]; @@ -24,19 +25,84 @@ export interface ModelOption { } export const MODELS: ModelOption[] = [ - { id: "claude-fable-5", label: "Claude Fable 5", group: "Anthropic" }, - { id: "claude-opus-4-8", label: "Claude Opus 4.8", group: "Anthropic" }, - { id: "claude-opus-4-7", label: "Claude Opus 4.7", group: "Anthropic" }, - { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", group: "Anthropic" }, - { id: "gemini-3.5-flash", label: "Gemini 3.5 Flash", group: "Google" }, - { id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", group: "Google" }, - { id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" }, - { id: "grok-4.5", label: "Grok 4.5", group: "xAI" }, - { id: "kimi-k2.5", label: "Kimi K2.5", group: "Moonshot" }, + { + id: "claude-fable-5", + label: "Claude Fable 5", + group: "Anthropic", + provider: "claude", + }, + { + id: "claude-opus-4-8", + label: "Claude Opus 4.8", + group: "Anthropic", + provider: "claude", + }, + { + id: "claude-opus-4-7", + label: "Claude Opus 4.7", + group: "Anthropic", + provider: "claude", + }, + { + id: "claude-sonnet-4-6", + label: "Claude Sonnet 4.6", + group: "Anthropic", + provider: "claude", + }, + { + id: "gemini-3.5-flash", + label: "Gemini 3.5 Flash", + group: "Google", + provider: "gemini", + }, + { + id: "gemini-3.1-pro-preview", + label: "Gemini 3.1 Pro", + group: "Google", + provider: "gemini", + }, + { + id: "gemini-3-flash-preview", + label: "Gemini 3 Flash", + group: "Google", + provider: "gemini", + }, + { id: "grok-4.5", label: "Grok 4.5", group: "xAI", provider: "xai" }, + { + id: "kimi-k2.5", + label: "Kimi K2.5", + group: "Moonshot", + provider: "moonshot", + }, + { + id: "gpt-5.6-sol", + label: "GPT-5.6 Sol", + group: "OpenAI", + provider: "openai", + reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], + defaultReasoningEffort: "medium", + }, + { + id: "gpt-5.6-terra", + label: "GPT-5.6 Terra", + group: "OpenAI", + provider: "openai", + reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], + defaultReasoningEffort: "medium", + }, + { + id: "gpt-5.6-luna", + label: "GPT-5.6 Luna", + group: "OpenAI", + provider: "openai", + reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], + defaultReasoningEffort: "medium", + }, { id: "gpt-5.6", - label: "GPT-5.6", + label: "GPT-5.6 (Sol alias)", group: "OpenAI", + provider: "openai", reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], defaultReasoningEffort: "medium", }, @@ -44,6 +110,7 @@ export const MODELS: ModelOption[] = [ id: "gpt-5.5", label: "GPT-5.5", group: "OpenAI", + provider: "openai", reasoningEfforts: ["none", "low", "medium", "high", "xhigh"], defaultReasoningEffort: "medium", }, @@ -51,6 +118,7 @@ export const MODELS: ModelOption[] = [ id: "gpt-5.4", label: "GPT-5.4", group: "OpenAI", + provider: "openai", reasoningEfforts: ["none", "low", "medium", "high", "xhigh"], defaultReasoningEffort: "medium", }, @@ -58,13 +126,24 @@ export const MODELS: ModelOption[] = [ export const SETTINGS_MODELS: ModelOption[] = [ ...MODELS, - { id: "claude-haiku-4-5", label: "Claude Haiku 4.5", group: "Anthropic" }, + { + id: "claude-haiku-4-5", + label: "Claude Haiku 4.5", + group: "Anthropic", + provider: "claude", + }, { id: "gemini-3.1-flash-lite-preview", label: "Gemini 3.1 Flash Lite", group: "Google", + provider: "gemini", + }, + { + id: "gpt-5.4-lite", + label: "GPT-5.4 Lite", + group: "OpenAI", + provider: "openai", }, - { id: "gpt-5.4-lite", label: "GPT-5.4 Lite", group: "OpenAI" }, ]; export const DEFAULT_MODEL_ID = "gemini-3-flash-preview"; @@ -104,7 +183,7 @@ export function ModelToggle({ const selectedLabel = selected?.label ?? "Model"; const selectedAvailable = selected?.available !== false && - (apiKeys ? isModelAvailable(value, apiKeys) : true); + (apiKeys ? isModelAvailable(value, apiKeys, selected?.provider) : true); const selectedUnavailableReason = selected?.available === false ? (selected.availabilityReason ?? @@ -143,7 +222,9 @@ export function ModelToggle({ {items.map((m) => { const available = m.available !== false && - (apiKeys ? isModelAvailable(m.id, apiKeys) : true); + (apiKeys + ? isModelAvailable(m.id, apiKeys, m.provider) + : true); const unavailableReason = m.available === false ? (m.availabilityReason ?? diff --git a/frontend/src/app/components/shared/types.ts b/frontend/src/app/components/shared/types.ts index 275a6bd6d6..d6fc03e0ec 100644 --- a/frontend/src/app/components/shared/types.ts +++ b/frontend/src/app/components/shared/types.ts @@ -373,7 +373,14 @@ export interface Message { files?: { filename: string; document_id?: string }[]; workflow?: { id: string; title: string }; model?: string; - reasoningEffort?: "none" | "low" | "medium" | "high" | "xhigh" | "max"; + reasoningEffort?: + | "none" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; jurisdictions?: Array<"CA-ON" | "CA" | "US">; legalAsOfDate?: string; citations?: Citation[]; diff --git a/frontend/src/app/hooks/useModelCatalog.ts b/frontend/src/app/hooks/useModelCatalog.ts index 39807ee08b..1c9f47c589 100644 --- a/frontend/src/app/hooks/useModelCatalog.ts +++ b/frontend/src/app/hooks/useModelCatalog.ts @@ -30,6 +30,7 @@ export function useModelCatalog() { return { id: model.id, label: model.label, + provider, group: provider === "openai" ? ("OpenAI" as const) diff --git a/frontend/src/app/hooks/useSelectedJurisdiction.ts b/frontend/src/app/hooks/useSelectedJurisdiction.ts new file mode 100644 index 0000000000..602a5c3a29 --- /dev/null +++ b/frontend/src/app/hooks/useSelectedJurisdiction.ts @@ -0,0 +1,106 @@ +"use client"; + +import { useCallback, useMemo, useSyncExternalStore } from "react"; + +export type ChatJurisdiction = "CA-ON" | "CA" | "US"; + +const STORAGE_PREFIX = "ross.selectedJurisdiction.v1"; +const listeners = new Map void>>(); + +function defaultJurisdiction( + jurisdictions?: readonly ChatJurisdiction[], +): ChatJurisdiction { + if (jurisdictions?.includes("CA-ON")) return "CA-ON"; + if (jurisdictions?.includes("CA")) return "CA"; + if (jurisdictions?.includes("US")) return "US"; + return "CA-ON"; +} + +function isJurisdiction(value: string | null): value is ChatJurisdiction { + return value === "CA-ON" || value === "CA" || value === "US"; +} + +function readStoredJurisdiction( + storageKey: string, + fallback: ChatJurisdiction, +): ChatJurisdiction { + if (typeof window === "undefined") return fallback; + const stored = window.sessionStorage.getItem(storageKey); + return isJurisdiction(stored) ? stored : fallback; +} + +function subscribe(storageKey: string, onStoreChange: () => void): () => void { + if (typeof window === "undefined") return () => undefined; + + let storageListeners = listeners.get(storageKey); + if (!storageListeners) { + storageListeners = new Set(); + listeners.set(storageKey, storageListeners); + } + storageListeners.add(onStoreChange); + + const handleStorage = (event: StorageEvent) => { + if (event.storageArea === window.sessionStorage && event.key === storageKey) { + onStoreChange(); + } + }; + window.addEventListener("storage", handleStorage); + + return () => { + window.removeEventListener("storage", handleStorage); + storageListeners.delete(onStoreChange); + if (storageListeners.size === 0) listeners.delete(storageKey); + }; +} + +function notify(storageKey: string): void { + listeners.get(storageKey)?.forEach((listener) => listener()); +} + +export function jurisdictionCodes( + jurisdiction: ChatJurisdiction, +): ChatJurisdiction[] { + return jurisdiction === "CA-ON" ? ["CA-ON", "CA"] : [jurisdiction]; +} + +/** + * Keep the submitted jurisdiction stable when the first-prompt view is + * replaced by the conversation view. Project callers use a project-scoped key + * so a matter's selection cannot leak into another project. + */ +export function useSelectedJurisdiction( + jurisdictions?: readonly ChatJurisdiction[], + persistenceScope = "assistant", +): [ChatJurisdiction, (jurisdiction: ChatJurisdiction) => void] { + const fallback = defaultJurisdiction(jurisdictions); + const storageKey = useMemo( + () => `${STORAGE_PREFIX}:${persistenceScope}`, + [persistenceScope], + ); + const subscribeToJurisdiction = useCallback( + (onStoreChange: () => void) => subscribe(storageKey, onStoreChange), + [storageKey], + ); + const getSnapshot = useCallback( + () => readStoredJurisdiction(storageKey, fallback), + [fallback, storageKey], + ); + const getServerSnapshot = useCallback(() => fallback, [fallback]); + const jurisdiction = useSyncExternalStore( + subscribeToJurisdiction, + getSnapshot, + getServerSnapshot, + ); + + const setJurisdiction = useCallback( + (next: ChatJurisdiction) => { + if (typeof window !== "undefined") { + window.sessionStorage.setItem(storageKey, next); + notify(storageKey); + } + }, + [storageKey], + ); + + return [jurisdiction, setJurisdiction]; +} diff --git a/frontend/src/app/hooks/useSelectedModel.ts b/frontend/src/app/hooks/useSelectedModel.ts index 93a637fcbc..a886fd797c 100644 --- a/frontend/src/app/hooks/useSelectedModel.ts +++ b/frontend/src/app/hooks/useSelectedModel.ts @@ -1,14 +1,23 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { ALLOWED_MODEL_IDS, DEFAULT_MODEL_ID } from "../components/assistant/ModelToggle"; +import { DEFAULT_MODEL_ID } from "../components/assistant/ModelToggle"; const STORAGE_KEY = "mike.selectedModel"; +function isSelectableModelId(id: string): boolean { + return ( + id.length <= 160 && + /^(?:(?:claude|gemini|gpt|grok|kimi)-[A-Za-z0-9][A-Za-z0-9._:-]*|o\d[A-Za-z0-9._:-]*|ft:(?:gpt-[A-Za-z0-9]|o\d)[A-Za-z0-9._:-]*)$/.test( + id, + ) + ); +} + function readStored(): string { if (typeof window === "undefined") return DEFAULT_MODEL_ID; const raw = window.localStorage.getItem(STORAGE_KEY); - if (raw && ALLOWED_MODEL_IDS.has(raw)) return raw; + if (raw && isSelectableModelId(raw)) return raw; return DEFAULT_MODEL_ID; } @@ -20,7 +29,7 @@ export function useSelectedModel(): [string, (id: string) => void] { }, []); const setModel = useCallback((id: string) => { - const next = ALLOWED_MODEL_IDS.has(id) ? id : DEFAULT_MODEL_ID; + const next = isSelectableModelId(id) ? id : DEFAULT_MODEL_ID; setModelState(next); if (typeof window !== "undefined") { window.localStorage.setItem(STORAGE_KEY, next); diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 8cf948e653..d71b9ce902 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -402,6 +402,7 @@ export type ApiKeyStatus = Record & { export type ReasoningEffort = | "none" + | "minimal" | "low" | "medium" | "high" @@ -411,7 +412,7 @@ export type ReasoningEffort = export interface ModelCatalogEntry { id: string; label: string; - provider: "claude" | "gemini" | "openai"; + provider: "claude" | "gemini" | "openai" | "xai" | "moonshot"; tier: "main" | "mid" | "low"; reasoningEfforts: ReasoningEffort[]; defaultReasoningEffort?: ReasoningEffort; @@ -422,7 +423,7 @@ export interface ModelCatalogEntry { export interface ModelCatalog { models: ModelCatalogEntry[]; - approvedProviders: Array<"claude" | "gemini" | "openai">; + approvedProviders: Array<"claude" | "gemini" | "openai" | "xai" | "moonshot">; selfHosted: boolean; refreshedAt: string; warning?: string; diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index c7a3e863df..dcf3daa357 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -1,24 +1,33 @@ -import { SETTINGS_MODELS, type ModelOption } from "../components/assistant/ModelToggle"; +import { + SETTINGS_MODELS, + type ModelOption, +} from "../components/assistant/ModelToggle"; import type { ApiKeyState } from "@/app/lib/mikeApi"; -export type ModelProvider = - | "claude" - | "gemini" - | "openai" - | "xai" - | "moonshot"; +export type ModelProvider = "claude" | "gemini" | "openai" | "xai" | "moonshot"; export function getModelProvider(modelId: string): ModelProvider | null { const model = SETTINGS_MODELS.find((m) => m.id === modelId); - if (!model) return null; - return modelGroupToProvider(model.group); + if (model) return model.provider; + if (modelId.startsWith("claude-")) return "claude"; + if (modelId.startsWith("gemini-")) return "gemini"; + if ( + modelId.startsWith("gpt-") || + /^o\d/.test(modelId) || + /^ft:(?:gpt-|o\d)/.test(modelId) + ) + return "openai"; + if (modelId.startsWith("grok-")) return "xai"; + if (modelId.startsWith("kimi-")) return "moonshot"; + return null; } export function isModelAvailable( modelId: string, apiKeys: ApiKeyState, + providerOverride?: ModelProvider | null, ): boolean { - const provider = getModelProvider(modelId); + const provider = providerOverride ?? getModelProvider(modelId); if (!provider) return false; return isProviderAvailable(provider, apiKeys); } diff --git a/tests/baseline/api-key-settings.test.mjs b/tests/baseline/api-key-settings.test.mjs index 1d7b25f9d6..726069baad 100644 --- a/tests/baseline/api-key-settings.test.mjs +++ b/tests/baseline/api-key-settings.test.mjs @@ -79,7 +79,7 @@ test("a future direct provider inherits stable API-key visibility", () => { ); }); -test("OpenAI model discovery recognizes the GPT-5.6 API alias", () => { +test("provider model discovery recognizes aliases and is exhaustive", () => { const discovery = read("backend/src/lib/llm/modelDiscovery.ts"); assert.match( @@ -97,4 +97,8 @@ test("OpenAI model discovery recognizes the GPT-5.6 API alias", () => { /available:\s*ids\.has\(capability\.id\)/, "literal-only discovery would incorrectly disable supported aliases", ); + assert.match(discovery, /satisfies Record/); + for (const provider of ["openai", "claude", "gemini", "xai", "moonshot"]) { + assert.match(discovery, new RegExp(`\\b${provider}:`)); + } }); diff --git a/tests/baseline/ross-hosted-runtime.test.mjs b/tests/baseline/ross-hosted-runtime.test.mjs index c593b98bcf..589c6d5f7c 100644 --- a/tests/baseline/ross-hosted-runtime.test.mjs +++ b/tests/baseline/ross-hosted-runtime.test.mjs @@ -78,10 +78,11 @@ test("shared dialogs and warnings expose keyboard and live-region semantics", () assert.match(warning, /aria-live="assertive"/); }); -test("model availability explains entitlement separately from missing keys", () => { +test("model availability shows only live entitlements and explains missing keys", () => { const discovery = read("backend/src/lib/llm/modelDiscovery.ts"); assert.match(discovery, /availabilityReason/); - assert.match(discovery, /does not currently list this model as available/); + assert.match(discovery, /listed\.filter\(adapter\.compatible\)/); + assert.match(discovery, /Add an API key for/); const toggle = read( "frontend/src/app/components/assistant/ModelToggle.tsx", diff --git a/tests/baseline/ross-selector-persistence.test.mjs b/tests/baseline/ross-selector-persistence.test.mjs new file mode 100644 index 0000000000..535710696d --- /dev/null +++ b/tests/baseline/ross-selector-persistence.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const read = (path) => readFileSync(resolve(root, path), "utf8"); + +test("the submitted jurisdiction survives the first-prompt route transition", () => { + const hook = read("frontend/src/app/hooks/useSelectedJurisdiction.ts"); + const input = read("frontend/src/app/components/assistant/ChatInput.tsx"); + const project = read( + "frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx", + ); + + assert.match(hook, /useSyncExternalStore/); + assert.doesNotMatch(hook, /useEffect|setJurisdictionState/); + assert.match(hook, /sessionStorage\.setItem\(storageKey, next\)/); + assert.match(hook, /notify\(storageKey\)/); + assert.match(hook, /jurisdiction === "CA-ON" \? \["CA-ON", "CA"\]/); + assert.match(input, /jurisdictions: jurisdictionCodes\(jurisdiction\)/); + assert.doesNotMatch(input, /jurisdictionOverride/); + assert.match( + project, + /jurisdictionPersistenceScope=\{`project:\$\{projectId\}`\}/, + ); +}); + +test("live provider models remain selectable without a hardcoded UI ID", () => { + const selectedModel = read("frontend/src/app/hooks/useSelectedModel.ts"); + const catalog = read("frontend/src/app/hooks/useModelCatalog.ts"); + const backend = read("backend/src/lib/llm/modelDiscovery.ts"); + + assert.match(selectedModel, /isSelectableModelId/); + assert.doesNotMatch(selectedModel, /ALLOWED_MODEL_IDS\.has/); + assert.match(catalog, /provider,/); + assert.match(backend, /listed\.filter\(adapter\.compatible\)/); + assert.match(backend, /models\.push\(liveModel\(model, provider\)\)/); +});