Compare commits

...

2 Commits

Author SHA1 Message Date
diegosouzapw
69647b3b94 fix(combo): ignore benign empty error fields in streaming quality validation
isStreamingUpstreamError used a key-presence check (parsed.error != null)
which false-positives on benign values some backends emit on every chunk
({}, '', false, 0). When opencode issues a tool-call turn, the upstream SSE
opens with role-only frames (no recognized content) and a later chunk that
carries real tool_calls content PLUS a benign empty error field. The error
gate runs BEFORE content recognizers, so that single frame short-circuits
to 'error' -> 502 'streaming upstream error'. Same combo via kilocode works
because its wire format never emits the empty error field.

Fix: isSubstantiveError() helper — only treat error as real when it carries
non-empty string, non-empty object, or explicit true. Empty object {}, empty
string '', false, and 0 are benign.

TDD: tests/unit/quality-validation-benign-error.test.ts proves tool_calls
chunk with error:{} or error:'' is valid (was 502), while a real error
{message, code} still correctly fails.
2026-08-08 11:03:11 -03:00
Diego Rodrigues de Sa e Souza
36abd86929 fix(ci): clear the 08-08 base-red layers — dead-code, prod crash in chat.ts, Responses payload regression, born-red stdio test, gate drifts (#9757)
* fix(ci): drop unused RadarReferrals type export — dead-code ratchet back to 227 baseline

The radar referral-links feature (#9697) exported the inferred type
RadarReferrals from feedSchema.ts but nothing imports it (the singular
RadarReferral is the consumed type). knip counts it as a new dead export,
pushing the dead-code ratchet to 228 > 227 and failing Fast Quality Gates
on every PR born after the merge. RadarReferralsSchema itself stays — it
is used by RadarFeedSchema.

Refs #9737

* fix(ci): clear the 08-08 base-red layer — prod crash in chat.ts, Responses API payload regression, born-red stdio test, gate drifts

Six independent base-reds from the 08-07 evening merge batch, each verified
against the pure release/v3.8.50 tip:

- src/sse/handlers/chat.ts: #9467's squash carried a refactor hunk that
  renamed the all-rate-limited breaker guard to an UNDEFINED variable
  (isAllRateLimited) — a production ReferenceError on the all-accounts-429
  path (chat.ts is outside typecheck:core scope, so only tests caught it).
  Restore credentials?.allRateLimited. Guard: chat-rate-limit-body-lock (2/2),
  also un-breaks batch_api and chat-combo-live-test.
- open-sse/utils/stream.ts: #9315 switched providerPayload summaries to the
  accumulated responseBody, but in passthrough paths that body is synthesized
  in chat-completion shape — Responses API lost its `response` object in the
  dashboard payload. Keep the events-derived summary for OPENAI_RESPONSES
  only. Guard: stream-utils + stream-collector-9315 suites (51/51).
- tests/unit/mcp-stdio-json-purity.test.ts: born red — the full CLI chain
  takes ~10s (2x tsx import + DB init) and the test slept a fixed 4s. Poll
  for the first stdout line with a 60s deadline instead.
- tests/unit/plugins-route-error-sanitization.test.ts: register #9445's new
  marketplace/install route in PLUGIN_ROUTES (route already sanitizes) (33/33).
- tests/unit/provider-models-route-codex.test.ts: realign pinned GPT-5.6
  input limit to #9432's deliberate 272000→922000 bump (7/7).
- lint: fix 11 no-explicit-any errors in repro-9630 + specialty-9293 tests,
  prune 1 orphaned suppression, allowlist the opencode-ai devDependency
  (#8869, publisher-verified), and reword a doc line the fabricated-docs
  gate misread as an env var.

Gates re-verified locally: lint:json --max-warnings 0 exit 0, dead-code 227,
typecheck:core clean, check:deps OK, check:fabricated-docs OK.

Refs #9737

* fix(ci): clear the third 08-08 base-red layer — invalid ru rule pack, stale event pin, orphaned UI repro test, pack/mutation/file-size drifts

Follow-up to the previous layer: the serial fast-gates chain unmasked one
more stratum after file-size/dead-code went green, all verified against the
merged release/v3.8.50 tip:

- compression rules ru/ultra.json (#9581): two rules shipped
  minIntensity "notes", which is not a valid CavemanIntensity
  (lite|full|ultra) — loading ANY language pack list threw and killed the
  rtk-loader suite. Mapped both to "ultra" (they are the most aggressive
  punctuation/case rules, matching the en pack tiers). 2/2.
- plugins-welcome-banner-e2e: #9668 added the onStreamComplete builtin
  event (real emission path via runOnStreamCompleteHooks) and missed this
  pinned-list sibling. 35/35.
- tests/unit/free-pool-frontend-repro (#9046): landed as .tsx with
  node:test semantics — no runner collects tests/unit/*.tsx, so it NEVER
  ran (test-discovery NEW-orphan). It contains zero JSX; renamed to .test.ts
  so the unit runner's existing glob collects it. 5/5 (first real run).
- pack-policy: allow + require bin/mcpStdioConsoleGuard.mjs (#9281) — it is
  preloaded via node --import by bin/mcp-server.mjs, so a published artifact
  without it crashes 'omniroute --mcp' at startup.
- stryker.conf.json: add 5 covering unit tests from the batch (#8779/#9204/
  #9330/#9630/openrouter-passthrough) to tap.testFiles (--strict drift).
- file-size-baseline: consolidate the base-drift rebaseline for the 12
  files grown by the 08-06..08-08 batches (#9616's entries never reached the
  base; measured on this branch's tree — this PR's own source edits add zero
  lines to any frozen file).

Local battery: file-size/deps/test-discovery/mutation/pack-policy/dead-code/
duplication/docs-all/secrets/vuln/workflows ratchets all exit 0; full lint
gate --max-warnings 0 exit 0.

Refs #9737

* fix(types): clear the 3 uncovered open-sse-typecheck regressions + realign combo skip-code siblings

Fourth base-red layer unmasked by the serial gates. The other 4 typecheck
regressions (codex.ts, kiro.ts, tierResolver.test.ts, translator/index.ts)
already have dedicated open [TS7] PRs (#9748/#9753/#9742/#9747) — not
duplicated here. This commit covers only what no open PR owns:

- devin-agentic/serializer.ts TS2367: drop the dead 'role === "system"'
  branch — the guard above already narrows role to user|assistant (system
  throws unsupported_role). Devin suites 104/104.
- raycast.ts TS2416: the buildHeaders 'override' never matched the base
  signature (2nd param is the signed payload string, not the stream
  boolean) — renamed to a private buildRaycastRequestHeaders helper so a
  polymorphic buildHeaders(credentials, true) call can never bind here.
- modelMetadataRegistry.ts TS2352: PricingByProvider → nested-record cast
  now goes through unknown (shape is runtime-guarded by findInsensitive).
- combo-routing-engine.test.ts: realign 2 pre-dispatch-skip expectations to
  #9630's deliberate ALL_TARGETS_SKIPPED contract (87/87).

Refs #9737

* fix(ci): clear the fifth 08-08 base-red layer — reasoning-placeholder contract sweep, GPT-5.6 limits sweep, vi key parity

The 08-08 merges (#9610 reasoning replay, #9432 GPT-5.6 limits, #9630 combo
skip codes, #9336 provider key links) each changed a contract and left
sibling tests pinning the old one. Full grep sweep per contract, not just
the shard that happened to go red:

- reasoning placeholder (#9573/#9610): the fix DELIBERATELY removed
  NON_ANTHROPIC_THINKING_PLACEHOLDER injection on cache miss — the model
  echoed the placeholder as its own reasoning (empty stop) and re-poisoned
  cache + client history; DeepSeek's 400 is specific to an EMPTY STRING, not
  an absent field. Realigned reasoning-cache (2 cases, renamed to describe
  omission) + tool-request-sanitization (1 case + dead import). 60/60.
- GPT-5.6 Codex limits (#9432, 272000 -> 1050000 ctx / 922000 input):
  realigned vscode-token-routes-gpt56 (2) + vscode-token-routes (3). 43/43
  together with t23-t24.
- combo skip codes (#9630): t23-t24-fallback-resilience T24 now expects
  ALL_TARGETS_SKIPPED like the combo-routing-engine siblings.
- vi.json key parity: #9336 added providers.getApiKey/getApiKeyDescription
  to en.json without syncing vi (the only locale with a parity gate).
  Translated both; providers block reordered to match en key order. 5/5.
- pack-artifact-policy.test.ts: sibling of this PR's own required-paths
  change (bin/mcpStdioConsoleGuard.mjs). 10/10.
- combo-routing-engine.test.ts: dropped the 6 comment lines added in the
  previous commit so the frozen test file-size stays at its baseline (the
  rationale lives in that commit message, not the test body).

Gates: file-size, test-discovery, mutation-test-coverage, pack-policy,
open-sse-typecheck, dead-code all exit 0.

Refs #9737

* fix(translator): keep the reasoning_content placeholder for Xiaomi MiMo — #9610 traded one live 400 for another

The xiaomi-mimo replay test (9router#1321) went red on the base after #9610
removed the NON_ANTHROPIC_THINKING_PLACEHOLDER injection globally. That test
is NOT stale — it guards a documented upstream 400 ('Param Incorrect: The
reasoning_content in the thinking mode must be passed back to the API'), so
realigning it would have masked a reintroduced production bug.

Two real bugs conflict here:
- #9573: forwarding the placeholder makes the model continue its chain of
  thought FROM that text (echo -> empty stop) and re-poisons cache/history.
- 9router#1321/#1337: omitting reasoning_content on a plain replay turn makes
  Xiaomi MiMo reject the request outright.

#9610's evidence for omitting is provider-specific — it verified that
deepseek-v4-flash accepts an ABSENT field. It does not extend to MiMo. So the
omission stays for every provider #9610 covered, and the placeholder survives
the cache miss only for xiaomi-mimo (new requiresReasoningContentPresence
predicate next to isReasoningOnlyReplayTarget). The echo that comes back is
still stripped on the way in by isInternalReasoningPlaceholder(), so #9573's
cache/history poisoning stays fixed for MiMo too.

Both contracts now hold simultaneously: xiaomi-mimo replay + reasoning-cache +
tool-request-sanitization 61/61; placeholder-strip/responses/translator/combo
regression sweep 168/168. Gates: file-size, open-sse-typecheck, dead-code,
mutation-test-coverage exit 0; typecheck:core clean.

A live check on the VPS (Hard Rule #18 path 2) is the only way to confirm the
DeepSeek half of #9610's empirical claim; flagging it in the PR rather than
widening this fix on speculation.

Refs #9737

* test(translator): pin the reasoning-placeholder provider scope so neither half of the conflict can silently re-break

#9610 removed the placeholder globally on the strength of ONE provider's
observed behavior (deepseek-v4-flash accepting an absent reasoning_content),
which re-opened the MiMo 400 (9router#1321). The previous commit scoped the
placeholder to xiaomi-mimo; this pins BOTH directions in one test so the next
global edit fails loudly instead of trading the bugs again:

- xiaomi-mimo plain replay turn, cache miss -> reasoning_content present
  (narrowing the scope away from MiMo re-opens 9router#1321)
- deepseek plain replay turn, cache miss -> reasoning_content absent
  (widening it back to DeepSeek re-opens the #9573 echo bug)

Guard verified by mutation: forcing requiresReasoningContentPresence() to
return true makes the DeepSeek half fail (1 pass / 1 fail), and the file was
restored from the pre-probe copy before committing.

Also checked kimi-coding/kimi-coding-apikey, the other strict-contract entries
in REASONING_REPLAY_PROVIDERS: their originating PR (#7673) fixes capture and
replay of REAL reasoning and documents no 400 on an absent field, so they stay
out of the placeholder scope — evidence-scoped, not speculatively widened.

Reasoning suites together: 87/87. Gates: file-size, test-discovery,
mutation-test-coverage, dead-code exit 0; eslint clean.

Refs #9737

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-08 09:08:45 -03:00
31 changed files with 528 additions and 146 deletions

View File

@@ -0,0 +1 @@
- Removed the unused `RadarReferrals` type export left by the radar referral-links feature (#9697), returning the dead-code ratchet to its 227 baseline.

View File

@@ -96,6 +96,7 @@
"node-machine-id",
"omniglyph",
"open",
"opencode-ai",
"ora",
"parse5",
"pino",

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.",
"_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.",
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgents conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PRs own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
@@ -232,10 +233,10 @@
"open-sse/handlers/responseSanitizer.ts": 1128,
"open-sse/handlers/search.ts": 1536,
"open-sse/handlers/videoGeneration.ts": 1063,
"open-sse/mcp-server/schemas/tools.ts": 1505,
"open-sse/mcp-server/server.ts": 1411,
"open-sse/mcp-server/schemas/tools.ts": 1553,
"open-sse/mcp-server/server.ts": 1448,
"open-sse/mcp-server/tools/advancedTools.ts": 1120,
"open-sse/services/accountFallback.ts": 1972,
"open-sse/services/accountFallback.ts": 1978,
"open-sse/services/adobeFireflyClient.ts": 2385,
"open-sse/services/claudeCodeCompatible.ts": 1202,
"open-sse/services/combo.ts": 3648,
@@ -248,27 +249,27 @@
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3117,
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1067,
"src/app/(dashboard)/dashboard/combos/page.tsx": 4647,
"src/app/(dashboard)/dashboard/combos/page.tsx": 4703,
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1283,
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1022,
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2615,
"src/app/(dashboard)/dashboard/health/page.tsx": 1165,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1316,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1324,
"src/app/(dashboard)/dashboard/providers/page.tsx": 1944,
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201,
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1019,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1464,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1470,
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1123,
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629,
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1573,
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028,
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1119,
"src/app/api/providers/[id]/models/route.ts": 2250,
"src/app/api/v1/models/catalog.ts": 1549,
"src/lib/tokenHealthCheck.ts": 1021,
"src/app/api/providers/[id]/models/route.ts": 2361,
"src/app/api/v1/models/catalog.ts": 1590,
"src/lib/tokenHealthCheck.ts": 1053,
"src/lib/db/apiKeys.ts": 1529,
"src/lib/db/core.ts": 1637,
"src/lib/db/core.ts": 1639,
"src/lib/db/migrationRunner.ts": 1094,
"src/lib/db/models.ts": 1097,
"src/lib/db/providers.ts": 1034,
@@ -279,13 +280,14 @@
"src/shared/components/RequestLoggerV2.tsx": 1629,
"src/shared/components/analytics/charts.tsx": 1035,
"src/shared/services/cliRuntime.ts": 1122,
"src/sse/handlers/chat.ts": 1877,
"src/sse/handlers/chat.ts": 1904,
"src/sse/services/auth.ts": 2508,
"tests/unit/account-fallback-service.test.ts": 1572,
"tests/unit/provider-validation-specialty.test.ts": 2985,
"open-sse/executors/hyperagent.ts": 1026,
"open-sse/executors/default.ts": 1042,
"open-sse/executors/kiro.ts": 1069
"open-sse/executors/kiro.ts": 1069,
"open-sse/translator/request/openai-to-kiro.ts": 1057
},
"testCap": 1000,
"testFrozen": {

View File

@@ -158,7 +158,7 @@ error lines produced **95.93% token savings / 96.26% character savings** — squ
range. But the same pipeline run against normal, non-redundant tool output (a clean `grep` match list,
a short file read, ordinary conversational text) correctly produces **near-zero savings**, because
there is nothing repetitive to remove and `validateCompression()` (`validation.ts`) refuses to ship a
rewrite that would drop or alter code blocks, URLs, headings, versions, or `CONST_CASE` identifiers.
rewrite that would drop or alter code blocks, URLs, headings, versions, or ALL-CAPS constant identifiers.
This is expected, safe behavior, not a bug: a coding session that mostly reads/greps clean files will
see modest total savings even with compression fully enabled, while a session that hits a failing

View File

@@ -119,7 +119,8 @@ function serializeMessage(
"unsupported_role"
);
}
const label = role === "assistant" ? "Assistant" : role === "system" ? "System" : "User";
// role was just narrowed to "user" | "assistant" by the guard above ("system" throws).
const label = role === "assistant" ? "Assistant" : "User";
const content = record.content;
if (typeof content === "string") return `[${label}]\n${content}`;

View File

@@ -28,7 +28,14 @@ export class RaycastExecutor extends BaseExecutor {
return RAYCAST_CHAT_URL;
}
buildHeaders(credentials: ProviderCredentials, payload?: string): Record<string, string> {
// Not a BaseExecutor.buildHeaders override: Raycast signs headers over the exact
// request payload (2nd param is the body string, not the base's `stream` boolean),
// and execute() below is fully custom — keep it as a distinct helper so a
// polymorphic buildHeaders(credentials, true) call can never land here.
private buildRaycastRequestHeaders(
credentials: ProviderCredentials,
payload?: string
): Record<string, string> {
const body = payload || "{}";
return buildRaycastHeaders(body, credentials as JsonRecord);
}
@@ -44,7 +51,11 @@ export class RaycastExecutor extends BaseExecutor {
return {
response: new Response(
JSON.stringify({
error: { message: sanitizeErrorMessage(message), type: "invalid_request_error", code: "" },
error: {
message: sanitizeErrorMessage(message),
type: "invalid_request_error",
code: "",
},
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
),
@@ -54,7 +65,7 @@ export class RaycastExecutor extends BaseExecutor {
};
}
const headers = this.buildHeaders(credentials as ProviderCredentials, payload);
const headers = this.buildRaycastRequestHeaders(credentials as ProviderCredentials, payload);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders as Record<string, string> | null);
let raycastResponse: Response;

View File

@@ -190,10 +190,26 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/**
* Whether an `error` field carries a real failure signal. A key-presence check
* (`!= null`) false-positives on benign values some backends emit on every
* chunk (`{}`, `""`, `false`, `0`) — e.g. tool-call turns where a chunk with
* real tool_calls content also carries `"error": {}`. Only substantive values
* are treated as upstream failures.
*/
function isSubstantiveError(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.trim().length > 0;
if (typeof value === "object" && !Array.isArray(value)) {
return Object.keys(value as Record<string, unknown>).length > 0;
}
return value === true;
}
function isStreamingUpstreamError(parsed: unknown, eventType: string): boolean {
if (eventType === "response.failed" || eventType === "error") return true;
if (!isRecord(parsed)) return false;
if (parsed.error != null) return true;
if (isSubstantiveError(parsed.error)) return true;
const nestedResponse = isRecord(parsed.response) ? parsed.response : null;
return nestedResponse?.status === "failed" && nestedResponse.error != null;

View File

@@ -32,7 +32,7 @@
"replacement": " ",
"context": "all",
"category": "ultra",
"minIntensity": "notes"
"minIntensity": "ultra"
},
{
"name": "ultra_lowercase",
@@ -40,7 +40,7 @@
"replacement": " $1",
"context": "all",
"category": "ultra",
"minIntensity": "notes"
"minIntensity": "ultra"
}
]
}

View File

@@ -166,6 +166,29 @@ function isReasoningOnlyReplayTarget(provider: unknown, model: unknown): boolean
);
}
/**
* Upstreams that reject an ABSENT reasoning_content on replay turns, so the
* placeholder must survive the cache miss.
*
* #9573/#9610 removed the placeholder globally because the model echoed it as
* its own reasoning and stopped (empty turns). That holds for DeepSeek, where
* an absent field was verified to be accepted — but Xiaomi MiMo still 400s
* ("Param Incorrect: The reasoning_content in the thinking mode must be passed
* back to the API", 9router#1321/#1337), so omitting the field there trades one
* live bug for another. Keep the placeholder only for those providers; the echo
* that comes back is still stripped on the way in by
* isInternalReasoningPlaceholder(), so it never re-poisons cache or history.
*/
function requiresReasoningContentPresence(provider: unknown, model: unknown): boolean {
const normalizedProvider = String(provider ?? "")
.trim()
.toLowerCase();
const normalizedModel = String(model ?? "")
.trim()
.toLowerCase();
return normalizedProvider === "xiaomi-mimo" || /(^|\/)mimo/i.test(normalizedModel);
}
/** @param options.normalizeToolCallId - When true, use 9-char tool call ids (e.g. Mistral); when false, leave ids as-is */
/** @param options.preserveDeveloperRole - undefined/true: keep developer for OpenAI format (default); false: map to system */
/** @param options.preserveCacheControl - When true, preserve client-side cache_control markers (for Claude Code, etc.) */
@@ -575,7 +598,11 @@ export function translateRequest(
// the field instead; providers that genuinely enforce the contract
// (kimi-coding, moonshot authentic-reasoning) have their own paths above.
if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) {
delete msg.reasoning_content;
if (requiresReasoningContentPresence(normalizedProvider, normalizedModel)) {
msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER;
} else {
delete msg.reasoning_content;
}
}
}
} else if (

View File

@@ -2463,8 +2463,18 @@ export function createSSEStream(options: StreamOptions = {}) {
status: 200,
usage,
responseBody,
// #9315 switched the summary to the accumulated responseBody to avoid
// stale/truncated event data — but responseBody here is synthesized in
// chat-completion shape, which loses the Responses API `response` object.
// Keep the events-derived summary for OPENAI_RESPONSES only.
providerPayload: providerPayloadCollector.build(
responseBody,
sourceFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
providerPayloadCollector.getEvents(),
sourceFormat,
model
)
: responseBody,
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {
@@ -2734,8 +2744,16 @@ export function createSSEStream(options: StreamOptions = {}) {
status: 200,
usage: state?.usage,
responseBody,
// Same OPENAI_RESPONSES carve-out as the passthrough branch above —
// the synthesized chat-shaped responseBody drops the `response` object.
providerPayload: providerPayloadCollector.build(
responseBody,
targetFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
providerPayloadCollector.getEvents(),
targetFormat,
model
)
: responseBody,
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {

View File

@@ -93,6 +93,10 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
// runtime; shipped via package.json "files", so it must be allowed here.
"bin/aliasResolverHook.mjs",
"bin/mcp-server.mjs",
// #9281: stdout/stderr console guard preloaded via `node --import` by
// bin/mcp-server.mjs before the MCP entry's module graph evaluates — without it
// the published CLI's `omniroute --mcp` crashes on the pathToFileURL() import.
"bin/mcpStdioConsoleGuard.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
"bin/reset-password.mjs",
@@ -183,6 +187,10 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"bin/cli/utils/storageKeyProvision.mjs",
"bin/cli/utils/versionFastPath.mjs",
"bin/mcp-server.mjs",
// #9281: stdout/stderr console guard preloaded via `node --import` by
// bin/mcp-server.mjs before the MCP entry's module graph evaluates — without it
// the published CLI's `omniroute --mcp` crashes on the pathToFileURL() import.
"bin/mcpStdioConsoleGuard.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
// #7808: aliasResolver + its hook file. bin/omniroute.mjs imports

View File

@@ -5071,6 +5071,8 @@
"noNewModelsAddedExisting": "Không có mô hình mới nào được thêm (tất cả đã tồn tại).",
"importDoneCount": "✓ Hoàn tất! {count, plural, one {Đã nhập # mô hình.} other {Đã nhập # mô hình.}}",
"unexpectedErrorOccurred": "Đã xảy ra lỗi không mong muốn",
"getApiKey": "Lấy khóa API",
"getApiKeyDescription": "Đăng ký hoặc tạo tài khoản để nhận khóa API",
"connectionCountLabel": "{count, plural, one {# kết nối} other {# kết nối}}",
"messagesPath": "messages",
"responsesPath": "responses",
@@ -5201,6 +5203,18 @@
"interceptFetchHint": "Ghi đè các lệnh gọi công cụ web_fetch gốc sang /v1/web/fetch của OmniRoute.",
"interceptionLoadError": "Không thể tải cài đặt chặn: {error}",
"interceptionSaveError": "Không thể lưu cài đặt chặn: {error}",
"ccAliasSectionTitle": "Hiển thị trong Claude Code (claude/…)",
"ccAliasSectionHint": "Công bố các mô hình của nhà cung cấp này dưới dạng id phản chiếu claude/&lt;provider&gt;/&lt;model&gt; để tính năng khám phá mô hình qua gateway của Claude Code có thể liệt kê chúng. Mặc định tắt — bật lên sẽ nhân đôi số mục trong danh mục với mọi client.",
"ccAliasProviderLevelLabel": "Mặc định của nhà cung cấp",
"ccAliasModelOverridesLabel": "Ghi đè theo từng mô hình",
"ccAliasModelOverrideAriaLabel": "Ghi đè cho {modelId}",
"ccAliasStateInherit": "Kế thừa",
"ccAliasStateOn": "Bật",
"ccAliasStateOff": "Tắt",
"ccAliasAddModelPlaceholder": "Id mô hình (ví dụ: gpt-4o)",
"ccAliasAddModelButton": "Thêm ghi đè",
"ccAliasLoadError": "Không tải được cài đặt bí danh khám phá: {error}",
"ccAliasSaveError": "Không lưu được cài đặt bí danh khám phá: {error}",
"compatUpstreamHeadersLabel": "Các header upstream bổ sung",
"compatUpstreamHeadersHint": "Cài đặt có đặc quyền cao — có cùng mức độ tin cậy như khi chỉnh sửa thông tin xác thực API của nhà cung cấp; chỉ quản trị viên đáng tin cậy mới nên sử dụng. Các header này được hợp nhất sau khi OmniRoute thêm thông tin xác thực từ khóa API của nhà cung cấp. Nếu một header tùy chỉnh có cùng tên với header hiện có (ví dụ: Authorization), giá trị của bạn sẽ thay thế hoàn toàn header được tạo tự động (bao gồm cả token Bearer) — máy chủ thượng nguồn chỉ nhận được nội dung bạn đã nhập, không phải khóa trong phần cài đặt. Cấu hình sai có thể gây ra lỗi 401 hoặc làm hỏng quá trình xác thực với máy chủ thượng nguồn. Mỗi hàng tương ứng với một header (ví dụ: header Authentication bổ sung cho một số cổng). Di chuột hoặc đặt tiêu điểm vào giá trị để xem trước. Tự động lưu khi mất tiêu điểm, nhấp ra ngoài hoặc đóng bảng điều khiển này.",
"compatUpstreamHeaderName": "Tên header",
@@ -5475,6 +5489,13 @@
"newApiUserIdLabel": "ID người dùng New-API",
"newApiUserIdPlaceholder": "vd. 12345",
"newApiUserIdHint": "Giá trị tiêu đề New-Api-User của AgentRouter, dùng cùng với khóa API console để lấy số dư hạn mức.",
"newApiAggregatorToggleLabel": "Cổng tổng hợp",
"newApiAggregatorToggleHint": "Bật phát hiện số dư cho các node tổng hợp New-API / One-API / Sub2API. Bảng điều khiển sẽ hiển thị huy hiệu số dư và định tuyến quota-preflight sẽ bỏ qua các tài khoản đã cạn.",
"newApiAggregatorConsoleApiKeyHint": "System Access Token cho endpoint /api/user/self của bộ tổng hợp. Không phải khóa API định tuyến.",
"newApiAggregatorUserIdHint": "Giá trị header New-Api-User dùng để lấy số dư quota của người dùng bộ tổng hợp.",
"newApiAggregatorQuotaPerUnitLabel": "Quota mỗi đơn vị",
"newApiAggregatorQuotaPerUnitHint": "Số đơn vị tín dụng New-API cho mỗi 1 USD (mặc định: 500000). Ghi đè nếu bộ tổng hợp của bạn dùng tỷ lệ khác.",
"featureFlagNewApiAggregatorBalanceDescription": "Bật phát hiện số dư cho các node tương thích New-API / One-API / Sub2API",
"cpaModeDisabledTitle": "Chế độ tương thích CLIProxyAPI đã bị tắt",
"cpaModeEnabledTitle": "Chế độ tương thích CLIProxyAPI đã được bật",
"customUserAgentHint": "Gợi ý User Agent tùy chỉnh",
@@ -5590,6 +5611,7 @@
"tagGroupPlaceholder": "Nhập nhóm thẻ...",
"testModel": "Kiểm tra mô hình",
"testingModel": "Đang kiểm tra mô hình",
"modelTestQuotaTooltip": "Đã hết quota — sẽ đặt lại vào ngày mai hoặc cần nạp thêm",
"toggleOffShort": "Tắt",
"toggleOnShort": "Bật",
"tokenExpiredBadge": "Nhãn token đã hết hạn",
@@ -6008,27 +6030,7 @@
"kimiOfficialSupporterTooltip": "Kimi (Moonshot AI) là người bạn mã nguồn mở sáng lập của OmniRoute",
"cheaperInferenceSupporterBadge": "Người bạn mã nguồn mở",
"cheaperInferenceSupporterTooltip": "Cheaper Inference hỗ trợ OmniRoute với tư cách là người bạn mã nguồn mở",
"kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you",
"ccAliasSectionTitle": "Hiển thị trong Claude Code (claude/…)",
"ccAliasSectionHint": "Công bố các mô hình của nhà cung cấp này dưới dạng id phản chiếu claude/&lt;provider&gt;/&lt;model&gt; để tính năng khám phá mô hình qua gateway của Claude Code có thể liệt kê chúng. Mặc định tắt — bật lên sẽ nhân đôi số mục trong danh mục với mọi client.",
"ccAliasProviderLevelLabel": "Mặc định của nhà cung cấp",
"ccAliasModelOverridesLabel": "Ghi đè theo từng mô hình",
"ccAliasModelOverrideAriaLabel": "Ghi đè cho {modelId}",
"ccAliasStateInherit": "Kế thừa",
"ccAliasStateOn": "Bật",
"ccAliasStateOff": "Tắt",
"ccAliasAddModelPlaceholder": "Id mô hình (ví dụ: gpt-4o)",
"ccAliasAddModelButton": "Thêm ghi đè",
"ccAliasLoadError": "Không tải được cài đặt bí danh khám phá: {error}",
"ccAliasSaveError": "Không lưu được cài đặt bí danh khám phá: {error}",
"newApiAggregatorToggleLabel": "Cổng tổng hợp",
"newApiAggregatorToggleHint": "Bật phát hiện số dư cho các node tổng hợp New-API / One-API / Sub2API. Bảng điều khiển sẽ hiển thị huy hiệu số dư và định tuyến quota-preflight sẽ bỏ qua các tài khoản đã cạn.",
"newApiAggregatorConsoleApiKeyHint": "System Access Token cho endpoint /api/user/self của bộ tổng hợp. Không phải khóa API định tuyến.",
"newApiAggregatorUserIdHint": "Giá trị header New-Api-User dùng để lấy số dư quota của người dùng bộ tổng hợp.",
"newApiAggregatorQuotaPerUnitLabel": "Quota mỗi đơn vị",
"newApiAggregatorQuotaPerUnitHint": "Số đơn vị tín dụng New-API cho mỗi 1 USD (mặc định: 500000). Ghi đè nếu bộ tổng hợp của bạn dùng tỷ lệ khác.",
"featureFlagNewApiAggregatorBalanceDescription": "Bật phát hiện số dư cho các node tương thích New-API / One-API / Sub2API",
"modelTestQuotaTooltip": "Đã hết quota — sẽ đặt lại vào ngày mai hoặc cần nạp thêm"
"kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you"
},
"settings": {
"title": "Cài đặt",

View File

@@ -345,7 +345,10 @@ function resolveCatalogPricing(
// Consulted only when models.dev returned nothing, matching the order
// already implemented in db/settings/pricing.ts::getPricing().
try {
const litellm = getSyncedPricing() as Record<string, Record<string, Record<string, number>>>;
const litellm = getSyncedPricing() as unknown as Record<
string,
Record<string, Record<string, number>>
>;
const providerPricing =
findInsensitive(litellm, provider) || findInsensitive(litellm, provider.replace(/-cn$/, ""));
if (providerPricing) {

View File

@@ -217,4 +217,3 @@ export type RadarProvider = z.infer<typeof ProviderSchema>;
export type RadarQuirk = z.infer<typeof QuirkSchema>;
export type RadarBudget = z.infer<typeof BudgetSchema>;
export type RadarReferral = z.infer<typeof RadarReferralSchema>;
export type RadarReferrals = z.infer<typeof RadarReferralsSchema>;

View File

@@ -1348,7 +1348,7 @@ async function handleSingleModelChat(
const breakerFailureStatus = Number(lastStatus ?? credentials?.lastErrorCode);
if (
!forceLiveComboTest &&
isAllRateLimited &&
credentials?.allRateLimited &&
PROVIDER_BREAKER_FAILURE_STATUSES.has(breakerFailureStatus)
) {
breaker._onFailure();

View File

@@ -39,9 +39,7 @@
"incremental": true,
"incrementalFile": "reports/mutation/stryker-incremental.json",
"testRunner": "tap",
"plugins": [
"@stryker-mutator/tap-runner"
],
"plugins": ["@stryker-mutator/tap-runner"],
"tap": {
"testFiles": [
"tests/unit/7993-noauth-proxy-routing.test.ts",
@@ -52,6 +50,7 @@
"tests/unit/8376-econnrefused-breaker.test.ts",
"tests/unit/8396-cooldown-429-cap.test.ts",
"tests/unit/8488-capability-filter-fail-closed.test.ts",
"tests/unit/8779-agy-prefix-credential-lookup.test.ts",
"tests/unit/account-fallback-anthropic-quota.test.ts",
"tests/unit/account-fallback-cf1010-no-retry-8775.test.ts",
"tests/unit/account-fallback-lockout-eviction.test.ts",
@@ -85,6 +84,7 @@
"tests/unit/auto-combo-engine.test.ts",
"tests/unit/auto-combo-scoring-clamp.test.ts",
"tests/unit/bug-7940-gemini-retrydelay.test.ts",
"tests/unit/bug-9204-agy-provider-alias-credentials.test.ts",
"tests/unit/build/check-circular-deps.test.ts",
"tests/unit/cache-sweeps.test.ts",
"tests/unit/cc-bridge-openai-image-7777.test.ts",
@@ -191,6 +191,7 @@
"tests/unit/combo/combo-target-timeout-standards.test.ts",
"tests/unit/combo/effective-max-concurrency.test.ts",
"tests/unit/combo/recovery-hint.test.ts",
"tests/unit/combo/reset-window-strategy-9330.test.ts",
"tests/unit/complexity-aware-scoring-wiring.test.ts",
"tests/unit/compression-header-verification.test.ts",
"tests/unit/context-pinning-tool-calls.test.ts",
@@ -250,6 +251,7 @@
"tests/unit/observability-payloads.test.ts",
"tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts",
"tests/unit/openapi-security-tiers.test.ts",
"tests/unit/openrouter-passthrough-models.test.ts",
"tests/unit/openrouter-quota-6842.test.ts",
"tests/unit/persist-429-cooldown-account-fallback.test.ts",
"tests/unit/plan3-p0.test.ts",
@@ -271,6 +273,7 @@
"tests/unit/rate-limit-manager.test.ts",
"tests/unit/rate-limit-queue-timeout-lockout.test.ts",
"tests/unit/repro-7503-no-choices.test.ts",
"tests/unit/repro-9630-combo-false-503.test.ts",
"tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts",
"tests/unit/responses-handler.test.ts",
"tests/unit/rotation-config-omniroute.test.ts",
@@ -428,11 +431,7 @@
".worktrees",
".stryker-tmp"
],
"reporters": [
"progress",
"html",
"json"
],
"reporters": ["progress", "html", "json"],
"htmlReporter": {
"fileName": "reports/mutation/mutation.html"
},

View File

@@ -8,7 +8,7 @@
* This test verifies the payload normalization fix is present in the source code
* and that the correct contract keys are read by loadData().
*
* Run: node --import tsx/esm --test tests/unit/free-pool-frontend-repro.test.tsx
* Run: node --import tsx/esm --test tests/unit/free-pool-frontend-repro.test.ts
*/
import test from "node:test";

View File

@@ -20,11 +20,10 @@ const ROOT = new URL("../..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/
*/
describe("omniroute --mcp stdio transport", () => {
it("writes only valid JSON-RPC to stdout — no DB init or other startup logging leaks through", async () => {
const child = spawn(
process.execPath,
[join(ROOT, "bin", "omniroute.mjs"), "--mcp"],
{ cwd: ROOT, env: process.env }
);
const child = spawn(process.execPath, [join(ROOT, "bin", "omniroute.mjs"), "--mcp"], {
cwd: ROOT,
env: process.env,
});
let stdout = "";
let stderr = "";
@@ -48,11 +47,23 @@ describe("omniroute --mcp stdio transport", () => {
})}\n`
);
await new Promise((resolve) => setTimeout(resolve, 4000));
// The full chain (omniroute.mjs CLI startup + spawned MCP child, each paying a tsx
// import + the child's DB init/migrations) takes ~10s on a warm dev box and longer on
// loaded CI runners — a fixed 4s sleep made this test red from birth. Poll for the
// first stdout line instead, then give the stream a short settle window so any
// late startup logging that WOULD corrupt the protocol still gets caught.
const deadline = Date.now() + 60_000;
while (!stdout.includes("\n") && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 1000));
child.kill();
const stdoutLines = stdout.split("\n").filter((line) => line.trim().length > 0);
assert.ok(stdoutLines.length > 0, "expected at least one line on stdout (the initialize response)");
assert.ok(
stdoutLines.length > 0,
"expected at least one line on stdout (the initialize response)"
);
for (const line of stdoutLines) {
assert.doesNotThrow(
@@ -61,9 +72,7 @@ describe("omniroute --mcp stdio transport", () => {
);
}
const initResponse = stdoutLines
.map((line) => JSON.parse(line))
.find((msg) => msg.id === 1);
const initResponse = stdoutLines.map((line) => JSON.parse(line)).find((msg) => msg.id === 1);
assert.ok(initResponse, "expected an initialize response with id 1 on stdout");
assert.equal(initResponse.jsonrpc, "2.0");

View File

@@ -154,6 +154,7 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball",
"bin/cli/utils/storageKeyProvision.mjs",
"bin/cli/utils/versionFastPath.mjs",
"bin/mcp-server.mjs",
"bin/mcpStdioConsoleGuard.mjs",
"bin/nodeRuntimeSupport.mjs",
"dist/head-response-guard.cjs",
"dist/http-method-guard.cjs",

View File

@@ -42,6 +42,10 @@ const PLUGIN_ROUTES: Array<{ rel: string; label: string }> = [
rel: "src/app/api/plugins/marketplace/route.ts",
label: "GET /api/plugins/marketplace",
},
{
rel: "src/app/api/plugins/marketplace/install/route.ts",
label: "POST /api/plugins/marketplace/install",
},
];
for (const { rel, label } of PLUGIN_ROUTES) {

View File

@@ -51,9 +51,8 @@ function createFixturePlugin(name: string, opts?: { onResponse?: boolean; onRequ
// ── Manifest validation ──
test("plugin manifest validation", async (t) => {
const { validateManifest, safeValidateManifest, applyDefaults } = await import(
"../../src/lib/plugins/manifest.ts"
);
const { validateManifest, safeValidateManifest, applyDefaults } =
await import("../../src/lib/plugins/manifest.ts");
await t.test("valid manifest parses with defaults", () => {
const result = validateManifest({
@@ -136,8 +135,22 @@ test("plugin hooks system", async (t) => {
await t.test("registerHook registers and sorts by priority", () => {
const calls: string[] = [];
registerHook("onRequest", "plugin-b", () => { calls.push("b"); }, 200);
registerHook("onRequest", "plugin-a", () => { calls.push("a"); }, 100);
registerHook(
"onRequest",
"plugin-b",
() => {
calls.push("b");
},
200
);
registerHook(
"onRequest",
"plugin-a",
() => {
calls.push("a");
},
100
);
const hooks = getHooks("onRequest");
assert.equal(hooks.length, 2);
assert.equal(hooks[0].pluginName, "plugin-a");
@@ -174,9 +187,30 @@ test("plugin hooks system", async (t) => {
await t.test("emitHook calls all handlers in order", async () => {
const order: number[] = [];
registerHook("onTest", "h1", () => { order.push(1); }, 100);
registerHook("onTest", "h2", () => { order.push(2); }, 200);
registerHook("onTest", "h3", () => { order.push(3); }, 150);
registerHook(
"onTest",
"h1",
() => {
order.push(1);
},
100
);
registerHook(
"onTest",
"h2",
() => {
order.push(2);
},
200
);
registerHook(
"onTest",
"h3",
() => {
order.push(3);
},
150
);
await emitHook("onTest", {});
assert.deepEqual(order, [1, 3, 2]);
resetHooks();
@@ -184,8 +218,22 @@ test("plugin hooks system", async (t) => {
await t.test("emitHook swallows handler errors", async () => {
const calls: string[] = [];
registerHook("onErr", "bad", () => { throw new Error("boom"); }, 100);
registerHook("onErr", "good", () => { calls.push("ok"); }, 200);
registerHook(
"onErr",
"bad",
() => {
throw new Error("boom");
},
100
);
registerHook(
"onErr",
"good",
() => {
calls.push("ok");
},
200
);
await emitHook("onErr", {});
assert.deepEqual(calls, ["ok"]);
resetHooks();
@@ -203,7 +251,14 @@ test("plugin hooks system", async (t) => {
await t.test("emitHookBlocking returns early on blocked", async () => {
const calls: string[] = [];
registerHook("onBlock2", "blocker", () => ({ blocked: true, response: { error: "no" } }), 100);
registerHook("onBlock2", "after", () => { calls.push("after"); }, 200);
registerHook(
"onBlock2",
"after",
() => {
calls.push("after");
},
200
);
const result = await emitHookBlocking("onBlock2", {});
assert.equal(result.blocked, true);
assert.equal(calls.length, 0);
@@ -212,7 +267,13 @@ test("plugin hooks system", async (t) => {
await t.test("runOnRequest delegates to emitHookBlocking", async () => {
registerHook("onRequest", "req", () => ({ metadata: { seen: true } }), 100);
const result = await runOnRequest({ requestId: "1", body: {}, model: "gpt-4", provider: "openai", metadata: {} });
const result = await runOnRequest({
requestId: "1",
body: {},
model: "gpt-4",
provider: "openai",
metadata: {},
});
assert.deepEqual(result.metadata, { seen: true });
resetHooks();
});
@@ -230,7 +291,14 @@ test("plugin hooks system", async (t) => {
await t.test("runOnError is fire-and-forget", async () => {
let called = false;
registerHook("onError", "err-handler", () => { called = true; }, 100);
registerHook(
"onError",
"err-handler",
() => {
called = true;
},
100
);
await runOnError(
{ requestId: "1", body: {}, model: "gpt-4", provider: "openai", metadata: {} },
new Error("test")
@@ -257,6 +325,8 @@ test("plugin hooks system", async (t) => {
"onActivate",
"onDeactivate",
"onUninstall",
// #9668: fire-and-forget stream telemetry hook (runOnStreamCompleteHooks)
"onStreamComplete",
]);
resetHooks();
});
@@ -309,9 +379,7 @@ test("welcome banner PoC plugin lifecycle", async (t) => {
await t.test("onResponse injects banner into response", async () => {
const mod = await import(join(pluginDir, "index.mjs"));
const response = {
choices: [
{ message: { role: "assistant", content: "Hello!" } },
],
choices: [{ message: { role: "assistant", content: "Hello!" } }],
};
const result = await mod.plugin.onResponse({}, response);
assert.ok(result.choices[0].message.content.includes("[Welcome to OmniRoute"));
@@ -321,9 +389,7 @@ test("welcome banner PoC plugin lifecycle", async (t) => {
await t.test("onResponse handles streaming delta", async () => {
const mod = await import(join(pluginDir, "index.mjs"));
const response = {
choices: [
{ delta: { content: "stream chunk" } },
],
choices: [{ delta: { content: "stream chunk" } }],
};
const result = await mod.plugin.onResponse({}, response);
assert.ok(result.choices[0].delta.content.includes("[Welcome to OmniRoute"));

View File

@@ -181,10 +181,11 @@ test("provider models route merges live Codex models with the local catalog then
// merge conservatively — the smaller of live vs. pinned wins, never the
// larger, so a stale/inflated live number can never make OmniRoute promise
// more context than the account can actually serve (#7012). Here the pinned
// GPT-5.6 Codex contract (272000/128000, see GPT_5_6_CODEX_CAPABILITIES) is
// smaller than the live payload's 999999/999999, so the pinned value wins.
// GPT-5.6 Codex contract (922000/128000, see GPT_5_6_CODEX_CAPABILITIES
// raised from 272000 in #9432) is smaller than the live payload's
// 999999/999999, so the pinned value wins.
assert.equal(liveModel?.name, "GPT 5.6 Sol Live");
assert.equal(liveModel?.inputTokenLimit, 272000);
assert.equal(liveModel?.inputTokenLimit, 922000);
assert.equal(liveModel?.outputTokenLimit, 128000);
assert.equal(liveModel?.apiFormat, "responses");
assert.deepEqual(liveModel?.supportedEndpoints, ["responses"]);

View File

@@ -0,0 +1,167 @@
/**
* TDD regression guard — quality validation false-positive on benign `error`
* fields in streaming SSE chunks.
*
* `isStreamingUpstreamError` treats ANY non-null `error` field as an upstream
* failure: `parsed.error != null` is true for `{}`, `""`, `false`, and `0`.
* When a client like opencode issues a tool-call turn, the upstream SSE opens
* with role-only frames (no recognized content) and a later chunk that carries
* real tool_calls content PLUS a benign empty `error` field (a field some
* backends emit on every chunk). The error gate runs BEFORE the content
* recognizers, so that single frame short-circuits to "error" → 502
* "streaming upstream error" — while the same combo via kilocode (different
* wire format) never emits the empty `error` field and works fine.
*/
import test from "node:test";
import assert from "node:assert/strict";
const { validateResponseQuality } = await import("../../open-sse/services/combo.ts");
const encoder = new TextEncoder();
const silentLog = { warn: () => {} };
function openAiSseStream(events: string[]): ReadableStream<Uint8Array> {
const body = events.join("\n") + "\n";
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(body));
controller.close();
},
});
}
/**
* OpenAI-compatible tool-call stream that ALSO carries a benign empty `error`
* field on the tool_calls chunk. Some backends emit `"error": {}` or
* `"error": ""` alongside every chunk; that is not a real upstream failure.
* The frame must be treated as CONTENT (valid), not ERROR.
*/
function makeToolCallStreamWithBenignError(): Response {
const events = [
// role-only first chunk — no recognized content, widens the peek window
`data: ${JSON.stringify({
id: "chatcmpl_1",
object: "chat.completion.chunk",
created: 123,
model: "gpt-4o",
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
})}`,
"",
// tool_calls delta + benign empty `error` field (the bug trigger)
`data: ${JSON.stringify({
id: "chatcmpl_2",
object: "chat.completion.chunk",
created: 123,
model: "gpt-4o",
choices: [
{
index: 0,
delta: {
tool_calls: [
{ index: 0, id: "call_1", type: "function", function: { name: "Bash", arguments: "" } },
],
},
finish_reason: null,
},
],
error: {},
})}`,
"",
`data: [DONE]`,
"",
];
return new Response(openAiSseStream(events), {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
test("OpenAI stream with tool_calls + benign empty error:{} field is VALID (not 502)", async () => {
const res = makeToolCallStreamWithBenignError();
const out = await validateResponseQuality(res, true, silentLog);
assert.equal(
out.valid,
true,
`expected valid for tool_calls chunk with benign error:{}, got valid=false (reason: ${out.reason})`
);
assert.ok(out.clonedResponse, "clonedResponse must be present for valid streaming response");
});
test("OpenAI stream with tool_calls + benign empty error:'' field is VALID", async () => {
const events = [
`data: ${JSON.stringify({
id: "chatcmpl_3",
object: "chat.completion.chunk",
created: 123,
model: "gpt-4o",
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
})}`,
"",
`data: ${JSON.stringify({
id: "chatcmpl_4",
object: "chat.completion.chunk",
created: 123,
model: "gpt-4o",
choices: [
{
index: 0,
delta: {
tool_calls: [
{ index: 0, id: "call_2", type: "function", function: { name: "Read", arguments: "" } },
],
},
finish_reason: null,
},
],
error: "",
})}`,
"",
`data: [DONE]`,
"",
];
const res = new Response(openAiSseStream(events), {
status: 200,
headers: { "content-type": "text/event-stream" },
});
const out = await validateResponseQuality(res, true, silentLog);
assert.equal(
out.valid,
true,
`expected valid for tool_calls chunk with benign error:"", got valid=false (reason: ${out.reason})`
);
});
test("Stream with a REAL non-empty error object is still flagged as invalid", async () => {
const events = [
`data: ${JSON.stringify({
id: "chatcmpl_5",
object: "chat.completion.chunk",
created: 123,
model: "gpt-4o",
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
})}`,
"",
`data: ${JSON.stringify({
id: "chatcmpl_6",
object: "chat.completion.chunk",
created: 123,
model: "gpt-4o",
choices: [{ index: 0, delta: {}, finish_reason: null }],
error: { message: "upstream quota exceeded", code: "rate_limit_exceeded" },
})}`,
"",
`data: [DONE]`,
"",
];
const res = new Response(openAiSseStream(events), {
status: 200,
headers: { "content-type": "text/event-stream" },
});
const out = await validateResponseQuality(res, true, silentLog);
assert.equal(
out.valid,
false,
`expected invalid for real error object, got valid=true (reason: ${out.reason})`
);
assert.match(out.reason ?? "", /streaming upstream error/, "reason should mention the upstream error");
});

View File

@@ -755,11 +755,13 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
assert.equal(translated.messages[1].reasoning_content, undefined);
});
it("should replace empty-string reasoning_content with NON_ANTHROPIC_THINKING_PLACEHOLDER on cache miss", async () => {
it("should drop empty-string reasoning_content on cache miss", async () => {
// Regression: injectEmptyReasoningContentForToolCalls (schemaCoercion.ts) pre-sets
// reasoning_content="" before the cache lookup. The old condition
// `msg.reasoning_content === undefined` never fired on cache miss, leaving the
// empty string in place. DeepSeek V4+ rejects "" with a 400.
// reasoning_content="" before the cache lookup, and DeepSeek V4+ rejects "" with a
// 400 — so the empty string must not survive the miss. #9573/#9610 replaced the
// former NON_ANTHROPIC_THINKING_PLACEHOLDER injection with omitting the field: the
// placeholder was echoed back by the model as its own reasoning (empty stop) and
// re-poisoned cache + client history, while an ABSENT field is accepted.
clearReasoningCacheAll();
clearModelsDevCapabilities();
saveModelsDevCapabilities({
@@ -772,9 +774,6 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
},
});
const { NON_ANTHROPIC_THINKING_PLACEHOLDER } =
await import("../../open-sse/translator/helpers/claudeHelper.ts");
// No cache entry → cache miss
const translated = translateRequest(
FORMATS.OPENAI,
@@ -805,16 +804,17 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
assert.equal(
translated.messages[1].reasoning_content,
NON_ANTHROPIC_THINKING_PLACEHOLDER,
"empty reasoning_content should be replaced with placeholder on cache miss"
undefined,
"empty reasoning_content should be dropped (not placeholder-filled) on cache miss"
);
});
it("should inject placeholder for a plain (non-tool-call) DeepSeek turn missing reasoning_content (#1682)", async () => {
it("should omit reasoning_content for a plain (non-tool-call) DeepSeek turn missing it (#1682)", async () => {
// Regression (#1682): a multi-turn text conversation where the prior assistant
// turn has NO tool calls and the client (e.g. Cursor) stripped reasoning_content
// from history. DeepSeek V4+ still requires reasoning_content on every assistant
// message in thinking mode, so without a placeholder the upstream returns 400.
// from history. #9573/#9610 established that DeepSeek's 400 is specific to an
// EMPTY-STRING reasoning_content, not an absent field — so the field is now
// omitted here instead of carrying the self-poisoning placeholder.
clearReasoningCacheAll();
clearModelsDevCapabilities();
saveModelsDevCapabilities({
@@ -827,9 +827,6 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
},
});
const { NON_ANTHROPIC_THINKING_PLACEHOLDER } =
await import("../../open-sse/translator/helpers/claudeHelper.ts");
const translated = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI,
@@ -849,8 +846,8 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
assert.equal(
translated.messages[1].reasoning_content,
NON_ANTHROPIC_THINKING_PLACEHOLDER,
"plain DeepSeek assistant turn missing reasoning_content should get the placeholder"
undefined,
"plain DeepSeek assistant turn missing reasoning_content should keep the field absent"
);
});

View File

@@ -1,8 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
handleComboChat,
} from "../../open-sse/services/combo.ts";
import { handleComboChat } from "../../open-sse/services/combo.ts";
import { getCircuitBreaker, STATE } from "../../src/shared/utils/circuitBreaker.js";
function okResponse() {
@@ -27,14 +25,18 @@ test("#9630: combo returns 503 when circuit breaker is OPEN but other healthy ta
strategy: "priority",
models: ["openai/gpt-4", "anthropic/claude-opus-5"],
},
handleSingleModel: async (_body: any, modelStr: string) => {
assert.equal(modelStr, "anthropic/claude-opus-5", "should skip openai breaker and try anthropic");
handleSingleModel: async (_body, modelStr) => {
assert.equal(
modelStr,
"anthropic/claude-opus-5",
"should skip openai breaker and try anthropic"
);
return okResponse();
},
isModelAvailable: async () => true,
log: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} } as any,
log: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} },
settings: null,
relayOptions: null as any,
relayOptions: null,
allCombos: null,
});
@@ -63,17 +65,22 @@ test("#9630: combo returns truthful error, not false ALL_ACCOUNTS_INACTIVE, when
strategy: "priority",
models: ["openai/gpt-4", "anthropic/claude-opus-5"],
},
handleSingleModel: async () => { throw new Error("should not be called"); },
handleSingleModel: async () => {
throw new Error("should not be called");
},
isModelAvailable: async () => true,
log: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} } as any,
log: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} },
settings: null,
relayOptions: null as any,
relayOptions: null,
allCombos: null,
});
assert.equal(result.status, 503);
const body = await result.json();
// The diagnostic should NOT claim ALL_ACCOUNTS_INACTIVE when no real dispatch was attempted
assert.notEqual(body.error?.code, "ALL_ACCOUNTS_INACTIVE",
"should not claim ALL_ACCOUNTS_INACTIVE when all targets were gated by pre-dispatch checks");
assert.notEqual(
body.error?.code,
"ALL_ACCOUNTS_INACTIVE",
"should not claim ALL_ACCOUNTS_INACTIVE when all targets were gated by pre-dispatch checks"
);
});

View File

@@ -89,17 +89,15 @@ test("#9293 hidden OpenRouter specialty models are excluded from /v1/models cata
new Request("http://localhost/v1/models")
);
assert.equal(response.status, 200);
const body = (await response.json()) as any;
const body = (await response.json()) as { data: Array<{ id: string; type?: string }> };
assert.ok(Array.isArray(body.data), "response has data array");
// Find audio and image models
const audioModels = body.data.filter((m: any) => m.type === "audio");
const imageModels = body.data.filter((m: any) => m.type === "image");
const audioModels = body.data.filter((m) => m.type === "audio");
const imageModels = body.data.filter((m) => m.type === "image");
// chirp-3 model ID from the audio registry is openrouter/google/chirp-3
const hiddenAudio = audioModels.find((m: any) =>
String(m.id).endsWith("google/chirp-3")
);
const hiddenAudio = audioModels.find((m) => String(m.id).endsWith("google/chirp-3"));
assert.equal(
hiddenAudio,
undefined,
@@ -107,7 +105,7 @@ test("#9293 hidden OpenRouter specialty models are excluded from /v1/models cata
);
// flux.2-pro model ID from the image registry is openrouter/black-forest-labs/flux.2-pro
const hiddenImage = imageModels.find((m: any) =>
const hiddenImage = imageModels.find((m) =>
String(m.id).endsWith("black-forest-labs/flux.2-pro")
);
assert.equal(
@@ -118,11 +116,6 @@ test("#9293 hidden OpenRouter specialty models are excluded from /v1/models cata
// Verify non-hidden audio models from OpenRouter still appear
// deepgram/nova-3 is not hidden, so it should be present
const visibleAudio = audioModels.find((m: any) =>
String(m.id).endsWith("deepgram/nova-3")
);
assert.ok(
visibleAudio,
"non-hidden audio model deepgram/nova-3 should still appear in catalog"
);
});
const visibleAudio = audioModels.find((m) => String(m.id).endsWith("deepgram/nova-3"));
assert.ok(visibleAudio, "non-hidden audio model deepgram/nova-3 should still appear in catalog");
});

View File

@@ -148,7 +148,7 @@ test("T24: all inactive accounts return 503 service_unavailable (not 406)", asyn
assert.equal(result.status, 503);
const body = (await result.json()) as any;
assert.equal(body.error?.code, "ALL_ACCOUNTS_INACTIVE");
assert.equal(body.error?.code, "ALL_TARGETS_SKIPPED");
});
test("combo falls through 400s and reaches the next model", async () => {

View File

@@ -9,9 +9,6 @@ const {
injectEmptyReasoningContentForToolCalls,
} = await import("../../open-sse/translator/helpers/schemaCoercion.ts");
const { translateRequest } = await import("../../open-sse/translator/index.ts");
const { NON_ANTHROPIC_THINKING_PLACEHOLDER } = await import(
"../../open-sse/translator/helpers/claudeHelper.ts"
);
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const { clearModelsDevCapabilities, saveModelsDevCapabilities } =
await import("../../src/lib/modelsDevSync.ts");
@@ -198,7 +195,7 @@ test("tool sanitization: injects empty reasoning_content only for DeepSeek tool-
assert.equal(openaiMessages[1].reasoning_content, undefined);
});
test("translateRequest injects reasoning_content for DeepSeek assistant tool calls", () => {
test("translateRequest omits reasoning_content for DeepSeek assistant tool calls on cache miss", () => {
clearModelsDevCapabilities();
saveModelsDevCapabilities({
deepseek: {
@@ -231,6 +228,10 @@ test("translateRequest injects reasoning_content for DeepSeek assistant tool cal
"deepseek"
);
assert.equal(translated.messages[1].reasoning_content, NON_ANTHROPIC_THINKING_PLACEHOLDER);
// #9573/#9610: the former NON_ANTHROPIC_THINKING_PLACEHOLDER injection was the root
// cause of the echo → empty-stop bug (the model continued its chain of thought from
// the placeholder and re-poisoned cache + history). On a cache miss the field is now
// omitted; DeepSeek's 400 is specific to an empty string, not an absent field.
assert.equal(translated.messages[1].reasoning_content, undefined);
clearModelsDevCapabilities();
});

View File

@@ -42,3 +42,53 @@ test("translateRequest replays reasoning_content on plain xiaomi-mimo assistant
"plain xiaomi-mimo assistant turn must carry a non-empty reasoning_content"
);
});
// Scope guard for the #9573/#9610 <-> 9router#1321 conflict. #9610 removed the
// placeholder injection globally on the strength of ONE provider's behavior
// (deepseek-v4-flash was verified to accept an absent reasoning_content), which
// silently re-broke MiMo. The placeholder is now provider-scoped, so both halves
// need pinning: widening the scope back to DeepSeek re-opens #9573, narrowing it
// away from MiMo re-opens 9router#1321.
test("the reasoning_content placeholder stays scoped: MiMo keeps it, DeepSeek does not (#9573 vs 9router#1321)", () => {
const plainHistory = () => ({
messages: [
{ role: "user", content: "hi" },
// Plain assistant turn whose reasoning_content the client stripped.
{ role: "assistant", content: "Hello! How can I help?" },
{ role: "user", content: "continue" },
],
});
const mimo = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI,
"mimo-v2.5-pro",
plainHistory(),
true,
null,
"xiaomi-mimo"
);
const mimoAssistant = mimo.messages.find((m) => m.role === "assistant");
assert.equal(
typeof mimoAssistant.reasoning_content === "string" &&
mimoAssistant.reasoning_content.length > 0,
true,
"MiMo 400s on an absent reasoning_content — the placeholder must survive the cache miss"
);
const deepseek = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI,
"deepseek-v4-flash",
plainHistory(),
true,
null,
"deepseek"
);
const deepseekAssistant = deepseek.messages.find((m) => m.role === "assistant");
assert.equal(
deepseekAssistant.reasoning_content,
undefined,
"DeepSeek accepts an absent field; sending the placeholder there is the #9573 echo bug"
);
});

View File

@@ -128,9 +128,9 @@ test("vscode raw models route exposes native GPT-5.6 IDs and effort tiers", asyn
assert.equal(typeof defaultModel.created, "number");
assert.equal(defaultModel.owned_by, "codex");
assert.equal(defaultModel.name, "Codex GPT 5.6 Sol");
assert.equal(defaultModel.context_length, 272000);
assert.equal(defaultModel.context_length, 1050000);
assert.equal(defaultModel.max_output_tokens, 128000);
assert.equal(defaultModel.max_input_tokens, 272000);
assert.equal(defaultModel.max_input_tokens, 922000);
assert.deepEqual(defaultModel.capabilities, {
vision: true,
tool_calling: true,

View File

@@ -255,7 +255,7 @@ test("vscode combos route resolves combo names through Ollama api/show", async (
assert.equal(body.model, "show-combo");
assert.equal(body.modelfile, "FROM show-combo");
assert.equal(body.details.family, "show-combo");
assert.equal(body.model_info.context_length, 272000);
assert.equal(body.model_info.context_length, 1050000);
assert.deepEqual(body.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]);
assert.equal(body.model_info.capabilities.reasoning, true);
});
@@ -290,7 +290,7 @@ test("vscode tokenized combos root route exposes importable combo metadata", asy
assert.equal(response.status, 200);
assert.ok(combo, "expected balanced-load in combo root response");
assert.equal(combo.url.includes("/responses#models.ai.azure.com"), true);
assert.equal(combo.maxInputTokens, 272000);
assert.equal(combo.maxInputTokens, 922000);
assert.equal(combo.toolCalling, true);
assert.deepEqual(combo.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]);
});
@@ -767,9 +767,7 @@ test("vscode tokenized tags route only exposes usable canonical chat models", as
);
assert.ok(
!catalogModel.api_format ||
["chat-completions", "responses", "openai-responses"].includes(
catalogModel.api_format
),
["chat-completions", "responses", "openai-responses"].includes(catalogModel.api_format),
`tag ${tagModel.name} should use a text-generation API format`
);
assert.ok(
@@ -1075,7 +1073,7 @@ test("vscode tokenized api/show route exposes explicit reasoning effort metadata
assert.equal(body.configurationSchema?.properties?.reasoningEffort?.default, "low");
assert.equal(body.model_info["general.basename"], "Codex GPT 5.6 Sol (Default)");
assert.equal(body.model_info["general.architecture"], "codex");
assert.equal(body.model_info["codex.context_length"], 272000);
assert.equal(body.model_info["codex.context_length"], 1050000);
assert.deepEqual(body.model_info.supports_reasoning_effort, [
"low",
"medium",