* fix(sse): Gemini TPM classification, combo-cooldown-wait for auto/quota-share, and target-timeout floor
Gemini TPM/RPM 429s were misclassified as QUOTA_EXHAUSTED because
sanitizeErrorMessage() truncates to the first line, hiding Google's
metric name and retry hint on lines 2-3. Added a rawMessage field
(internal-only, never reaches the client) and classifyGeminiQuotaMetricFromText()
to classify from the untruncated text, reordered ahead of the generic
credits/daily-quota checks.
Widened comboCooldownWaitEnabled (wait out a short transient cooldown
instead of crystallizing a 429/503) from quota-share-only to also cover
auto-strategy combos, and raised the wait ceiling to 65s/130s-budget/90s-cap
to match Gemini's ~60s TPM/RPM windows.
The per-target timeout (DEFAULT_COMBO_TARGET_TIMEOUT_MS, 120s) was shorter
than the new 130s cooldown-wait budget, so a target could get cut off
mid-wait with a synthetic 524 instead of completing the retry. Added
resolveComboTargetTimeoutMsForCombo()/isComboCooldownWaitEligible() in
comboConfig.ts to raise the per-target floor to budgetMs+buffer only for
wait-eligible strategies (auto/quota-share), verified live: a 12-request
concurrent burst against a TPM-exhausted combo went from 2/12 succeeding
(10 x 524) to 12/12 succeeding with zero 503/524.
Also: liveGeminiShared.ts's sendAndValidate now fails fast on a 503
instead of retrying past it, and the health dashboard + request logger
surface TPM stats alongside RPM/RPD.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): combo-exhausted rejection logs now capture request body + attempted models
recordRejectedRequestUsage() (the fast path for combo requests that never
reach handleChatCore, e.g. all targets locked by resilience cooldown)
hardcoded provider: "-" and never passed a request body to saveCallLog(),
so /dashboard/logs entries for these failures were nearly useless for
debugging: no way to see the client's request or which models were tried.
- recordRejectedRequestUsage() now accepts requestBody and persists it
through the existing saveCallLog() artifact mechanism (same path
handleChatCore's own logging uses).
- Added summarizeComboAttemptedModels(), which reads the combo's own model
list (always available, unlike the response's combo-diagnostics headers —
a model-level resilience-lockout skip never touches the
exhaustedProviders/exhaustedConnections sets those headers are built
from) to populate a real "provider" value instead of "-".
- Wired both into the call site in src/sse/handlers/chat.ts.
NOTE: unrelated to the Gemini TPM/combo-cooldown-wait fix on this branch —
landed here per operator request, to be split into its own branch/PR.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* feat(sse): synthetic streaming keep-alive event + 5-minute Gemini cooldown-wait ceiling
Many clients enforce a first-SSE-byte timeout, which made it unsafe to wait
out a longer upstream rate-limit cooldown on a streaming request — the
client would abandon the connection before any bytes arrived. This landed
in two parts:
1. Synthetic startup "thinking" event (OpenAI chat/completions format):
the already-existing withEarlyStreamKeepalive wrapper (open-sse/utils/
earlyStreamKeepalive.ts, wired into /v1/chat/completions, /v1/messages,
/v1/responses since #2544) opens the SSE stream immediately once a
request runs past its threshold, but only ever sent empty/no-op
keepalive frames. Added a `startupFrame` option (defaults to
`keepaliveFrame` — zero behavior change unless a route opts in) so the
very first frame can carry real content instead. Wired
OPENAI_STARTUP_THINKING_FRAME (a reasoning_content delta: "OmniRoute:
got request, sending to provider") into /v1/chat/completions only —
Claude Messages and Responses API formats both require a preceding
envelope event (message_start / response.created) that a synthetic
pre-dispatch frame can't safely fabricate without risking a duplicate
envelope once the real stream arrives, so those two routes keep their
existing (safe, proven) keepalive frames unchanged.
2. Raised the "wait out a known cooldown, then retry" ceiling to 5 minutes
for both retry mechanisms, now that a client-side first-byte timeout is
no longer a risk on the (opted-in) route:
- comboCooldownWait (auto/quota-share combos, open-sse/services/combo.ts):
maxWaitMs hard clamp raised 90s -> 300s (src/lib/resilience/settings/
normalize.ts); defaults raised to maxWaitMs:90s/maxAttempts:5/
budgetMs:300s. comboConfig.ts's resolveComboTargetTimeoutMsForCombo
already derives the per-target timeout floor from budgetMs, so it
tracks the new ceiling with no further changes.
- waitForCooldown (direct, non-combo model requests, src/sse/handlers/
chat.ts): this mechanism had NO cumulative cap before — only a
per-wait cap (maxRetryWaitMs) and a retry count (maxRetries), so
maxRetries x maxRetryWaitMs could exceed 5 minutes with no ceiling.
Added a budgetMs field (mirrors comboCooldownWait) to
WaitForCooldownSettings/CooldownAwareRetrySettings, threaded a
requestRetryBudgetLeftMs tracker through chat.ts's requestAttemptLoop
(mirrors combo.ts's comboCooldownBudgetLeftMs), and made
getCooldownAwareRetryDecision refuse to wait once the cumulative
budget is exhausted even if the single wait is under maxRetryWaitMs.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): extend the synthetic keep-alive thinking event to /v1/responses
Live incident (OpenClaw, log id 1784407081908-cbc24f): a /v1/responses
request to gemini/gemma-4-31b-it took 56s to produce a first byte and the
client disconnected (499 request_signal_aborted) — the same client-first-byte-
timeout problem the previous commit fixed for /v1/chat/completions, but
/v1/responses only had the generic bare-comment keepalive (no content), so it
wasn't covered.
Added RESPONSES_STARTUP_THINKING_FRAME: a self-contained synthetic reasoning
item (response.output_item.added -> reasoning_summary_part.added ->
reasoning_summary_text.delta -> reasoning_summary_part.done), opened AND
closed within this one frame rather than left dangling — it never carries a
response_id, so it can't collide with the real upstream response's own
independent response.created lifecycle that follows. Mirrors the abbreviated
delta+part.done close pattern open-sse/utils/stream.ts's own
emitSyntheticResponsesReasoningSummary already uses for real mid-stream
reasoning content.
Wired into src/app/api/v1/responses/route.ts via the startupFrame option
added in the previous commit.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): combo cooldown-wait vars reset every setTry, crystallizing a bogus 503 instead of waiting
Live incident (log id 1784416706646-51): a request to the "default" combo
(strategy=auto, maxSetRetries=3) hit a real Gemini TPM 429 on both gemma-4
targets, correctly classified as a short 40s rate_limit lockout — then
crystallized a 503 "all upstream accounts are inactive" in 6.9s instead of
ever reaching the cooldown-aware wait.
Root cause: `lastError`/`earliestRetryAfter`/`lastStatus` were declared with
`let` INSIDE the `for (setTry...)` loop body, so they reset to null at the
start of every set-try. When both targets lock out on setTry 0, every
subsequent setTry (1..maxSetRetries) pre-skips both targets via the
isModelLocked check with no real dispatch — so on the FINAL setTry (the only
one whose values the post-loop decision reads, since it's gated behind
`if (setTry < maxSetRetries) continue`), lastStatus was null, hitting the
"!lastStatus" branch (ALL_ACCOUNTS_INACTIVE 503) and completely bypassing the
comboCooldownWaitEnabled / earliestRetryAfter wait logic — even though a
real 429 with a known ~40s retry-after WAS observed on setTry 0.
This bug predates today's Gemini TPM work (any combo with maxSetRetries > 0
whose targets all lock out on the first pass was affected) but was masked in
existing tests: the "auto strategy (2 models...)" regression test uses
maxSetRetries: 0, so it only ever runs ONE setTry iteration and never
exercises the reset-on-retry path. It also explains why the dedicated
12-concurrent-request burst test passed cleanly — with concurrent requests,
timing variance meant some request's FINAL setTry iteration still had a live
target to dispatch to, giving lastStatus/earliestRetryAfter fresh data. A
single isolated request has no such luck.
Fix: hoist lastError/earliestRetryAfter/lastStatus to just inside
dispatchWithCooldownRetry, before the setTry loop, so they persist across
set-tries (still reset fresh on each recursive dispatchWithCooldownRetry()
call after a wait, which is correct). recordedAttempts/fallbackCount/
exhaustedProviders etc. are intentionally left per-iteration (unrelated to
this bug).
New regression test in tests/unit/combo-quota-share-cooldown-wait.test.ts
reproduces the exact live scenario (2 targets, both lock out on setTry 0,
maxSetRetries: 3) — confirmed red (503) against the pre-fix code, green
(200, waits and retries) against the fix.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* test(sse): extend live Gemini workload to Responses API + add large-context TPM test
Two additions to the live Gemini test suite, both live-verified against the
dev instance:
1. sendAndValidate() (tests/integration/liveGeminiShared.ts) now accepts an
apiFormat: "chat" | "responses" parameter, building the Responses-API
request shape (input array, max_output_tokens) and parsing its SSE events
(response.output_text.delta / response.reasoning_summary_text.delta /
response.completed) via the new readResponsesSSEStream(). Wired into two
new tests in live-gemini-workload.test.ts ([30]/[31]), mirroring the
existing Chat Completions streaming coverage. Verified live: 24/25 + 5/5
payloads succeeded end-to-end through the new code path (the one failure
was a ~300s test-client fetch timeout unrelated to the Responses API code
itself — a separate, not-yet-addressed test-harness limitation).
2. genHugeContextMessage() builds a single message large enough (~4
chars/token estimate) to approach or exceed Gemini's free-tier TPM ceiling
(16000 input tokens/min for gemma-4) by itself. Every other prompt
generator in this file tops out around 1-2k tokens — nowhere near that
ceiling — so none of the existing workload tests ever exercised a REAL TPM
429, only RPM-style rate limiting. tests/integration/gemini-large-context-tpm.test.ts
sends two ~12-13k-token requests back-to-back (comfortably exceeding
16000/min together) to exercise the full path against production Gemini:
TPM classification, the comboCooldownWait retry, and the synthetic
keep-alive frame on a genuinely slow request.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): abandoned combo target dispatch now observes its own per-target timeout, fixing a permanent "pending" dashboard leak
Live incident (dashboard log id 1784418258231-14961a, reported as "an ongoing
request even though there's already a 200"): a combo target dispatch
abandoned by comboTargetTimeoutMs (open-sse/services/combo/targetTimeoutRunner.ts)
left a permanent phantom "pending" entry in the dashboard, even after the
overall combo request had already succeeded via a different retry.
Root cause: chatCore.ts's createStreamController — and everything downstream
that depends on it (withRateLimit's Promise.race against Bottleneck,
acquireAccountSemaphore) — only ever watches clientRawRequest.signal, which
is the ORIGINAL client's request signal (set once via buildClientRawRequest
and reused unchanged across every target dispatch in a combo). It has no
connection to targetTimeoutRunner.ts's OWN AbortController
(target.modelAbortSignal), which is what actually fires when
comboTargetTimeoutMs (300s) elapses. src/sse/handlers/chat.ts's
handleSingleModel bridge between combo.ts and handleSingleModelChat received
`target.modelAbortSignal` but silently dropped it — never forwarded it
anywhere. So when a target got abandoned (e.g. stuck inside a wedged
Bottleneck rate-limiter queue, see the WEDGED force-reset log line from the
same incident), its per-target timeout fired and let the COMBO move on and
retry successfully elsewhere — but the abandoned dispatch's own promise
chain never learned it had been superseded, so it hung forever waiting on a
signal that was never going to fire, and trackPendingRequest(false) (the
finalize call) never ran.
Fix: thread target.modelAbortSignal through as a new modelAbortSignal
runtimeOption, and merge it into clientRawRequest.signal (via the existing
mergeAbortSignals helper from open-sse/executors/base.ts) right before
dispatch, so an abandoned target's own promise chain now observes its abort
and can reach its cleanup path — new resolveDispatchClientRawRequest() makes
this mechanically testable in isolation.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): combo cooldown-wait state recording, rate-limit wedge recovery, OpenAI-format SSE error frames
Five related fixes surfaced by live incidents (dashboard log ids 1784457764961-73,
1784465227489-a2cbc0, 1784504040241-6f8b9a) while validating the Gemini TPM/cooldown-wait
work on this branch against real OpenClaw traffic:
- combo.ts: the model-lockout bail-out branches in dispatchWithCooldownRetry never
recorded lastStatus, so once every target in a set hit an existing lockout the final
check crystallized a bogus ALL_ACCOUNTS_INACTIVE 503 instead of reaching the
cooldown-wait decision, even with a real 429 + short retry-after observed.
- combo.ts/combo/types.ts: the "all credentials cooling down" pre-dispatch rejection
(buildModelCooldownBody) nests its retry hint as error.retry_after/reset_seconds, not
the top-level retryAfter every other 429 shape uses — combo's extraction only read the
latter, so earliestRetryAfter stayed null for this shape even after lastStatus was fixed.
- rateLimitManager.ts: the wedge-recovery watchdog used disconnect(), which releases the
heartbeat timer but never rejects jobs already QUEUED on that instance — orphaned
dispatches hung until the outer ~300s per-target timeout, well past real clients'
patience. Switched to stop({ dropWaitingJobs: true }), safe because the wedge condition
already requires RUNNING===0 && EXECUTING===0.
- earlyStreamKeepalive.ts: the in-band error frame emitted after committing to a 200 SSE
stream was hardcoded to Anthropic's `event: error` convention for every route, including
the OpenAI-format ones (/v1/chat/completions, /v1/responses) where that framing is
either invisible or malformed to a plain data-line parser. Added per-route
OPENAI_CHAT_ERROR_FRAME / OPENAI_RESPONSES_ERROR_FRAME and wired them in.
- chatCore.ts: persisted a synthetic clientResponse error body even when the client had
already disconnected (AbortError) before that body was ever computed — misleading the
dashboard into showing "what the client received" for a response that was never sent.
Also: RequestLoggerDetail.tsx — Provider/Client Event Stream panes lost their collapse
toggle when StreamSection replaced the collapsible PayloadSection (692d6be80, unifying
active/finished request views) without carrying the toggle over.
Each fix has a TDD regression test with a confirmed red-before-green cycle.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* test(sse): free-tier model + gemma-4 TPM-ceiling benchmark harness
Adds a live benchmark comparing free models OmniRoute exposes across
configured providers plus previously-unexercised no-auth providers
(felo-web, aihorde, opencode, duckduckgo-web — none need a connection
row, they were just never tried). Reuses liveGeminiShared.ts's SSE
parsers and CASE_BUILDERS instead of duplicating them.
Also adds a targeted TPM-stress test firing back-to-back large-context
prompts at the gemma-4-31b model across its 3 free hosts (Gemini,
NVIDIA, AI Horde) to isolate whether the documented 16k-tokens/minute
free-tier ceiling is Gemini-specific enforcement or an inherent
model property.
FORCE_TOOL_CHOICE_REQUIRED is a test-only, default-off env flag added
to liveGeminiShared.ts and live-gemini-agentic-loop.test.ts for an
earlier live A/B comparison of tool_choice: required vs unset — kept
as a reusable knob for future runs.
Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>
* test(sse): benchmark for the 2026-07-22 newly-enabled provider batch
Adds NEWLY_ENABLED_MODELS to freeModelBenchmarkShared.ts (Mistral
Leanstral, OpenRouter's live "free"-tagged roster, OpenCode Zen's
current free models — refetched live from
https://opencode.ai/zen/v1/models since the static catalog had
drifted) and a dedicated workload benchmark test for them.
Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>
* test(sse): sync geminiRateLimitTracker tests with e74a1722b's corrected Gemma 4 limits
e74a1722b updated geminiRateLimits.json's gemma-4-* entries from the stale
15/1500/-1 (rpm/rpd/tpm) to the real published free-tier values
16000/14400/16000, but never updated the tests asserting the old numbers.
Surfaced by running the full test:unit suite as a post-rebase sanity check.
Co-Authored-By: Markus Hartung <markus.hartream@gmail.com>
* chore(quality): file-size baseline for own-growth (#8213)
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
Co-authored-by: Markus Hartung <markus.hartream@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(sse): surface OpenRouter mid-stream error chunks instead of a false empty success
OpenRouter (and similar OpenAI-compatible aggregators) can send an HTTP 200
SSE stream whose body carries a chat.completion.chunk with empty choices and
a top-level error object instead of any delta -- e.g. the underlying
provider hitting its own capacity limit mid-request. The Responses-API
response translator's `!chunk.choices?.length` branch treated this exactly
like a legitimate trailing-usage/no-op chunk, so the stream silently ended
with response.completed / error: null / output: [] -- a false "successful
but empty" response masking a real 502 provider_unavailable failure.
Live repro: request 1784726796287-a45bb3 (OpenClaw via the default combo,
nvidia/nemotron-3-ultra-550b-a55b:free via OpenRouter) got HTTP 200 with 0
tokens in/out after Nvidia returned "Worker local total request limit
reached (33/32)" mid-stream; the client saw an empty completed response
with no indication anything failed.
Mirrors the Gemini mid-stream error fix (#4177): set state.upstreamError
from the in-band error chunk so stream.ts's existing upstreamError handling
takes over (sendCompleted emits status: "failed" with a real error object).
Co-Authored-By: Markus Hartung <markus.hartream@gmail.com>
* chore(quality): file-size baseline for own-growth (#8210)
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Markus Hartung <markus.hartream@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(logs): stop an async EPIPE becoming an uncaughtException loop
A raw process.stderr.write into a broken pipe fails asynchronously, so the
try/catch around it never sees the failure. The stream emits 'error'; with no
listener on process.stderr Node re-throws it as an uncaughtException; the
framework's handler logs that through console.error; and the patched console
writes back into the same dead stream. That closes a self-sustaining loop.
Attach an 'error' listener to process.stdout and process.stderr. Node only
converts a stream 'error' into an uncaughtException when the emitter has no
listener, so the listener alone terminates the cycle. Measured in a spawn
harness over 1.5s: 3,387 uncaught exceptions before, 0 after.
Absorb EPIPE only. Attaching a listener otherwise makes every stream error on
those streams non-fatal process-wide, so ENOSPC, EBADF and the rest are
re-raised on a fresh stack to preserve today's crash semantics. The
accompanying test asserts that in a child process, because node:test
attributes any in-process uncaughtException to the running test.
Add a test-only reset() to undo the patched console and the listeners:
test:unit:fast runs --test-isolation=none, so leaked state would reach every
subsequent test file.
Refs #8181
* fix(logs): bound interceptor disk writes and self-heal a missing log dir
Two write-path defects in the same file, both independent of the loop itself.
writeEntry appended with no rate limit, so while the loop spun it wrote
unbounded lines to disk (4.3 GB in 90 minutes in the reported incident). Apply
the same policy #1006 established in structuredLogger -- 50 writes/sec, a 5s
dedup window, a bounded tracking map -- but scoped to `error` entries only.
That scoping is deliberate: structuredLogger applies its limiter solely to
error() and fatal(), whereas writeEntry serves all five of
log/info/warn/error/debug across ~800 non-error call sites. Capping those would
silently drop routine logging from the Console Log Viewer's file. A test
asserts non-error levels stay unlimited.
ensureDir() ran once in initConsoleInterceptor and never again, so a log
directory removed while the process was alive made every later append throw
ENOENT into a bare catch -- console file-logging then stopped permanently with
nothing surfaced anywhere. Recreate the directory and retry once, and report
the failure exactly once through the unpatched stderr so it cannot recurse
through the patched console or become a flood of its own.
Refs #8181
* fix(logs): skip raw stderr writes to a stream already known dead
error() and fatal() write with a raw process.stderr.write wrapped in
try {} catch {}. The comment on that line says the raw write exists to avoid
Next.js console patching "that triggers EPIPE loops" -- but on a broken pipe
the write fails asynchronously, so the catch never sees it, and the resulting
stream error is what ignites the loop.
Skip the write when the stream is already destroyed or ended, falling through
to the file sink as before. The listener added earlier is what breaks the
cycle; this stops the ignition point firing into a dead stream in the first
place. The existing try/catch is retained for the synchronous cases it always
covered.
The #1006 suppression policy and its call sites are untouched.
Refs #8181
* fix(logs): install the stdio guard independently of console interception
initConsoleInterceptor() returns early when APP_LOG_TO_FILE=false, and when the
log directory cannot be created. The stdio 'error' listeners were installed
after that return, so in those supported configurations no listener was
attached at all.
structuredLogger's raw stderr writes still happen there, and an ordinary broken
pipe raises an async EPIPE without destroyed or writableEnded being set first,
so the guard in safeStderrWrite does not cover it either. The loop this change
exists to prevent was therefore still reachable with file logging turned off.
Extract installStdioErrorGuard() and call it before the early return. It is
idempotent and cleared by reset(). A new test asserts, in a child process, that
both listeners are present when APP_LOG_TO_FILE=false.
Also restore APP_LOG_TO_FILE and APP_LOG_FILE_PATH in the test's after() hook.
test:unit:fast runs with --test-isolation=none, so the previous top-level
mutations leaked into later test files, leaving file logging enabled against a
path this file deletes.
Refs #8181
* fix(sse): collapse single-text-part Responses-API content to a plain string
Every /v1/responses request — even the simplest single-string input —
got 500'd by AI Horde's Aphrodite-backed facade. Root cause:
normalizeResponsesInputForChat() always wraps a plain string input as
`content: [{ type: "input_text", text: value }]` (a one-element array),
and openaiResponsesToOpenAIRequest() mapped that straight through to
`content: [{ type: "text", text: value }]` on the Chat Completions side
— an array. That's spec-valid (OpenAI's own API accepts both shapes),
but strict/naive OpenAI-compatible backends like AI Horde's only
implement the plain-string form and reject the array form outright.
A single-text-part array and a plain string are semantically
identical, so collapse is safe. Real multi-part messages (text+image,
text+file) are left untouched.
Regression test: tests/unit/openai-responses-single-text-content-string.test.ts
(RED before the fix — every collapsed-content assertion failed with an
object instead of a string; GREEN after).
Also adds a deeper AI Horde load-test suite (sequential/concurrent/
cross-model/sustained-throughput/new-capable-model-candidates) that
surfaced this bug via real live traffic after Behemoth-X-123B was
temporarily added to the "default" combo for evaluation.
Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): unsupportedParams provider-level fallback for aihorde's live-discovered models
Real OpenClaw traffic against the newly-added Behemoth-X-123B combo
target kept 500ing on every attempt even after the Responses-API
content-array fix landed. The pipeline artifact showed why: `tools`
was still present, unstripped, in the request actually sent to AI
Horde's Aphrodite backend.
Root cause: `unsupportedParams: ["tools", "tool_choice",
"parallel_tool_calls"]` was only declared on the 3 models statically
listed in the aihorde registry entry (Cydonia-24B, Skyfall-31B,
google/gemma-4-31b). AI Horde uses `passthroughModels: true` — its
live worker roster changes constantly — so Behemoth-X-123B, like every
other dynamically-discovered aihorde model, had no model-specific
unsupportedParams entry, and getUnsupportedParams() returned [] for
it. But "the workers run raw text-completion backends" (no tool
calling) is true of every model AI Horde serves, not just the 3
catalogued ones.
Adds a provider-level `unsupportedParams` fallback on RegistryEntry,
checked by getUnsupportedParams() after the per-model lookup misses.
Set on the aihorde entry so it covers its entire live-discovered
roster, present and future, without needing a static per-model catalog
entry for each one.
Regression test: tests/unit/aihorde-tools-unsupported-provider-fallback.test.ts
(RED before the fix — Behemoth-X and deepseek-v4-flash both returned
[] instead of the stripped param list; GREEN after, with a control
case confirming the fallback doesn't leak to unrelated providers).
Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): flatten leftover tool-call history when stripping unsupported tools
Third bug in the same AI Horde/Behemoth-X saga: even after tools/
tool_choice were correctly stripped from the live request (previous
fix), real combo traffic still 500'd. The conversation history itself
carried a prior turn's role:"assistant" tool_calls and role:"tool"
result messages, left over from before the combo failed over from a
tool-capable model (Gemini) to a non-tool-capable one (AI Horde). Its
raw completion backend doesn't understand those message shapes at all,
independent of whether live `tools` is present — confirmed by
reproducing with a role:"tool" message and NO tools param at all.
flattenToolHistory() (open-sse/utils/flattenToolHistory.ts) already
existed for exactly this, fully unit-tested — it just had zero call
sites anywhere in the request pipeline. Extracts the unsupported-params
strip into a small testable module
(open-sse/handlers/chatCore/unsupportedParamsStrip.ts, following the
existing chatCore god-file decomposition pattern e.g.
executorClientHeaders.ts) that now also flattens tool-call history
whenever "tools" was among the stripped params.
Regression test: tests/unit/chatcore-unsupported-params-strip.test.ts
(RED before the fix — the flattening test failed with the raw
tool_calls array still present; GREEN after). All 434 existing
chatcore-*.test.ts tests still pass.
Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): gate tool-history flattening on unsupported, not on stripped-this-request
The previous commit's flattening only fired when "tools" was actually
present-and-stripped on THIS request. A second live reproduction
against AI Horde had no live `tools` param at all — only stale
tool_calls/tool-result messages inherited from before a combo
failover — and still 500'd, because that condition never triggered.
A model that can't do tool calling can't do it whether or not the
current request happens to carry a `tools` array. Gate on the
unsupported-params list itself (unsupported.includes("tools")) instead
of the subset that was actually present-and-deleted this time.
Regression test added to the same file (RED before — the no-live-tools
case left tool_calls/role:"tool" untouched; GREEN after). All 435
chatcore-*.test.ts still pass.
Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): skip tool-incapable combo targets, error clearly on direct requests
Two complementary fixes for a model that structurally can't do tool
calling at all (e.g. AI Horde's raw completion backends) rather than
silently degrading — following up on the earlier strip/flatten fix,
which stopped the crashes but let a tool-incapable target still get
selected and return a 200 that narrates a fake tool call in prose
instead of erroring or being skipped.
1. Root cause, combo routing: getResolvedModelCapabilities()'s
`supportsTools` resolution only checked per-model registry entries,
synced capabilities, and static specs — none of which exist for a
dynamically-discovered model (AI Horde's passthroughModels roster
changes as workers come and go). It fell through to
heuristicToolCalling(), which optimistically defaults to `true` for
any unrecognized model (TOOL_CALLING_UNSUPPORTED_PATTERNS is empty).
Added a provider-level fallback reusing the same unsupportedParams
signal the request-time strip already relies on. This makes the
EXISTING filterTargetsByRequestCompatibility (comboStructure.ts) —
which already correctly excludes non-tool-capable targets when a
request requires tools — actually work for these models; no combo.ts
changes were needed, it was only ever fed bad capability data.
2. Direct/pinned requests: filterTargetsByRequestCompatibility only
protects combo routing. A direct request naming an exact
tool-incapable model has no other target to fail over to — added
checkToolCallingRequiredButUnsupported (chatCore/toolCallingRequiredCheck.ts),
gated on isCombo: false, returning a clear 400 instead of a 200 that
silently can't do what was asked.
Regression tests (both RED before, GREEN after):
- tests/unit/model-capabilities-provider-unsupported-tools.test.ts
- tests/unit/chatcore-tool-calling-required-check.test.ts
All 463 chatcore-*/model-capabilities-*.test.ts and 31 combo
compatibility-filter tests still pass.
Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): correct handleChatCore return shape for the tool-calling-blocked error
handleChatCore's documented contract is `{ success, response, status,
error }`, not a raw Response — returning `new Response(...)` directly
(copied from a different early-return whose surrounding context turned
out not to share this function's top-level contract) produced "No
response is returned from route handler ... Expected a Response object
but received 'undefined'" and a bare 500 with an empty body, caught
immediately when verifying the previous commit live.
Uses createErrorResult() (already used by the adjacent
translation-failure branch a few lines up) instead of hand-building the
Response, matching the same pattern already established in this
function for early error returns.
Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): scope Responses single-text-content collapse to providers that need it
The single-text-part content array -> plain string collapse (added for AI
Horde's Aphrodite facade, which 500s on the array form) was applied
unconditionally to every provider, silently breaking the standard OpenAI
array-shaped content contract that other providers and existing tests
depend on. Added RegistryEntry.requiresPlainStringContent, gated the
collapse on it (true only for aihorde), and threaded modelInfo.provider
through responsesHandler -> responsesApiHelper -> the translator so the
real /v1/responses call site can identify the provider.
Co-Authored-By: Markus Hartung <markus.hartream@gmail.com>
---------
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
Co-authored-by: Markus Hartung <markus.hartream@gmail.com>
* fix(sse): synthesize tool_calls for Gemini's malformed function-call abort reasons
Live incident (dashboard log id 1784489701456-d8c0e9): Gemini terminates a
stream with finishReason MALFORMED_FUNCTION_CALL/UNEXPECTED_TOOL_CALL when its
own parser rejects an attempted tool call — there's no real functionCall part,
only a human-readable finishMessage. gemini-to-openai.ts passed this through
raw as finish_reason (9router#2462's fix, correctly keeping it off a clean
"stop"/Claude end_turn), but a raw "malformed_function_call" isn't one of
OpenAI's 5 documented finish_reason values, so a real OpenAI-format client
(OpenClaw) has no handling for it at all and silently never notices the turn
failed — confirmed live via tests/integration/live-gemini-workload.test.ts's
[28] streaming case after the Gemini TPM/rebase work on this branch.
Fix: synthesize a tool_calls entry (arguments carry the error code + Gemini's
finishMessage, valid JSON) and finish_reason: "tool_calls" instead, routing
the failure into the ordinary "tool call arguments didn't parse" path every
OpenAI-compatible agent loop already handles. Defers to a real tool call if
one already completed earlier in the same turn — the real call wins, no
synthetic entry piles on top of it.
Tests (TDD, each confirmed red-before-green):
- 5 new unit tests in the existing 9router#2462 regression file, covering the
synthesis itself, UNEXPECTED_TOOL_CALL, the real-tool-call-wins edge case,
and no-regression on a clean STOP.
- New fixture (tests/fixtures/translation/gemini-malformed-function-call-stream.json):
the real 6-chunk event series from the live incident, sanitized (personal
paths/URLs replaced with generic placeholders, structure preserved exactly).
- New integration test chains the real translator into the real Responses API
transformer using that same fixture, proving correct behavior on BOTH
/v1/chat/completions and /v1/responses from one shared ground-truth event
series.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* fix(sse): don't drop a malformed tool-call failure when it lands beside a real one
Live incident (dashboard log id 1784589106014-2a42f8), analyzing why the
prior malformed-function-call fix (3568c7259) still wasn't reaching the
client in this case: Gemini can emit a REAL, valid functionCall AND finish
the SAME candidate with MALFORMED_FUNCTION_CALL — the model attempted
multiple tool calls in one turn (here: a real status-check call plus a
malformed "exec"+"cron" multi-call attempt), one parsed cleanly and the
other didn't.
The first fix version skipped synthesizing a failure signal whenever a real
tool call already existed (state.toolCalls.size > 0), on the assumption
that meant the model was retrying a LATER, separate attempt after an
earlier one already succeeded. That's indistinguishable, from the
translator's state, from this same-turn case — so it silently discarded
the malformed attempt's information entirely: the client saw the real call
succeed and never learned the other tool calls were attempted and rejected.
Fix: always synthesize the failure entry when a malformed abort reason is
seen, appending it alongside any real tool call rather than skipping it.
Multiple tool_calls in one response is normal, well-supported OpenAI
behavior (parallel tool calls), so this adds the failure as an additional
entry instead of replacing or hiding the real one.
Tests (TDD, confirmed red-before-green):
- Rewrote the unit test that encoded the old (wrong) assumption to assert
both the real and synthesized calls are present.
- New fixture (gemini-malformed-function-call-parallel-real-call-stream.json):
the real event series from this incident, sanitized.
- New integration tests (same file as 3568c7259's) prove both
/v1/chat/completions and /v1/responses surface both tool calls correctly
from this fixture.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* feat(sse): honor tool_choice when translating OpenAI requests to Gemini
Investigating a live report that gemini-3.1-flash-lite frequently narrates
an intended tool call in plain text instead of actually emitting one
(dashboard log id 1784591483850-49c408 — 9 raw provider chunks, all plain
text, zero functionCall parts, clean finishReason STOP): body.tool_choice
was never read anywhere in the OpenAI->Gemini request translator.
result.toolConfig was unconditionally hardcoded to
{ functionCallingConfig: { mode: "VALIDATED" } } whenever tools were
present, regardless of what the caller sent. VALIDATED lets the model
respond with plain text OR a schema-validated function call at its own
discretion — it never forces a call the way OpenAI's tool_choice:
"required" (Gemini's ANY mode) does, so a caller had no way to compel a
tool call even when explicitly requesting one.
Added convertOpenAIToolChoiceToGemini(), mirroring the existing
convertOpenAIToolChoice() in openai-to-claude.ts for the same OpenAI
tool_choice shapes (string "auto"/"none"/"required", or
{type:"function",function:{name}} to force one specific tool):
- unset/"auto" -> VALIDATED (unchanged default, no regression)
- "required"/"any" -> ANY (forces a call)
- "none" -> NONE (disables function calling)
- {type:"function",...} -> ANY + allowedFunctionNames: [name]
Wired into both Gemini request paths: the direct/base translator
(openaiToGeminiBase) and the Antigravity/Cloud Code envelope
(wrapInCloudCodeEnvelope), which now reuses the base translator's already-
computed toolConfig instead of re-deriving its own hardcoded VALIDATED.
This unblocks (but does not itself resolve) the live question — a
tool_choice: "required" A/B test against gemini-3.1-flash-lite follows to
confirm ANY mode actually changes the narrate-vs-act behavior in practice.
Also updates the T11 any-budget allowlist for this file: the "any" string
comparisons (tool_choice value "any", not a TypeScript type) are the same
documented false-positive pattern already carved out for executors/base.ts.
Tests (TDD, confirmed red-before-green): 9 new unit tests covering all
tool_choice shapes on both the direct and Antigravity/Cloud Code paths,
plus the no-tools and unset-default no-regression cases.
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
* chore(quality): file-size baseline for own-growth (#8211)
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* chore(quality): refresh stale any-suppression count for combo-routing-engine.test.ts
Rebasing onto release/v3.8.49 pulled in upstream's #8008 (prompt-cache
affinity), which added 2 more `any` usages to this test file (269 ->
271). ESLint's suppressions mechanism requires an exact count match —
any drift makes the whole file's suppression stale and reports every
violation as new. Not a violation to fix (pre-existing test-mock any
usage in an upstream commit), just an allowlist count refresh.
Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>
* chore(quality): type the oauth-refresh-dedup test's connection filter instead of any
Upstream #8062 introduced this test file with an untyped `any` filter
callback param, which the strict any-budget lint rule flags as a new
violation (not a pre-existing one to allowlist). Derives the element type
from getProviderConnections' own return type instead of importing/hand-
writing it.
Co-Authored-By: Markus Hartung <markus.hartream@gmail.com>
---------
Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
Co-authored-by: Markus Hartung <markus.hartream@gmail.com>
Dependency-free (stdlib-only) libpcap/Ethernet/IPv4/TCP parser that answers
one question: does OmniRoute or the far end (Caddy, on behalf of whichever
client it's proxying) close the TCP connection first? Dashboard-level 499s
only tell us OmniRoute detected a dropped connection, not which side's FIN/
RST actually landed first -- this settles it from the raw packets.
Handles classic Ethernet and both "Linux cooked" linktypes (SLL/SLL2, what
`tcpdump -i any` produces) since rootless Podman has no host-visible bridge
interface to capture on directly -- the capture instructions in the script
document the nsenter-into-container-netns workaround.
Adds --find to grep every reassembled stream for a literal marker string --
in practice the reliable way to locate one specific request (the
x-correlation-id header isn't echoed on every hop) is dropping a fresh UUID
into an actual chat message and searching for it, then cross-referencing
the matched stream's timing against data/call_logs/<date>/*.json.
Co-authored-by: Markus Hartung <markus.hartream@gmail.com>
* feat(github): refresh Copilot model catalog
* feat(github): refresh Copilot model catalog (gpt-5.6 family)
Dropped the claude-opus-4.6 reinstatement (contradicts #7223/#2821 with no new
evidence; risks a production 400 on /v1/messages). Kept the gpt-5.6-sol/terra/luna
additions, which already exist on the Codex provider.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: backryun <backryun@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
RubyLLM (and other OpenAI-convention clients) embed strict:true/false directly
inside a function tools parameters JSON schema. Gemini/Antigravity rejects the
unrecognized keyword with a 400 ("Unknown name strict ... Cannot find field"),
the same failure class as the existing multipleOf entry. Broke every Chatwit
Captain tool-calling call routed through witdev_antigravity/gemini-*.
Reconstructed onto current release/v3.8.49 tip (preserves #8231 CIVIC_INTEGRITY
exclusion; the author's stale base showed it as a spurious revert).
Co-authored-by: Witroch4 <wital@example.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Scoped to the docs-truthfulness fix: reverted the erroneous INPUT_SANITIZER_ENABLED
default flip (flag is intentionally on-by-default per the #8093 ruling) and dropped
5 unrelated bundled changes. Keeps only the accurate SECURITY.md correction plus the
sanitizerFixtures / security-docs-truthfulness test.
Co-authored-by: rafaumeu <rafaumeu@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
The fast bucket fed every changed tests/unit file to node:test; vitest-only
subdirs (autoCombo) always fail under that runner and redden the train. The
classifier now carries the same {api,auth,…} allowlist as package.json's
test:unit (guarded by a sync test) and stops excluding ui/*.test.ts, which
test:unit does run.
Restrict the auto-combo no-auth (keyless) candidate pool to an allowlist —
opencode + felo — the only keyless backends verified to answer without any
credential on the reference egress (VPS .15). Excluded no-auth providers stay
usable via direct <alias>/<model> calls; they are just no longer auto-routed to.
Guard: tests/unit/noauth-autocombo-allowlist.test.ts.
Docs: add a "works the second you install it" free section near the top of the
README; sync the compression stack count 11 → 12 engines; document 13 env vars
(VNC browser-login knobs + VIBEPROXY_DATA_DIR) in .env.example / ENVIRONMENT.md.
Live AGY model discovery (isDiscoverableAgyModelId + filterUserCallableAntigravityModels)
composing with the #8013 antigravity discovery rewrite. Reconstructed onto the current
release tip (branch was ~977 commits behind); updated the test to the renamed version-cache
API (seedAntigravityVersionCache -> seedAntigravityIde/CliVersionCache) after the fusion.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Scopes the Antigravity 404 model-not-found lockout to the exact model (not the whole
family) so one missing bare model no longer hijacks the family cooldown, plus bare-model
autopick via resolveModelByProviderInference dedup. The thinking-signature-recovery portion
was dropped — #7899 is already fixed and merged on the release via #7906.
Co-authored-by: AndrianBalanescu <AndrianBalanescu@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix: strip internal reasoning placeholder from user-visible content (#8081)
The internal reasoning replay sentinel '(prior reasoning summary unavailable)'
can leak into user-visible assistant content when a model echoes it through
ordinary message.content / delta.content. Existing suppression only checked
reasoning_content fields and reasoning-specific events.
Changes:
- Add stripInternalReasoningPlaceholder() to reasoningPlaceholder.ts —
removes all occurrences of the sentinel and trims; returns '' when
nothing meaningful remains
- Streaming: strip in responsesTransformer.ts, openai-responses.ts, and
openai-to-claude.ts at the delta.content entry point; skip emission
entirely when only the placeholder was present
- Non-streaming: strip in responseSanitizer.ts sanitizeMessageContent()
and sanitizeResponsesMessageContent() (all three text paths)
translateText is unaffected (uses mode='translate' via plain newsClient).
The per-provider reasoning_content check remains as defense-in-depth.
* fix: skip only empty content block on reasoning-placeholder, keep finish_reason/tool_calls (#8081)
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* chore(quality): rebaseline openai-responses.ts own-growth (#8081 guard)
---------
Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
Co-authored-by: Probe Test <probe@example.com>
Co-authored-by: Dingding-leo <Dingding-leo@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Final catalog dedupe pass drops a generic/untyped chat-like sibling row
when a typed non-chat specialty row (audio/video/moderation/...) exists
for the same public id — closing the #4424 follow-up (whisper-1, tts-1,
omni-moderation-latest, elevenlabs/*, veo-free/*). Pure, I/O-free,
order-preserving; also removes a stray raw NUL byte that was embedded in
the dedupe key template literal (which made git render the file binary).
Restores test coverage for relative-order preservation across distinct
ids and for two distinct id-less entries never being grouped, keeping the
suite's assertion count at parity with the pre-fix baseline.
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
* fix(windows): add windowsHide to all child process spawns (#8131)
On Windows, child processes spawned without windowsHide: true cause
transient conhost.exe/cmd console windows to flash open. Audited all
spawn/exec/execFile/execSync/execFileSync call sites and added
windowsHide: true where missing.
Files patched:
- src/mitm/manager.ts (MITM server spawn)
- src/mitm/systemCommands.ts (sudo/system command spawn)
- src/mitm/inspector/systemProxyConfig.ts (execFile wrapper)
- src/shared/services/cliRuntime.ts (CLI spawn + npm execFileSync)
- src/lib/plugins/loader.ts (plugin host spawn)
- src/lib/providerModels/cursorAgent.ts (cursor binary spawn)
- src/lib/cloudflaredTunnel.ts (cloudflared spawn)
Unix-only call sites (shell: /bin/bash, which) are unaffected.
electron/main.js already had windowsHide: true.
* fix(windows): cover remaining spawn sites missed by #8131 windowsHide sweep
Extends the #8131 windowsHide audit to the three call sites the original
sweep missed: ServiceSupervisor.start() and processManager.startProcess()
(both spawn() embedded-service child processes), and
installers/utils.ts::buildNpmExecOptions() (the execFile() options runNpm()
uses to install services). All three now always set windowsHide: true so
no transient conhost.exe/cmd console window flashes open on Windows.
The two spawn() options objects are factored into small, pure, exported
builder functions (buildServiceSpawnOptions, buildCliproxyapiSpawnOptions)
so the regression test can assert on the constructed options directly,
since both call sites use a bare named `import { spawn } from
"node:child_process"` that ESM live-binding semantics make unmockable
without --experimental-test-module-mocks (not currently enabled repo-wide).
Bumps config/quality/file-size-baseline.json for cloudflaredTunnel.ts
934->935 (the PR's own +1 windowsHide line at the existing spawn options
object).
Co-Authored-By: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
Co-authored-by: Probe Test <probe@example.com>
Co-authored-by: Dingding-leo <Dingding-leo@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(combos): expose synced reasoning-effort variants in Combo Builder model picker (#8072)
Synced reasoning-effort aliases (e.g. GLM-5.2-high, GLM-5.2-medium)
appear in the catalog and Playground but were missing from the Combo
Builder's inline model picker. buildModelOptions() added base synced
records but never ran appendSyncedEffortVariants().
Convert synced models with non-empty supportedThinkingEfforts into
catalog-shaped entries, run the shared appendSyncedEffortVariants
utility (preserving its effort normalization, provider exclusions,
suffix-collision handling, and naming behavior), and add any new
variant ids to the builder model map. Variants inherit the base
model's endpoints, context length, output limit, and thinking support.
* fix(combos): correct baseId derivation for synced effort variants (#8072)
appendSyncedEffortVariants sets a variant's own root field to
${baseRoot}-${tier} (still tier-suffixed), not the true base model id.
buildModelOptions() was deriving baseId from variant.root, so the lookup
into modelMap never matched and every <model>-<tier> variant silently
fell back to bare defaults instead of inheriting contextLength,
outputTokenLimit, supportedEndpoints, and supportsThinking from its
base model.
Track each variant's true base raw id directly while iterating tiers
during catalogShaped construction instead of re-deriving it from
variant.root.
Adds a regression test seeding a synced model with
supportedThinkingEfforts via replaceSyncedAvailableModelsForConnection
and asserting the resulting <model>-<tier> variants both appear and
inherit the base entry's metadata through getComboBuilderOptions().
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
Co-authored-by: Probe Test <probe@example.com>
Co-authored-by: Dingding-leo <Dingding-leo@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(responses): close namespace round-trip for Responses-Chat translation (#7936)
The #7905 custom-tool-call path landed in release/v3.8.49 but left #7936
open: Responses namespace sub-tools were flattened to a bare leaf on the
Chat wire with no response-side closure, so Codex's adjudicator rejected
every namespace sub-tool call with `unsupported call` -- it only has a
dispatch entry for the header bits (namespace+name), no entry for the
bare leaf.
This patch closes the round-trip without mutating the Chat wire name
(alignment with #7905's bare-leaf contract and with the issue author's
proposed fix):
* request side (openai-responses.ts): keep tool.function.name as the bare
leaf, populate a side-band namespaceToolIdentityMap keyed on that leaf,
and thread it through translatedBody._toolNameMap.
* request -> response seam (chatCore.ts): extract the identity map before
dispatch and pass it through to the non-stream completion path and to
all three stream pipelines (translate openai-responses, translate
other, passthrough).
* response translator (response/openai-responses.ts): in emitToolCall
(response.output_item.added) and closeToolCall (custom_tool_call /
function_call output_item.done), call resolveRequestToolIdentity() to
rewrite the bare leaf back to {namespace,name} and emit codex-compatible
independent fields.
* passthrough (utils/stream.ts): add a response passthrough rewriter
restoreResponsesPassthroughFunctionCallIdentity that intercepts
response.output_item.added, response.output_item.done, and
response.completed and stamps the same {namespace,name} tuple.
* helper (requestToolIdentity.ts): a 20-line stateless resolver; never
parses a name.
The wire-visible Chat tool.function.name stays the bare leaf -- non-OpenAI
providers (NVIDIA, GLM, Kimi, Gemini, ...) frequently truncate or rewrite
long __-dotted names; bare leaves avoid that failure mode entirely. The
codex ResponseItem::FunctionCall schema (models.rs) declares an
independent namespace: Option<String> field and has a
function_call_deserializes_optional_namespace round-trip test, so
emitting it separately matches the codex adjudicator dispatch.
Includes 19 new test cases across 4 files:
- request-side bare-leaf wire + side-band ledger construction
- response-side tuple emit + unmapped passthrough + apply_patch exclusion
- ambiguous-leaf collision safety (entry dropped, leaf emits verbatim)
- per-request isolation between concurrent streams
- a precompiled Atlassian-style nested namespace override
* fix(responses): skip tool_search_call input items instead of 400 (#7936 addendum)
Codex 0.42+ emits `tool_search_call` (and later `tool_search_result`) input
items when the model uses the dynamic tool-search optimization. They are
metadata-only: they record that the model queried a subset of the
available tools, and carry nothing that OpenAI Chat Completions can
represent.
Without an explicit skip in openai-responses.ts, the input loop threw
Unsupported Responses API feature: input item type 'tool_search_call'
cannot be represented in Chat Completions -- and because these items
stay in the Responses API `input` for every follow-up turn, the whole
server returned 400 on EVERY subsequent /v1/responses in the same
session until the user cleared history.
Observed in the wild:
/v1/responses 400 "Unsupported Responses API feature: input item
type 'tool_search_call' cannot be represented in Chat Completions
[longcat/LongCat-2.0 (400), longcat/LongCat-2.0 (400)]"
The meituan combo (longcat fallback) was the most visible victim, but
the underlying throw is source-format-side and hits any Responses-API
consumer whose upstream does not natively support Responses.
Fix: stop on the item type the same way `reasoning` is skipped --
display-only metadata, no chat side-effect. Covers both
`tool_search_call` and the follow-up `tool_search_result` shapes.
Adds 3 unit tests:
- tool_search_call is silently skipped (no 400)
- tool_search_result is silently skipped
- tool_search_call items interspersed with real messages are skipped
in order; real messages survive
* chore(quality): rebaseline openai-responses.ts + stream.ts own-growth (#7936 namespace round-trip)
---------
Co-authored-by: TonPro <hello@tonpro.fu>
Co-authored-by: RCrushMe <RCrushMe@users.noreply.github.com>
* fix(security): decouple request PII redaction from injection mode
PII_REDACTION_ENABLED now rewrites request PII independently of
INPUT_SANITIZER_MODE, so the enterprise recipe (MODE=block + PII on)
actually redacts. Also cover Responses API string input/prompt shapes
and correct docs that claimed MODE=redact strips injection text.
Refs: #8092#8093#8094#8096#8097
* test(security): drop no-explicit-any in sanitizer unit tests
Unblocks CI lint/quality ratchet on the PII redaction PR by typing
chat-like payloads instead of casting to any.
---------
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Recognize Google RESOURCE_EXHAUSTED responses that include a billing-period reset window while preserving transient rate-limit classification for generic exhaustion messages.
Fixes#8060
persistOAuthConnection gated its whole dedup step behind if(tokenData.email).
The matcher (findExistingOAuthConnectionMatch) already matches by explicit
connectionId first, but it was never reached when the payload had no top-level
email. GitHub Copilot's device-code flow keeps identity under
providerSpecificData.githubEmail, so tokenData.email is undefined — a refresh
(which passes the existing connectionId) skipped the match and fell through to
createProviderConnection, producing a duplicate connection.
- Widen the gate to if(connectionId || tokenData.email) so an explicit
connectionId is honored regardless of email.
- Guard the matcher's email branch with if(!tokenData.email) return false, so a
widened gate can't false-match an email-less connection via
safeEqual(undefined, undefined).
Fixes#8059.
Two accuracy problems in buildCodexUsageQuotas (open-sse/services/codexUsageQuotas.ts):
1. ChatGPT Codex's /wham/usage advertises a latent per-feature ceiling for the
spark feature (metered_feature codex_bengalfox) to accounts by default. A
never-used bucket is unanchored (used_percent 0, reset_after_seconds ==
limit_window_seconds), so it recomputes its reset as now + full_window on
every fetch and was rendered as a permanent GPT-5.3-Codex-Spark row at 100%
for a model the operator never used. Skip latent windows (isLatentWindow);
they reappear once the feature is actually used. The label now comes from the
payload's own limit_name, falling back to the constant.
2. primary_window/secondary_window were labeled session/weekly purely by
position, ignoring limit_window_seconds, so a 7-day primary_window showed
'Session'. The session/weekly keys (routing semantics) stay unchanged; only
the display label is corrected from the real window duration
(windowDurationLabel), so a 7-day window shows 'Weekly'.
Fixes#8051.
Point dashboard provider cards at current Baidu developer landings instead of the deprecated yiyan nag page and the 301ing wenxinworkshop path.
Co-authored-by: LandLord64 <ulofeuduokhai@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Explain release/* as the active cycle, main as the published line, and tags as ship markers so contributors know where to aim PRs.
Co-authored-by: Ulofe Uduokhai <179733406+c4usal@users.noreply.github.com>
Document the failure mode where a leftover hand-made anthropic-compatible /
openai-compatible-chat provider owns the agentrouter/<model> IDs (or is
referenced by a combo), so requests route to it instead of the built-in
agentrouter provider and get rejected with 'unauthorized client detected' or
an HTML error page. Point users at the ROUTING log line to diagnose and steer
them back to the native provider.
One-shot 提供商->提供者 normalization across src/i18n/messages/zh-CN.json
(679 substitutions) and bin/cli/locales/zh-CN.json (54), mirroring #8024's
zh-TW pass. Adds a versioned terminology glossary
(scripts/i18n/glossary/zh-CN.json), a protected-names list
(scripts/i18n/glossary/protected-terms.json), and a pure-function
consistency check (scripts/i18n/check-glossary-consistency.mjs,
npm run i18n:check-glossary) wired into CI as the i18n-glossary-zhcn job.
zh-CN added to the visual-QA harness default locales. Complements the
existing parity (check-ui-keys-coverage.mjs) and ICU (validate_translation.py)
gates without replacing them.