Files
OmniRoute/open-sse/utils/noThinkingAlias.ts
Diego Rodrigues de Sa e Souza af65171e3f fix(ci): clear the base-reds the afternoon merge batch left on release/v3.8.51 (round 5: provider count 352, TS2554/TS2677) (#12144)
* fix(ci): clear the base-reds the 2026-08-30 afternoon merge batch left on release/v3.8.51 (round 5)

- docs-counts / check-docs-counts-sync test: #12103 (Perplexity Agent) made it 352
  providers; README, AGENTS.md, llm.txt (+42 i18n mirrors), package.json description
  and the 4 README diagrams still said 351.
- api-route-typecheck: #11971 passes a third `{ featureEnabled }` argument to
  appendNoThinkingVariants() that the helper never accepted (TS2554 — and the flag
  silently did nothing); the helper now honours it. src/lib/skills/interception.ts
  narrowed a mapped object with a `Record<string, string>` predicate (TS2677) —
  predicate typed with the actual element shape.

Gates: check:docs-counts OK (test 28/28), check:docs-sync PASS, check:api-typecheck
OK (289 frozen). Refs #12103, #11971

* docs(env): document RATE_LIMIT_EXECUTION_MAX_WAIT_MS (#12027 added it to .env.example only)

* fix(ci): round 5b — freeze the react-hooks compiler-rule violations, align 7 tests to merged contracts

No new ESLint warnings: the exact CI command (lint:json --max-warnings 0) reports 278
problems on the tip — 226 from eslint-plugin-react-hooks 7 compiler rules
(set-state-in-effect 167, immutability 36, refs/static-components/purity/
preserve-manual-memoization) that were masked until the lockfile change of
dfc84ba030 invalidated the ESLint cache, plus 46 no-explicit-any in
tests/unit/call-log-cap.test.ts (#12026). Velocity phase: frozen with
`eslint --suppress-all` (+668 suppressions); the 5 now-unused
`eslint-disable react-hooks/immutability` directives and one unused import removed.
Verified: lint:json --max-warnings 0 → 0 problems.

Tests aligned to contracts merged this afternoon (all reproduced red on the pure tip):
- providers-constants-split: 235 → 236 (Perplexity Agent, #12103)
- sse-auth: a forced pin outside allowedConnections now yields no credential
  instead of silently falling back (#12080)
- with-chat-admission-10786: withInjectionGuard(postHandler, { logger: null }) (#12117)
- hard-session-lease-bypass-inventory: classify src/app/api/oauth/codex/import/route.ts (#12116)
- usage-service-hardening: OpenCode Go official usage API shape (#12124)
- i18n placeholder parity: apiManager.restrictedToConnections rewritten as a plain
  ICU plural (`{count, plural, one {# connection} other {# connections}}`) in en,
  vi, pt-BR and the 40 __MISSING__ mirrors — the parity extractor counts every
  `{word}` including the old literal `{s}`

Refs #12103, #12080, #12117, #12116, #12124, #12026

* fix(ci): run the ESLint warnings job on the box with an 8 GB heap; reserved-prefix set 398 → 400

The cold full lint with the react-hooks 7 compiler rules is killed on the 7 GB hosted
runner with no message (status null → exit 1, JSON never written) — it only looked
green while the ESLint cache was warm. tests/unit/provider-node-reserved-prefix.test.ts
aligned to the two prefixes the afternoon batch registered (#12103).

* test(ci): document the lint-guard runner exception; #9147 event-loop gap 400 → 800 ms

quality-rail-gate-membership pinned lint-guard to ubuntu-latest; the cold full lint is
OOM-killed there, so the job now runs on omni-light with an 8 GB heap — the test keeps
fast-gates pinned and asserts the documented exception. With the catalog at 352
providers the hosted shards measure 410–633 ms gaps on 9147-catalog-eventloop-yield
(3 runs); 800 ms still fails a true pin. Re-tighten with the v4.0 catalog split.

* chore(quality): summarize the ESLint report on failure — a red lint:json printed nothing

--format json --output-file swallows every problem; a red 'No new ESLint warnings' job
gave zero output (three blind debugging rounds in #12144), and a killed process (OOM,
status null) was equally silent. On any non-zero exit the runner now prints the problem
count and the first 60 'file:line rule — message' lines from the report.

* chore(lint): freeze react-hooks/immutability for the 5 UI test harnesses in the suppressions file

The rule fires for these files in CI but not locally (compiler analysis divergence),
so the inline eslint-disable directives read as 'unused directive' warnings locally.
A suppressions entry is symmetric: suppressed where the rule fires, tolerated as
unpruned (--pass-on-unpruned-suppressions) where it does not. Found via the new
lint:json failure summary.
2026-08-30 18:03:47 -03:00

186 lines
8.1 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.
* @param options.featureEnabled - `false` returns the models untouched (feature flag off).
*/
export function appendNoThinkingVariants<T extends CatalogModelEntry>(
models: T[],
aliasToCanonical?: Record<string, string>,
options?: { featureEnabled?: boolean }
): T[] {
if (!Array.isArray(models)) return models;
// #11971: the catalog passes the DISABLE_THINKING_LEVEL_VARIANTS feature flag here; when
// the alias feature is switched off no variant is appended (the call site typed this
// third argument before it existed — TS2554 on the release tip).
if (options?.featureEnabled === false) 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;
}