diff --git a/open-sse/utils/claudeEffortVariants.ts b/open-sse/utils/claudeEffortVariants.ts new file mode 100644 index 0000000000..a5c549fe55 --- /dev/null +++ b/open-sse/utils/claudeEffortVariants.ts @@ -0,0 +1,158 @@ +/** + * Claude reasoning-effort catalog variants. + * + * Effort-capable Claude models steer their reasoning via `reasoning_effort` + * (translated to Claude `output_config.effort` / thinking config downstream). + * Rich clients such as VS Code render this as a `reasoningEffort` *config schema* + * slider (see `src/lib/vscode/reasoningMetadata.ts`), but catalog-only clients — + * OpenCode, plain OpenAI-SDK model pickers — can only choose a model by its `id`. + * For those clients an effort level is unreachable unless it is advertised as a + * standalone model id: + * + * /- e.g. claude/claude-fable-5-high + * + * The gateway already ACCEPTS these ids: `applyClaudeEffortVariant()` strips the + * `-` suffix back to the real base model and surfaces the level as + * `reasoning_effort` before dispatch (see + * `open-sse/handlers/chatCore/claudeEffortVariant.ts` and `splitClaudeEffortSuffix` + * in `open-sse/config/providerModels.ts`). Until now nothing ENUMERATED them, so a + * catalog-only client saw the base model (e.g. `claude/claude-fable-5`) but never + * its effort levels. This module closes that gap the same way `noThinkingAlias.ts` + * exposes `no-think/…` variants: it synthesizes the effort ids from the + * already-key-filtered catalog list, so a variant only appears when its real model + * is permitted. + * + * Levels come from the single source of truth (`supportsXHighEffort`): every + * effort-capable Claude model advertises Low/Medium/High, and xHigh is added only + * for models that support it (e.g. Fable 5, Opus 4.8, Sonnet 5 — not Opus 4.6/4.5 + * or Haiku). "none" is intentionally omitted: it is the base model id, already in + * the catalog. Max/ultra are codex-only presets and are not synthesized here. + */ +import { getModelSpec } from "@/shared/constants/modelSpecs"; +import { supportsXHighEffort } from "../config/providerModels.ts"; + +/** Base reasoning-effort levels advertised for every effort-capable Claude model. */ +export const CLAUDE_EFFORT_VARIANT_LEVELS = ["low", "medium", "high"] as const; +/** Extra level advertised only for models that support extra-high effort. */ +export const CLAUDE_XHIGH_EFFORT_LEVEL = "xhigh"; + +export type ClaudeEffortVariantLevel = + (typeof CLAUDE_EFFORT_VARIANT_LEVELS)[number] | typeof CLAUDE_XHIGH_EFFORT_LEVEL; + +// Ids that already carry a reasoning-effort suffix — never double-suffix them. +const CLAUDE_EFFORT_SUFFIX_RE = /-(?:xhigh|high|medium|low)$/i; +const CLAUDE_NAME_RE = /claude/i; +const NO_THINKING_PREFIX = "no-think/"; + +interface CatalogModelEntry { + id?: unknown; + owned_by?: unknown; + name?: unknown; + root?: unknown; + [key: string]: unknown; +} + +/** Strip a `/` prefix to get the bare model name for spec lookup. */ +function bareModelName(id: string): string { + const slash = id.lastIndexOf("/"); + return slash >= 0 ? id.slice(slash + 1) : id; +} + +/** Human label for an effort level, matching the VS Code catalog casing. */ +export function formatClaudeEffortLabel(level: string): string { + if (level === CLAUDE_XHIGH_EFFORT_LEVEL) return "XHigh"; + return level.charAt(0).toUpperCase() + level.slice(1); +} + +/** + * Whether the catalog should advertise reasoning-effort variants for this entry. + * + * Rule: a thinking-capable Claude-family base model. Combos are virtual, and ids + * that are already an effort variant or a no-think alias are skipped so we never + * double-synthesize. Unlike the no-think gate this deliberately does NOT exclude + * `rejectsThinkingDisabled` models — Fable 5 / Sonnet 5 are adaptive-only (they + * reject `thinking:{type:"disabled"}`) yet still take a reasoning effort. + */ +export function shouldExposeClaudeEffortVariants( + model: CatalogModelEntry +): model is CatalogModelEntry & { id: string } { + if (!model || typeof model !== "object") return false; + const id = model.id; + if (typeof id !== "string" || id.length === 0) return false; + if (model.owned_by === "combo") return false; + if (id.startsWith(NO_THINKING_PREFIX)) return false; + if (CLAUDE_EFFORT_SUFFIX_RE.test(id)) return false; + + const name = bareModelName(id); + const spec = getModelSpec(name); + if (!spec) return false; + + return spec.supportsThinking === true && CLAUDE_NAME_RE.test(name); +} + +/** + * Normalize the provider prefix inside a qualified model id using an alias→canonical + * map, e.g. "cc/claude-fable-5" → "claude/claude-fable-5". Ids without a "/" or whose + * prefix is not in the map are returned unchanged. Mirrors `noThinkingAlias.ts`. + */ +function normalizeProviderPrefix( + qualifiedId: string, + aliasToCanonical: Record +): string { + const slash = qualifiedId.indexOf("/"); + if (slash < 0) return qualifiedId; + const prefix = qualifiedId.slice(0, slash); + const canonical = aliasToCanonical[prefix]; + return canonical && canonical !== prefix + ? `${canonical}${qualifiedId.slice(slash)}` + : qualifiedId; +} + +/** + * Effort levels to advertise for `/`. Low/Medium/High always; + * xHigh only when the model supports it (single source of truth `supportsXHighEffort`). + */ +export function claudeEffortLevelsFor(providerId: string, modelId: string): string[] { + const levels: string[] = [...CLAUDE_EFFORT_VARIANT_LEVELS]; + if (supportsXHighEffort(providerId, modelId)) { + levels.push(CLAUDE_XHIGH_EFFORT_LEVEL); + } + return levels; +} + +/** + * Append reasoning-effort variants for every eligible Claude model. Returns the + * original array reference unchanged when nothing is eligible (no allocation in the + * common case). + * + * @param aliasToCanonical - When provided, the provider prefix of each variant id is + * normalized to its canonical form (e.g. "cc" → "claude"), matching the catalog's + * canonical prefix mode. Pass the same map used for `appendNoThinkingVariants`. + */ +export function appendClaudeEffortVariants( + models: T[], + aliasToCanonical?: Record +): T[] { + if (!Array.isArray(models)) return models; + const variants: T[] = []; + for (const model of models) { + if (!shouldExposeClaudeEffortVariants(model)) continue; + const rawId = model.id; + const qualifiedId = aliasToCanonical ? normalizeProviderPrefix(rawId, aliasToCanonical) : rawId; + const slash = qualifiedId.indexOf("/"); + const providerId = slash >= 0 ? qualifiedId.slice(0, slash) : ""; + const bareName = bareModelName(qualifiedId); + for (const level of claudeEffortLevelsFor(providerId, bareName)) { + const variantId = `${qualifiedId}-${level}`; + // root stays UNPREFIXED (base root, or the bare model name, plus the suffix): + // the provider-scoped models route uses `root` verbatim as the unprefixed id. + const baseRoot = typeof model.root === "string" && model.root ? model.root : bareName; + const variant: T = { ...model, id: variantId, root: `${baseRoot}-${level}` }; + if (typeof model.name === "string" && model.name) { + variant.name = `${model.name} (${formatClaudeEffortLabel(level)})`; + } + variants.push(variant); + } + } + return variants.length > 0 ? [...models, ...variants] : models; +} diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 41b7a99451..b37559677d 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -11,6 +11,7 @@ import { } from "@/lib/localDb"; import { extractAliasBackedModels } from "./aliasBackedModels"; import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias"; +import { appendClaudeEffortVariants } from "@omniroute/open-sse/utils/claudeEffortVariants"; import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry"; import { getAllImageModels, @@ -1493,6 +1494,16 @@ async function buildUnifiedModelsResponseCore( } } + // Advertise Claude reasoning-effort variants (claude/-{low,medium,high[,xhigh]}). + // Derived from the already key-filtered list so a variant only appears when its real + // model is permitted. Runs before the no-thinking pass: the gateway already routes these + // suffixed ids (claudeEffortVariant.ts), this just makes them selectable in catalog-only + // clients (OpenCode) that can't set a reasoning_effort config the way VS Code does. + finalModels = appendClaudeEffortVariants( + finalModels, + prefixMode === "canonical" ? aliasToProviderId : undefined + ); + // Advertise no-thinking gateway variants (Fase 8.1). Derived from the already // key-filtered list, so a variant only appears when its real model is permitted. finalModels = appendNoThinkingVariants( diff --git a/tests/unit/claude-effort-variants.test.ts b/tests/unit/claude-effort-variants.test.ts new file mode 100644 index 0000000000..d07da37045 --- /dev/null +++ b/tests/unit/claude-effort-variants.test.ts @@ -0,0 +1,135 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + CLAUDE_EFFORT_VARIANT_LEVELS, + CLAUDE_XHIGH_EFFORT_LEVEL, + formatClaudeEffortLabel, + shouldExposeClaudeEffortVariants, + claudeEffortLevelsFor, + appendClaudeEffortVariants, +} from "../../open-sse/utils/claudeEffortVariants.ts"; + +const mk = (id: string, extra: Record = {}) => ({ + id, + owned_by: id.split("/")[0], + name: id.split("/").pop(), + ...extra, +}); + +// ── constants / labels ─────────────────────────────────────────────────────── + +test("advertises Low/Medium/High as the base effort levels", () => { + assert.deepEqual([...CLAUDE_EFFORT_VARIANT_LEVELS], ["low", "medium", "high"]); + assert.equal(CLAUDE_XHIGH_EFFORT_LEVEL, "xhigh"); +}); + +test("formatClaudeEffortLabel matches the VS Code catalog casing", () => { + assert.equal(formatClaudeEffortLabel("low"), "Low"); + assert.equal(formatClaudeEffortLabel("medium"), "Medium"); + assert.equal(formatClaudeEffortLabel("high"), "High"); + assert.equal(formatClaudeEffortLabel("xhigh"), "XHigh"); +}); + +// ── shouldExposeClaudeEffortVariants ───────────────────────────────────────── + +test("exposes variants for thinking-capable Claude base models", () => { + assert.equal(shouldExposeClaudeEffortVariants(mk("claude/claude-fable-5")), true); + assert.equal(shouldExposeClaudeEffortVariants(mk("claude/claude-opus-4-8")), true); + assert.equal(shouldExposeClaudeEffortVariants(mk("cc/claude-fable-5")), true); +}); + +test("adaptive-only models (Fable 5) still get effort variants despite rejecting disabled", () => { + // Regression guard: the no-thinking gate excludes rejectsThinkingDisabled models, + // but effort variants must NOT — Fable 5 is adaptive-only yet takes an effort. + assert.equal(shouldExposeClaudeEffortVariants(mk("claude/claude-fable-5")), true); +}); + +test("does not expose variants for non-Claude, combos, or non-thinking models", () => { + assert.equal(shouldExposeClaudeEffortVariants(mk("codex/gpt-5.5")), false); + assert.equal(shouldExposeClaudeEffortVariants({ id: "x", owned_by: "combo" }), false); + assert.equal(shouldExposeClaudeEffortVariants(mk("gemini-cli/gemini-3.1-pro-preview")), false); +}); + +test("never double-synthesizes: already-suffixed or no-think ids are skipped", () => { + assert.equal(shouldExposeClaudeEffortVariants(mk("claude/claude-fable-5-high")), false); + assert.equal(shouldExposeClaudeEffortVariants(mk("claude/claude-fable-5-xhigh")), false); + assert.equal(shouldExposeClaudeEffortVariants(mk("no-think/claude/claude-fable-5")), false); +}); + +test("non-string / empty / non-object ids never match", () => { + assert.equal(shouldExposeClaudeEffortVariants(undefined as never), false); + assert.equal(shouldExposeClaudeEffortVariants({ id: "" }), false); + assert.equal(shouldExposeClaudeEffortVariants({ id: 42 as never }), false); +}); + +// ── claudeEffortLevelsFor ──────────────────────────────────────────────────── + +test("xHigh is added only for models that support it", () => { + assert.deepEqual(claudeEffortLevelsFor("claude", "claude-fable-5"), [ + "low", + "medium", + "high", + "xhigh", + ]); + assert.deepEqual(claudeEffortLevelsFor("claude", "claude-opus-4-8"), [ + "low", + "medium", + "high", + "xhigh", + ]); + // Opus 4.6 and Haiku 4.5 are flagged supportsXHighEffort:false in the registry. + assert.deepEqual(claudeEffortLevelsFor("claude", "claude-opus-4-6"), ["low", "medium", "high"]); + assert.deepEqual(claudeEffortLevelsFor("claude", "claude-haiku-4-5-20251001"), [ + "low", + "medium", + "high", + ]); +}); + +// ── appendClaudeEffortVariants ─────────────────────────────────────────────── + +test("appends effort variant ids + names for eligible models only", () => { + const out = appendClaudeEffortVariants([mk("claude/claude-fable-5"), mk("codex/gpt-5.5")]); + const ids = out.map((m) => m.id); + assert.deepEqual(ids, [ + "claude/claude-fable-5", + "codex/gpt-5.5", + "claude/claude-fable-5-low", + "claude/claude-fable-5-medium", + "claude/claude-fable-5-high", + "claude/claude-fable-5-xhigh", + ]); + const high = out.find((m) => m.id === "claude/claude-fable-5-high"); + assert.equal(high?.name, "claude-fable-5 (High)"); + // root stays unprefixed — the provider-scoped models route serves it verbatim. + assert.equal(high?.root, "claude-fable-5-high"); +}); + +test("normalizes the provider prefix (cc → claude) when a canonical map is given", () => { + const out = appendClaudeEffortVariants([mk("cc/claude-fable-5")], { cc: "claude" }); + const variantIds = out.map((m) => m.id).filter((id) => /-(low|medium|high|xhigh)$/.test(id)); + assert.deepEqual(variantIds, [ + "claude/claude-fable-5-low", + "claude/claude-fable-5-medium", + "claude/claude-fable-5-high", + "claude/claude-fable-5-xhigh", + ]); +}); + +test("returns the original array reference when nothing is eligible", () => { + const input = [mk("codex/gpt-5.5"), mk("gemini-cli/gemini-3.1-pro-preview")]; + const out = appendClaudeEffortVariants(input); + assert.equal(out, input); +}); + +test("never generates variants-of-variants when the list already contains effort ids", () => { + // The catalog calls this once, but even if suffixed ids are already present they + // must be skipped — no `claude/claude-fable-5-high-high` etc. + const withVariants = appendClaudeEffortVariants([mk("claude/claude-fable-5")]); + const again = appendClaudeEffortVariants(withVariants); + const doubleSuffixed = again + .map((m) => m.id) + .filter((id) => /-(low|medium|high|xhigh)-(low|medium|high|xhigh)$/.test(id)); + assert.deepEqual(doubleSuffixed, []); +});