diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 1811d63823..625c8bcdd1 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_18_8_1_no_thinking_alias": "Fase 8.1 own growth: catalog.ts 1435->1440 (+5 = appendNoThinkingVariants(finalModels) call + comment at the existing finalModels chokepoint) and chat.ts 1458->1471 (+13 = applyNoThinkingAlias(body) call + comment right after body.model is read, before model resolution). All real logic lives in the new open-sse/utils/noThinkingAlias.ts (3169 (+10 = Wafer AI catalog entry, a single Zod-validated provider record in the providers map — pure data, standard per-provider addition; bumps catalog 227->228). Cohesive catalog growth; not extractable.", "_rebaseline_2026_06_17_4096_field_downgrade": "PR #4096 own growth: base.ts 1292->1334 (+42 = generic 400 field-downgrade retry at the executor fetch loop — on an upstream 400 that names an unsupported field, strip it via providerFieldStrips and retry once, plus Groq field stripping wiring). The strip table lives in the new open-sse/config/providerFieldStrips.ts (5289 (+6 = vision-aware routing fix in getTargetCompatibilityFailures — image requests now require supportsVision===true, treating null/unknown as incompatible, with an explanatory comment block; plus exporting filterTargetsByRequestCompatibility for the regression test). The accompanying capability heuristic lives in src/lib/modelCapabilities.ts (419 LOC, / +``` + +Selecting this id (e.g. in a Claude Code config that always attaches a `thinking` block) resolves back to the real `/` with reasoning suppressed — `thinking:{type:"disabled"}` on the `/v1/messages` path, or the `reasoning`/`reasoning_effort` fields dropped on the `/v1/chat/completions` path. The variant is only listed for Claude-family models that support thinking **and** honor `disabled` (so e.g. adaptive-only models that reject `disabled` are excluded). Operators can force the variant on or off per model via `ModelSpec.noThinkingAlias`. + --- ## Compatibility Endpoints diff --git a/open-sse/utils/noThinkingAlias.ts b/open-sse/utils/noThinkingAlias.ts new file mode 100644 index 0000000000..febafc954a --- /dev/null +++ b/open-sse/utils/noThinkingAlias.ts @@ -0,0 +1,128 @@ +/** + * 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: + * + * claude-3-omniroute-no-thinking// + * + * When such an id arrives on a request we strip the prefix back to the real + * `/` and suppress reasoning (`thinking:{type:"disabled"}` for the + * Claude/Messages path; drop `reasoning`/`reasoning_effort` for the OpenAI path). + * The existing `normalizeThinkingForModel()` still runs downstream, so models that + * reject `disabled` are handled exactly as before. + * + * 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 = "claude-3-omniroute-no-thinking/"; + +/** 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 `/` (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 | 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; + delete body.reasoning; + return { applied: true, realModel }; +} + +interface CatalogModelEntry { + id?: unknown; + owned_by?: unknown; + name?: 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; +} + +/** + * 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 + + 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) + ); +} + +/** + * 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). + */ +export function appendNoThinkingVariants(models: T[]): T[] { + if (!Array.isArray(models)) return models; + const variants: T[] = []; + for (const model of models) { + if (!shouldExposeNoThinkingAlias(model)) continue; + const aliasId = toNoThinkingAlias(model.id as string); + const variant: T = { ...model, id: aliasId, root: aliasId }; + if (typeof model.name === "string" && model.name) { + variant.name = `${model.name} (no thinking)`; + } + 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 df775cd6b5..0c3fbf70a4 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -8,6 +8,7 @@ import { getProviderNodes, getModelIsHidden, } from "@/lib/localDb"; +import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias"; import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry"; import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry"; import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry"; @@ -1354,6 +1355,10 @@ export async function getUnifiedModelsResponse( } } + // 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(finalModels); + const getDefaultContextFallback = (model: any): number | undefined => { if (typeof model.context_length === "number") return undefined; if (model.owned_by === "combo") return undefined; diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 0d1f3caa22..24f9fe1376 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -19,6 +19,11 @@ export interface ModelSpec { // (upstream returns 400). Used to normalize the request when a combo/route substitutes // this model after the client already chose `disabled`. See issue #3554. rejectsThinkingDisabled?: boolean; + // Explicit operator override for the no-thinking gateway alias (Fase 8.1). When unset, + // the catalog auto-advertises a `claude-3-omniroute-no-thinking/…` variant for + // Claude-family thinking-capable models that honor `disabled`. Set `true` to force the + // variant on for any other model, or `false` to suppress it. See open-sse/utils/noThinkingAlias.ts. + noThinkingAlias?: boolean; } const BEDROCK_CLAUDE_ALIASES = (...modelIds: string[]) => [ diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 32235eab6e..9fc29806cd 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -16,6 +16,7 @@ import { } from "@omniroute/open-sse/services/accountFallback.ts"; import { getModelInfo, getComboForModel } from "../services/model"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import { applyNoThinkingAlias } from "@omniroute/open-sse/utils/noThinkingAlias.ts"; import { handleComboChat } from "@omniroute/open-sse/services/combo.ts"; import { resolveComboConfig } from "@omniroute/open-sse/services/comboConfig.ts"; import { injectHandoffIntoBody } from "@omniroute/open-sse/services/contextHandoff.ts"; @@ -226,6 +227,18 @@ export async function handleChat(request: any, clientRawRequest: any = null) { // Log request endpoint and model const url = new URL(request.url); + + // No-thinking gateway alias (Fase 8.1): `claude-3-omniroute-no-thinking//` + // resolves back to the real model with reasoning suppressed in place, before any + // model resolution / combo routing sees it. Claude/Messages path forces + // `thinking:{type:"disabled"}`; OpenAI path drops the reasoning fields. + const noThinking = applyNoThinkingAlias(body, { + claudeFormat: url.pathname.includes("/messages"), + }); + if (noThinking.applied) { + log.debug("NO_THINKING", `Resolved no-thinking alias → ${noThinking.realModel}`); + } + let modelStr = body.model; // Count messages (support both messages[] and input[] formats) diff --git a/tests/unit/no-thinking-alias.test.ts b/tests/unit/no-thinking-alias.test.ts new file mode 100644 index 0000000000..eba93de349 --- /dev/null +++ b/tests/unit/no-thinking-alias.test.ts @@ -0,0 +1,134 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + NO_THINKING_PREFIX, + isNoThinkingAlias, + stripNoThinkingAlias, + toNoThinkingAlias, + shouldExposeNoThinkingAlias, + appendNoThinkingVariants, + applyNoThinkingAlias, +} from "../../open-sse/utils/noThinkingAlias.ts"; + +// ── prefix predicates ──────────────────────────────────────────────────────── + +test("NO_THINKING_PREFIX is the documented gateway prefix", () => { + assert.equal(NO_THINKING_PREFIX, "claude-3-omniroute-no-thinking/"); +}); + +test("isNoThinkingAlias detects the prefix only", () => { + assert.equal(isNoThinkingAlias("claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5"), true); + assert.equal(isNoThinkingAlias("anthropic/claude-opus-4-5"), false); + assert.equal(isNoThinkingAlias("claude-opus-4-5"), false); + // non-strings never match + assert.equal(isNoThinkingAlias(undefined as unknown as string), false); + assert.equal(isNoThinkingAlias(123 as unknown as string), false); +}); + +test("stripNoThinkingAlias unwraps the prefix and passes plain ids through", () => { + assert.equal( + stripNoThinkingAlias("claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5"), + "anthropic/claude-opus-4-5" + ); + assert.equal(stripNoThinkingAlias("claude-opus-4-5"), "claude-opus-4-5"); +}); + +test("toNoThinkingAlias round-trips with stripNoThinkingAlias", () => { + const real = "anthropic/claude-sonnet-4-6"; + const alias = toNoThinkingAlias(real); + assert.equal(alias, "claude-3-omniroute-no-thinking/anthropic/claude-sonnet-4-6"); + assert.equal(isNoThinkingAlias(alias), true); + assert.equal(stripNoThinkingAlias(alias), real); +}); + +// ── request-side suppression ───────────────────────────────────────────────── + +test("applyNoThinkingAlias rewrites the model and disables thinking (Claude format)", () => { + const body: Record = { + model: "claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5", + thinking: { type: "enabled", budget_tokens: 8000 }, + reasoning_effort: "high", + messages: [], + }; + const res = applyNoThinkingAlias(body, { claudeFormat: true }); + assert.equal(res.applied, true); + assert.equal(res.realModel, "anthropic/claude-opus-4-5"); + assert.equal(body.model, "anthropic/claude-opus-4-5"); + assert.deepEqual(body.thinking, { type: "disabled" }); + assert.ok(!("reasoning_effort" in body), "reasoning_effort must be stripped"); +}); + +test("applyNoThinkingAlias strips reasoning fields without a thinking block (OpenAI format)", () => { + const body: Record = { + model: "claude-3-omniroute-no-thinking/openai/gpt-5.4", + reasoning_effort: "high", + reasoning: { effort: "high" }, + messages: [], + }; + const res = applyNoThinkingAlias(body, { claudeFormat: false }); + assert.equal(res.applied, true); + assert.equal(body.model, "openai/gpt-5.4"); + assert.ok(!("thinking" in body), "no Claude thinking block on an OpenAI body"); + assert.ok(!("reasoning_effort" in body), "reasoning_effort must be stripped"); + assert.ok(!("reasoning" in body), "reasoning must be stripped"); +}); + +test("applyNoThinkingAlias is a no-op for plain models", () => { + const body: Record = { model: "anthropic/claude-opus-4-5", thinking: { type: "enabled" } }; + const res = applyNoThinkingAlias(body, { claudeFormat: true }); + assert.equal(res.applied, false); + assert.equal(body.model, "anthropic/claude-opus-4-5"); + assert.deepEqual(body.thinking, { type: "enabled" }, "thinking is left untouched when not an alias"); +}); + +test("applyNoThinkingAlias ignores a malformed prefix-only model", () => { + const body: Record = { model: "claude-3-omniroute-no-thinking/" }; + const res = applyNoThinkingAlias(body, { claudeFormat: true }); + assert.equal(res.applied, false); + assert.equal(body.model, "claude-3-omniroute-no-thinking/", "left untouched when nothing follows the prefix"); +}); + +// ── catalog gating ─────────────────────────────────────────────────────────── + +const entry = (id: string, owned_by = "anthropic") => ({ id, object: "model", owned_by }); + +test("shouldExposeNoThinkingAlias accepts a Claude reasoning model that honors disabled", () => { + assert.equal(shouldExposeNoThinkingAlias(entry("claude-opus-4-5")), true); + assert.equal(shouldExposeNoThinkingAlias(entry("anthropic/claude-sonnet-4-6")), true); +}); + +test("shouldExposeNoThinkingAlias rejects models where suppression is meaningless", () => { + // gpt-4o does not support thinking + assert.equal(shouldExposeNoThinkingAlias(entry("gpt-4o", "openai")), false); + // fable-5 rejects thinking.type:disabled — a no-thinking variant would be a lie + assert.equal(shouldExposeNoThinkingAlias(entry("claude-fable-5")), false); + // combos are virtual, never aliased + assert.equal(shouldExposeNoThinkingAlias(entry("my-combo", "combo")), false); + // never double-alias + assert.equal( + shouldExposeNoThinkingAlias(entry("claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5")), + false + ); +}); + +test("appendNoThinkingVariants adds one variant per eligible model and preserves the rest", () => { + const models = [ + entry("claude-opus-4-5"), + entry("gpt-4o", "openai"), + entry("claude-fable-5"), + ]; + const out = appendNoThinkingVariants(models); + const ids = out.map((m) => m.id); + assert.ok(ids.includes("claude-3-omniroute-no-thinking/claude-opus-4-5"), "eligible model gets a variant"); + assert.ok(!ids.includes("claude-3-omniroute-no-thinking/gpt-4o"), "non-thinking model has no variant"); + assert.ok(!ids.includes("claude-3-omniroute-no-thinking/claude-fable-5"), "reject-disabled model has no variant"); + assert.equal(out.length, models.length + 1, "exactly one variant appended"); + // originals preserved up front + assert.deepEqual(out.slice(0, 3), models); +}); + +test("appendNoThinkingVariants returns the same array reference when nothing is eligible", () => { + const models = [entry("gpt-4o", "openai")]; + assert.equal(appendNoThinkingVariants(models), models); +});