* fix(combo): allow fill-first failover across Codex OAuth connections
applyNativeCodexTurnPin previously narrowed the target pool to the
single pinned connection, making same-provider failover impossible when
the pinned connection was rejected by pre-dispatch checks. Return all
compatible connections (same provider + model) with the pinned connection
first, so the combo engine can fall over to siblings. Also allow
pinNativeCodexTurn to update connectionId for failover recovery while
still rejecting provider/model changes.
Fixes#10379
Signed-off-by: Minxi Hou <houminxi@gmail.com>
* fix(test): replace as any with properly typed ResolvedComboTarget literal
Addresses ESLint no-explicit-any error in tests/.
Signed-off-by: Minxi Hou <houminxi@gmail.com>
---------
Signed-off-by: Minxi Hou <houminxi@gmail.com>
* fix(logging): redact client IPs and account prefixes by default
ProxyEgress and AUTH logs exposed client IPs, egress IPs, and account
prefixes at info level — a privacy leak in multi-tenant/shared-log
environments. Now redacted by default, only shown when debugMode=true.
Fixes#10348
* fix(sse): default SSE comment lines to disabled
Strict SSE clients (WorkBuddy, etc.) JSON.parse every SSE line and
crash on comment lines. Changed OMNIROUTE_SSE_COMMENTS
default from enabled to disabled. Operators can opt in with
OMNIROUTE_SSE_COMMENTS=on.
Fixes#10524
* fix(logging): gate AUTH account-prefix redaction on a narrow flag, not debugMode
The proxy-log redaction half of #10348 is superseded by an already-merged
fix (PROXY_LOG_INCLUDE_IPS, decoupled from debugMode). The remaining gap was
the chat.ts AUTH log line ("Using <provider> account: <prefix>..."), which
this PR gated on the broad `debugMode` setting. `debugMode` is a general
dashboard-visibility toggle unrelated to log privacy — coupling redaction to
it means any future, unrelated change to debugMode's default silently
changes whether account prefixes leak into logs.
Add a dedicated AUTH_LOG_INCLUDE_ACCOUNT_ID feature flag (default off,
security category) and gate the AUTH log line on it via
isFeatureFlagEnabled(), which reads the DB override synchronously on every
call (no stale in-memory cache to invalidate) and fails safe to redacted on
any lookup error.
Also update the SSE-comments tests/docs that still asserted the old
enabled-by-default behavior (tests/unit/sseHeartbeat.test.ts,
tests/unit/sse-comments-optout-9305.test.ts, docs/reference/ENVIRONMENT.md)
to match the new default-off behavior from this PR's earlier commit.
Refs #10348, #10524
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(cli): guarantee non-empty [STARTUP] Fatal log on instrumentation-hook boot throw
Refs #10171: on native Windows / WSL2 boots, an instrumentation-hook throw
during module-load or registerNodejs() leaves the HTTP listener up while
every DB-touching route 500s, with app.log staying completely empty. The
#7773/#7828 guard in ensureDbReadyForBoot only logs one specific failure
class (DB driver init). register() in src/instrumentation.ts now wraps the
boot call in a try/catch at the outermost boundary and unconditionally logs
a "[STARTUP] Fatal: instrumentation hook failed during boot:" line before
rethrowing, so app.log/stdout is never silently empty on a failed boot
regardless of platform or which step threw.
This is a partial diagnostic hardening, not the full fix for #10171 — the
platform-specific root cause on native Windows/WSL2 still needs the
reporter's raw child stderr from a real host (tracked separately, see
_tasks/pipeline/bugs/2-implementing/10171-instrumentation-hook-500-on-windows-wsl.plan.md).
* fix(cli): normalize instrumentation boot errors
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(cli): reuse shared normalizeBootError helper in instrumentation.ts
The outermost instrumentation-hook boot boundary (#10171) was inlining its
own err-instanceof-Error normalization instead of reusing the existing
normalizeBootError() helper already defined in instrumentation-node.ts for
the same purpose (#6560/#7773). Extract it into a dependency-free
src/lib/instrumentationBootError.ts so both instrumentation.ts (which also
loads under the Edge runtime) and instrumentation-node.ts can import it
statically without risking a second failing dynamic import of
instrumentation-node.ts from within the catch block.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Diagnosed while chasing the reused-output-index incident (see
705ac7335 / OpenClaw issue #123342): every call-log artifact showed a
wire-clean response, even for requests that actually failed, because
withEarlyStreamKeepalive injects its startup/keepalive/error frames
directly into the outer response stream, entirely outside the request
handler's own reqLogger. reqLogger.appendConvertedChunk (which
populates pipeline.streamChunks.client) never sees those bytes — only
what chatCore.ts's own SSE writer produced. The persisted artifact was
answering "what did the handler generate," not "what did the client
actually receive," which is the wrong question when diagnosing a
client-visible stream defect.
withEarlyStreamKeepalive wraps the handler's Promise from OUTSIDE its
call tree; the reqLogger it needs to feed is created deep inside
chatCore.ts, after routing/model/provider resolution, and doesn't
exist yet when the keepalive frames are written. The two sides share
no reference — only an identifier, if one is deliberately threaded
through both.
Fix: responses/route.ts now generates a correlationId before calling
handleChat, passes it as handleChat's existing (already-supported,
previously-unused-here) 4th positional arg — which chatCore.ts already
threads into trackPendingRequest's metadata as entry.correlationId,
zero changes needed there — and also into
withEarlyStreamKeepalive's options. The wrapper buffers every direct-
to-client write (startup frame, periodic ticks, in-band error frames)
via the new earlyKeepaliveByteBuffer module, keyed by that same id.
chatCore/attemptLogging.ts, which already has correlationId in scope
right where it assembles the final pipeline payload before saveCallLog,
takes the buffered bytes and prepends them into streamChunks.client in
send order. The verbatim-forwarded real response body is deliberately
NOT re-recorded here — the handler's own reqLogger already captures
that; recording it twice would duplicate it in the artifact.
The buffer is consumed exactly once per correlationId and swept on a
10-minute TTL so a request that never reaches the persist call
(aborted, detailed logging disabled, a route that doesn't opt in)
cannot leak entries forever.
Scoped to /v1/responses only, where the incident actually happened.
/v1/chat/completions and /v1/messages call withEarlyStreamKeepalive the
same way and would need the identical two-line route change to opt in;
left as a follow-up rather than bundled in sight-unseen.
Test plan:
- tests/unit/early-keepalive-byte-buffer.test.ts (new): record/take
ordering, single-consumption, per-id isolation, empty-input no-ops,
unbounded-growth cap
- tests/unit/early-stream-keepalive.test.ts: two new tests — a
correlationId records the startup frame and keepalive ticks but NOT
the forwarded body; omitting correlationId is a true no-op
- tests/unit/attempt-logging-early-keepalive-merge.test.ts (new): real
temp-DB end-to-end proof against the actual persisted call-log row —
early bytes prepended in send order, consumed exactly once, no-op
without a correlationId, gated by detailedLoggingEnabled matching the
existing streamChunks capture gate
- tests/unit/chatcore-attempt-logging.test.ts (existing): unchanged,
still passing — confirms the merge addition doesn't disturb existing
persistence behavior
- 44 passed total across the above plus earlyStreamKeepalive.test.ts,
2 pre-existing skips unrelated to this change
- tsgo --noEmit: clean on all touched files
Kiro's device poll endpoint reports progress in a `status` field (e.g.
`authorization_pending`), but `classifyKiroSocialPoll()` only inspected
`data.error`. This caused every pre-authorization poll to fall through to
the terminal `invalid_token_response` error, making social login impossible
for Kiro AI and Amazon Q via Google/GitHub.
Changes:
- Add `status` field to `KiroSocialPollData` type
- Update `classifyKiroSocialPoll()` to check `data.status` as fallback
for `data.error` when detecting pending states
- Add tests covering `status`-based pending detection and precedence
Closes#10618
* feat(resilience): scope auto-disable banned accounts to subscriptions
Prepaid API keys should stay in the routing pool after a permanent-ban
signal; subscription/OAuth accounts can still be deactivated. Default
scope remains all so existing installs do not change.
* docs(security): document auto-disable scope and log skipped prepaid keys
Keep the operator ban-detection page aligned with the new setting and
reuse the shared scope enum in the settings schema and dashboard radios.
* chore(changelog): name the auto-disable scope fragment for #10617
* docs(settings): treat free login seats as auto-disable targets
The first-cut scope is still all vs login-style auth. Copy now states
that paid subscriptions and free accounts both disable, while prepaid
API keys stay in the pool until per-account overrides exist.
* i18n: backfill autoDisableBannedScope keys across all locales
npm run i18n:sync-ui — the 6 new autoDisableBannedScope* keys landed
in en.json and vi.json but not the other 40 locales (including
pt-BR), tripping the pt-BR no-drift regression test (#6695).
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(auth): add missing state parameter to OIDC authorization URL
The OIDC login route generates a state UUID and stores it in the
oidc_state cookie, but never includes it in the authorization URL.
This causes the OIDC callback to receive state=null, failing with
'oidc_error=missing_code' because the provider has no state to echo.
Add url.searchParams.set('state', state) after setting scope, so the
state parameter is sent to the OIDC provider and returned in the
callback for proper CSRF protection.
* test(auth): add regression coverage for OIDC login state parameter
Adds a TDD regression test proving the fix in this PR: the OIDC login
route now includes the state query parameter in the authorization
redirect URL, and it matches the oidc_state cookie value set on the
same response. Modeled on tests/unit/oidc-callback.test.ts.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(auth): use originEarly for OIDC err redirects (#10224)
* test(auth): verify OIDC err redirects use proxy origin (#10224)
---------
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(cli): let setup --api-key reach the provider setup path
`bin/cli/program.mjs` declares a program-level `--api-key` (the OmniRoute server
key) and `bin/cli/commands/setup.mjs` declares its own `--api-key` (the provider
key). Commander binds the value to the program-level option, so the subcommand's
`opts.apiKey` was always `undefined` and
omniroute setup --non-interactive --add-provider \
--provider openrouter --api-key sk-...
aborted with "Provider API key is required. Pass --api-key or OMNIROUTE_API_KEY."
— naming the very flag that had just been passed. The documented headless setup
path was unusable; the only way in was `omniroute keys add`.
Fall back to the program-level value in a small exported helper. This also makes
`OMNIROUTE_API_KEY` satisfy the provider key, which the error message already
promised (that env var feeds the program-level option).
Tests cover the real Commander flag shape, the env-var path, and precedence when
both are supplied.
* chore(changelog): use the real PR number for the fragment
* fix(cli): derive machine-id token under plain Node and honor salt rotation
`getCliToken()` destructured `machineIdSync` off `await import("node-machine-id")`.
That module is CommonJS, so under plain Node its exports land on `.default` and the
destructured binding is `undefined`. Calling it threw, the bare catch blanked the
token, and every management request went out with no `x-omniroute-cli-token` header
— silently unauthenticated, 401 on every `omniroute combo` / `usage budget` call.
Resolve the binding the same way `src/lib/machineToken.ts` already does, and read
`OMNIROUTE_CLI_SALT` so the rotation documented in docs/security/CLI_TOKEN.md
actually reaches CLI processes (the salt was hardcoded). The catch now logs instead
of failing mute, per the error-handling convention in CONTRIBUTING.md.
The existing test asserted `token === "" || token.length === 32`, so the blanked
token passed. Tightening it in-process is not enough either: the suite runs under
`tsx/esm`, which resolves CJS named exports and hides the bug. The regression test
therefore spawns plain `node` — the loader the CLI actually runs under.
Both new tests fail on the previous code and pass on this one.
* chore(changelog): use the real PR number for the fragment
Whisper-compatible upstreams pick the decoder from the multipart filename
against an allow-list (flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav,
webm) that has no `opus`, and OmniRoute forwarded the client's filename
verbatim. The same bytes transcribed as `note.ogg` and 400'd as
`note.opus`. Since /v1/audio/speech emits audio/opus for
`response_format=opus`, clients re-uploading their own voice notes hit
this on every round trip.
A `.opus` file is Opus in an Ogg container (RFC 7845), so `.ogg` is a
truthful relabel and is already on the allow-list. Rewrite the extension
in getUploadedFileName, the single choke point feeding
buildMultipartBody.
The OpenRouter STT path had the same root cause with a quieter symptom:
`.opus` matched neither its extension list nor its MIME map, so it fell
through to the "wav" default and announced Opus bytes as WAV. Map both
the extension and audio/opus to its already-supported ogg container.
Fixes#10588
* fix(xai): cap chat history at xAI 800-message limit
xAI returns 413 when messages/input exceed 800 items. Token
compression never fires on a long tool loop that still fits the
context window, so trim at the executor edge after Responses
expansion and drop orphaned tool pairs from the cut.
* chore(changelog): attach PR number to xAI 800-message fragment
* fix(xai): resolve TS2339 generic assignment in capXaiRequestHistory
Drop the T extends Record<string, unknown> generic on
capXaiRequestHistory and type it directly as
Record<string, unknown> -> Record<string, unknown>. Assigning
next.messages / next.input onto a generic T was rejected by
TypeScript even though every call site already passes/consumes a
JsonRecord (= Record<string, unknown>), so no caller relied on the
generic preserving a narrower type.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: mikolaj92 <mikolaj92@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(audio): fall back nested STT models when the prefix provider has no credentials
Bare ids such as deepgram/nova-3 prefix-match the native provider and 400
when that key is missing, even if OpenRouter lists the same model. Retry
the gateway and mention qualified catalog ids in the error.
Closes#10583
* test(audio): scope whisper-1 fallback test to a 2-provider registry
nanogpt was added to AUDIO_TRANSCRIPTION_PROVIDERS (already merged,
unrelated to this fix) with a bare "whisper-1" model id, which now
intercepts findAlternateAudioProvider's first candidate before the
qualified-alias branch this test exists to cover. Scope the test to a
local {openai, openrouter} registry subset so it deterministically
exercises the qualified `${provider}/${model}` fallback regardless of
future providers that also list a bare "whisper-1" id.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* feat(providers): complete Jina AI via OmniRoute including Omni multimodal
Dashboard and env keys share one Jina credential pool, native v5 Omni
{text}/{image}/{content} docs pass through /v1/embeddings intact, and
classify/segment/search are proxied without a third unused Jina card.
* chore(changelog): name Jina complete-provider fragment for #10581
* feat(providers): make Gemini Embedding 2 multimodal work via OmniRoute
Route gemini-embedding-2 through embedContent/batchEmbedContents so N
OpenAI input items become N vectors, pass through native multimodal
parts, and use dashboard Gemini keys (GEMINI_API_KEY only as fallback).
* fix(providers): resolve rebase fallout for Jina/Gemini embeddings
- narrow the two new no-explicit-any violations introduced by this PR
(validateJinaFoundationProvider's params + catch, search.ts's
normalizeJinaSearchResponse data param)
- cast credentials to Record<string, unknown> at the two quota-preflight
call sites in src/sse/services/auth.ts so the new JinaEnvCredentials /
GeminiEnvCredentials union members type-check without loosening the
allRateLimited narrowing used elsewhere in the same function
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Add githubSkillTools to getAllToolDefinitions() so the searchable MCP
catalog matches TOTAL_MCP_TOOL_COUNT, which already counts them. The
GitHub skill tools were registered and counted but missing from the
catalog, so omniroute_tool_search could not surface them.
Adds regression tests at both layers: catalog aggregation and
client-visible discovery via the MCP client.
Co-authored-by: Brandon Bennett <brandonbennett@macbookair.myfiosgateway.com>
* fix(opencode): session stability, free-tier routing, and CLI defaults
- Wire generateSessionId() into opencodeHeaders so x-opencode-session
is a deterministic fingerprint instead of randomUUID() per request,
enabling upstream prompt caching across a conversation
- Thread request body through buildHeaders() so session fingerprint
has access to model, system, messages, and tools
- Default CLI header synthesis to ON (opt-out via false), align
values with 9router proven defaults (opencode/desktop/global)
- Auto-echo listing-valid model names for noAuth providers so
response.model matches /v1/models listing
- Short-circuit free-tier model resolution to opencode provider first
to prevent prefix inference misrouting when catalog is unreachable
* fix(opencode): make free-tier default flip self-consistent + add coverage
PR #10571 flipped OPENCODE_SYNTHESIZE_CLI_HEADERS to on-by-default and
changed the synthesized UA/client/project default values, but shipped
with 2 broken assertions in the existing #5997 regression test and no
coverage for the new session-fingerprinting, free-tier routing, or
noAuth echoModel logic (Hard Rule #18).
- Update tests/unit/opencode-cli-headers-synthesis-5997.test.ts to match
the new on-by-default behavior and new default values; add an explicit
opt-out coverage test so the forward-only path is still guarded.
- Fix 20 further test failures in tests/unit/opencode-executor.test.ts
and tests/unit/refactor-buildHeaders-opencode.test.ts caused by the
same default flip (pin OPENCODE_SYNTHESIZE_CLI_HEADERS=false for the
characterization suites that predate #10571; use a genuinely
CLI-looking UA where the preserved-UA test requires one).
- Fix a real bug found via TDD while adding the mandated free-tier
routing regression test: the big-pickle/*-free short-circuit in
open-sse/services/model.ts checked activeProviders?.has("opencode")
literally, but getActiveProviderSet() canonicalizes every connection's
provider id through resolveProviderAlias(), which rewrites "opencode"
to "opencode-zen" via a manual override — so an active no-auth
opencode connection could never satisfy the check. Now checks both
opencode-family candidate ids. Proven with a test that fails on the
original code and passes with the fix (both connections active with a
stale synced catalog omitting big-pickle).
- Extract the noAuth-provider echoModel aliasing in chatCore.ts into a
pure, directly-testable helper (open-sse/handlers/chatCore/noAuthEchoModel.ts),
matching the existing chatCore god-file decomposition pattern.
- Add regression tests for generateSessionId()-based x-opencode-session
fingerprinting (stable within a conversation, changes on model/message
changes), the free-tier routing short-circuit, and the noAuth echoModel
aliasing.
- Add the changelog.d/ fragment and sync docs/reference/ENVIRONMENT.md's
OPENCODE_SYNTHESIZE_CLI_HEADERS/OPENCODE_USER_AGENT/OPENCODE_CLIENT/
OPENCODE_PROJECT rows to the new defaults.
Does NOT resolve whether flipping OPENCODE_SYNTHESIZE_CLI_HEADERS's
default was the right call, and does NOT touch the separate open PR
#10357 which flips the same flag with a different literal default value
- that decision is left to the maintainer at merge time.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
GET /v1/models listed google/gemini-embedding-001 but omitted
google/gemini-embedding-2 even though that id already returns 3072-d vectors.
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Native gemini-embedding-2 400s with a dead-end credentials error even though
openrouter/google/gemini-embedding-2 already serves 3072-d vectors.
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(vision): preserve high detail for inline images
* fix(vision): scope high-detail image default to OpenCode clients
defaultImageDetail() was applied at prepareUpstreamBody, the shared
upstream-body prep path for every provider and format, not just the
OpenCode path the fix targets. Gate it on isOpencodeClient (the
existing User-Agent/x-opencode-* header signal already used for
bypassDefaultToolLimit at this call site) so non-OpenCode callers keep
the provider's own image detail default. Adds a regression test
covering a non-OpenCode caller against the same opencode-zen provider.
* fix(vision): document and test the global vs OpenCode-only detail scope
The OpenCode-only high-detail default in chatCore/upstreamBody.ts
(defaultImageDetail, gated on isOpencodeClient) forwards the caller's
own image_url.detail and was already correctly scoped in a prior
commit on this branch.
The internal vision-bridge describe self-loop (visionBridgeHelpers.ts)
is architecturally global: VisionBridgeGuardrail runs for every
caller/provider whenever the target model lacks vision support, and
there is no client-identity signal at that layer to gate on. Its
describe prompt explicitly asks the vision model to transcribe visible
text, so requesting "high" detail unconditionally is justified on its
own merits (OCR accuracy), independent of the OpenCode motivation.
Adds a compatibility assertion proving the Anthropic wire-format
branch of the same describe self-loop carries no `detail` field (it
has no such concept) and is therefore unaffected by this default, and
documents the split (OpenCode-only forwarding vs. global describe
default) in docs/security/GUARDRAILS.md.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: rinseaid <rinseaid@rinseaid.net>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(a2a): use a constant-time bearer compare in /api/a2a/tasks
* fix(a2a): drop new Function from tasks-auth test in favor of dynamic import
The regression test for the constant-time bearer compare loaded tokensMatch
and authenticateA2A by regex-extracting their source and eval'ing it via
new Function, which trips the repo's no-new-func/no-implied-eval ESLint
rules (error-level everywhere, including tests). Export both helpers as a
test seam from the route module (mirrors the existing
bridgeSecretMatches/authRouteInternals pattern) and import them directly
in the test instead. Also drops the now-unused eslint-disable directives.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* feat(providers): optional AI Horde API key and live image catalog
Allow a registered Horde key on the no-auth connection and send it for
chat and image jobs. List only image models that currently have workers,
and generate through Horde's native async API.
# Conflicts:
# open-sse/config/imageRegistry.ts
# src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx
# src/shared/constants/providers.ts
# src/sse/services/auth.ts
* fix(providers): validate AI Horde keys against find_user
The OpenAI-compatible /v1/models probe returns 200 for any Bearer token
on oai.aihorde.net, so Check always succeeded. Use Horde's /v2/find_user
lookup instead; an empty key still counts as the optional anonymous path.
* chore(changelog): name the AI Horde fragment for #10542
* fix(images): harden AI Horde optional-key selection and outbound fetches
- Optional-key selection now honors connection health (rate-limit cooldown
and terminal/unavailable test status) before handing a stored key back,
rotating to the next healthy key or falling back to the anonymous no-auth
path instead of using an unhealthy stored key.
- Route the Horde submit/check/status/cancel and catalog calls through the
repository's bounded outbound-fetch helper (timeout, no more bare fetch())
and route R2 image downloads through the established bounded remote-image
fetch (SSRF host guard, DNS-rebinding pin, streaming byte cap, redirect
limit) instead of an unbounded fetch().
- Extend the generation deadline to cover the full request lifecycle
(catalog freshness check, submit, polling, and image download), and add a
regression test proving that exceeding the deadline issues a DELETE
cancel to Horde's API rather than only timing out locally.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: pqr <pqr@soraka.ititti.es>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(sse): clear quota_exhausted cooldown when real window recovers
The claude-token-fallback combo was not auto-returning to Sonnet/Opus
after a subscription 429 recovered. maybeClearRecoveredQuotaState()
was honoring the synthetic 1h cooldown (SUBSCRIPTION_QUOTA_COOLDOWN_MS,
persisted when no upstream reset was parseable) instead of the REAL
per-window resetAt returned by the scheduled quota poller, so the
connection stayed locked long past the actual quota reset.
Add windowStillExhaustedAfterRealReset() and use it to decide recovery
per-quota-window: a quota_exhausted connection now clears as soon as no
governing window is still exhausted with a future-or-unknown real
reset, instead of waiting out the synthetic cooldown. Falls back to the
previous synthetic-cooldown guard when the fetch has no quota object at
all (degraded/failed shape) so existing behavior is unchanged there.
Preserves the existing kimi-coding partial-refresh semantics: an
exhausted window with no parseable resetAt still blocks recovery.
* fix(sse): preserve Claude extra-usage block from general quota recovery
maybeClearRecoveredQuotaState()'s new per-window recovery check (added in
this branch) only inspected usage.quotas, so a Claude connection blocked by
the extra-usage guard (lastErrorSource: "extra_usage") could be released
just because the session/weekly quota windows looked recovered, even while
extraUsage.queued was still true. Extra-usage blocking is orthogonal to
quota-window exhaustion and must only be released by
syncClaudeExtraUsageStateIfNeeded (buildClaudeExtraUsageConnectionUpdate).
Add a guard that keeps the connection locked when lastErrorSource is
"extra_usage", the blockExtraUsage policy is still enabled, and the fresh
usage snapshot still reports extraUsage.queued === true.
Add an integration test walking the real
fetchLiveProviderLimitsWithOptions -> syncClaudeExtraUsageStateIfNeeded ->
maybeClearRecoveredQuotaState call chain with recovered quota windows but
extraUsage.queued=true, asserting the connection stays unavailable with
lastErrorSource still "extra_usage".
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(settings,auth): default debugMode to false and skip account rotation on model-unsupported 400
* fix(auth): disambiguate model-unsupported from auth-credential 400
The model-unsupported guard used MODEL_ACCESS_DENIED_PATTERNS directly,
which also matches auth-credential errors like 'invalid api key for
model X'. Add the AUTH_CREDENTIAL_ERROR_PATTERNS exclusion (same as
checkFallbackError) and use provider_model_unsupported log reason.
Addresses maintainer feedback on PR #10525
Signed-off-by: Minxi Hou <houminxi@gmail.com>
* fix(auth): narrow model-unsupported guard to avoid misclassifying account-scoped entitlement 400s
The #10460 guard reused MODEL_ACCESS_DENIED_PATTERNS directly, which also
matches ambiguous "access"/"permission" phrasing (e.g. "does not have
permission to access this model") that commonly signals an ACCOUNT-scoped
entitlement gap (PRO vs free tier) rather than a genuinely provider-wide
unsupported model — a different account of the same provider may still
have access, so those must keep rotating normally instead of being
short-circuited.
Extract isProviderModelUnsupported400() in accountFallback.ts: reuses the
same AUTH_CREDENTIAL_ERROR_PATTERNS exclusion checkFallbackError's 400
branch already applies, narrowed to a strict subset of unambiguous
"provider does not serve this model at all" phrasings. auth.ts now calls
this shared helper instead of testing the broader patterns in isolation,
and exposes the sanitized reason ("provider_model_unsupported") on the
returned result, not just in the log line.
Also fix DATA_DIR test-isolation ordering in
account-fallback-service.test.ts: it was assigned after the first
dynamic import of accountFallback.ts, which transitively imports
src/lib/db/core.ts (DATA_DIR is captured once at module-load time), so
the intended isolated test directory was silently never used. Move the
assignment before any transitive DB import, and add regression tests for
the 3-account rotation contract: exactly one upstream call for an
unambiguous provider-wide 400 with the combo advancing to the next
target, continued rotation for account-scoped 401/403/429 and for the
permission/entitlement 400 case that motivated this narrowing.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(providers): test token-backed web sessions
* fix(providers): restrict token-web-session test dispatch to validated providers
Narrow shouldUseApiKeyConnectionTest to the token-kind web-session providers
that actually have a token-aware connection validator (deepseek-web, kimi-web,
tinycms-web, copilot-m365-web, copilot-web, zai-web). WEB_SESSION_CREDENTIAL_REQUIREMENTS
marks more providers as kind: "token" than have a matching validator in
SPECIALTY_VALIDATORS (hailuo-web, microsoft-designer-web, t3-chat-web, promptql) — those
were falling through to the generic cookie-based validateWebCookieProvider probe, which
sends the stored credential as a Cookie header and treats most non-401/403 responses as
valid, so an invalid token could be reported as a healthy connection.
Add regression coverage for hailuo-web and promptql (plus microsoft-designer-web and
t3-chat-web) proving they stay off the API-key test path, and for every currently
validated token-kind provider proving they still use it.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(dashboard): count live usage_history rows in Free Tier 'used this month' (#10381)
* fix(dashboard): use an indexable UTC month-range predicate for used-this-month (#10381)
sumUsageTokensThisMonth() filtered usage_history with
substr(timestamp, 1, 7) = strftime('%Y-%m', 'now') — a substr() expression
SQLite cannot use a range index on, and fragile against any timestamp
that isn't exactly ISO-shaped. Replace with an indexable inclusive-start/
exclusive-end UTC range: timestamp >= <month start> AND timestamp <
<next month start>, matching the ISO 8601 format saveRequestUsage()
already writes.
Adds a boundary regression test: the first instant of the current month
is included, the last instant of the previous month is excluded, and a
next-month row is excluded too (covers the upper bound substr() could
never express).
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(combo): surface context-overflow before compression so oversized requests fail fast with a clear error (#10225)
* fix(combo): make context-overflow deferral target-aware for native Codex passthrough (#10225)
The deferral added by the prior commit checked only operator-named
compression exclusions when deciding whether at least one target "can
compress" — it never accounted for native Codex Responses passthrough
targets, which chatCore.ts unconditionally excludes from compression
(compressionExcluded = nativeCodexPassthrough || ...). Deferring on such
a target's account let an oversized request skip both the combo preflight
AND compression, reaching fetch() uncompressed.
Thread the same request-shape facts chatCore.ts uses
(shouldUseNativeCodexPassthrough: provider/sourceFormat/endpointPath/body/
headers) down into getKnownContextOverflow so the deferral decision can
never drift from chatCore's own — a native-codex-passthrough target now
never counts as "compressible", so a pool made only of such targets keeps
the fast local 400 instead of a wasted round trip.
Adds regression coverage: the pure getKnownContextOverflow target-aware
check, an end-to-end handleComboChat proof that a native-codex-only pool
fails fast with zero dispatches, and two real handleChatCore-path tests
proving compression actually reduces the dispatched body when eligible,
and that a still-too-large-after-compression request is rejected locally
without an upstream call.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(resilience): keep combo quality and auth reasons separate and redact connection labels in terminal errors (#10314)
* fix(resilience): sanitize identifiers in error text, add explicit terminal-status policy, fix classifier ordering (#10314)
Four gaps in the prior combo-error-aggregation fix:
- formatComboOutcomes() only redacted connection identifiers in the model
label, never in the raw upstream error TEXT — a proxy echoing a
connection/account id back in its error body leaked it into the
client-facing terminal message. Redact both.
- The terminal HTTP status was still `lastStatus` — whichever target
happened to fail last, independent of the other targets' reasons. Add
resolveComboTerminalStatus(): preserve a 4xx only when every eligible
target's failure is genuinely "the request is invalid" (model-class);
a heterogeneous mix (e.g. a quality failure + a sibling's 401) now
normalizes to a 5xx-class status reflecting an infra/provider problem,
never a misleading client error borrowed from an unrelated target.
- classifyComboOutcome()'s ordering had `status === 408 || status >= 499`
checked before `status >= 500`, making the provider branch permanently
unreachable — every real 5xx (500/502/503/504) was silently mislabeled
as "timeout". Fixed to an exact match (408/499) and gave 429 its own
explicit `rate_limit` kind instead of falling into the generic "model"
(request-invalid) bucket by accident.
- Added an integration-level regression driving the real handleComboChat
wiring end-to-end (quality failure + sibling 401, and a success-after-
quality-failure case), not just the pure aggregation helpers.
Updated three pre-existing tests whose assertions encoded the OLD
last-writer-wins contract this fix intentionally supersedes (#8486 Part B
antigravity retryAfter tests, two combo-routing-engine status/message
tests) to the new, more precise contract; verified the underlying #8486
concern (wrong target's retryAfter header) is still honored under the new
status policy.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(providers): fall back to public Code Suggestions endpoint on GitLab Duo direct_access 401 (#10365)
* fix(providers): extend GitLab Duo 401 fallback to the connection-test path (#10365)
The chat-completion path (open-sse/executors/gitlab.ts) already falls back to
the public Code Suggestions completions endpoint when the direct_access
exchange is rejected with 401, but testOAuthConnection() / the dashboard
Retest button still reported the connection unhealthy on the same 401 —
even though a real chat request through that connection would have
succeeded via the fallback. Apply the identical fallback contract to the
connection-test path (first attempt and the post-refresh retry), sharing the
predicate with the executor via shouldFallbackToPublicCodeSuggestions.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(compression): add i18n support for less-code and terse-prose
Translates less-code output style to pt-BR, vi, ja, and id. Adds missing vi translation to terse-prose caveman mode. Removes less-code from English-only allowlist and updates matrix tests.
Fixes#10426
* docs(compression): add output styles coverage table
Adds the requested Output Styles matrix to the compression guide covering styles, supported languages, and intensity levels.
Fixes#10426
* fix(cli): recognize {connections} envelope from /api/providers
GET /api/providers returns {connections, total} (src/app/api/providers/
route.ts:78), but `omniroute test --all-providers` and `omniroute oauth
providers` both parsed the response as `data.providers ?? data.items ??
data` -- an object, not an array -- so `.filter` threw
"(data.providers ?? data.items ?? data).filter is not a function" on
every call. keys.mjs already had the correct fallback chain
(`data.keys || data.connections || data.items || data`); apply the same
`connections` field to both remaining call sites.
* test(cli): cover provider connections envelope
Add regression coverage for both CLI consumers of the /api/providers connections envelope.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(memory): auto-check Qdrant health on mount and stop false-red badge
The Qdrant engine card on /dashboard/memory?tab=engine showed a red
"Error" badge after every page refresh even when Qdrant was healthy:
the badge derives its state from a health check, but the mount effect
only fetched settings + embedding models — health started as null and
the render treated `health?.ok` (undefined) as a failure. Clicking
"Test connection" (which runs the same server-side /readyz check)
immediately turned it green, proving the connection was fine.
Two changes:
- Auto-run the health check on mount once settings load and Qdrant is
enabled, so a refreshed page reflects the real state (verified live:
/api/settings/qdrant/health returns ok:true in ~2ms on a healthy
compose deployment).
- While health has not been checked yet (null), render a neutral gray
"Testing..." state instead of red — red is now reserved for an
actual failed health check.
Regression test added (fails on the old code): with enabled settings
and a healthy mock, the card must hit /api/settings/qdrant/health on
mount and show statusActive, never statusError.
* chore(changelog): fragment for #10489
* Merge branch 'release/v3.8.50' into fix/qdrant-health-badge
* test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base
- alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod
(2026-08-16 16:00 UTC) is in the past, making every quota entry classify
as expired/not_capable; bump to 2028-01-01 UTC so the text/merge
classification tests exercise the intended path again.
- optional-transformers-dependency.test.ts: onnxruntime-node pin assertion
updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403);
the regular-not-optional intent is unchanged.
* test(fix): align optional-transformers-dependency with onnxruntime ~1.24.3 pin (base #10543)
* docs(fix): sync 150-migration count and document PROXY_LOG_INCLUDE_IPS (base drift #10348/#10507)
* fix(memory): re-check Qdrant health after saving settings
save() optimistically flipped enabled and started the PUT while the mount
effect could immediately GET /api/settings/qdrant/health against the OLD
persisted settings. If that GET won, it returned not_configured/failed and -
because health was non-null - the effect never retried after the PUT
succeeded, leaving a healthy Qdrant red until a manual Test connection.
Invalidate health (generation counter + setHealth(null)) at save start and
after a successful PUT, then explicitly schedule a fresh check: setting
health to null alone is not enough, React bails on the no-op when health is
already null (the exact GET-wins ordering). Stale responses are dropped via
the sequence guard so an in-flight pre-save check can never overwrite the
post-save result. Adds a regression test covering enable ordering.
Addresses PR #10489 review finding (issuecomment-5312271806).
* fix: narrow omniglyph transform result union (merge base aa912c42a typecheck gate)
* test(compression): align contract tests with base aa912c42a merge (providerTransport shape, engine metadata)
* fix(memory): silence set-state-in-effect on Qdrant auto health-check
The health-check re-check fix (3469234) introduced an effect that calls
checkHealth() (an async fetch that eventually calls setState) directly
from a useEffect gated on loading/enabled/health. The
react-hooks/set-state-in-effect rule flags this as a potential cascading
render, matching the same pattern already accepted elsewhere in the
dashboard (FreePoolTab.tsx, ConnectionsTable.tsx) for gated async
data-fetch effects. Suppress with the established inline convention;
no behavior change.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(memory): drop unused set-state-in-effect disable (rule inert on pinned react-hooks 7.0.1)
The eslint-disable-next-line for react-hooks/set-state-in-effect is unused:
eslint-plugin-react-hooks@7.0.1 (lockfile-pinned) does not report this rule,
so the directive itself was flagged as a warning and the 'No new ESLint
warnings' CI gate failed with --max-warnings 0. The effect body only calls
checkHealth() (async fetch) with no raw setState, so no disable is needed.
* ci(quality): sync ratchet configs to release/v3.8.50 (0a74bfbde) merge
- re-freeze open-sse typecheck baseline at merged-tree live counts
(64 stale entries dropped, 11 frozen; base video/usage drift covered)
- register tests/unit/video-bridge-drilldown-route.test.ts in stryker tap.testFiles
- regenerate skills/cli-contexts/SKILL.md (contexts migrate docs from CLI closure)
---------
Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(sse): downgrade adaptive thinking and gate context-1m beta on model eligibility (#10119)
* fix(sse): thread resolved model into DefaultExecutor's anthropic-beta merge (#10119)
DefaultExecutor.buildHeaders() merged the client-negotiated anthropic-beta
header without ever passing the resolved target model into
mergeClientAnthropicBeta(), so the context-1m-2025-08-07 eligibility gate
added earlier in this PR could not see which model a combo/fallback had
actually routed to at this call site. buildHeaders() now accepts an
optional model parameter (mirroring BaseExecutor.buildHeaders' existing
signature and the pattern already used by grok-cli.ts/qoder.ts) and
forwards it through, so an ineligible model target (e.g. Haiku) has the
beta dropped instead of forwarded blind.
Restores a CHANGELOG bullet (PR #10366) that a prior merge auto-resolve
had dropped from this branch.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(admission): resolve adaptive latency-collapse self-lock with solo-progress and idle recovery (#10111)
* fix(admission): refresh recovery ceiling on updateConfig (#10111)
updateConfig() clamped currentLimit to the new min/maxLimit but left
recoveryCeiling pinned to the value computed at construction time, so
a raised initialLimit could never recover past the stale ceiling and
a lowered one could leave the ceiling above the new maxLimit.
Recompute recoveryCeiling from the new initialLimit on every
updateConfig call, clamped to the (possibly also new) min/maxLimit.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(usage): wire agentrouter balance quota into dashboard Quota UI (#10078)
* fix(usage): render AgentRouter wallet balance as USD in the Quota UI (#10078)
The prior fix wired AgentRouter's balance into getUsageForProvider() and
USAGE_SUPPORTED_PROVIDERS, but the actual dollar figure never reached the
Dashboard Quota UI: quotas.balance.remaining carried a synthetic two-state
percent (100/0) instead of the real dollarBalance, and the Provider Limits
renderer only formats a row as "$X.XX" when isCredits/currency/creditCount
are set, which the generic quota-parsing path never sets. A configured
balance rendered as a bare "100% left" percentage, not USD.
Shape quotas.balance.remaining as the real USD amount (clamped to 0) and add
an agentrouter branch to quotaParsing.ts that builds a credits-style row
(same buildCreditsQuota() pattern as DeepSeek/Claude extra-usage), so a
configured balance shows a currency-formatted dollar amount and an
exhausted balance always renders as exactly $0.00.
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
* fix(providers): resolve combo names on /v1/audio/speech and /v1/videos/generations
`GET /v1/models` advertises combos with `owned_by: combo`, and chat, embeddings,
transcriptions (#9134) and images (#8986, #9239) all resolve those names. Speech
and video did not: both rejected a combo name at model validation, before any
resolution could happen.
POST /v1/audio/speech {"model":"my-combo","input":"hi"}
-> 400 Invalid speech model: my-combo. Use format: provider/model
POST /v1/videos/generations {"model":"my-combo","prompt":"a cube"}
-> 400 Invalid video model: my-combo. Use format: provider/model
A client picking a model out of /v1/models therefore could not tell which
entries the catalogue would actually accept, and callers ended up hardcoding
vendor ids for these two routes while using combo names everywhere else.
Both routes now mirror the images route: detect a combo name before the
provider lookup and divert to a strategy executor. The two new executors follow
imageCombo — expand targets with resolveComboTargets(), filter to targets the
route can actually serve, walk them in priority order, and return the first
success or the last failure, with 400/401/403 treated as terminal.
Two details differ from the image strategy:
Speech filters at model level rather than provider level. parseSpeechModel()
resolves a provider prefix without checking that the model behind it can speak,
so `openai/gpt-4o` would otherwise be accepted as a target and fail only once
dispatched. The filter now checks the provider's own model list, and keeps
targets from dynamic provider nodes that do not enumerate models.
Speech also returns the handler's Response untouched instead of building a JSON
body, because that route streams audio; only the ADD-only meta headers are
attached, exactly as the direct path does. The failure branch is the only place
the body is read.
successfulMediaGenerationResponse() gains optional `strategy` and
`fallbackAttempts` so the video strategy can report them the way imageCombo
does, rather than duplicating the cost calculation. Both are omitted on the
direct single-model path, where neither is meaningful.
Tests mirror tests/unit/combo/image-combo.test.ts for both routes: combo not
found, no capable targets, empty combo, and targets present with no provider
connection. 16/16 pass across the three combo test files.
* fix(providers): preserve local overrides, custom models and per-target prompt rules through video combo dispatch
executeVideoCombo() diverged from the direct /v1/videos/generations route in
three ways: it dropped the ComfyUI-style local-override credential lookup for
authType:"none" targets, its capability filter only matched the built-in
video registry (skipping custom OpenAI-compatible provider nodes tagged with
the "videos" endpoint), and the route validated the prompt against the
unresolved combo name before combo targets were expanded — rejecting
prompt-optional I2V targets that never got the chance to opt out.
Extracts the shared resolution rules (resolveVideoModelTarget,
isVideoPromptOptional, resolveLocalOverrideCredentials) into
src/app/api/v1/_shared/videoModelResolution.ts so the direct route and the
combo executor apply identical rules, moves the combo-name diversion ahead of
the prompt-required check so validation runs against the real resolved
target, and adds per-target prompt validation inside the combo loop so a
missing prompt only rules out that target instead of the whole combo.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(dashboard): send periodic WS heartbeat pings to stop live-dashboard reconnect churn
The live-dashboard WS client (src/hooks/useLiveDashboard.ts) only sent a
subscribe frame on open and never emitted the protocol's { type: "ping" }
heartbeat. The server (src/server/ws/liveServer.ts) refreshes client
liveness only from inbound messages and terminates any client idle past
HEARTBEAT_TIMEOUT_MS (35s), so a healthy, connected-but-idle dashboard
client was force-terminated roughly every 35-45s, causing constant
reconnect churn (#10319).
Fix (both directions, per the analyzed plan):
- Client: start a 15s ping interval on open, cleared on close/unmount/
reconnect, so the connection stays inside the server's liveness window.
- Server (defense in depth): the outbound heartbeat pong now also bumps
client.lastActivity, so even a third-party client that never pings is
not dropped for being idle.
Regression coverage:
- tests/unit/useLiveDashboard-heartbeat.test.tsx: fast fake-timer check
that the hook emits periodic ping frames and cleans up the interval on
close/unmount (no leaked timers).
- tests/integration/live-ws-heartbeat-keepalive.test.ts: real WS-server
integration test asserting a silent-but-subscribed client stays
connected past the 35s heartbeat timeout (~50s window), converted from
the plan file's TDD RED repro.
Closes#10319
* fix(dashboard): stop renewing stale LiveWS sockets
Keep application-level heartbeat responses from refreshing server liveness, and add a regression covering silent stale sockets alongside clients that answer protocol heartbeats.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>