Files
OmniRoute/open-sse/utils/claudeEffortVariants.ts
Will Gordon 4795825513 fix(sse): make Claude effort/no-think catalog variants dispatchable on every provider (#9006)
* fix(executors): route Claude-via-Vertex through native rawPredict with real streaming

Claude models on Vertex AI were being sent through the generic OpenAI-
compatible partner endpoint, which 404s/errors for Claude on at least
some projects. Route them through Vertex's native Anthropic Messages
API (publishers/anthropic/.../rawPredict) instead, stripping the
body-level model field rawPredict rejects and injecting the required
anthropic_version field.

rawPredict only ever returns a complete JSON body, never real SSE
framing, so streaming requests now get a genuine Anthropic-format SSE
stream synthesized from that JSON (message_start/content_block_*/
message_delta/message_stop), which the existing claude-to-openai
response translator already knows how to parse.

Also fixes two response-format resolution bugs that silently dropped
a custom model's DB-stored targetFormat override whenever the model
id also existed in the static provider registry (as claude-sonnet-4-6
and claude-opus-4-7 do under vertex): resolveModelOrError had its own
ad-hoc resolution that never consulted the override, and even once
fixed, executeChatWithBreaker discarded the correctly-resolved format
before handleChatCore's own resolution ran a second time.

* docs: add changelog fragment for #8909

* refactor(sse): extract shared Claude effort-model predicate

* fix(sse): strip Claude effort-suffix ids for any provider serving a real Claude model

* fix(sse): keep no-think and CC-discovery catalog variant roots unprefixed

* fix(dashboard): re-qualify no-think playground model ids correctly

* fix(sse): scope Vertex 404s to a per-model lockout via passthroughModels

* docs: add changelog fragment for the Claude catalog/dispatch fix

* fix(sse): align regex naming and changelog formatting

* fix(sse): clarify effort-variant strip comment and add cross-module drift guard

* fix(sse): disambiguate Vertex connection-wide vs per-model 403s

* docs: document Vertex 403 disambiguation in changelog fragment

* fix(sse): correlate reason and resource within the same ErrorInfo detail

* fix(sse): extract Vertex error classifier and rebaseline frozen file sizes

* test: register vertex-passthrough-model-lockout in stryker tap.testFiles

* fix(sse): reconciles rebase-onto-tip drift for 9006

Two categories of inherited base-branch breakage surfaced when
rebasing onto release/v3.8.50's latest tip, both confirmed unrelated
to this PR's own diff:

- check:file-size: base.ts and chat.ts drifted further past their
  frozen caps via already-merged commits (7163081f5 and others) that
  didn't rebaseline after growing them. Documented and bumped in
  file-size-baseline.json.
- chat-helpers.test.ts: two gpt-5.5 routing assertions predate #9275
  (fix(routing): bare model ids route to codex first), which
  deliberately made gpt-5.5 route to codex unconditionally, regardless
  of which other providers are active. Confirmed via #9275's own
  commit message and code comments this is intentional, not a
  regression; verified reproducible on the raw base tip alone, with
  no changes from this PR involved. Updated both assertions and their
  names to match the new, intentional default.

* ci: re-trigger checks after GitHub Actions incident (2026-08-07, resolved)

* ci: re-trigger checks (previous push event was dropped)

* fix(quality): rebaseline combo-routing-engine.test.ts own-comment growth

The ALL_ACCOUNTS_INACTIVE->ALL_TARGETS_SKIPPED fix (a32aed738) added explanatory comments (+7 lines), pushing the file past its frozen 3457 cap. CI's PR-mode check:file-size caught it; local check-file-size.mjs was not re-run after that specific commit.
2026-08-11 10:02:48 -03:00

167 lines
7.4 KiB
TypeScript

/**
* 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 `bareModelId` (no provider prefix, no effort suffix) is a real,
* effort-capable Claude-family model — the single source of truth used both to
* decide whether the catalog should advertise an effort variant AND whether
* dispatch-time stripping should unwind one back to this model.
*/
export function isKnownClaudeEffortBaseModel(bareModelId: string): boolean {
const spec = getModelSpec(bareModelId);
return spec?.supportsThinking === true && CLAUDE_NAME_RE.test(bareModelId);
}
/**
* 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);
return isKnownClaudeEffortBaseModel(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;
}