feat(api): no-thinking gateway model IDs (FCC port, Fase 8.1) (#4145)

No-thinking gateway model IDs (FCC port, Fase 8.1): synthetic claude-3-omniroute-no-thinking/<provider>/<model> catalog id that resolves to the real model with reasoning suppressed. Integrado em release/v3.8.29.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-18 02:30:25 -03:00
committed by GitHub
parent 3beba2d77f
commit c5c612e80d
7 changed files with 298 additions and 2 deletions

View File

@@ -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 (<cap); both edits are thin wiring of tested helpers at the single correct integration point in each file. Not extractable.",
"_rebaseline_2026_06_17_4098_wafer": "PR #4098 own growth: providers.ts 3159->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 (<cap). Cohesive resilience logic at the existing fetch chokepoint; not extractable.",
"_rebaseline_2026_06_17_4071_vision_routing": "PR #4071 own growth: combo.ts 5283->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, <cap). Cohesive bug fix at the existing compatibility-filter chokepoint; not extractable.",
@@ -109,7 +110,7 @@
"src/app/api/providers/[id]/models/route.ts": 2512,
"src/app/api/providers/[id]/test/route.ts": 842,
"src/app/api/usage/analytics/route.ts": 941,
"src/app/api/v1/models/catalog.ts": 1435,
"src/app/api/v1/models/catalog.ts": 1440,
"src/lib/cloudflaredTunnel.ts": 934,
"src/lib/db/apiKeys.ts": 1661,
"src/lib/db/core.ts": 1820,
@@ -136,7 +137,7 @@
"src/shared/constants/sidebarVisibility.ts": 1100,
"src/shared/services/cliRuntime.ts": 1090,
"src/shared/validation/schemas.ts": 2523,
"src/sse/handlers/chat.ts": 1458,
"src/sse/handlers/chat.ts": 1471,
"src/sse/services/auth.ts": 2219
},
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",

View File

@@ -131,6 +131,16 @@ Authorization: Bearer your-api-key
→ Returns all chat, embedding, and image models + combos in OpenAI format
```
### No-thinking model variants
For thinking-capable Claude models, `/v1/models` also advertises a **no-thinking** variant whose id is prefixed with `claude-3-omniroute-no-thinking/`:
```
claude-3-omniroute-no-thinking/<provider>/<model>
```
Selecting this id (e.g. in a Claude Code config that always attaches a `thinking` block) resolves back to the real `<provider>/<model>` 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

View File

@@ -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/<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; 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 `<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;
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
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<T extends CatalogModelEntry>(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;
}

View File

@@ -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;

View File

@@ -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[]) => [

View File

@@ -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/<provider>/<model>`
// 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)

View File

@@ -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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = { 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<string, unknown> = { 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);
});