Commit Graph

5619 Commits

Author SHA1 Message Date
diegosouzapw
db1f435f55 fix(security): remove quadratic trailing-slash trim in Alibaba endpoint normalization
CodeQL js/polynomial-redos (alerts #765/#766).

`/\/+$/` has no left anchor, so the engine retries the match at every
start offset and each attempt re-walks the whole slash run before failing
`$` — O(n^2) on a connection baseUrl made of many slashes. Measured on
the vulnerable code: 10k slashes = 102ms, 30k = 968ms, 60k = 4028ms
(clean quadratic); through the public resolvers the same input took 22s.

providerSpecificData.baseUrl is operator-supplied config and reaches the
trim via isFamilyPresetUrl() -> normalizeEndpoint() and both resolve*Url()
helpers, so the input is reachable.

Replaces all three identical occurrences with a linear charCodeAt scan.
CodeQL only flagged two of them; the third (resolveAlibabaProviderModelsUrl)
carries the same defect and is fixed here rather than left behind.

Behavior is unchanged: every trailing slash is still removed (not a bounded
subset), interior slashes are preserved, and an all-slash string still
collapses to empty — asserted by the new behavior test.
2026-07-23 16:56:55 -03:00
diegosouzapw
c525a0f452 Add SVG flags for various countries
- Added Sweden flag (se.svg)
- Added Slovakia flag (sk.svg)
- Added Thailand flag (th.svg)
- Added Turkey flag (tr.svg)
- Added Taiwan flag (tw.svg)
- Added Tanzania flag (tz.svg)
- Added Ukraine flag (ua.svg)
- Added United States flag (us.svg)
- Added Vietnam flag (vn.svg)
2026-07-23 14:55:50 -03:00
NOXX - Commiter
27f0ad0db0 fix(notion-web): use Chrome TLS impersonation for runInferenceTranscript (#8159)
Node/undici fetch is rejected by Notion's edge with HTTP 200
temporarily-unavailable and empty assistant text (messages appear in the
thread, UI shows 502 No response from Notion AI). The same cookie and body
succeed via curl/Schannel and a browser Chrome JA3 handshake.

Route inference through tls-client-node (chrome_146), matching Claude/
Perplexity web providers. Also detect nested patch-start error objects so
operators see temporarily-unavailable instead of a misleading empty-body
502, and treat that subtype as retryable.

Verified live: notion-web/fable-5, hyperagent/fable, and
promptql/vertex-claude-fable-5 all return PONG through the packaged
backend; unit tests 83/83.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 12:17:58 -03:00
Ravi Tharuma
08a21bcf27 fix(backend): add structure-aware chat admission (#8296)
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 11:59:20 -03:00
Sean Ford
14468cdec5 fix(bifrost): send v-prefixed transport version, not bare semver (#8194)
* fix(bifrost): send v-prefixed transport version, not bare semver

bifrost.ts resolveSpawnArgs() set BIFROST_TRANSPORT_VERSION straight from
getInstalledVersionSync(), which reads the raw "version" field out of
node_modules/@maximhq/bifrost/package.json - always bare semver per npm
convention (e.g. "1.6.3").

@maximhq/bifrost's own bin.js validates that env var against
/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ or the literal "latest" and rejects
anything else with "Invalid transport version format", exiting immediately.
Every embedded Bifrost instance failed to start as a result.

Adds formatTransportVersion() to normalize at the call site that owns the
env var, so bifrost.ts and the upstream @maximhq/bifrost package both stay
exactly as designed - no changes needed to bifrost itself.

Strengthens the existing resolveSpawnArgs test to assert the actual v-prefix
format (it previously only checked for a non-empty string, the same gap
#6877 called out for cliproxy's pre-existing test), and adds a dedicated
regression test file covering the pure formatTransportVersion() helper plus
a real-filesystem resolveSpawnArgs() integration check.

Found and fixed while self-hosting OmniRoute and diagnosing why bifrost
kept crash-looping on startup.

* fix(bifrost): resolve install dir lazily so version read honors runtime DATA_DIR

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: seanford <seanford@users.noreply.github.com>
2026-07-23 11:25:28 -03:00
Moseyuh333
d43e71613e optimize(chaos+ponytail): i18n ponytail, dedupl dispatch, provider diversity (#8264)
* feat(chaos+ponytail): parallel chaos-mode dispatch + ponytail output style (rebased on v3.8.49)

- Chaos mode: new auto/chaos variant fans the prompt out to the top-N
  stable models in parallel and returns a single merged SSE stream.
  - Progressive streaming: each panel model's answer is enqueued as it
    lands (omni-chaos-part event), instead of awaiting the whole panel.
  - withTimeout now aborts the underlying request (modelAbortSignal) on
    timeout so the connection is released, not leaked.
  - concatSseText parses both OpenAI and Anthropic SSE wire formats.
  - autoPrefix/modePacks add the chaos-mode weight pack; virtualFactory
    materializes auto/chaos with fusion strategy + chaos config flag.
- Ponytail (lazy-senior-dev mode) integrated into the existing
  OUTPUT_STYLE_CATALOG registry (id 'ponytail') so it rides the production
  output-style injector, instead of a bespoke duplicate module. Dev-only
  scripts and the duplicate ponytail/ module are removed.
- Tests: chaosEngine/chaosVirtualCombo cover panel dispatch, progressive
  broadcast, timeout abort, and Anthropic parsing; autoCombo pack count
  updated to 6.

Rebased onto release/v3.8.49 (no provider-registry or validation changes —
those are split out per review).

* optimize(chaos+ponytail): i18n ponytail, dedupl chaos dispatch, provider diversity

- Ponytail: add vi/ja/pt-BR/id i18n with lite/full/ultra levels
- chaosEngine: extract dispatchOnePanelModel (shared), add onResult for
  progressive SSE streaming, fix withTimeout anti-pattern
- virtualFactory: deduplicate chaos panel by provider, add tuning overrides
- dispatchChaosFromCombo: accept ChaosTuning, enforce minPanel
- Add/port 8 node-runner tests for ponytail i18n + catalog integrity
- Add muse-spark-web.ts to KNOWN_MISSING_ERROR_HELPER (pre-existing)

* fix(8264): use HandleSingleModel type in chaosEngine dispatch (base-drift)

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 11:21:53 -03:00
growab
e9f297021e fix(usage): correct token/request counting for 30D/90D/YTD/ALL ranges (#7300)
* chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (#7168)

* fix(ci): add the auto-enqueue pull_request_rule to the Mergify config (queue_conditions alone are eligibility-only) (#7179)

* fix(ci): migrate Mergify auto-enqueue to merge_protections_settings.auto_merge_conditions (rules-based path is EOL 2026-07-16) (#7216)

* fix(ci): drop Mergify batch settings (batching is a paid-tier feature; free plan queue is serial) (#7220)

* fix(ci): merge queue tolerates the advisory dast-smoke failure (its GH-hosted build hang dequeued every attempt) (#7225)

* fix(usage): correct token/request counting for 30D/90D/YTD/ALL ranges

Two bugs caused incorrect usage statistics for date ranges beyond the
raw data retention window:

1. Cutoff mismatch: the analytics route computed rawCutoffDate from
   aggregation.rawDataRetentionDays (migration 046 seeds =7) while
   cleanupUsageHistory rolls up and deletes at retention.usageHistory
   (=30). The window [day-30, day-7) existed in usage_history but was
   excluded from BOTH UNION legs — raw leg floored at day-7, aggregated
   leg ended at day-7 — producing undercounted token sums for 30D,
   90D, YTD, and ALL ranges.

   Fix: use dbSettings.retention.usageHistory for the raw cutoff in
   both route.ts and getRawDataCutoffDate() (aggregateHistory.ts),
   matching the actual cleanup boundary.

2. Request undercount: COUNT(*) on the unified source counted each
   daily_usage_summary row as 1, not total_requests. A day with 50
   rolled-up requests counted as 1.

   Fix: add a 'requests' column to both UNION legs (raw: 1, aggregated:
   total_requests), change COUNT(*) to SUM(requests) in 6 query
   functions, and change successfulRequests from
   SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) to
   SUM(CASE WHEN success=1 THEN requests ELSE 0 END). Also set
   agg leg latency_ms to NULL so AVG(latency_ms) is not skewed.

Tests: 33/33 source-level tests pass (db-usageanalytics-split.test.ts),
verifying 'requests' column presence and SUM(requests) usage in all
affected queries. DB-level integration test added to
usage-analytics.test.ts (requires node + better-sqlite3).

* fix(ci): green CI reds on #7300 — file-size ratchet, stale test fixture, shallow-checkout selfref test

- src/app/api/usage/analytics/route.ts: trim the new comment to keep the file
  at the frozen file-size baseline (942 lines) after the retention.usageHistory
  cutoff fix — no logic change.
- tests/unit/usage-analytics-route.test.ts: the pre-existing "does not
  double-count raw and aggregated rows" test hardcoded a 30-day cutoff that
  matched the OLD (buggy) aggregation.rawDataRetentionDays default. Now that
  the raw/aggregated boundary correctly uses retention.usageHistory (365 days
  by default, matching cleanupUsageHistory's actual rollup/delete boundary),
  the fixture's synthetic "old" row was within the raw window and got
  excluded from the aggregated leg. Read the real retention setting instead
  of hardcoding 30 so the fixture reflects the corrected boundary. Same
  assertions (still expects no double-counting, totalRequests=2,
  totalTokens=185) — only the fixture dates change.
- tests/unit/check-test-masking-selfref-6634.test.ts: tolerate the shallow/
  single-ref checkout used by GitHub-hosted Unit Tests runners (no local
  origin/main ref) by fetching it on demand and skipping (never failing) when
  unreachable offline. Matches the fix already applied on another branch
  (2e42b8efc/#7174) for the same root cause, not yet on main.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* test(ci): make the #6634 selfref test checkout-independent (read the real file, no git ref)

The previous on-demand `git fetch origin main` + t.skip() fallback cleared the
shallow-checkout failure but tripped the PR Test Policy's test-masking gate
(a new .skip counts as a silenced assert — correctly so).

Drop the git dependency entirely instead: read the REAL current source of
tests/unit/check-test-masking.test.ts from disk (so the actual #6404 fixture
literals stay under test) and model the pre-#6404 state with an empty base,
which maximizes headTaut - baseTaut — the strictest input for the exclusion
this test asserts. No skip, no weakened assertion, same deepEqual guarantee.

Verified non-vacuous: neutralizing SELF_TEST_FIXTURE_RE in
scripts/check/check-test-masking.mjs makes this test fail; restoring it makes
it pass.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* test(ci): make the #6634 selfref guard hermetic — main's copy hard-fails every PR (#7341)

main's copy of this test still does git I/O inside a unit test:

    const baseSrc = git(['show', 'origin/main:' + FILE]);

Runners check out a shallow single ref, so origin/main does not resolve and the
test dies with 'fatal: invalid object name origin/main'. Every PR into main
fails Unit Tests (7/8) on it — today that is #7313, #7315, #7316, #7334, #7336
and #7337, six PRs red on a defect none of them introduced. #7313 has no other
red at all.

release/v3.8.49 already carries a fix (2e42b8efc, #7174: try/catch, fetch
origin/main on demand, t.skip() when unreachable), but it only reaches main at
release time — so main stays broken for the whole cycle. Cherry-picking it would
also import a new problem: PR Test Policy classifies t.skip() as a silenced
assertion, which we watched it correctly catch on #7300 today.

This is the hermetic version instead (ported from #7327, which does the same for
the release branch): read the file straight off disk, compare against an empty
base so baseTaut/baseExtTaut are 0 — the strictest possible comparison point —
and call evaluateMasking() directly. No git ref, no fetch, no skip, nothing the
runner's checkout depth can break.

The #6634 regression stays covered: the guard's logic lives in
SELF_TEST_FIXTURE_RE (check-test-masking.mjs:337), not in the test. Proven both
ways on main before committing — neutralise SELF_TEST_FIXTURE_RE to /$^/ and
the test FAILS; restore it and it passes 2/2, with check-test-masking.mjs left
byte-identical.

Co-authored-by: growab <nekron@icloud.com>

* chore(quality): tighten main's coverage baseline to the CI's real numbers (#7347)

main's ratchet had been failing --require-tighten on every PR: 11 metrics
improved but the baseline was never tightened. Same class as the #6634
selfref guard — an infra fix that lands only on the release branch leaves
main red for the whole cycle, and every PR into main pays for it.

Values are the merged-coverage numbers from a run on main itself (a local
run measures ~68% vs CI's ~80%; the baseline's own note warns about that
gap). Only the 11 coverage values change — gitleaks and semgrepFindings
keep main's own state.

No changelog fragment: #7326 carries it on release/v3.8.49, and a second
one here would double the entry at release time.

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-07-23 11:15:18 -03:00
backryun
869d08ff8b Add Alibaba-family media model support (#8266)
* Add Alibaba-family media models

* chore(quality): rebaseline imageRegistry+cognitive for #8266 media own-growth

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 11:12:28 -03:00
Alberto Punter
2670674107 fix(i18n): re-sync and complete es-ES translations with latest release/v3.8.49 (#8289) 2026-07-23 11:07:08 -03:00
Ravi Tharuma
069d5a7925 fix(dashboard): stop sidebar RSC prefetch storms (#8292)
* fix(dashboard): disable sidebar route prefetch

Co-Authored-By: Claude <noreply@anthropic.com>

* test(dashboard): cover sidebar prefetch traffic

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 11:06:59 -03:00
Austin Liu
acd3d46ab4 fix(sanitizer): strip zero-width chars from Anthropic-native streaming text_delta (#8271) (#8287)
sanitizeStreamingChunk() only stripped zero-width characters (U+200B,
U+200C, U+200D, U+FEFF) from OpenAI-format choices[].delta.content.
Anthropic-native content_block_delta events with text_delta or
thinking_delta payloads bypassed that path entirely, leaking U+200D
to clients on the Messages API streaming route.

Add a content_block_delta branch that strips zero-width characters
from delta.text and delta.thinking before returning the event,
matching the existing OpenAI path behavior from #5857.

Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
2026-07-23 11:06:51 -03:00
Austin Liu
99ad15a37f fix(resilience): skip terminal connections in token health check sweep (#8182) (#8286)
Background token health check sweep was probing connections with
terminal statuses (credits_exhausted / banned / expired) on every
cycle, wasting CPU and network. These connections can never self-heal
via token refresh — they need manual re-auth or credit top-up.

Add a terminal status guard in checkConnection() that mirrors the
existing isTerminalConnectionStatus() in auth.ts and
TERMINAL_CONNECTION_STATUSES in connectionRecovery.ts.

Verified by reporter: after manually disabling 11 credits_exhausted
connections, CPU dropped from ~53% to ~2.7%. This fix automates that
skip so the sweep never touches terminal connections in the first place.

Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
2026-07-23 11:06:44 -03:00
Kayn Xu
56e2d2efb0 fix(providers): update Learn more documentation link (#8284)
* fix(providers): update Learn more documentation link

* docs(changelog): note provider documentation link fix
2026-07-23 11:06:36 -03:00
Austin Liu
d3cdd489be fix(cli): resolve claude.cmd on Windows in omniroute launch (#8246) (#8283)
spawn('claude') without shell:true cannot resolve the .cmd shim
that npm installs on Windows, causing ENOENT and a misleading
'not found in PATH' error. Use claude.cmd + shell:true + windowsHide
on win32, matching the pattern already used in launch-codex.mjs.

Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
2026-07-23 11:06:28 -03:00
diegosouzapw
91fd5f946a chore(quality): rebaseline accountFallback+combo for #8252 own-growth (post-merge follow-up) 2026-07-23 09:51:35 -03:00
小妍儿 ✨
fbb8d45757 docs(db): add reproducible SQLite coupling inventory (#8262)
Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
2026-07-23 09:51:06 -03:00
小妍儿 ✨
da6d72e26b docs(db): propose pluggable persistence boundary (#8261)
Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
2026-07-23 09:50:57 -03:00
backryun
2f65339378 fix(providers): limit Gemini CLI to legacy OAuth refresh (#8275)
#8232 correctly targeted OAuth auto-refresh for legacy stored Gemini CLI connections, but exceeded that compatibility goal by recreating a complete routable and UI-visible provider with an Antigravity model catalog.

Preserve legacy refresh by mapping gemini-cli rows to the existing Gemini OAuth credentials. Remove the public provider registry, OAuth preset, model routing snapshot, and canonical-provider tests while keeping Gemini API and Antigravity unchanged.
2026-07-23 09:50:49 -03:00
Ravi Tharuma
1110f9ca5f fix(combo): advance on model-scoped 400s wrapped as invalid/Bad Request (#8252)
#2101 still hard-stopped model-not-supported failures when upstream
wrapped them as invalid_request_error / Bad Request. Keep models in the
combo and try the next target (#8251, residual of #5249).

- export MODEL_ACCESS_DENIED_PATTERNS + broaden does-not-support shapes
- add isModelScoped400() and exempt it from the body-specific stop guard
- regression tests for wrapper forms; body-specific stop still intact

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
2026-07-23 09:50:41 -03:00
diegosouzapw
610ff8527a docs(changelog): reconcile v3.8.49 living section + Contributors hall (289 uncovered PRs)
Regenerated the [3.8.49] CHANGELOG from the full cycle range (bump 2c62333b0..tip):
added 289 previously-uncovered merged PRs as categorized bullets with per-PR author
attribution, then re-injected the 🙌 Contributors hall (83 external contributors).
Closed-PR credit audit: clean — the 3 closed-not-merged authors not in the hall
(#8178 AI Council 'opened by mistake, stays on fork', #8076 Rust fetch 'closing at my
request', dependabot bot) had no landed work, so no missing credit. Synced 41 i18n
CHANGELOG mirrors.
2026-07-23 09:14:00 -03:00
diegosouzapw
86963830dc docs(diagrams): re-render hero + promise SVGs to 290 providers
Sync the hand-authored readme-hero.svg / promise-pillars.svg provider count
(number + accessibility desc) to 290, matching the catalog + the README/AGENTS/CLAUDE
text updated in the prior commit.
2026-07-23 09:04:35 -03:00
diegosouzapw
8f42b9c8e1 docs: sync provider count to 290 across README/AGENTS/CLAUDE (check-docs-counts-sync)
The auto-generated catalog now resolves 290 providers (grew from the tier-1/2/3
provider additions this cycle); README/AGENTS/CLAUDE still carried 278/283. Updated
all provider-count mentions + the TOC anchor/heading to 290 so check-docs-counts-sync
exits 0. Note: readme-hero.svg / promise-pillars.svg still render the old number and
need a re-render on the site-deploy machine (image render is out of gate scope).
2026-07-23 08:59:22 -03:00
nguyenha935
a6eb4d8166 fix: normalize Codex URLs and dashboard regressions (#8233)
Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:53:03 -03:00
Apostol Apostolov
f3ed4a49d4 feat(providers): add weekly quota tracking for grok-web (#8127)
* feat(providers): add weekly quota fetcher for grok-web (grok.com SSO)

Implements a bespoke QuotaFetcher for the grok-web provider that:
- Reads OIDC tokens from ~/.grok/auth.json (local Grok CLI login)
- Refreshes tokens via auth.x.ai OIDC if expired
- Calls https://cli-chat-proxy.grok.com/v1/billing?format=credits
- Returns a single 'weekly' window with creditUsagePercent and resetAt
- Caches results with 60s TTL (matching codexQuotaFetcher pattern)
- Supports GROK_AUTH_PATH env var override for testing
- Registers in chat.ts before registerGenericQuotaFetchers

Tests cover: missing auth, successful fetch with header verification,
401-triggered token refresh with retry, 60s cache TTL, and preflight
integration.

Closes #6444

* chore(quality): rebaseline chat.ts for #8127 own-growth

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:47:49 -03:00
Paijo
2a865aaaa7 feat(settings): configurable model catalog cache TTL (#8219)
* feat(settings): configurable model catalog cache TTL

Add modelCatalogCacheTtlMs to DatabaseSettings with default 1500ms.
Extend cache-config API route to accept the new field.
Replace hardcoded catalog cache TTL with dynamic settings value.
Add 'Cache' settings tab at /dashboard/settings/cache with sidebar entry,
i18n keys, header description, and legacy route redirect.

* feat(combo): add model connection filter toggle to ModelSelectModal

Adds a 'Show configured only' checkbox below the search bar in
ModelSelectModal that filters each provider group's models through
hasEligibleConnectionForModel. Toggle state persists in localStorage.

- Import hasEligibleConnectionForModel from domain/connectionModelRules
- showConfiguredOnly state + localStorage persistence
- connectionFilteredGroups memo layered on filteredGroups
- Renders both provider section and empty state from connectionFilteredGroups

* test(combo): add connection filter toggle tests for ModelSelectModal

Three test cases: (1) hide excluded models when toggle on,
(2) show empty state when all models excluded,
(3) drop provider group when all its models excluded.
All 3 tests pass.

* fix(settings): correct cache-config route import + add route/tab coverage

The cache-config route imported get/update helpers from a nonexistent
module (@/lib/localDb/databaseSettings) and called an undefined
updateSettings() in PUT, crashing every request. Import the real
databaseSettings module (matching the sibling database/route.ts
convention) and call updateDatabaseSettings(); idempotencyWindowMs is
routed through the flat @/lib/db/settings module instead, since that is
where it is actually read at runtime (idempotencyLayer.ts,
runtimeSettings.ts) — it was never part of the databaseSettings "cache"
section type.

Also fixes a dashboard-typecheck regression in ModelSelectModal.tsx: the
new connection-filter toggle called hasEligibleConnectionForModel() with
activeProviders entries typed too narrowly to include
providerSpecificData, which real connection objects carry at runtime.

Adds:
- tests/unit/cache-config-route-8219.test.ts: GET/PUT resolve without
  crashing, modelCatalogCacheTtlMs and idempotencyWindowMs round-trip.
- tests/unit/ui/cache-settings-tab-bounds-8219.test.tsx: CacheSettingsTab
  min/max TTL bounds (100ms/60000ms) gate the Save button and surface a
  validation message; in-bounds values PUT correctly.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(quality): rebaseline sections.ts for #8219 own-growth

---------

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:46:22 -03:00
pazhik
d59eec6b19 docs(i18n): full Russian README rewrite (#8217)
* docs(i18n): full Russian README rewrite for native readers

Rewrite docs/i18n/ru/README.md from an outdated English dump into a proper Russian manual aligned with the modern product README (v3.8.x structure, free-tier budget, combos, compression, CLI/MCP). Fix relative screenshot/doc links for the i18n path.

* fix(8217): changelog fragment bullet prefix

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:44:49 -03:00
Álvaro Ángel Molina
81ade9e122 feat(providers): add Typhoon (Thailand) and Inception Mercury diffusion LLM (#8170)
* feat(providers): add Typhoon and Inception Mercury API-key providers

Two OpenAI-compatible API-key providers, each verified against a live
endpoint smoke test with a negative control before registration: an
unknown route answers 404 while /v1/chat/completions answers 401, which
rules out gateways that reply identically to every path.

- typhoon (SCB 10X, Thailand): first Thai-first provider in the catalog.
  /v1/models answers 200 unauthenticated and serves exactly one chat
  model, typhoon-v2.5-30b-a3b-instruct (128K ctx). The docs also list
  typhoon-v2.1-12b-instruct, but the live endpoint no longer serves it,
  so it is deliberately not registered. The typhoon-ocr* and typhoon-asr*
  entries are OCR and speech models, not chat, and are omitted.
- inception (Inception Labs): first diffusion LLM (dLLM) in the catalog.
  mercury-2 has a 128K context, 50K max output, and supports tools,
  json_mode and structured outputs. The mercury-coder models advertised
  on the vendor blog are no longer served by the live endpoint and are
  therefore not registered.

Both expose a working /v1/models catalog, so they are added to
NAMED_OPENAI_STYLE_PROVIDERS for discovery and key validation.

Free-tier metadata is claimed only where it is documented and durable:
Typhoon issues a free API key rate-limited to 5 req/s and 200 req/m, and
Inception grants 10M tokens on signup with no card, so both are
registered with hasFree: true.

Provider count moves from 280 to 282; README/AGENTS/CLAUDE counters were
stale at 278 and are resynced against docs/reference/PROVIDER_REFERENCE.md.

* fix(8170): union frontier-labs Inception+Writer, regen ref+golden

* fix(8170): close inception object + regen ref/golden

---------

Co-authored-by: Álvaro Ángel Molina <alvaretto@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:18:16 -03:00
Álvaro Ángel Molina
7ca821aeee feat(providers): add Sarvam AI, Writer Palmyra and PLaMo API-key providers (#8161)
* feat(providers): add Sarvam AI, Writer Palmyra and PLaMo API-key providers

Three OpenAI-compatible API-key providers, each verified against a live
endpoint smoke test before registration:

- sarvam (India): /v1/models answers 200 unauthenticated and lists
  sarvam-105b (128K ctx) and sarvam-30b (64K ctx). The older sarvam-m is
  discontinued upstream and is deliberately not registered.
- writer (Palmyra): api.writer.com exposes the OpenAI alias
  /v1/chat/completions alongside its native /v1/chat — confirmed with a
  negative control, since an unknown route answers 404 'endpoint not
  available via API gateway' while /v1/chat/completions answers 401.
  Registers palmyra-x5 (1M ctx) and palmyra-x4 (128K ctx); the
  medical/financial/creative/vision variants are deprecated upstream and
  are omitted.
- plamo (Preferred Networks, Japan): only plamo-3.0-prime (262K ctx) is
  registered. plamo-3.0-prime-beta is discontinued on 2026-07-31 and
  plamo-2.2-prime on 2026-09-30, so neither is worth wiring up.

Free-tier metadata is claimed only where it is documented and durable:
Sarvam ships a permanent signup credit, while PLaMo's 10M-token grant is
a campaign that expires on 2026-07-31 and Writer documents no free tier,
so both are registered with hasFree: false.

* regen golden+ref

---------

Co-authored-by: Álvaro Ángel Molina <alvaretto@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:14:50 -03:00
Álvaro Ángel Molina
6ba2260178 feat(providers): add CLOVA Studio, InternLM and Ant Ling API-key providers (#8077)
* feat(providers): add CLOVA Studio, InternLM and Ant Ling API-key providers

Adds three OpenAI-compatible frontier-lab providers, closing regional gaps
in the catalog (Korea had none; the Shanghai AI Lab and Ant Group families
were both missing).

- clova-studio: Naver HyperCLOVA X (HCX-007 reasoning, HCX-005 multimodal)
  on the current clovastudio.stream.ntruss.com host. The legacy
  clovastudio.apigw.ntruss.com endpoint is being deprecated and is not used.
- internlm: Shanghai AI Lab Intern-S1 family (intern-s1-pro is a 1T MoE).
  Ships a free monthly quota, so it is flagged hasFree.
- ant-ling: Ant Group / inclusionAI Ling-2.6-1T and Ring-2.6-1T.

All three endpoints were smoke-tested: each returns HTTP 401 on <baseUrl>/models
(endpoint live, awaiting auth) and resolves against public DNS. All three are
registered for live model discovery, so their catalogs refresh from upstream.

Known limitation: the ant-ling model ids are best-effort from public docs and
are NOT verified against a live /v1/models response, which requires an API key.
This is recorded in the registry comment and in the provider authHint so it is
visible to operators rather than silently assumed. Its baseUrl is likewise not
published in the public docs and was found by smoke test.

Two AI SUTRA was evaluated for this batch and deliberately excluded: its
documented endpoint api.two.ai does not resolve in public DNS (ENOTFOUND
against 1.1.1.1 and 8.8.8.8, with www.two.ai resolving as control).

The translate-path golden snapshot is regenerated; the change is additive only
(209 -> 212 keys, exactly the three new providers, none removed or altered).

* feat(providers): verify ant-ling against official docs, add Ling-2.6-flash and free tier

The ant-ling entry was added with model ids marked best-effort because they
could not be checked without an API key. Ant Ling's own documentation turns
out to publish enough to verify them, so the uncertainty is now resolved:

- The quickstart sample uses base_url "https://api.ant-ling.com/v1" with
  model "Ling-2.6-1T", confirming both the endpoint and the exact casing.
- The pricing page bills exactly three models over the API, so Ling-2.6-flash
  was missing from the catalog and is added.
- Each account gets 500,000 free tokens per day (resets 02:00 UTC+8, no
  rollover), so the provider is flagged hasFree with a freeNote.

The Ming family (Ming-Flash-Omni, Ming-Light) is deliberately left out: it is
documented as open-source / Ling Studio only and does not appear on the
pricing page, so it is not served over this chat-completions API. That
reasoning is recorded in the registry so it is not re-litigated later.

The authHint no longer claims the ids are unverified, and now points at the
API console (https://chat.ant-ling.com/open) where keys are actually created.
Same correction applied to the en, pt-BR and vi message catalogs.

* docs: sync provider counts to 283 and regenerate the provider reference

---------

Co-authored-by: Álvaro Ángel Molina <alvaretto@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:11:15 -03:00
Rafael Dias Zendron
7c07c9d8a6 fix(#8093): align INPUT_SANITIZER_ENABLED default to true across all docs (#8185)
* fix(ci): resolve upstream-inherited check failures

* fix(docs): align INPUT_SANITIZER_ENABLED default to true across all docs

Code defaults INPUT_SANITIZER_ENABLED to enabled (any value that is
not exactly 'false'). Updated .env.example and 41 i18n translations
of ENVIRONMENT.md to match this default instead of showing false.

Fixes #8093

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:09:10 -03:00
Rafael Dias Zendron
1e1941d551 fix(#8135): suppress sql.js build warning via non-analyzable dynamic import (#8184)
* fix(ci): resolve upstream-inherited check failures

* fix(build): suppress sql.js build warning via non-analyzable import

Replace literal import('sql.js') with a computed specifier and
webpackIgnore magic comment so Next.js/webpack doesn't try to
statically resolve sql.js/package.json during the build phase.

Fixes #8135

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:04:35 -03:00
Rafael Dias Zendron
9b2968fc07 fix(resilience): add max/step to NumberField for provider cooldown inputs (#8107) (#8203)
* fix(ci): resolve upstream-inherited check failures

* fix(resilience): add max/step to NumberField for provider cooldown inputs

Fixes #8107: integer input rejects typed value on Chrome/Windows
because frontend allowed values exceeding backend zod schema limits.

- Add  and  props to NumberField component
- Apply correct limits for provider cooldown (min: 300000ms, max: 3600000ms)
- Apply correct limits for waitForCooldown (maxRetries: 10, maxRetryWaitSec: 300)
- Apply correct limits for requestQueue (requestsPerMinute: 1000, minTimeBetweenRequestsMs: 10000, concurrentRequests: 100, maxWaitMs: 300000, maxQueueDepth: 100000)
- Apply correct limits for connection cooldown (baseCooldownMs: 3600000, maxBackoffSteps: 100)
- Apply correct limits for provider breaker (failureThreshold: 1000, degradationThreshold: 1000, resetTimeoutMs: 300000)
- Apply correct limits for combo cooldown (maxWaitMs: 30000, maxAttempts: 10, budgetMs: 300000)
- Apply correct limits for quota share concurrency (enabled only - no numeric limits)

* fix(resilience): clamp cooldown bounds before save + regression test (#8107)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:02:21 -03:00
Rafael Dias Zendron
2dfc67e035 test(#8140): verify keepalive interval cleanup on disconnect, resolve, and reject (#8190)
* fix(ci): resolve upstream-inherited check failures

* test(#8140): verify keepalive interval cleanup on disconnect, resolve, and reject

Closes #8140

Adds 3 unit tests covering earlyStreamKeepalive timer cleanup:
- Client disconnect (abort signal): interval cleared, no leaked timers
- Handler resolves normally (slow path): interval cleared in finally block
- Handler rejects (slow path): interval cleared despite error

Each test verifies handle count stability across a 30ms gap to ensure
no leaked setInterval keeps ticking after stream closure.

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:01:08 -03:00
Rafael Dias Zendron
7322740e7e fix(#8141): log pending request counter decrement failures (#8179)
* fix(ci): resolve upstream-inherited check failures

* fix(streamHandler): log trackPendingRequest decrement failures instead of swallowing

The clearPendingRequest function had an empty catch block around the
trackPendingRequest decrement call. If it threw, the pending request
counter stayed incremented — causing drift, false-positive rate limiting,
and masked overload conditions.

Now logs the error with context for observability.

Fixes #8141

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 07:58:20 -03:00
小妍儿 ✨
b640b69078 feat(codex): support reference image edits (#8122)
* feat(codex): support reference image edits

* docs(changelog): add Codex edit fragment

* fix(codex): harden image edit admission

* fix(security): redact image error credentials

* fix(security): close error redaction bypasses

* feat(codex): support multiple image references

* fix(codex): preserve reference candidate semantics

* chore(quality): rebaseline image-generation-handler.test.ts for #8122 codex image edits own-growth

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 05:39:03 -03:00
Rafael Dias Zendron
5e8b130e77 fix(compression): make memo key model-independent for non-vision engines (#8196)
* fix(ci): resolve upstream-inherited check failures

* fix(compression): make memo key model-independent for non-vision engines (#8137)

The compression result memo included `model` and `supportsVision` in the
cache key for ALL deterministic modes. This was correct for the `lite` engine
(which strips data:image URLs based on vision support) but unnecessary for
model-independent engines like `rtk`, `caveman`, and stacked pipelines
without a `lite` step.

In the combo retry loop, the body and config are identical across targets —
only the model changes each attempt. Including model in the key forced a fresh
cache miss on every retry, re-running the full compression pipeline 5-8x per
request instead of serving the cached result.

Fix: `makeMemoKey` now only includes model + supportsVision when the
compression pipeline actually uses a vision-dependent engine (lite, standard,
or stacked containing lite). All other deterministic engines use a
model-independent key.

- Add `usesVisionDependentEngine()` helper to classify modes
- `makeMemoKey` conditionally includes model/vision fields
- 5 new tests covering rtk, caveman, stacked-with-lite, stacked-without-lite

Closes #8137

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 05:36:49 -03:00
Rafael Dias Zendron
1b42044c15 fix(combo): skip remaining same-provider targets on 401/403 auth failure (#8195)
* fix(ci): resolve upstream-inherited check failures

* fix(combo): skip remaining same-provider targets on 401/403 auth failure (#8133)

When a provider returns 401/403 (auth failure), every remaining model behind
the same provider will fail identically. Previously the combo engine continued
trying sibling models on the dead connection, wasting attempts.

Now auth-level failures (401, 403) are classified as provider-level exhaustion,
marking exhaustedProviders so subsequent same-provider targets are skipped via
the existing exhaustion-skip mechanism.

Tests: 4 new cases in combo-target-exhaustion.test.ts covering 401, 403,
unknown-provider guard, and per-model-quota provider (auth is provider-wide).

* fix(combo): connection-level exhaustion on 401/403, not whole-provider (#8137)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 05:33:57 -03:00
dependabot[bot]
612b6b38ef deps: bump next from 16.2.10 to 16.2.11 (#8235)
Bumps [next](https://github.com/vercel/next.js) from 16.2.10 to 16.2.11.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.10...v16.2.11)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-23 05:33:49 -03:00
Michael YC JO
f3d512eeff fix(dashboard): correct machine-translated Korean UI strings in ko.json (#8224)
Fix 527 mistranslated values in the Korean locale, all verified against
the en.json source:

- Restore protected product/protocol names garbled by machine translation
  (응록→ngrok, 인류/인류학→Anthropic, 쌍둥이자리→Gemini, 반중력→Antigravity,
  꼬리비늘 깔때기→Tailscale Funnel, 진공→VACUUM, 우편번호→ZIP)
- Fix wrong-sense homonym translations (달리기→실행 중 for Running,
  장애인→비활성화됨 for Disabled, 열쇠→키 for Key, 안타→적중 for Hits,
  유물→아티팩트 for Artifacts, 건강검진→상태 확인 for Healthcheck)
- Repair translated identifiers that broke literal values (양말5→socks5,
  볼록-세션-id→convex-session-id, 채팅/완료→chat/completions,
  메시지/보내기→message/send JSON-RPC methods)
- Replace key-name dumps shipped as values ("Table Name", "Overview
  Title", "Cli Tools Redirect Title" etc.) with real Korean translations
- Unify ngrok casing (Ngrok→ngrok) and trailing punctuation with the
  English source; align terminology across fixes (공급자, 폴백, 사용자 정의)

All {placeholder} tokens, markdown, and protected terms preserved
verbatim; i18n UI coverage and ko validation gates pass.
2026-07-23 05:33:41 -03:00
Prudhvi Vuda
a29341ff1b fix(memory): resolve remote embedding dimensions for reindex (#8074) (#8220)
Use getEmbeddingDimension() in resolveEmbeddingSource so sqlite-vec can
create vec_memories before the first upsert, and abort reindex batches
when ensureReady returns ready=false instead of wasting embed credits.
2026-07-23 05:33:33 -03:00
Sean Ford
71887ae529 fix: restore OAuth auto-refresh for gemini-cli connections (#8232)
* fix: restore OAuth auto-refresh for gemini-cli connections

gemini-cli OAuth connections had no PROVIDERS registry entry at all, so
the token-refresh health check permanently skipped them once the access
token expired, forcing a full re-authentication instead of using the
still-valid refresh token.

Two layered gaps, both required:
1. supportsTokenRefresh()'s explicit allow-set had "gemini" but not
   "gemini-cli" (the id actually stored on these connections), and its
   PROVIDERS[e].tokenUrl fallback also failed since...
2. ...open-sse/config/providers registry had zero entry for "gemini-cli"
   at all: no clientId/clientSecret/tokenUrl/refreshUrl, so even the
   generic refresh path had nothing to refresh with.

Adds a "gemini-cli" registry entry mirroring antigravity's Google
Cloud Code OAuth shape, reusing the same well-known public Gemini CLI
client credentials already embedded (and already used, unchanged, by
the Gemini Studio API-key provider's own oauth block) via
resolvePublicCred("gemini_id"/"gemini_alt"). Adds "gemini-cli" to the
explicit refresh allow-set, the Google-refresh dispatch case, and the
15-minute non-rotating-token proactive lead alongside antigravity/agy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: register gemini-cli in canonical provider list (provider-consistency gate)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(gemini-cli): use ANTIGRAVITY_RUNTIME_BASE_URLS (renamed by #8013 antigravity split)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seanford <seanford@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 05:27:47 -03:00
Sean Ford
337373f8f0 fix(services): resolve and record a real pid when adopting a service (#8218)
ServiceSupervisor.start(), when probeBeforeSpawn detects an already-healthy
instance on the target port, "adopts" it (marks the service running without
spawning a duplicate that would die with EADDRINUSE). This adopt path calls
setToolStatus(tool, "running") with no pid argument at all, so this.pid stays
at its constructor default of null for the rest of that supervisor's life --
only the spawn path (a genuinely new child process) ever sets a real pid.

In production this "adopt" path is common, not an edge case: any embedded
sidecar service (cliproxy, 9router, bifrost, mux) whose child process
survives a `systemctl --user restart omniroute.service` gets adopted by the
new supervisor instance on the next start(), and its pid is lost from that
point on -- even though the service is genuinely healthy and running. The
observed symptom: a service shows state "running" but pid null, and
something downstream that keys liveness tracking off pid eventually treats
it as untrustworthy/stale despite nothing actually being wrong.

Fix: resolve the real pid of the process holding the port (via `lsof -ti
:<port>`, best-effort -- a lookup failure leaves pid null rather than
blocking adoption) and record it the same way the spawn path does.

An existing test asserted pid === null on adopt. That was accurate for the
old behavior but reflected a missing resolution, not a deliberate "adopted
services never get a pid" design choice -- updated its assertion to match
the corrected behavior and added a dedicated regression test proving the
resolved pid matches the real process actually bound to the port.
2026-07-23 05:24:30 -03:00
backryun
18f1f667bf fix(claude-web): align session transport and fallback (#8230) 2026-07-23 05:24:22 -03:00
backryun
686375ba72 fix(devin-cli): refresh shared model catalog (#8227) 2026-07-23 05:24:14 -03:00
Markus Hartung
cc17b304ab fix(sse): Gemini TPM/RPD quota classification + combo cooldown-wait resilience (#8213)
* 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>
2026-07-23 05:17:35 -03:00
Markus Hartung
ebe086ebb5 fix(sse): surface OpenRouter mid-stream error chunks instead of a false empty success (#8210)
* 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>
2026-07-23 05:14:14 -03:00
Marcus Bearden
5d764a40ee fix(logs): stop the async-EPIPE log-flood loop at its ignition point (#8207)
* 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
2026-07-23 05:11:11 -03:00
Markus Hartung
6e1e5c9a45 fix(sse): tool-incapable provider handling (AI Horde + Responses content-collapse scoping) (#8212)
* 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>
2026-07-23 05:03:53 -03:00
Markus Hartung
4ea08f520b fix(sse): Gemini malformed function-call handling + tool_choice translation (#8211)
* 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>
2026-07-23 05:03:44 -03:00
Markus Hartung
40eb5a87a7 chore(quality): fix 2 pre-existing lint/suppression drift issues (#8209)
* 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>
2026-07-23 05:03:35 -03:00