mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
feat(models): advertise Claude reasoning-effort variants in /v1/models (#7497)
* 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 <nekron@icloud.com>
* 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/<model>-{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 <nekron@icloud.com>
Co-authored-by: Mrinal Joshi <mri-jo@users.noreply.github.com>
This commit is contained in:
158
open-sse/utils/claudeEffortVariants.ts
Normal file
158
open-sse/utils/claudeEffortVariants.ts
Normal file
@@ -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:
|
||||
*
|
||||
* <provider>/<model>-<level> e.g. claude/claude-fable-5-high
|
||||
*
|
||||
* The gateway already ACCEPTS these ids: `applyClaudeEffortVariant()` strips the
|
||||
* `-<level>` 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 `<provider>/` 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, string>
|
||||
): 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 `<providerId>/<modelId>`. 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<T extends CatalogModelEntry>(
|
||||
models: T[],
|
||||
aliasToCanonical?: Record<string, string>
|
||||
): 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;
|
||||
}
|
||||
@@ -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/<model>-{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(
|
||||
|
||||
135
tests/unit/claude-effort-variants.test.ts
Normal file
135
tests/unit/claude-effort-variants.test.ts
Normal file
@@ -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<string, unknown> = {}) => ({
|
||||
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, []);
|
||||
});
|
||||
Reference in New Issue
Block a user