Files
OmniRoute/open-sse/utils/ccDiscoveryAliases.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

110 lines
4.7 KiB
TypeScript

/**
* Claude Code discovery aliases (`claude/<id>` mirror entries).
*
* Claude Code's gateway model discovery only lists models whose id begins with
* `claude` or `anthropic` — any other provider prefix (`kimi/…`, `gemini-cli/…`,
* combo names, etc.) is invisible to it even when the underlying model is fully
* routable through OmniRoute. To make every enabled model reachable from Claude
* Code without renaming anything in the real catalog, this module synthesizes a
* mirror entry for each eligible model:
*
* claude/<original-id> e.g. claude/kimi/kimi-k2.6
* claude/combo/<combo-name> for owned_by:"combo" entries
*
* The mirror entry keeps every field from the original (translated by the
* existing model-id handling once the request lands, same as `no-think/…` and
* the effort-variant aliases), only overriding `id`, `root` (back-pointer to the
* real id), and `display_name`. This mirrors the structure of
* `claudeEffortVariants.ts` and `noThinkingAlias.ts`: pure synthesis over the
* already key-filtered catalog list, no I/O, no mutation of the input array.
*
* Never aliased:
* - ids that already start with `claude` or `anthropic` (with or without a
* following `/`, case-insensitive) — would double-prefix or shadow the base id.
* - `no-think/…` aliases and reasoning-effort variants (`-low`/`-medium`/`-high`/
* `-xhigh` suffix) — v1 only mirrors base ids; effort/no-think discovery is a
* separate concern.
* - entries the caller's `isEnabled` predicate rejects.
*/
export const CC_DISCOVERY_PREFIX = "claude/";
export const CC_DISCOVERY_COMBO_PREFIX = "claude/combo/";
// Ids that already live under the claude/anthropic namespace — never re-mirror them.
const ALREADY_CLAUDE_RE = /^(?:claude|anthropic)(?:\/|$)/i;
// Ids that already carry a reasoning-effort suffix — v1 only mirrors base ids.
const CLAUDE_EFFORT_SUFFIX_RE = /-(?:xhigh|high|medium|low)$/i;
const NO_THINKING_PREFIX = "no-think/";
// Built-in `auto`/`auto/*` combos are synthesized by createBuiltinAutoCombo, NOT
// stored in the DB combos table — the request-path resolver (getComboByName) can't
// find them, so mirroring them would advertise a `claude/combo/auto/*` id that the
// request path rejects. Skip them; DB-defined combos (arbitrary names) still mirror.
const BUILTIN_AUTO_COMBO_RE = /^auto(?:\/|$)/;
interface CcDiscoveryCatalogEntry {
id?: unknown;
owned_by?: unknown;
name?: unknown;
root?: unknown;
[key: string]: unknown;
}
/**
* Append `claude/<id>` (or `claude/combo/<id>` for combos) discovery-mirror
* entries for every eligible model. Returns the original array reference
* unchanged when nothing is eligible (no allocation in the common case).
*
* `isEnabled` is caller-supplied so this module stays free of feature-flag /
* gate lookups — it only knows how to synthesize the mirror shape.
*/
/**
* Ids the mirror must never cover: already claude/anthropic (would double-prefix
* or shadow the base id), `no-think/…` aliases, and reasoning-effort variants —
* v1 mirrors base ids only.
*/
function isMirrorableId(id: string): boolean {
if (id.length === 0) return false;
if (ALREADY_CLAUDE_RE.test(id)) return false;
if (id.startsWith(NO_THINKING_PREFIX)) return false;
return !CLAUDE_EFFORT_SUFFIX_RE.test(id);
}
/** Strip a `<provider>/` prefix to get the bare model name, matching the convention in
* claudeEffortVariants.ts / noThinkingAlias.ts. */
function bareModelName(id: string): string {
const slash = id.lastIndexOf("/");
return slash >= 0 ? id.slice(slash + 1) : id;
}
export function appendCcDiscoveryAliases<T extends CcDiscoveryCatalogEntry>(
models: T[],
isEnabled: (entry: T) => boolean
): T[] {
if (!Array.isArray(models)) return models;
const aliases: T[] = [];
for (const model of models) {
const id = model.id;
if (typeof id !== "string" || !isMirrorableId(id)) continue;
if (!isEnabled(model)) continue;
const isCombo = model.owned_by === "combo";
// Skip built-in auto combos — advertised-but-unroutable (see the regex above).
if (isCombo && BUILTIN_AUTO_COMBO_RE.test(id)) continue;
const aliasId = isCombo ? `${CC_DISCOVERY_COMBO_PREFIX}${id}` : `${CC_DISCOVERY_PREFIX}${id}`;
const label = typeof model.name === "string" && model.name ? model.name : id;
aliases.push({
...model,
id: aliasId,
// Combo names may legally contain "/" (comboNameSchema allows it), so a combo's
// root must stay the full name verbatim — only real provider-qualified ids get
// the "/" stripped down to the bare model name.
root: isCombo ? id : bareModelName(id),
display_name: `${label} (OmniRoute)`,
} as T);
}
return aliases.length > 0 ? [...models, ...aliases] : models;
}