diff --git a/.env.example b/.env.example index 0be8cec9d4..0f377edffb 100644 --- a/.env.example +++ b/.env.example @@ -292,6 +292,12 @@ ALLOW_API_KEY_REVEAL=false # Default: 5000 | Minimum: 1000 # OMNIROUTE_PAYLOAD_RULES_RELOAD_MS=5000 +# Prefer Claude Code OAuth for unprefixed Claude-family model IDs such as +# claude-sonnet-4-6 or newly released IDs like claude-fable-5. +# Used by: open-sse/services/model.ts. Explicit provider prefixes still win. +# Default: false +# OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=false + # ═══════════════════════════════════════════════════════════════════════════════ # 7. URLS & CLOUD SYNC # ═══════════════════════════════════════════════════════════════════════════════ @@ -1519,4 +1525,3 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by: # scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY. # OPENCODE_API_KEY= - diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index ec0f0844ff..5d306d6fa7 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -272,6 +272,40 @@ async function getActiveProviderSet() { } } +function isTruthyEnv(value: string | undefined) { + return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim()); +} + +async function getPreferClaudeCodeForUnprefixedClaudeModels() { + try { + const { getCachedSettings } = await import("@/lib/localDb"); + const settings = (await getCachedSettings()) as Record; + if (typeof settings.preferClaudeCodeForUnprefixedClaudeModels === "boolean") { + return settings.preferClaudeCodeForUnprefixedClaudeModels; + } + } catch { + // Standalone open-sse usage may not have the app DB layer available. + } + + return isTruthyEnv(process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS); +} + +function shouldPreferClaudeCodeForUnprefixedClaudeModel( + modelId: string, + activeProviders: Set | null, + preferClaudeCode: boolean +) { + if (!preferClaudeCode || !/^claude-/i.test(modelId)) { + return false; + } + + // If DB/provider state is unavailable in a lightweight runtime, honor the + // explicit operator flag and let the normal credential path report any missing + // Claude Code account. When state is available, avoid stealing traffic from + // other Claude-family providers unless Claude Code is actually active. + return activeProviders === null || activeProviders.size === 0 || activeProviders.has("claude"); +} + function shouldTreatAsExactModelId(modelStr: string | null) { if (!modelStr || typeof modelStr !== "string" || !modelStr.includes("/")) return false; if (!KNOWN_MODEL_IDS.has(modelStr)) return false; @@ -448,7 +482,10 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: }; } - const activeProviders = await getActiveProviderSet(); + const [activeProviders, preferClaudeCodeForUnprefixedClaudeModels] = await Promise.all([ + getActiveProviderSet(), + getPreferClaudeCodeForUnprefixedClaudeModels(), + ]); // Preserve historical behavior: OpenAI stays default when model exists there. // Connection availability must not make unprefixed OpenAI models resolve to a @@ -485,6 +522,21 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: const candidatesToUse = nonOpenAIProviders; + if ( + candidatesToUse.includes("claude") && + shouldPreferClaudeCodeForUnprefixedClaudeModel( + modelId, + activeProviders, + preferClaudeCodeForUnprefixedClaudeModels + ) + ) { + return { + provider: "claude", + model: resolveInferredProviderModel("claude", modelId), + extendedContext, + }; + } + if (candidatesToUse.length === 1) { const provider = candidatesToUse[0]; const canonicalModel = resolveInferredProviderModel(provider, modelId); @@ -510,6 +562,15 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: // FIX #73: Models like claude-haiku-4-5-20251001 sent without provider prefix // would incorrectly route to OpenAI. Use heuristic prefix detection first. if (/^claude-/i.test(modelId)) { + if ( + shouldPreferClaudeCodeForUnprefixedClaudeModel( + modelId, + activeProviders, + preferClaudeCodeForUnprefixedClaudeModels + ) + ) { + return { provider: "claude", model: modelId, extendedContext }; + } // Claude models → Anthropic provider (canonical source for Claude models) return { provider: "anthropic", model: modelId, extendedContext }; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index cb06cc977e..fadb2cb876 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -1222,6 +1222,15 @@ export default function ProviderDetailPageClient() { const [codexSettingsLoaded, setCodexSettingsLoaded] = useState(false); const [codexSettingsLoadError, setCodexSettingsLoadError] = useState(null); const [savingCodexGlobalServiceMode, setSavingCodexGlobalServiceMode] = useState(false); + const [ + preferClaudeCodeForUnprefixedClaudeModels, + setPreferClaudeCodeForUnprefixedClaudeModels, + ] = useState(false); + const [claudeRoutingSettingsLoaded, setClaudeRoutingSettingsLoaded] = useState(false); + const [claudeRoutingSettingsLoadError, setClaudeRoutingSettingsLoadError] = useState< + string | null + >(null); + const [savingClaudeRoutingPreference, setSavingClaudeRoutingPreference] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchDeleting, setBatchDeleting] = useState(false); const [batchUpdating, setBatchUpdating] = useState<"activate" | "deactivate" | null>(null); @@ -1630,6 +1639,41 @@ export default function ProviderDetailPageClient() { void loadCodexSettings(); }, [loadCodexSettings]); + const loadClaudeRoutingSettings = useCallback(async () => { + if (providerId !== "claude") { + setClaudeRoutingSettingsLoaded(false); + setClaudeRoutingSettingsLoadError(null); + return; + } + + setClaudeRoutingSettingsLoaded(false); + setClaudeRoutingSettingsLoadError(null); + + try { + const response = await fetch("/api/settings", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Settings request failed with HTTP ${response.status}`); + } + const data = await response.json(); + if (!data || typeof data !== "object") { + throw new Error("Settings response was empty"); + } + setPreferClaudeCodeForUnprefixedClaudeModels( + data.preferClaudeCodeForUnprefixedClaudeModels === true + ); + setClaudeRoutingSettingsLoaded(true); + } catch (error) { + setClaudeRoutingSettingsLoaded(false); + setClaudeRoutingSettingsLoadError( + error instanceof Error ? error.message : "Failed to load settings" + ); + } + }, [providerId]); + + useEffect(() => { + void loadClaudeRoutingSettings(); + }, [loadClaudeRoutingSettings]); + const loadConnProxies = useCallback(async (conns: { id?: string }[]) => { if (!conns.length) return; try { @@ -2676,6 +2720,48 @@ export default function ProviderDetailPageClient() { } }; + const handleToggleClaudeRoutingPreference = async (enabled: boolean) => { + if (savingClaudeRoutingPreference || !claudeRoutingSettingsLoaded) return; + setSavingClaudeRoutingPreference(true); + const previous = preferClaudeCodeForUnprefixedClaudeModels; + setPreferClaudeCodeForUnprefixedClaudeModels(enabled); + + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + preferClaudeCodeForUnprefixedClaudeModels: enabled, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setPreferClaudeCodeForUnprefixedClaudeModels(previous); + notify.error(data.error || "Failed to update Claude Code routing preference"); + return; + } + + const data = await res.json().catch(() => null); + if (data && typeof data === "object") { + setPreferClaudeCodeForUnprefixedClaudeModels( + data.preferClaudeCodeForUnprefixedClaudeModels === true + ); + } + notify.success( + enabled + ? "Unprefixed Claude models now prefer Claude Code" + : "Unprefixed Claude models no longer prefer Claude Code" + ); + } catch (error) { + setPreferClaudeCodeForUnprefixedClaudeModels(previous); + console.error("Error updating Claude Code routing preference:", error); + notify.error("Failed to update Claude Code routing preference"); + } finally { + setSavingClaudeRoutingPreference(false); + } + }; + const handleChangeCodexGlobalServiceMode = async (mode: CodexGlobalServiceMode) => { if (savingCodexGlobalServiceMode || !codexSettingsLoaded) return; setSavingCodexGlobalServiceMode(true); @@ -4157,6 +4243,66 @@ export default function ProviderDetailPageClient() {

{t("connections")}

+ {providerId === "claude" && ( +
+ + alt_route + + + {providerText( + t, + "preferClaudeCodeForUnprefixedClaudeModelsLabel", + "Claude Code default" + )} + + + + {preferClaudeCodeForUnprefixedClaudeModels + ? providerText(t, "toggleOnShort", "On") + : providerText(t, "toggleOffShort", "Off")} + + {claudeRoutingSettingsLoadError ? ( + + ) : null} +
+ )} {providerId === "codex" && (
(); @@ -100,6 +104,9 @@ export async function getSettings() { hideEndpointCloudflaredTunnel: false, hideEndpointTailscaleFunnel: false, hideEndpointNgrokTunnel: false, + preferClaudeCodeForUnprefixedClaudeModels: isTruthyEnvFlag( + process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS + ), autoRefreshProviderQuota: false, autoRefreshProviderQuotaInterval: 180, comboConfigMode: "guided", diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index fac4880acd..b5b9ec81eb 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -748,6 +748,7 @@ export const updateSettingsSchema = z.object({ hideEndpointCloudflaredTunnel: z.boolean().optional(), hideEndpointTailscaleFunnel: z.boolean().optional(), hideEndpointNgrokTunnel: z.boolean().optional(), + preferClaudeCodeForUnprefixedClaudeModels: z.boolean().optional(), pinProviderQuotaToHome: z.boolean().optional(), showQuickStartOnHome: z.boolean().optional(), showProviderTopologyOnHome: z.boolean().optional(), diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index d426feaef1..40ae9f3268 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -36,6 +36,7 @@ export const updateSettingsSchema = z.object({ hideEndpointCloudflaredTunnel: z.boolean().optional(), hideEndpointTailscaleFunnel: z.boolean().optional(), hideEndpointNgrokTunnel: z.boolean().optional(), + preferClaudeCodeForUnprefixedClaudeModels: z.boolean().optional(), autoRefreshProviderQuota: z.boolean().optional(), autoRefreshProviderQuotaInterval: z.number().int().min(10).max(3600).optional(), pinProviderQuotaToHome: z.boolean().optional(), diff --git a/src/types/settings.ts b/src/types/settings.ts index f5af082fcc..b46b68cfc7 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -29,6 +29,7 @@ export interface Settings { hideEndpointCloudflaredTunnel?: boolean; hideEndpointTailscaleFunnel?: boolean; hideEndpointNgrokTunnel?: boolean; + preferClaudeCodeForUnprefixedClaudeModels?: boolean; autoRefreshProviderQuota?: boolean; autoRefreshProviderQuotaInterval?: number; pinProviderQuotaToHome?: boolean; diff --git a/tests/unit/model-resolver.test.ts b/tests/unit/model-resolver.test.ts index 2e4b860d8b..d7d0d0e739 100644 --- a/tests/unit/model-resolver.test.ts +++ b/tests/unit/model-resolver.test.ts @@ -1,8 +1,51 @@ import test from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const previousDataDir = process.env.DATA_DIR; +const modelResolverDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-model-resolver-")); +process.env.DATA_DIR = modelResolverDataDir; const model = await import("../../open-sse/services/model.ts"); +function withEnv(name: string, value: string | undefined, fn: () => Promise) { + return async () => { + const { invalidateDbCache } = await import("../../src/lib/db/readCache.ts"); + const previous = process.env[name]; + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + invalidateDbCache("settings"); + try { + await fn(); + } finally { + if (previous === undefined) { + delete process.env[name]; + } else { + process.env[name] = previous; + } + invalidateDbCache("settings"); + } + }; +} + +test.after(async () => { + const core = await import("../../src/lib/db/core.ts"); + const { invalidateDbCache } = await import("../../src/lib/db/readCache.ts"); + core.resetDbInstance(); + invalidateDbCache(); + fs.rmSync(modelResolverDataDir, { recursive: true, force: true }); + if (previousDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = previousDataDir; + } +}); + test("resolveProviderAlias returns null for null/undefined", () => { assert.equal(model.resolveProviderAlias(null), null); assert.equal(model.resolveProviderAlias(undefined), null); @@ -116,3 +159,84 @@ test("getModelInfoCore handles null", async () => { assert.ok(result); assert.ok(typeof result === "object"); }); + +test( + "getModelInfoCore routes newly released unprefixed Claude models to Claude Code when enabled", + withEnv("OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS", "true", async () => { + const result = await model.getModelInfoCore("claude-fable-5", {}); + assert.deepEqual(result, { + provider: "claude", + model: "claude-fable-5", + extendedContext: false, + }); + }) +); + +test( + "getModelInfoCore resolves ambiguous unprefixed Claude catalog models to Claude Code when enabled", + withEnv("OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS", "true", async () => { + const result = await model.getModelInfoCore("claude-haiku-4-5-20251001", {}); + assert.deepEqual(result, { + provider: "claude", + model: "claude-haiku-4-5-20251001", + extendedContext: false, + }); + }) +); + +test("getModelInfoCore routes unprefixed Claude models to Claude Code from settings toggle", async () => { + const previousEnvFlag = + process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS; + delete process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS; + + try { + const { updateSettings } = await import("../../src/lib/db/settings.ts"); + await updateSettings({ preferClaudeCodeForUnprefixedClaudeModels: true }); + + const result = await model.getModelInfoCore("claude-fable-5", {}); + assert.deepEqual(result, { + provider: "claude", + model: "claude-fable-5", + extendedContext: false, + }); + + const extendedResult = await model.getModelInfoCore("claude-fable-5[1m]", {}); + assert.deepEqual(extendedResult, { + provider: "claude", + model: "claude-fable-5", + extendedContext: true, + }); + } finally { + if (previousEnvFlag === undefined) { + delete process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS; + } else { + process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS = previousEnvFlag; + } + } +}); + +test("getModelInfoCore lets settings toggle disable Claude Code preference", async () => { + const previousEnvFlag = + process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS; + process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS = "true"; + + try { + const { updateSettings } = await import("../../src/lib/db/settings.ts"); + await updateSettings({ preferClaudeCodeForUnprefixedClaudeModels: false }); + + // With the toggle OFF, a bare `claude-*` id must NOT be auto-preferred to the + // Claude Code ("claude") provider — it falls back to normal inference. The exact + // fallback target depends on the live catalog (an id present in a single provider + // resolves there; one present in several is reported ambiguous and requires a + // provider prefix), so assert the toggle's effect rather than a catalog-dependent + // target: the result is simply not routed to Claude Code. + const result = await model.getModelInfoCore("claude-fable-5", {}); + assert.notEqual(result.provider, "claude"); + } finally { + if (previousEnvFlag === undefined) { + delete process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS; + } else { + process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS = previousEnvFlag; + } + } +}); diff --git a/tests/unit/settings-schema-routing-strategies.test.ts b/tests/unit/settings-schema-routing-strategies.test.ts index 2e0fb878ee..04b94523a4 100644 --- a/tests/unit/settings-schema-routing-strategies.test.ts +++ b/tests/unit/settings-schema-routing-strategies.test.ts @@ -58,6 +58,18 @@ test("settings schemas accept wsAuth toggle", () => { assert.equal(sharedParsed.wsAuth, false); }); +test("settings schemas accept Claude Code unprefixed model routing toggle", () => { + const routeParsed = settingsRouteSchema.parse({ + preferClaudeCodeForUnprefixedClaudeModels: true, + }); + const sharedParsed = sharedSettingsSchema.parse({ + preferClaudeCodeForUnprefixedClaudeModels: false, + }); + + assert.equal(routeParsed.preferClaudeCodeForUnprefixedClaudeModels, true); + assert.equal(sharedParsed.preferClaudeCodeForUnprefixedClaudeModels, false); +}); + test("settings schemas accept combo configuration modes", () => { const routeParsed = settingsRouteSchema.parse({ comboConfigMode: "expert" }); const sharedParsed = sharedSettingsSchema.parse({ comboConfigMode: "guided" });