Add Claude Code routing preference for unprefixed Claude models (#3540)

Integrated into release/v3.8.22 — page.tsx conflict resolved (re-applied toggle to ProviderDetailPageClient) + disable-test updated for catalog drift in review.
This commit is contained in:
Witroch4
2026-06-11 11:53:59 -03:00
committed by GitHub
parent 3813fa4a85
commit 2b39b8357c
9 changed files with 360 additions and 2 deletions

View File

@@ -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=

View File

@@ -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<string, unknown>;
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<string> | 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 };
}

View File

@@ -1222,6 +1222,15 @@ export default function ProviderDetailPageClient() {
const [codexSettingsLoaded, setCodexSettingsLoaded] = useState(false);
const [codexSettingsLoadError, setCodexSettingsLoadError] = useState<string | null>(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<Set<string>>(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() {
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">{t("connections")}</h2>
{providerId === "claude" && (
<div
className="inline-flex items-center gap-2 rounded-lg border border-orange-500/20 bg-orange-500/5 px-2 py-1 text-xs font-medium text-text-muted"
title={providerText(
t,
"preferClaudeCodeForUnprefixedClaudeModelsTooltip",
"Route bare claude-* model IDs from Claude Code clients through the Claude Code account instead of asking for a provider prefix."
)}
>
<span className="material-symbols-outlined text-[14px] text-orange-500">
alt_route
</span>
<span>
{providerText(
t,
"preferClaudeCodeForUnprefixedClaudeModelsLabel",
"Claude Code default"
)}
</span>
<Toggle
size="sm"
checked={preferClaudeCodeForUnprefixedClaudeModels}
onChange={handleToggleClaudeRoutingPreference}
disabled={savingClaudeRoutingPreference || !claudeRoutingSettingsLoaded}
ariaLabel={providerText(
t,
"preferClaudeCodeForUnprefixedClaudeModelsAria",
"Prefer Claude Code for unprefixed Claude models"
)}
title={
preferClaudeCodeForUnprefixedClaudeModels
? providerText(
t,
"preferClaudeCodeForUnprefixedClaudeModelsDisable",
"Disable Claude Code preference for bare claude-* model IDs"
)
: providerText(
t,
"preferClaudeCodeForUnprefixedClaudeModelsEnable",
"Enable Claude Code preference for bare claude-* model IDs"
)
}
/>
<span className="text-[11px] text-text-muted/70">
{preferClaudeCodeForUnprefixedClaudeModels
? providerText(t, "toggleOnShort", "On")
: providerText(t, "toggleOffShort", "Off")}
</span>
{claudeRoutingSettingsLoadError ? (
<button
type="button"
onClick={() => void loadClaudeRoutingSettings()}
className="rounded border border-orange-500/30 px-2 py-0.5 text-[11px] font-medium text-orange-600 hover:bg-orange-500/10 dark:text-orange-300"
title={claudeRoutingSettingsLoadError}
>
{providerText(t, "retry", "Retry")}
</button>
) : null}
</div>
)}
{providerId === "codex" && (
<div
className="inline-flex items-center gap-2 rounded-lg border border-sky-500/20 bg-sky-500/5 px-2 py-1 text-xs font-medium text-text-muted"

View File

@@ -31,6 +31,10 @@ type ProxyResolutionCacheEntry = {
const PROXY_RESOLUTION_CACHE_MAX_ENTRIES = 100;
function isTruthyEnvFlag(value: string | undefined): boolean {
return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim());
}
let proxyConfigGeneration = 0;
const proxyResolutionCache = new Map<string, ProxyResolutionCacheEntry>();
@@ -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",

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -29,6 +29,7 @@ export interface Settings {
hideEndpointCloudflaredTunnel?: boolean;
hideEndpointTailscaleFunnel?: boolean;
hideEndpointNgrokTunnel?: boolean;
preferClaudeCodeForUnprefixedClaudeModels?: boolean;
autoRefreshProviderQuota?: boolean;
autoRefreshProviderQuotaInterval?: number;
pinProviderQuotaToHome?: boolean;

View File

@@ -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<void>) {
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;
}
}
});

View File

@@ -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" });