From d82ca3025386393078daf81efdd6fc9017cafc69 Mon Sep 17 00:00:00 2001 From: Mrinal Joshi <166646515+thepigdestroyer@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:19:27 +0100 Subject: [PATCH] feat(models): advertise Claude reasoning-effort variants in /v1/models (#7497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (#7168) * fix(ci): add the auto-enqueue pull_request_rule to the Mergify config (queue_conditions alone are eligibility-only) (#7179) * fix(ci): migrate Mergify auto-enqueue to merge_protections_settings.auto_merge_conditions (rules-based path is EOL 2026-07-16) (#7216) * fix(ci): drop Mergify batch settings (batching is a paid-tier feature; free plan queue is serial) (#7220) * fix(ci): merge queue tolerates the advisory dast-smoke failure (its GH-hosted build hang dequeued every attempt) (#7225) * test(ci): make the #6634 selfref guard hermetic — main's copy hard-fails every PR (#7341) main's copy of this test still does git I/O inside a unit test: const baseSrc = git(['show', 'origin/main:' + FILE]); Runners check out a shallow single ref, so origin/main does not resolve and the test dies with 'fatal: invalid object name origin/main'. Every PR into main fails Unit Tests (7/8) on it — today that is #7313, #7315, #7316, #7334, #7336 and #7337, six PRs red on a defect none of them introduced. #7313 has no other red at all. release/v3.8.49 already carries a fix (2e42b8efc, #7174: try/catch, fetch origin/main on demand, t.skip() when unreachable), but it only reaches main at release time — so main stays broken for the whole cycle. Cherry-picking it would also import a new problem: PR Test Policy classifies t.skip() as a silenced assertion, which we watched it correctly catch on #7300 today. This is the hermetic version instead (ported from #7327, which does the same for the release branch): read the file straight off disk, compare against an empty base so baseTaut/baseExtTaut are 0 — the strictest possible comparison point — and call evaluateMasking() directly. No git ref, no fetch, no skip, nothing the runner's checkout depth can break. The #6634 regression stays covered: the guard's logic lives in SELF_TEST_FIXTURE_RE (check-test-masking.mjs:337), not in the test. Proven both ways on main before committing — neutralise SELF_TEST_FIXTURE_RE to /$^/ and the test FAILS; restore it and it passes 2/2, with check-test-masking.mjs left byte-identical. Co-authored-by: growab * chore(quality): tighten main's coverage baseline to the CI's real numbers (#7347) main's ratchet had been failing --require-tighten on every PR: 11 metrics improved but the baseline was never tightened. Same class as the #6634 selfref guard — an infra fix that lands only on the release branch leaves main red for the whole cycle, and every PR into main pays for it. Values are the merged-coverage numbers from a run on main itself (a local run measures ~68% vs CI's ~80%; the baseline's own note warns about that gap). Only the 11 coverage values change — gitleaks and semgrepFindings keep main's own state. No changelog fragment: #7326 carries it on release/v3.8.49, and a second one here would double the entry at release time. * feat(models): advertise Claude reasoning-effort variants in /v1/models Effort-capable Claude models (Fable 5, Opus 4.8, Sonnet 5, ...) steer reasoning via reasoning_effort, and the gateway already routes suffixed ids like claude/claude-fable-5-high back to the base model + reasoning_effort (applyClaudeEffortVariant / splitClaudeEffortSuffix). Rich clients such as VS Code render this as a reasoningEffort config slider, but catalog-only clients (OpenCode, plain OpenAI-SDK pickers) can only choose a model by id, so an effort level was unreachable: they saw claude/claude-fable-5 but never its Low/Medium/High/XHigh options. Synthesize those variants in the catalog the same way no-thinking variants are exposed (appendNoThinkingVariants): appendClaudeEffortVariants derives claude/-{low,medium,high[,xhigh]} from the already key-filtered list, so a variant only appears when its real model is permitted. Levels come from the single source of truth (supportsXHighEffort): xhigh only for models that support it (not Opus 4.6/4.5 or Haiku). Purely additive to catalog visibility; routing is unchanged. - open-sse/utils/claudeEffortVariants.ts: new capability-gated synthesizer - src/app/api/v1/models/catalog.ts: wire it in before the no-thinking pass - tests/unit/claude-effort-variants.test.ts: 12 cases (gating, levels, prefix normalization, no variants-of-variants) * refactor(models): make shouldExposeClaudeEffortVariants a type guard Address PR review feedback (gemini-code-assist): turn the predicate into a type guard `model is CatalogModelEntry & { id: string }` so TypeScript narrows `model.id` to string after the check, removing the explicit `as string` cast in appendClaudeEffortVariants. No behavior change; 12/12 unit tests still pass. * fix(catalog): keep effort-variant root unprefixed (provider-scoped models route serves root verbatim) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: growab Co-authored-by: Mrinal Joshi --- open-sse/utils/claudeEffortVariants.ts | 158 ++++++++++++++++++++++ src/app/api/v1/models/catalog.ts | 11 ++ tests/unit/claude-effort-variants.test.ts | 135 ++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 open-sse/utils/claudeEffortVariants.ts create mode 100644 tests/unit/claude-effort-variants.test.ts 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, []); +});