mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
* 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 (7163081f5and 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.
180 lines
7.7 KiB
TypeScript
180 lines
7.7 KiB
TypeScript
/**
|
|
* No-thinking gateway model IDs (free-claude-code port, Fase 8.1).
|
|
*
|
|
* Some clients — most notably Claude Code — always attach a `thinking` block to
|
|
* certain Claude models and offer no UI to turn it off. To let an operator force a
|
|
* thinking-capable model into a no-thinking mode purely by *model selection*, the
|
|
* gateway exposes a synthetic catalog id:
|
|
*
|
|
* no-think/<provider>/<model>
|
|
*
|
|
* When such an id arrives on a request we strip the prefix back to the real
|
|
* `<provider>/<model>` and suppress reasoning (`thinking:{type:"disabled"}` for the
|
|
* Claude/Messages path; `reasoning_effort:"none"` for the OpenAI path — #6879: a
|
|
* thinks-by-default OpenAI-shape model left with no reasoning field at all keeps
|
|
* thinking with its provider default, so the alias must express "none" rather than
|
|
* merely deleting the field. The `reasoning` object is still dropped, since a
|
|
* Responses-shaped client's `reasoning:{...}` cannot itself express "none" and the
|
|
* translator promotes `reasoning_effort` into it downstream when absent).
|
|
* The existing `normalizeThinkingForModel()` still runs downstream, so models that
|
|
* reject `disabled` are handled exactly as before, and the per-lane
|
|
* unsupported-param strip (open-sse/translator/paramSupport.ts) still removes
|
|
* `reasoning_effort` for lanes known to reject it, falling back to today's
|
|
* delete-only behavior for those.
|
|
*
|
|
* Catalog visibility is gated (see `shouldExposeNoThinkingAlias`): we only advertise
|
|
* the variant for Claude-family models that actually support thinking AND honor
|
|
* `disabled` — advertising it for a model that ignores suppression would be a lie.
|
|
* An explicit registry override (`ModelSpec.noThinkingAlias`) wins over the default.
|
|
*/
|
|
import { getModelSpec } from "@/shared/constants/modelSpecs";
|
|
|
|
export const NO_THINKING_PREFIX = "no-think/";
|
|
|
|
// Ids that already carry a Claude reasoning-effort suffix (see
|
|
// claudeEffortVariants.ts's identical constant) — a no-think variant of an effort
|
|
// variant would combine two independent OmniRoute catalog conventions on the same
|
|
// id. Dispatch-time, applyNoThinkingAlias pre-sets reasoning_effort:"none" before
|
|
// applyClaudeEffortVariant's hasExplicitClaudeEffort() check runs, so the pre-set
|
|
// "none" is treated as explicit and the suffix's implied effort is silently
|
|
// discarded — semantically incoherent, so never advertise the combination.
|
|
const CLAUDE_EFFORT_SUFFIX_RE = /-(?:xhigh|high|medium|low)$/i;
|
|
|
|
/** True when `modelId` carries the no-thinking gateway prefix. */
|
|
export function isNoThinkingAlias(modelId: unknown): modelId is string {
|
|
return typeof modelId === "string" && modelId.startsWith(NO_THINKING_PREFIX);
|
|
}
|
|
|
|
/** Remove the gateway prefix, returning the real `<provider>/<model>` (plain ids pass through). */
|
|
export function stripNoThinkingAlias(modelId: string): string {
|
|
return isNoThinkingAlias(modelId) ? modelId.slice(NO_THINKING_PREFIX.length) : modelId;
|
|
}
|
|
|
|
/** Wrap a real qualified model id in the no-thinking gateway prefix. */
|
|
export function toNoThinkingAlias(qualifiedModelId: string): string {
|
|
return `${NO_THINKING_PREFIX}${qualifiedModelId}`;
|
|
}
|
|
|
|
interface ApplyResult {
|
|
applied: boolean;
|
|
realModel?: string;
|
|
}
|
|
|
|
/**
|
|
* Request-side hook: if `body.model` is a no-thinking alias, rewrite it to the real
|
|
* model and suppress reasoning in place. No-op (and body untouched) otherwise.
|
|
*/
|
|
export function applyNoThinkingAlias(
|
|
body: Record<string, unknown> | null | undefined,
|
|
opts: { claudeFormat?: boolean } = {}
|
|
): ApplyResult {
|
|
if (!body || typeof body !== "object") return { applied: false };
|
|
const model = body.model;
|
|
if (!isNoThinkingAlias(model)) return { applied: false };
|
|
|
|
const realModel = stripNoThinkingAlias(model);
|
|
if (!realModel) return { applied: false }; // malformed: nothing after the prefix
|
|
|
|
body.model = realModel;
|
|
if (opts.claudeFormat === true) {
|
|
body.thinking = { type: "disabled" };
|
|
delete body.reasoning_effort;
|
|
} else {
|
|
// #6879: express "none" instead of deleting, so a thinks-by-default model
|
|
// actually stops thinking instead of falling back to its provider default.
|
|
// Lanes that reject reasoning_effort are still cleaned up downstream by the
|
|
// per-lane unsupported-param strip (paramSupport.ts), which removes it just
|
|
// like it would have been removed here — same end state, correct on more lanes.
|
|
body.reasoning_effort = "none";
|
|
}
|
|
delete body.reasoning;
|
|
return { applied: true, realModel };
|
|
}
|
|
|
|
interface CatalogModelEntry {
|
|
id?: unknown;
|
|
owned_by?: unknown;
|
|
name?: 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;
|
|
}
|
|
|
|
/**
|
|
* Whether the catalog should advertise a no-thinking variant for this entry.
|
|
*
|
|
* Default rule: Claude-family model that supports thinking and does NOT reject
|
|
* `thinking:{type:"disabled"}`. An explicit `ModelSpec.noThinkingAlias` boolean
|
|
* overrides the default in either direction (operator opt-in / opt-out).
|
|
*/
|
|
export function shouldExposeNoThinkingAlias(model: CatalogModelEntry): boolean {
|
|
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; // combos are virtual
|
|
if (isNoThinkingAlias(id)) return false; // never double-alias
|
|
if (CLAUDE_EFFORT_SUFFIX_RE.test(id)) return false; // never combine with an effort-suffix id
|
|
|
|
const name = bareModelName(id);
|
|
const spec = getModelSpec(name);
|
|
if (!spec) return false;
|
|
|
|
if (spec.noThinkingAlias === true) return true;
|
|
if (spec.noThinkingAlias === false) return false;
|
|
|
|
return (
|
|
spec.supportsThinking === true && spec.rejectsThinkingDisabled !== true && /claude/i.test(name)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Normalize the provider prefix inside a qualified model id using an alias→canonical map.
|
|
* e.g. "cc/claude-opus-4-6" → "claude/claude-opus-4-6" when aliasToCanonical["cc"]="claude".
|
|
* Ids without a "/" or whose prefix is not in the map are returned unchanged.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Append a no-thinking variant for every eligible model. Returns the original array
|
|
* reference unchanged when nothing is eligible (no allocation in the common case).
|
|
*
|
|
* @param aliasToCanonical - When provided, the inner provider prefix of each variant id is
|
|
* normalized to its canonical form (e.g. "cc" → "claude"). Pass this when the catalog is
|
|
* emitting canonical-prefixed ids so no-think variants stay consistent with the prefix mode.
|
|
*/
|
|
export function appendNoThinkingVariants<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 (!shouldExposeNoThinkingAlias(model)) continue;
|
|
const rawId = model.id as string;
|
|
const qualifiedId = aliasToCanonical ? normalizeProviderPrefix(rawId, aliasToCanonical) : rawId;
|
|
const aliasId = toNoThinkingAlias(qualifiedId);
|
|
const bareRoot = toNoThinkingAlias(bareModelName(qualifiedId));
|
|
const variant: T = { ...model, id: aliasId, root: bareRoot };
|
|
if (typeof model.name === "string" && model.name) {
|
|
variant.name = `${model.name} (no thinking)`;
|
|
}
|
|
variants.push(variant);
|
|
}
|
|
return variants.length > 0 ? [...models, ...variants] : models;
|
|
}
|