Compare commits

...

32 Commits

Author SHA1 Message Date
diegosouzapw
eddf330cbf fix: repair stray brace in combo.ts and fix no-explicit-any types in repro-9630 test 2026-08-07 15:04:45 -03:00
diegosouzapw
9d8985880a Merge remote-tracking branch 'origin/release/v3.8.50' into babysit/pr-9673 2026-08-07 13:58:10 -03:00
Diego Rodrigues de Sa e Souza
976d670ff3 fix(combo): distinguish pre-dispatch skips from genuine failures to prevent false 503 ALL_ACCOUNTS_INACTIVE (#9630)
Closes #9630
2026-08-07 13:45:58 -03:00
Diego Rodrigues de Sa e Souza
ff679ab86e fix(sse): move Antigravity client system content to first user message to avoid upstream 429 (#9030)
Closes #9030
2026-08-07 11:23:33 -03:00
Diego Rodrigues de Sa e Souza
ebf151e057 fix(cursor): preserve tool context across multi-turn conversations when client lacks conversation_id (#9029)
Closes #9029
2026-08-07 11:23:27 -03:00
Diego Rodrigues de Sa e Souza
7d3dc0bc35 fix(vertex): route Claude models to native rawPredict and respect targetFormat overrides (#8994)
Closes #8994
2026-08-07 11:23:19 -03:00
Diego Rodrigues de Sa e Souza
5e2429ce15 fix(errorClassifier): classify ChatGPT Web SENTINEL_BLOCKED 403 as FORBIDDEN, enabling combo fallback (#8813)
Closes #8813
2026-08-07 11:23:12 -03:00
Diego Rodrigues de Sa e Souza
6629a9b698 fix(yuanbao-web): accept content field in SSE text events (upstream format change) (#8739)
Closes #8739
2026-08-07 11:23:07 -03:00
Diego Rodrigues de Sa e Souza
c40d4b17ee fix(ci): clear the NEW base-reds from the 08-06 merge batch (migration collision #2 + broken import) (#9688)
* test(base): realign six suites with contracts that #9100/#8990/#9009 deliberately changed

Continuing the base-red drain — every one of these reproduces on the pure tip.

- tests/snapshots/provider/translate-path.json: regenerated via UPDATE_GOLDEN=1.
  The diff is ADDITION-ONLY — the unorouter block from #9009; no existing
  provider entry changed. 3/3.
- tests/unit/provider-models-route.test.ts: ff012ff420 added onboardUser as a
  bootstrap fallback next to loadCodeAssist; the mock now excludes it from the
  discovery-URL ledger like it already excluded loadCodeAssist, otherwise it
  consumed the injected 503 and the retry assertion misfired. 59/59.
- tests/unit/responses-commentary-passthrough-6199.test.ts: #8990 (c996dc93c2)
  deliberately preserves `tools` on the TERMINAL response.completed snapshot
  (Codex CLI rebuilds its tool list from it); the assertion now pins the echoed
  tools instead of their absence. Still stripped on created/in_progress. 7/7.
- tests/unit/vision-compression-authoritative-capability-7237.test.ts:
  68cb678780 added the 'gpt-5' fragment, so the heuristic-vs-spec DRIFT this
  suite documented no longer exists; the cases now guard the agreement, keep a
  conservative-for-unknown-ids probe, and reproduce the strip-bug shape with an
  explicit false instead of deriving it. 4/4.
- tests/unit/provider-limits-proxy-fail-closed.test.ts +
  tests/unit/image-generation-route.test.ts: #9100 made the proxy reachability
  probe NON-BLOCKING (optimistic dispatch; the probe aborts only in-flight
  requests — its own t14 sibling was updated to this exact pattern). Instant
  mocks therefore won the race and the PROXY_UNREACHABLE 503 became unobservable
  (a success or a generic 502). The mocks now stay in flight (never-resolving,
  so the aborted continuation cannot reach the restored real fetch), and the
  fail-closed proof is the settled rejection itself plus zero egress AFTER the
  fast-fail. Production fail-closed semantics are unchanged — the proxy dispatch
  path still throws; only the mock timing was stale. 3/3 and 20/20.

Refs #9298

* fix(guardrails): forward the router deps seam through callVisionModel

tests/unit/guardrails/vision-bridge-sse-and-reasoning.test.ts was 7/7 red on any
clean box (CI shard 3/4): callVisionModel() called getBestVisionModel()/
getFallbackModels() WITHOUT the routers' existing VisionBridgeRouterDeps seam,
so the credential check always hit the live connections DB — no vision-capable
connection meant 'No vision-capable provider connected' before the mocked fetch
was ever reached, and on a dev box auto-selection could swap the fixed model
under the assertions.

The routers already accepted deps; only the forwarding was missing. Added the
optional 5th param (backward compatible — the sole production caller,
visionBridge.ts, injects its own callVisionModel and is unaffected) and the
suite now pins selection with hasUsableCredentials: async () => null
(indeterminate → the fixed model is honored, DB untouched). 7/7.

Sibling suites re-run green: vision-bridge-callmodel 2/2, visionBridge 25/25,
visionBridgeHelpers.callVisionModel 8/8, visionBridgeRouter 10/10,
vision-bridge-cc-no-reroute 8/8.

Refs #9298

* fix(db,combo): clear the NEW base-reds the 08-06 merge batch introduced

The tip moved while the first sweep PR (#9600) was in review, and three fresh
base-reds landed with it — same classes as before, all reproduced on the pure
tip 9995bc4893:

1. ANOTHER migration collision: #9061 shipped 134_ccr_blocks.sql onto the slot
   134_proxy_logs_egress_ip.sql (#9291) has held since 08-04. getMigrationFiles()
   throws on collision, so every DB-touching test died at bootstrap again.
   Renumbered to 139 (next free slot). No retroactive guard needed this time:
   both statements are IF NOT EXISTS, and no DB can have applied it as 134 —
   the runner refused to run at all while the collision existed.
2. BROKEN IMPORT killing the combo module graph: #8894 imported
   preferAntigravityConnectionsWithStoredProject from
   ../antigravityProjectPersistence.ts — a module that exists NOWHERE in the
   repo (it came from an unmerged sibling branch). Anything importing
   quotaStrategies.ts died with ERR_MODULE_NOT_FOUND. Implemented the helper in
   the real persistence module (antigravityProjectPersist.ts, #8491) with the
   semantics the call site needs — prefer connections that already carry a
   stored projectId, never emptying the pool — and pointed the import there.
   New regression suite tests/unit/antigravity-prefer-stored-project.test.ts
   (5/5), including an import-graph probe that reproduces the break shape.
3. Sibling-test drift from #9106 (gemini-3.1-pro-high now user-callable): its
   own suites were updated but provider-models-route.test.ts was not. Expected
   discovery list realigned; testFrozen 1784->1787 justified in the baseline
   (irreducible +2 after comment compression; gate counts split-newlines).

Also regenerated tests/snapshots/provider/translate-path.json — addition-only:
devin-cli-agentic, raycast, regolo (today's provider merges), zero removals.

image-generation-route 20/20 (was import-dead), provider-models-route 59/59,
antigravity-prefer-stored-project 5/5, provider-translate-path-golden 3/3.

Refs #9298

* fix(changelog): convert the #9415 fragment to the required bullet shape

Another base-red from the 08-06 batch: bd4407cb64 landed
changelog.d/features/9415-newapi-sub2api-aggregator-balance.md as YAML
frontmatter + a prose paragraph. Every other fragment in changelog.d/ is a
single markdown bullet, and both consumers enforce that —
scripts/check/check-changelog-integrity.mjs:97 and the release aggregator
(scripts/release/aggregate-changelog.mjs:57) reject anything that does not
start with '- ', so 'Merge integrity (changelog + generated skills)' was red
for every PR targeting the release branch.

Rewritten as a bullet with the standard issue link, preserving the feature
description (aggregator gateway toggle, /api/user/self balance read, dashboard
badge, quota-preflight skip, NEWAPI_AGGREGATOR_BALANCE flag default off,
quotaPerUnit override). Swept the rest of changelog.d/ — this was the only
malformed fragment.

check:changelog-integrity OK.

Refs #9298

* fix(types,docs): clear the 5 typecheck errors and the fabricated env vars on the base

Third pass over the base-reds, from the 2026-08-06T22:51Z verdict on #9298 —
it reported "Typecheck (core)" with only the FIRST error; there are five, all on
the pure tip 9995bc4893. Two are real production defects.

**Real bugs**

- open-sse/services/compression/engines/ccr/index.ts:295 called
  enforceGlobalBudget(entry.bytes) against an (owner, bytes) signature. The
  `bytes` argument arrived undefined, so `ccrTotalBytes + undefined` is NaN,
  `NaN > MAX` is false (the eviction loop exits immediately) and `NaN <= MAX` is
  false (the re-admit is refused). The #9061 durable tier therefore NEVER
  repopulated its in-memory map: every retrieve after a restart or an eviction
  re-read from SQLite forever, and evictions could not prefer the owning
  principal. Fixed and pinned by a new case in
  tests/unit/ccr-durable-store-9061.test.ts (11/11) — verified failing against
  the buggy call and passing against the fix.
- open-sse/services/combo/fusionPanel.ts:54 read `step.model` after #8894
  widened ComboStep with ComboProviderWildcardStep (which carries modelPattern,
  not model), so a wildcard step in a fusion panel pushed `undefined` onto the
  panel. Now resolved through getComboModelString(), which already handles every
  step shape and returns null for the ones without a concrete model id.

**Type-only**

- accountSemaphore.ts:203 — isBypassed() returns a plain boolean and cannot
  narrow `number | null` (an `x is null | undefined` predicate would be unsound:
  0 bypasses too). Added resolveActiveCap(), the narrowing companion isBypassed
  is now defined in terms of; the acquire path uses the narrowed value.
- comboStructure.ts:140 — same #8894 widening: `prompt` only exists on a model
  step, so it is now read under a kind check.
- firecrawlQuotaFetcher.ts:136 — the function returns full FirecrawlQuota
  objects but was annotated Promise<QuotaInfo | null>, which made the
  custom-base literal an excess-property error. Widened to the accurate type
  (FirecrawlQuota extends QuotaInfo, so callers are unaffected).

**Fabricated docs (the "Docs sync + fabricated-docs (strict)" HARD failure)**

docs/ops/VM_DEPLOYMENT_GUIDE.md recommended OMNIROUTE_MAX_POOL_SIZE and
OMNIROUTE_DB_POOL_SIZE (#9471). Neither is read anywhere in the codebase.
Replaced with the two knobs that do exist and are already documented in
ENVIRONMENT.md: OMNIROUTE_MEMORY_MB and OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT.

typecheck:core 5 errors -> 0. check:fabricated-docs + check:env-doc-sync OK.
accountSemaphore 6/6, ccr-durable-store 11/11, ccr-protocol 9/9,
combo-fusion-strategy 10/10, combo-fusion-comboref 5/5, combo-fusion-warn 4/4,
firecrawl-executor 7/7, executor-firecrawl-fetch 4/4.

Refs #9298

* fix(tests): type the #3440 vertex helpers instead of `any` (the 3 base ESLint errors)

The "ESLint errors: 3 error(s)" HARD failure in the #9298 verdict is
tests/unit/vertex-functioncall-id-3440.test.ts lines 32/41/50: the three
find*(result: any) walkers. `@typescript-eslint/no-explicit-any` is an ERROR in
tests/ (and open-sse/) since #6218, and this file landed on 2026-08-04 without a
suppressions entry, so every run of `lint:json --max-warnings 0` failed. That
step prints nothing on failure, which is why the gate looked like a silent
crash across the open PRs.

Replaced with a GeminiRequestLike interface describing exactly what the three
walkers traverse (contents[].parts[]), so the assertions keep their meaning and
nothing is cast away.

eslint on the file: clean. Suite: 6/6.

Refs #9298

* docs(proxy): use an RFC 5737 documentation IP in the proxy examples

The #9298 verdict headlines its docs failure with
`L810 [stale-version] 1.2.3: const removed = await failOneproxyProxy("1.2.3.4", 8080)`.
That is a false positive: check-deprecated-versions.mjs matches
`/\bv?[12]\.\d+\.\d+\b/`, and the example IP literal 1.2.3.4 contains "1.2.3".

Swapped both occurrences in PROXY_GUIDE.md (and its pl mirror) for 203.0.113.7,
from the RFC 5737 documentation range that exists precisely for examples — it
cannot collide with a version pattern and is the correct thing to print in docs
regardless. Drift count 64 -> 62; no gate threshold was touched.

The gate that actually FAILED under "Docs sync + fabricated-docs (strict)" was
check:fabricated-docs (the invented pool env vars), fixed in the previous
commit; this one removes the misleading line the verdict quotes.

* test(base): allowlist probeUtils and realign the #7849 suite to the replacement bound

Two more base-reds, both visible only after the migration collision stopped
killing the shards.

**check-db-rules — src/lib/db/probeUtils.ts not classified**

#9541 added probeUtils.ts (transient-error retry for the SQLite corruption
probe). It is imported ONLY by src/lib/db/core.ts, exactly like its siblings
schemaColumns / optimizationSettings / providerNodeSelect, so re-exporting it
through localDb.ts would push callers toward the barrel-import anti-pattern the
gate exists to prevent. Added to INTENTIONALLY_INTERNAL with that rationale.
check-db-rules 22/22, check:db-rules exit 0.

**session-dedup-memory-7849 — pinned a mechanism that was replaced**

7f36b192f0 (#7855 follow-up) swapped the shared "suffix work budget" for the
MAX_SUFFIX_STARTS / MAX_TOTAL_BLOCK_BYTES guards and deleted both the budget and
its SUFFIX_WORK_BUDGET_WARNING string. It updated session-dedup.test.ts but not
this sibling, so 3 of its 4 cases asserted a warning that can no longer be
emitted.

Realigned to the contract that actually survives — which is the invariant #7849
was opened for, not the mechanism:
- the pathological pair must stay BOUNDED (completes in <4s, body intact) —
  measured at ~280ms on the current guards;
- it must FAIL OPEN — original body returned by identity, compressed false,
  stats null (the explanatory zero-savings stats belonged to the removed
  budget path, which skipped before producing any);
- the 512 MiB child fixture must still exit 0 with the full engine chain
  (session-dedup, lite, rtk, headroom, caveman) — that IS the OOM guard — and
  session-dedup must still report its skip, now pinned by prefix since the
  reason string moved with the mechanism.

No threshold was loosened and no case was deleted: 4/4 here, 8/8 on the sibling
session-dedup.test.ts.

Refs #9298

* docs(mcp): bump the tool count to 105 and realign two vitest count pins

Three more base-reds from the same 08-06 batch, all count/contract drift that
the merged PRs left in sibling files.

**Docs Gates (fast-path) — 3 STRICT drifts**

check:docs-counts measures the MCP tool set from live code: it is 105 now
(#8925 added omniroute_create_combo), while README.md, AGENTS.md and
docs/frameworks/MCP-SERVER.md still claimed 104. Updated all five occurrences
(two of them inside SVG alt text). check:docs-all exits 0.

**Vitest (fast-path) — 2 failures**

- open-sse/mcp-server/__tests__/essentialTools.test.ts pinned 11 phase-1 tools;
  #8925 shipped omniroute_create_combo as phase 1, making it 12. Verified by
  enumerating MCP_ESSENTIAL_TOOLS directly.
- tests/unit/autoCombo/provider-family-combos.test.ts pinned the auto/glm
  provider set to [auggie, glm, zai]. #8914 (Devin ACP bridge) added
  devin-cli-agentic, whose catalog (registry/devin/catalog.ts:90-93) advertises
  the glm-5-2* line — so it belongs in the family pool for exactly the reason
  the test's own comment gives for auggie: a no-auth backend that genuinely
  serves a family model is a legitimate member. Expected set updated, invariant
  unchanged.

npm run test:vitest 36/36 files, 340/340 tests.

Refs #9298

* fix(combo,usage,oauth): drain the base-reds the shard fix exposed

With the migration collision and the broken import out of the way the four unit
shards actually run, and a further layer of base-reds became visible on the pure
tip 9995bc4893. Three are production defects.

**Production defects**

- open-sse/services/combo/runtimeUnitCapacity.ts:58 called resolveComboTargets()
  WITHOUT the hidden-model snapshot, so it fell back to the default
  getHiddenModelsByProvider() — a fresh full key_value read PER nested combo-ref
  unit, on every request. #8878 threaded the snapshot through the other call
  sites and missed this one. Threaded it from executeRuntimeUnitCombo (and from
  the dispatchPrelude call site), restoring the one-snapshot-per-request
  invariant combo-hidden-leaf-routing.test.ts pins. 9/9.
- open-sse/services/usage/firecrawl.ts silently ignored its own `apiKey`
  parameter: 91bb6aa619 moved the fetch to
  fetchFirecrawlQuota(connectionId, connection), which reads the key off the
  connection record, so any caller passing the key directly got "Firecrawl API
  key not available". The explicit key is now merged into the connection passed
  down. firecrawl-usage 8/8.
- src/lib/oauth/constants/oauth.ts was missing a RAYCAST entry in PROVIDERS
  while src/lib/oauth/providers/index.ts registers `raycast` (#8895), so every
  consumer reading PROVIDERS did not know Raycast Pro exists. Also added its
  OAUTH_TEST_CONFIG entry (checkExpiry only — it is an `import_token` provider
  with refreshToken always null), which #8408's guard explicitly requires rather
  than grandfathering. oauth-providers-config 25/25, oauth-test-config-8408 2/2.

**Count / contract drift from the same batch**

- feature flags 45 -> 46, APIKEY_PROVIDERS 197 -> 198 (Raycast Pro #8895),
  unique MCP tools 107 -> 108. Each re-derived from the source of truth.
- vi + pt-BR locales: translated the 8 keys #9415 added
  (providers.newApiAggregator* and providers.modelTestQuotaTooltip) instead of
  relaxing the parity guard. i18n-vi 5/5, i18n-pt-br 3/3.
- login-bootstrap-route: #9491 added `authenticated` to the require-login
  payload so /login can redirect an active session; the three deepEqual bodies
  now carry it. 10/10.

**Flaky-by-construction, made deterministic**

tests/unit/chat-combo-live-test.test.ts asserted the early-keepalive frame with
a 100ms mocked upstream while resolveKeepaliveThreshold() is 2000ms for
openai/*. It only ever passed while unrelated handler latency happened to push
the total past the threshold — incidental, not deterministic, and it stopped
holding once the handler got faster. The mock now sleeps 2400ms so the slow path
is guaranteed and the assertion means what it says. 5/5.

typecheck:core exit 0. check:file-size (base-relative) OK.

Refs #9298

* test(base): run the orphaned #8890 suite and realign three mechanism pins

**check:test-discovery — a suite that had NEVER executed**

#8890 landed open-sse/services/__tests__/fail-fast-concurrency-gate.test.ts into
a directory no runner collects (only one explicit file from that folder is in
vitest.mcp.config.ts), so it ran zero times since it merged. Wired it into the
runner AND into check-test-discovery.mjs's mirrored collector list, which the
gate keeps in sync deliberately. It passes 4/4 now that it actually runs —
test:vitest goes 36 -> 37 files, 340 -> 344 tests.

**check-db-rules-classification** — 37 -> 38 audited modules, adding probeUtils
alongside the INTENTIONALLY_INTERNAL entry from the previous commit.

**ratelimit-reservoir-refresh** — #9604 (rolling RPM leases) DELETED Bottleneck's
fixed-window reservoir, so currentReservoir() is null and the poll for
`reservoir === 2` could never settle. It updated several sibling suites but not
this one. The pin on the removed mechanism is gone; what remains is the
invariant the original Bottleneck heartbeat bug actually broke and that #9529
opened this test for — after a header-learned updateSettings() the limiter must
keep admitting work, proven by racing a post-exhaustion request against a 5s
timer. 1/1.

**translator-openai-to-gemini** — #9568 (c9a3361e5a) made
buildChangedToolNameMap emit IDENTITY entries too, because Gemini lowercases
tool names in functionCall responses and the response translator needs a key to
map them back. Any request carrying tools therefore carries `_toolNameMap` in
the Antigravity envelope now. Expected key list updated and the map's contents
asserted explicitly rather than left implicit. 45/45.

Refs #9298

* fix(db): restore node-backed synced catalogs and realign the #8944 context hints

**Production regression from #9294 (d69f521491)**

lookupModelMeta moved from getSyncedAvailableModels(providerId) to
getActiveSyncedCatalog(providerId). The new reader unions models only from rows
in `provider_connections` with isActive = 1 — but a provider NODE lives in
`provider_nodes` and NEVER has a connections row, so filtering by active
connection ids silently dropped every node's synced catalog.

The consequence was not just a missing list: lookupModelMeta reads that catalog
for RUNTIME METADATA, so for openai-compatible nodes it took out
- `supportedThinkingEfforts`, which is what splitSyncedEffortSuffix needs — so
  `<prefix>/<model>-high` stopped resolving to the base id and the effort was
  never derived (#7694), and
- `contextWindow` / `maxInputTokens`, used by the combo context-window filter.

getActiveSyncedCatalog now falls back to the provider-wide key_value set — the
exact pre-#9294 source — when no active connection carries a catalog, and marks
that fallback explicitly NON-authoritative. #9294's live-catalog gating is about
what an active connection actually serves, so a node-backed catalog informs
metadata while never being able to reject a model as unavailable. `available`
therefore stays fail-open for nodes, as it was before.

sync-reasoning-supported-efforts-7694 23/23 (was 21/2).
live-model-catalog-reconciliation-8926 11/11 and combo-provider-wildcard 23/23
confirm #9294's own coverage is untouched.

**#8944 sibling-test drift**

714a315a1a ("Treat context metadata as a routing hint") deliberately turned the
context-window check from a HARD filter into an ordering hint: a catalog-too-small
target is demoted, not removed, because a stale catalog entry must never delete
the only target that could accept the request at runtime. The PR updated one case
in this suite and left three asserting the old drop behaviour. Realigned to the
new contract — the too-small target must lose the ordering to the fitting one
while remaining present — and renamed them from "still rejects"/"still dropped"
to "is demoted"/"ordered last" so the names stop describing the removed
behaviour. 14/14.

**file-size**

tests/unit/translator-openai-to-gemini.test.ts testFrozen 1616 -> 1619: the
frozen value sat exactly at the base size, so the 3 lines the previous commit's
_toolNameMap alignment needs could not fit. Justified in the baseline.

typecheck:core exit 0.

Refs #9298

* chore(stryker): register the two covering suites missing from tap.testFiles

check:mutation-test-coverage flags any unit test that covers a mutated module but
is absent from stryker.conf.json tap.testFiles — without the entry its mutant
kills do not count toward the module's score.

- tests/unit/antigravity-prefer-stored-project.test.ts covers
  open-sse/services/combo/quotaStrategies.ts (added earlier in this PR).
- tests/unit/executor-devin-cli-agentic-acp.test.ts covers
  src/sse/services/auth.ts — pre-existing drift, same gate, same fix.

Inserted in alphabetical position only; the rest of the file is byte-identical
(it is not prettier-formatted upstream and reformatting it is out of scope here).

Refs #9298

* fix(db): drop the never-wired getSessionModelUsageCounts (knip regression)

The dead-code ratchet only ran once the earlier Fast Quality Gates steps stopped
failing, and it lands at 228 vs baseline 227.

The extra symbol is src/lib/db/contextHandoffs.ts::getSessionModelUsageCounts,
added by #8894 "for least-used strategy" and never wired: the least-used branch
in applyStrategyOrdering.ts uses the pre-existing sortTargetsByUsage(), and the
helper has no caller in src/, open-sse/ or tests/. It is the same incomplete-PR
shape as that PR's import of a module which does not exist in the repo (fixed
earlier in this branch).

Removed rather than baselined — bumping the ratchet would loosen the gate, and
removal is exactly the remedy the gate prescribes. Same treatment the Dario
installer's never-wired uninstall() got in #9600. The implementation is
recoverable from a598fbb090 whenever someone actually wires a session-aware
least-used strategy.

check:dead-code 228 -> 227 (baseline untouched). check:db-rules exit 0.
context-handoff 13/13, db-context-handoffs 7/7, service-context-handoff 11/11.

Refs #9298

* fix(security): embed the Raycast signature secret via resolvePublicCred (HR#11)

The secret-scan ratchet only ran once the earlier Fast Quality Gates steps
stopped failing, and it lands at 1 finding vs baseline 0.

The finding is open-sse/services/raycast.ts:19 —
RAYCAST_DEFAULT_SIG_SECRET, a 64-hex request-signature secret that #8895
committed as a bare string literal. It is genuinely public (community-extracted
from the Raycast macOS client; the SAME value ships to every install, it is not
a per-user credential), which is exactly the category Hard Rule #11 governs:
public upstream credentials MUST go through resolvePublicCred()
(open-sse/utils/publicCreds.ts), never a literal — see
docs/security/PUBLIC_CREDS.md.

So the fix is the mandated pattern, not a .gitleaks.toml allowlist entry: added
`raycast_sig_secret` to EMBEDDED_DEFAULTS as the XOR-masked byte sequence and
resolved it with the existing RAYCAST_SIG_SECRET env override. The
providerSpecificData.sigSecret override is untouched. Verified the decoded value
is byte-identical to the literal it replaces.

check:secrets secretFindings 1 -> 0. check:public-creds exit 0.
publicCreds 12/12, raycast-auth 6/6, raycast-local-extract 1/1,
trae-publiccred 3/3. typecheck:core exit 0.

Refs #9298

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-07 11:13:20 -03:00
Diego Rodrigues de Sa e Souza
d86ea99713 feat(radar): referral links — free-credits tab + default provider link (#9697)
* feat(radar): client-side schema + accessor for referral links (D28)

Server already publishes a signed `referrals` section on the Radar feed
({fixed, campaigns}); this adds the client mirror: RadarFeedSchema gains a
`.default()`-backed `referrals` field (old cached feeds without it stay
valid) with https-only url validation, and src/lib/radar/index.ts exposes
getRadarReferrals()/getDefaultReferralFor() (never throw: flag off, no
cache, or a corrupt/old payload all resolve to the empty shape). The
provider-default lookup itself lives in a new DB-free src/lib/radar/
referrals.ts so it stays safe to import from a "use client" component.

* feat(radar): add GET /api/radar/referrals route (D28)

Local-only route mirroring the /api/radar/catalog gate order: RADAR_ENABLED
off => 404 before any auth check (byte-identical flag-off inertia),
unauthenticated => 401, otherwise 200 with {fixed, campaigns, tier} read
straight from the local cache. Never proxies the private feed server.

* feat(dashboard): add "free credits" tab to the Radar page (D28)

Reuses the existing /dashboard/radar page instead of a new route (less
routing/i18n surface): a second tab lists fixed referral links (grouped by
provider, with requiredAction + an external-link button) and temporary
campaigns (with validUntil). When campaigns is empty and the served tier is
community, shows a soft upsell note — never gates the fixed links list,
which stays fully populated on every tier. Adds 10 new radarPage i18n keys
(English fallback) to all 43 locale files to avoid dropping i18n-ui-coverage
below threshold.

* feat(providers): use Radar default referral link on the provider name (D28)

ProviderPageHeader already linked the provider name to providerInfo.website
with a precedent for a monetized link (the Kimi partner-link note); this
lets a Radar default referral override that URL, reusing the exact same
discreet note instead of a new visual treatment.

Loose coupling: resolveProviderHeaderLink() in providerPageUtils.ts is a
pure function with no @/lib/radar or @/lib/db/* import (asserted by the new
test), so the providers dashboard never depends on the DB-touching Radar
module to render. ProviderDetailPageClient (a "use client" component) is
the only place that fetches Radar data, via the local /api/radar/referrals
route (same pattern the Radar page itself uses) and the DB-free
findDefaultReferral() helper. With RADAR_ENABLED off, no cache, or no
default referral for the provider, the header renders byte-identical to
before this feature existed.

* docs(radar): document referral links / free credits (D28)

Adds a "Referral links (free credits)" section covering the referrals feed
shape, the getRadarReferrals()/getDefaultReferralFor() accessors, the new
GET /api/radar/referrals route, the Radar page's "Free credits" tab, and
the loosely-coupled referral link on the provider-name header. Also
corrects the local-routes count (four -> five) now that /api/radar/
referrals exists.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-07 10:17:46 -03:00
Diego Rodrigues de Sa e Souza
1e15583f29 fix(radar): close audit gaps (auth, feed fields, opt-in state, sidebar gate, size cap) + daily sync scheduler (#9686)
* fix(radar): preserve extended feed fields and honor local enable override

applyFeed()'s MergedEntry shape omitted contextWindow/capabilities/limits/
setup even though FeedModel always carries them, so the dashboard's setup
link, Context column, and capability badges never rendered and the setup
page's provider lookup always failed. Both merge paths (mergeOne and
feedModelToMerged) now copy the four fields through, respecting rule 1
(local override wins) same as every other field.

feedModelToMerged() also unconditionally forced enabled:false when the feed
disabled a feed-only entry, even when the operator had locally overridden
enabled:true — mergeOne() already applies overrides after the disable rule
and got this right. feedModelToMerged() now only force-disables when there
is no local `enabled` override, matching mergeOne()'s semantics.

* fix(radar): cap feed sync response body at 10MB

syncRadar() buffered the entire feed response via
Buffer.from(await res.arrayBuffer()) with no size limit, so a
misconfigured or hostile RADAR_FEED_URL (or an upstream serving garbage)
could force an unbounded in-memory buffer. Enforcement is two-layered: a
Content-Length preflight skips reading an already-oversized body entirely,
and a running-total check while reading the stream enforces the cap even
when Content-Length is absent or understates the real size — concatenating
the accumulated chunks preserves the exact bytes the signature check needs.

Exceeding the cap returns a new { status: "too_large" } SyncStatus and
leaves the cache untouched, following the same non-destructive pattern as
every other sync failure (invalid_signature/invalid_schema/stale).

* fix(radar): gate the sidebar radar item behind RADAR_ENABLED

The "radar" sidebar item was registered unconditionally in
sidebarVisibility/sections.ts, but Sidebar.tsx has no feature-flag
awareness (it's a client component), so the link stayed visible and
clickable with RADAR_ENABLED off, landing on a 404 dashboard page.

Sidebar items gain an opt-in `featureFlagKey` field plus a pure
isSidebarItemVisibleForFlags() filter (fails open when a flag isn't in the
map, so a missing/not-yet-loaded key never hides an unrelated item). The
resolved flag value piggy-backs on the /api/settings response the sidebar
already fetches on mount (new `radarEnabled` field) rather than adding a
dedicated round trip.

* fix(radar): require auth on management routes, add GET settings

GET /api/radar/catalog, POST /api/radar/sync, and POST /api/radar/settings
had zero authentication — any client that could reach the local server
could read the merged catalog, trigger a sync, or flip the opt-in/set the
supporter key. All three (plus the new GET below) now call
isAuthenticated() from the shared apiAuth guard, same gate as the rest of
/api/settings/*. The RADAR_ENABLED flag-off 404 check keeps running FIRST
so flag-off inertia stays byte-identical (no auth prompt just to learn the
surface doesn't exist); auth runs after it, before any DB access.

Adds GET /api/radar/settings, returning { optIn, hasSupporterKey,
supporterKeyMasked } — the raw key never leaves the server on either verb.
The dashboard page's fetchSettings() now calls this endpoint instead of
inferring opt-in state from the catalog response (which always defaulted
to unknown/null), so an already-activated operator no longer sees the
activation screen on every reload. handleSync() also handles the new
too_large sync status introduced by the response-cap fix, reusing the
existing generic sync-failed copy (no new UI strings).

* docs(radar): fix stale feed URL, document tier header/auth/size cap

- RADAR_FEED_URL default was documented as radar.omniroute.dev in
  ENVIRONMENT.md; the actual default (src/lib/radar/sync.ts) and every
  other reference use radar.omniroute.online — fix the one stale spot.
- Correct the FREE_MODEL_BUDGETS source path: it's declared in
  freeModelCatalog.data.ts, not freeModelCatalog.ts (which only
  re-exports it).
- Document that the signed feed body's `tier` is always "live" (one
  signed artifact per version) and the actually-served tier comes from
  the `x-omniroute-feed-tier` response header, resolved with a Zod parse
  + fallback to the body field.
- Document that all four /api/radar/* routes now require auth
  (isAuthenticated(), same gate as /api/settings/*), the new
  GET /api/radar/settings route, and the new too_large sync status from
  the 10MB response cap.

* feat(radar): daily sync scheduler + auto-sync on page open

Spec asks for a 1x/day sync while opted in and fresh data on every page
open. The scheduler only arms itself when RADAR_ENABLED AND the opt-in are
already on (boot) or right after the user opts in (settings route) — a
flag-off install never creates the timer, preserving the inertia contract.
The page auto-syncs once per mount when the cached feed is older than 6h.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-07 08:26:18 -03:00
diegosouzapw
aec0de28be fix(eslint): prune stale suppressions after base merge
Refs #9673
2026-08-07 01:21:56 -03:00
diegosouzapw
5a6787f0f9 fix(docs): document FORGOTTEN_SIBLING_MAX_CHANGED in .env.example and ENVIRONMENT.md
Refs #9673
2026-08-07 01:20:15 -03:00
diegosouzapw
33f0336d39 fix(pr): rename changelog.d/feats/ to features/ and fix fragment format
- Rename changelog.d/feats/ -> changelog.d/features/ (validator rejects 'feats')
- Fix 9415 fragment: body must start with a markdown bullet

Refs #9673
2026-08-07 01:04:57 -03:00
diegosouzapw
976c8fa054 Merge remote-tracking branch 'origin/release/v3.8.50' into babysit/pr-9673 2026-08-06 23:42:35 -03:00
Diego Rodrigues de Sa e Souza
9995bc4893 fix(security): anchor hostname comparison in Adobe Firefly login (#778)
Parse and compare hostname with dot-anchored endsWith instead of substring includes. Closes code-scanning #778.
2026-08-06 23:12:54 -03:00
Diego Rodrigues de Sa e Souza
c9a3361e5a fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)
Closes #9575
2026-08-06 22:58:51 -03:00
Diego Rodrigues de Sa e Souza
c9debe92bd fix(translator): restore original tool name casing in Gemini response translators (#9568)
Closes #9568
2026-08-06 22:55:50 -03:00
Diego Rodrigues de Sa e Souza
fad3539a69 fix(sse): replace timer-based waits with polling to fix flaky chatCore/SSE tests under CI load (#9567)
Closes #9567
2026-08-06 22:55:44 -03:00
Diego Rodrigues de Sa e Souza
919f9acd80 fix(build): lazy-resolve module-level fs paths to avoid Turbopack NFT whole-source trace (#9560)
Closes #9560
2026-08-06 22:55:38 -03:00
Diego Rodrigues de Sa e Souza
8a573c56e3 fix(proxy): NO_PROXY now bypasses context-level proxy in resolveProxyForRequest (#9551)
Closes #9551
2026-08-06 22:55:33 -03:00
Diego Rodrigues de Sa e Souza
f338363cd3 fix(model): add aq alias for amazon-q provider so parseModel resolves it instead of falling back to OpenAI (#9550)
Closes #9550
2026-08-06 22:55:27 -03:00
Diego Rodrigues de Sa e Souza
28dc5af7ba fix(providers): strip provider prefix in getModelTargetFormat to route GPT-5.6 models to /v1/responses (#9545)
Closes #9545
2026-08-06 22:55:21 -03:00
Diego Rodrigues de Sa e Souza
616175a93e fix(search): mark searxng-search as fallbackOnly to prevent auto-select without instance (#9543)
Closes #9543
2026-08-06 22:55:16 -03:00
Diego Rodrigues de Sa e Souza
e4e0c254ea fix(db): add transient-error retry to corruption probe to prevent data loss under concurrent load (#9541)
Closes #9541
2026-08-06 22:55:10 -03:00
Diego Rodrigues de Sa e Souza
2e9abab944 fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)
Closes #9536
2026-08-06 22:55:05 -03:00
Diego Rodrigues de Sa e Souza
4cc9cf8123 fix(test): prevent flaky modelsDevSync timer assertions by serializing test execution within the file (#9534)
Closes #9534
2026-08-06 22:54:59 -03:00
Diego Rodrigues de Sa e Souza
6c3aea6ba6 fix(ci): include combo-matrix tests in test-integration job (#9531)
Closes #9531
2026-08-06 22:54:53 -03:00
Diego Rodrigues de Sa e Souza
9edefd4572 fix(oauth): Kiro import token endpoint no longer overwrites existing connection when using shared cached OIDC clientId (#9435)
Closes #9435
2026-08-06 22:54:48 -03:00
Diego Rodrigues de Sa e Souza
8fbd331567 fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946)
Closes #8946
2026-08-06 22:54:42 -03:00
diegosouzapw
6c5329bcfe feat(ci): add forgotten-sibling-tests quality gate (#9530)
New CI policy gate that detects when a PR changes source file Y but the test siblings of Y's consumers are not in the same diff. Prevents the 'forgotten sibling test' pattern (7 documented occurrences in PR #9529).

- Extracts shared import resolution library (resolveImport, sourceDepsOf) from build-test-impact-map.mjs
- Creates check-forgotten-sibling-tests.mjs gate with consumer-walk logic
- Adds forgotten-sibling-allowlist.json for false positive mitigation
- Wires into pr-test-policy CI job and adds npm script
- Documents in QUALITY_GATES.md
- 17 TDD unit tests for isSourceFile, resolveBase, testSiblingOf, isAllowlisted, findConsumers
2026-08-06 21:53:00 -03:00
diegosouzapw
535c75b60a fix(security): parse and compare hostname instead of substring match in Adobe Firefly login
Replace request.url.includes(FIREFLY_3P_HOST_SUFFIX) with parsed-hostname
comparison (anchored endsWith), closing CodeQL alert #778.

The old substring check could be bypassed by an attacker-controlled page
visited during the browser login window — a URL like
'https://evil.com/firefly-3p.ff.adobe.io' would pass the gate and its
Bearer token would be captured as the Adobe credential.

Practical severity is low (only during operator-initiated, time-boxed
login on a temp-profile browser), but the fix is one line and matches
the dot-anchored idiom used in docker/devin-bridge/network-guard/.

Closes code-scanning #778.
2026-08-06 19:09:49 -03:00
223 changed files with 6570 additions and 978 deletions

View File

@@ -2483,3 +2483,8 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# signature, replacing the pinned default key. Required when self-hosting a
# feed signed with a different key pair.
# RADAR_FEED_PUBKEY=
# Threshold for the forgotten-sibling-tests quality gate. When the number of
# changed source files in a PR exceeds this value, the consumer scan is skipped
# (typically for release PRs or mass refactors). Default is 300.
# FORGOTTEN_SIBLING_MAX_CHANGED=300

1
.eslintcache-probe Normal file

File diff suppressed because one or more lines are too long

View File

@@ -587,6 +587,9 @@ jobs:
# Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests.
- name: Detect test-masking (weakened assertions)
run: npm run check:test-masking
# Detect forgotten sibling tests (consumers whose test sibling is not in the diff).
- name: Detect forgotten sibling tests
run: npm run check:forgotten-sibling-tests
# Evidence-in-PR-body (Hard Rule #18 mechanized): claims of "tests pass" must carry output.
- name: Require evidence in PR body
run: npm run check:pr-evidence
@@ -1213,8 +1216,10 @@ jobs:
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
# (tsx/esm = QW-b; o alinhamento de ESCOPO do integration com o npm script fica p/ follow-up)
- run: node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
- name: Integration tests (shard ${{ matrix.shard }}/2)
env:
TEST_SHARD: ${{ matrix.shard }}/2
run: npm run test:integration:ci
test-security:
name: Security Tests

View File

@@ -58,7 +58,7 @@ Repository map and Reference Documentation sections below.
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (130 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 104 tools (42 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 scopes |
| MCP Server | `open-sse/mcp-server/` | 105 tools (42 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
| Skills | `src/lib/skills/` | Extensible skill framework |
| Memory | `src/lib/memory/` | Persistent conversational memory |

0
MAX Normal file
View File

View File

@@ -188,7 +188,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 291 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 291 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 40+ free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 104 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 291 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 291 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 40+ free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 105 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
<br/>
<br/>
@@ -439,7 +439,7 @@ All **19** strategies — mix & match per combo step:
</div>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 291 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 104 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project&apos;s docs."/>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 291 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 105 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project&apos;s docs."/>
<sub>📊 Full methodology &amp; per-feature detail vs 9router, OpenRouter, CLIProxyAPI &amp; LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
@@ -723,7 +723,7 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo
<table>
<tr><th align="left">Interface</th><th align="left">Endpoint / command</th><th align="left">Use it for</th></tr>
<tr><td align="left" nowrap>🧰 <b>MCP (stdio)</b></td><td align="left" nowrap><code>omniroute --mcp</code></td><td align="left">Plug into Claude Desktop, Cursor, any MCP client</td></tr>
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>104 tools</b>, 31 scopes, full audit trail</td></tr>
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>105 tools</b>, 31 scopes, full audit trail</td></tr>
<tr><td align="left" nowrap>📡 <b>MCP (SSE)</b></td><td align="left" nowrap><code>/api/mcp/sse</code></td><td align="left">Streaming MCP transport</td></tr>
<tr><td align="left" nowrap>🤝 <b>A2A</b></td><td align="left" nowrap><code>/.well-known/agent.json</code></td><td align="left">Agent-to-agent, <b>JSON-RPC 2.0</b> + SSE, 6 skills</td></tr>
<tr><td align="left" nowrap>🌐 <b>REST API</b></td><td align="left" nowrap><code>/v1/*</code></td><td align="left">OpenAI-compatible — chat, embeddings, images, audio, OCR</td></tr>

View File

@@ -1,6 +1 @@
---
kind: feature
ref: "#9415"
---
New-API / One-API / Sub2API aggregator balance detection for compatible nodes. When a compatible provider node has the "Aggregator Gateway" toggle enabled, OmniRoute will query the aggregator's `/api/user/self` endpoint to detect the account balance. The dashboard shows the balance badge and quota-preflight routing skips exhausted accounts. The feature is gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default: off). A custom `quotaPerUnit` override is supported for aggregators that use a different rate than the default 500000 units/$1.
- **sse:** New-API / One-API / Sub2API aggregator balance detection for compatible nodes — with the "Aggregator Gateway" toggle on, OmniRoute queries the aggregator's `/api/user/self` to read the account balance, shows it as a dashboard badge and lets quota-preflight routing skip exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default off), with a `quotaPerUnit` override for aggregators that do not use the default 500000 units/$1 rate ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415))

View File

@@ -0,0 +1 @@
- **feat(ci):** new quality gate `check-forgotten-sibling-tests` detects when a source symbol changes but consumer tests are not in the same diff — preventing the 7 documented "forgotten sibling test" occurrences from PR #9529 ([#9530](https://github.com/diegosouzapw/OmniRoute/issues/9530))

View File

@@ -0,0 +1 @@
- fix(yuanbao-web): accept `content` field in SSE text events (upstream format change) (#8739)

View File

@@ -0,0 +1 @@
- fix(errorClassifier): classify ChatGPT Web SENTINEL_BLOCKED 403 as terminal FORBIDDEN, enabling proper combo fallback (#8813)

View File

@@ -0,0 +1 @@
- fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946)

View File

@@ -0,0 +1 @@
- fix(vertex): route Claude models to native rawPredict endpoint and respect custom targetFormat overrides (#8994)

View File

@@ -0,0 +1 @@
- fix(cursor): preserve tool context across multi-turn conversations when client lacks conversation_id (#9029)

View File

@@ -0,0 +1 @@
- fix(sse): move Antigravity client system content to first user message to avoid upstream 429 RESOURCE_EXHAUSTED on oversized systemInstruction (#9030)

View File

@@ -0,0 +1 @@
- fix(oauth): Kiro import token endpoint no longer overwrites existing connection when using shared cached OIDC clientId (#9435)

View File

@@ -0,0 +1 @@
- fix(ci): include combo-matrix tests in test-integration job (#9531)

View File

@@ -0,0 +1 @@
- fix(test): prevent flaky modelsDevSync timer assertions by serializing test execution within the file (#9534)

View File

@@ -0,0 +1 @@
- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)

View File

@@ -0,0 +1 @@
- fix(db): add transient-error retry to corruption probe to prevent data loss under concurrent load (#9541)

View File

@@ -0,0 +1 @@
- fix(search): mark searxng-search as fallbackOnly to prevent auto-select without instance (#9543)

View File

@@ -0,0 +1 @@
- fix(providers): strip provider prefix in getModelTargetFormat to route GPT-5.6 models to /v1/responses (#9545)

View File

@@ -0,0 +1 @@
- fix(model): add "aq" alias for amazon-q provider so parseModel resolves it instead of falling back to OpenAI (#9550)

View File

@@ -0,0 +1 @@
- fix(proxy): NO_PROXY now bypasses context-level proxy in resolveProxyForRequest (#9551)

View File

@@ -0,0 +1 @@
- fix(build): lazy-resolve module-level fs paths to avoid Turbopack NFT whole-source trace (#9560)

View File

@@ -0,0 +1 @@
- fix(sse): replace timer-based waits with polling to fix flaky chatCore/SSE tests under CI load (#9567)

View File

@@ -0,0 +1 @@
- **fix(translator):** restore original tool name casing in Gemini/Antigravity response translators ([#9568](https://github.com/diegosouzapw/OmniRoute/issues/9568))

View File

@@ -0,0 +1 @@
- fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)

View File

@@ -0,0 +1 @@
- fix(combo): distinguish pre-dispatch skips from genuine failures to prevent false 503 ALL_ACCOUNTS_INACTIVE (#9630)

View File

@@ -1,5 +1,4 @@
{
"_comment": "Congelamento da divida ESLint da migracao TypeScript 7 (release/v3.8.50, 2026-08-05; regenerado 2026-08-06 apos prune de entradas orfas). Gerado pelo modo nativo `eslint --suppress-all --suppressions-location config/quality/eslint-suppressions.json` (NODE_OPTIONS=--max-old-space-size=12288). Politica: violacao PRE-EXISTENTE fica suprimida aqui; violacao NOVA (fora deste arquivo) e vermelho imediato e deve ser corrigida, nunca adicionada. Entradas que deixarem de ocorrer sao podadas com `eslint --prune-suppressions` (o job 'No new ESLint warnings' falha com supressoes orfas). A baseline eslintWarnings em config/quality/quality-baseline.json e 0 — o valor real medido com estas supressoes aplicadas.",
"open-sse/executors/blackbox-web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -2080,11 +2079,6 @@
"count": 5
}
},
"tests/unit/combo-cache-invalidation.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"tests/unit/combo-context-length.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -3328,11 +3322,6 @@
"count": 2
}
},
"tests/unit/vertex-functioncall-id-3440.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"tests/unit/vertex-media.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
@@ -3363,4 +3352,4 @@
"count": 5
}
}
}
}

View File

@@ -371,7 +371,7 @@
"tests/unit/model-sync-route.test.ts": 1016,
"tests/unit/models-catalog-route.test.ts": 1636,
"tests/unit/perplexity-web.test.ts": 1355,
"tests/unit/provider-models-route.test.ts": 1784,
"tests/unit/provider-models-route.test.ts": 1787,
"tests/unit/provider-validation-specialty.test.ts": 2985,
"tests/unit/providers-page-utils.test.ts": 1106,
"tests/unit/response-sanitizer.test.ts": 1063,
@@ -381,7 +381,7 @@
"tests/unit/stream-utils.test.ts": 2445,
"tests/unit/token-refresh-service.test.ts": 1378,
"tests/unit/translator-openai-responses-req.test.ts": 1194,
"tests/unit/translator-openai-to-gemini.test.ts": 1616,
"tests/unit/translator-openai-to-gemini.test.ts": 1619,
"tests/unit/translator-openai-to-kiro.test.ts": 1275,
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1234,
"tests/unit/usage-service-hardening.test.ts": 1483,
@@ -528,7 +528,7 @@
"open-sse/executors/base.ts": 1640,
"open-sse/executors/chatgpt-web.ts": 3241,
"open-sse/executors/codex.ts": 1562,
"open-sse/executors/cursor.ts": 1560,
"open-sse/executors/cursor.ts": 1563,
"open-sse/executors/deepseek-web.ts": 1148,
"open-sse/executors/grok-web.ts": 1044,
"open-sse/executors/muse-spark-web.ts": 1405,
@@ -607,5 +607,7 @@
"_rebaseline_2026_08_05_9323_agentrouter_waf_retry": "PR #9323 (fix(agentrouter): retry on 400 content-blocked + burst guard) own growth: open-sse/executors/base.ts 1578->1623 (check-file-size.mjs conta via split(\"\\n\").length; wc -l ve 1622). As +45 linhas sao o WAF_RETRY_CONFIG + o burst guard via gateOutboundRequest() para o WAF do agentrouter.org, com comentarios explicando o porque de cada mitigacao e cobertos por tests/unit/base-executor-waf-retry.test.ts e tests/unit/wafRateLimit.test.ts. Crescimento funcional legitimo, nao inchaco.",
"_rebaseline_2026_08_05_9529_own_growth": "PR #9529 own growth (base release/v3.8.50 medida EXATAMENTE nos frozen antigos, entao o modo base-relative #8522 nao cobre): open-sse/services/rateLimitManager.ts 1060->1105 (+45: helper applyLimiterSettings() que re-arma o heartbeat do reservoir apos updateSettings — fix do bug Bottleneck 2.19.5 que congelava a fila weighted; TDD em tests/unit/ratelimit-reservoir-refresh.test.ts); tests/integration/chat-pipeline.test.ts 1592->1598 (+6: User-Agent do codex derivado de getCodexClientVersion() em vez de literal pinado — teste-irmao alinhado ao contrato); tests/unit/provider-validation-specialty.test.ts 2980->2985 (+5: cobertura NOVA claude-web 429 -> valid:false, alinhamento #9406); open-sse/translator/response/openai-responses.ts 1174->1204 (+30: buildResponsesReasoningSummaryDelta MOVIDA do leaf pureHelpers.ts para o host — a funcao do #9500 muta stream state e violava o contrato do leaf puro; o LOC total do par host+leaf nao cresceu, o pureHelpers encolheu o mesmo tanto). Crescimento por fix de producao + cobertura adicional + realocacao arquitetural, nao inchaco.",
"_rebaseline_2026_08_06_v3850_inherited_drift_reconcile": "Reconciliacao 2026-08-06 do drift ACUMULADO da release/v3.8.50 apos o lote de merges de 08-05/06: 13 arquivos acima do frozen no tip puro 8180b49ce1 (medidos pelo proprio gate). O modo PR base-relative (#8522) deixa PRs inocentes passarem, e os rebaselines individuais dos PRs se perderam nas resolucoes sucessivas de conflito deste hot-file — o drift so aparece no modo absoluto (nightly/local). Crescimentos funcionais dos PRs mergeados: #9024 topology click-nav src/app/(dashboard)/dashboard/HomePageClient.tsx; #9324 OpenRouter enrich src/app/(dashboard)/dashboard/providers/page.tsx; #9329 quota card ordering src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx; #9193 context-window suffixes src/sse/handlers/chat.ts; #9332 nested Claude server tool ids open-sse/executors/base.ts; #9228 strip orphaned tool outputs open-sse/executors/codex.ts; #9236 nvidia tool-name normalize open-sse/executors/default.ts; #9314 nested tool_call validation open-sse/executors/kiro.ts; #9260 caller identity REST hops open-sse/mcp-server/server.ts; #8934 cache breakpoints tests tests/unit/chatcore-translation-paths.test.ts; #9193 suffix tests tests/unit/combo-routing-engine.test.ts; #9196 reasoning-on-tool-finish tests tests/unit/sse-auth.test.ts; #9163 GPT-5.6 Max reasoning tests tests/unit/translator-openai-to-kiro.test.ts. default.ts e kiro.ts entram no frozen (estavam sem entrada, acima do cap 1000). Atualizacao pos-medicao (a base avancou durante o ciclo do PR): src/sse/handlers/chat.ts 1857->1877 (#9184 affinity EOF evict) e open-sse/executors/default.ts 1027->1042 (#9005 Kimi K3 tool-name backfill).",
"_rebaseline_2026_08_06b_v3850_sweepreds_drift": "Segunda reconciliacao de 2026-08-06 (/sweep-reds sobre o tip puro 2ddbbc61a6): 3 arquivos voltaram a passar do frozen apos os merges do mesmo dia, com atribuicao 1:1 por commit. (1) src/app/(dashboard)/dashboard/providers/page.tsx 1928->1944 e (2) open-sse/executors/base.ts 1635->1640, ambos do #9515 (feat(radar): flag-gated signed free-model catalog overlay, commit e7f6b1d130) — o overlay do Radar entra por wiring nos chokepoints ja existentes (a resolucao/verificacao do catalogo assinado mora fora destes dois arquivos); +16 e +5 linhas liquidas nao sao extraiveis sem inventar um leaf por callsite. (3) open-sse/services/accountFallback.ts 1966->1972 do #8704 (commit c4527f97bd), +6 linhas de dados em CREDITS_EXHAUSTED_SIGNALS ('has been exhausted', fixes #8631). src/sse/handlers/chat.ts 1880>1877 tambem estava violando e NAO entra aqui de proposito: e drenado por encolhimento na PR #9598, sem rebaseline. Crescimento proprio DESTA PR: src/lib/db/migrationRunner.ts 1077->1084 (+7) — o guard retroativo em isSchemaAlreadyApplied para os arquivos renumerados 137/138, exigido pela propria mensagem de erro de colisao do runner (ambas as migracoes sao ALTER TABLE ADD COLUMN puro, nao idempotente). Dois `case` + dois `return hasColumn(...)` + 3 linhas de comentario dentro do switch existente; nao extraivel."
"_rebaseline_2026_08_06b_v3850_sweepreds_drift": "Segunda reconciliacao de 2026-08-06 (/sweep-reds sobre o tip puro 2ddbbc61a6): 3 arquivos voltaram a passar do frozen apos os merges do mesmo dia, com atribuicao 1:1 por commit. (1) src/app/(dashboard)/dashboard/providers/page.tsx 1928->1944 e (2) open-sse/executors/base.ts 1635->1640, ambos do #9515 (feat(radar): flag-gated signed free-model catalog overlay, commit e7f6b1d130) — o overlay do Radar entra por wiring nos chokepoints ja existentes (a resolucao/verificacao do catalogo assinado mora fora destes dois arquivos); +16 e +5 linhas liquidas nao sao extraiveis sem inventar um leaf por callsite. (3) open-sse/services/accountFallback.ts 1966->1972 do #8704 (commit c4527f97bd), +6 linhas de dados em CREDITS_EXHAUSTED_SIGNALS ('has been exhausted', fixes #8631). src/sse/handlers/chat.ts 1880>1877 tambem estava violando e NAO entra aqui de proposito: e drenado por encolhimento na PR #9598, sem rebaseline. Crescimento proprio DESTA PR: src/lib/db/migrationRunner.ts 1077->1084 (+7) — o guard retroativo em isSchemaAlreadyApplied para os arquivos renumerados 137/138, exigido pela propria mensagem de erro de colisao do runner (ambas as migracoes sao ALTER TABLE ADD COLUMN puro, nao idempotente). Dois `case` + dois `return hasColumn(...)` + 3 linhas de comentario dentro do switch existente; nao extraivel.",
"_rebaseline_2026_08_06c_v3850_sweepreds_pr2": "Segunda PR do /sweep-reds (fix/release-v3.8.50-basereds-0806b): tests/unit/provider-models-route.test.ts 1784->1787 (medido pelo gate, que conta split(\"\\n\").length) (+2 apos compressao de comentarios) — alinhamento de contrato forcado por dois merges do dia: #9106 tornou gemini-3.1-pro-high user-callable (a entry do alias entra na lista esperada do teste de discovery-retry, +1 linha de dado + 1 de comentario) e ff012ff420 adicionou onboardUser como bootstrap hop (exclusao no mock, ja comprimida a 1 linha). Nao ha o que encolher sem apagar o comentario que explica o porque.",
"_rebaseline_2026_08_07_v3850_sweepreds_pr2_toolnamemap": "tests/unit/translator-openai-to-gemini.test.ts 1616->1619 (+3). O frozen estava EXATAMENTE no tamanho da base, entao qualquer linha nova viola. #9568 (c9a3361e5a) fez buildChangedToolNameMap emitir entradas IDENTIDADE (o Gemini minusculiza nomes de tool nas respostas, entao o tradutor de resposta precisa da chave para mapear de volta), o que passou a incluir `_toolNameMap` no envelope Antigravity de qualquer request com tools. As 3 linhas sao: a chave nova na lista esperada de Object.keys, 1 comentario explicando POR QUE ela aparece (sem ele o proximo leitor tenta remove-la de novo) e 1 assert do CONTEUDO do map — presenca de chave sozinha nao provaria a entrada identidade, que e justamente o comportamento novo. Nao ha o que extrair: e alinhamento de contrato dentro de um teste existente."
}

View File

@@ -0,0 +1,10 @@
{
"_comment": "Forgotten-sibling-tests allowlist (check-forgotten-sibling-tests.mjs). Each entry exempts a (sourcePath, forgottenSibling) pair. Use when a consumer test legitimately does not need changes (compatible refactor, same interface). Every entry needs a reason with the tracking issue or PR ref. Wildcard sourcePath=\"*\" exempts the sibling regardless of which source triggered it.",
"_schema": [
{
"sourcePath": "src/lib/example.ts",
"forgottenSibling": "tests/unit/lib/example-consumer.test.ts",
"reason": "Issue #1234: compatible interface change only — consumer tests verify contract, not implementation"
}
]
}

View File

@@ -176,11 +176,12 @@ Full i18n validation matrix (one job per locale). Entire job is advisory.
Runs on pull requests only.
| Script | Validates | Blocking |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------- |
| `check:pr-test-policy` | PRs that change production code in `src/`, `open-sse/`, `electron/`, or `bin/` must include or update tests (Hard Rule #8) | Yes |
| `check:test-masking` | Changed test files do not reduce net assert count or add `assert.ok(true)` tautologies | Yes |
| `check:pr-evidence` | PR body cites test/VPS evidence for the change (mechanizes Hard Rule #18 by grepping PR prose — fragile, see Backlog) | Yes |
| Script | Validates | Blocking |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `check:pr-test-policy` | PRs that change production code in `src/`, `open-sse/`, `electron/`, or `bin/` must include or update tests (Hard Rule #8) | Yes |
| `check:test-masking` | Changed test files do not reduce net assert count or add `assert.ok(true)` tautologies | Yes |
| `check:pr-evidence` | PR body cites test/VPS evidence for the change (mechanizes Hard Rule #18 by grepping PR prose — fragile, see Backlog) | Yes |
| `check:forgotten-sibling-tests` | When a PR changes source file Y, detects if any production consumer Z of Y has a test sibling that is NOT included in the same diff — preventing the forgotten-sibling-test pattern | Yes |
### Job: `test-vitest`

View File

@@ -6,7 +6,7 @@ lastUpdated: 2026-06-28
# OmniRoute MCP Server Documentation
> Model Context Protocol server with 104 tools across routing, cache, compression, memory, skills, proxy, pool, and context source operations.
> Model Context Protocol server with 105 tools across routing, cache, compression, memory, skills, proxy, pool, and context source operations.
>
> Source of truth: `open-sse/mcp-server/server.ts` computes **104 unique tools** with `countUniqueMcpTools()`: 42 canonical definitions (including the six CCR lifecycle tools and the agent-skills trio), plus memory (3), skills (4), GitHub skills (3), pool (6), gamification (8), plugins (8), Notion (6), Obsidian (22), and two RTK-only compression tools.
@@ -369,7 +369,7 @@ MCP tool, prompt, and resource registries can compress descriptions at registrat
Description compression shrinks each tool's metadata; **tool-cardinality reduction** goes one step further by reducing _how many_ tools are announced at all. Advertising fewer tools in the `tools/list` manifest cuts the per-request token cost the client's model pays for the tool catalog ("layer 5" compression). The implementation is a pure, stateless filter in `open-sse/mcp-server/toolCardinality.ts` (`reduceToolManifest`), wired into the registration loop in `createMcpServer()` (`open-sse/mcp-server/server.ts`).
**Opt-in, off by default.** The filter only runs when at least one of two environment variables is set; with neither set, all 104 tools are announced unchanged.
**Opt-in, off by default.** The filter only runs when at least one of two environment variables is set; with neither set, all 105 tools are announced unchanged.
| Variable | Mode |
| :--------------- | :-------------------------------------------------------------------------------------- |

View File

@@ -1,17 +1,17 @@
---
title: "Radar Free-Model Catalog"
version: 3.8.50
lastUpdated: 2026-08-05
lastUpdated: 2026-08-07
---
# Radar Free-Model Catalog
> **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/`
> **Last updated:** 2026-08-05 — v3.8.50
> **Last updated:** 2026-08-07 — v3.8.50
Radar is an **optional add-on** that overlays a signed, freshly-curated free-model
catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in
`open-sse/config/freeModelCatalog.ts`). It exists because the free-tier landscape moves
`open-sse/config/freeModelCatalog.data.ts`). It exists because the free-tier landscape moves
faster than release cadence — providers add, shrink, or discontinue free quotas between
releases, and the baseline catalog can only be refreshed when a new version ships.
@@ -127,6 +127,24 @@ untouched. The cached payload is defensively re-validated again on every read
(`getRadarCatalog()`) — a corrupted or hand-edited cache row falls back to the
baseline rather than being served.
### Response size cap (10 MB)
`syncRadar()` enforces a **10 MB hard cap** on the feed response body — the signed
feed is a KB-scale JSON document, so anything past this points at a misconfigured or
hostile `RADAR_FEED_URL` (or an upstream serving garbage), not a legitimate catalog.
Enforcement is two-layered:
1. A `Content-Length` preflight check skips reading the body entirely when the
header already declares a value over the cap.
2. A running-total check while reading the body enforces the cap even when
`Content-Length` is absent or understates the real size — the header is never
trusted on its own. Concatenating the accumulated chunks preserves the exact
bytes needed for the Ed25519 signature check afterward.
Exceeding the cap returns `{ status: "too_large" }` and leaves the cache untouched,
following the same non-destructive pattern as every other sync failure
(`invalid_signature`, `invalid_schema`, `stale`).
---
## Tiers: `community` and `live`
@@ -146,6 +164,28 @@ recoverable, all non-fatal to the cached state) from a successful `{ status:
"updated", version, tier }`. There is no tier-specific error path a client needs to
handle.
### The served tier comes from a response header, not the signed body
The signed feed **body**'s `tier` field is always `"live"` — the feed service ships
**one signed artifact per version**, so the body cannot carry a per-request tier
without invalidating the Ed25519 signature (re-signing per request would defeat the
point of a pinned, cacheable, verifiable artifact). The tier actually served for a
given request is instead carried in the **`x-omniroute-feed-tier` response header**,
decided server-side from the request's `Authorization` key.
`syncRadar()` (`src/lib/radar/sync.ts::parseServedTierHeader()`) is the single place
that resolves the tier a client should trust:
1. Parse `x-omniroute-feed-tier` with `RadarTierSchema` (Zod) — an absent header, or
a value that isn't exactly `"community"` or `"live"`, is treated as **not
present** (never trusted into the cache/UI as-is; this also covers older feed
servers that predate the header).
2. Fall back to the signed body's `tier` field (always `"live"`) only when step 1
yields nothing.
3. The resolved tier is what gets cached and returned as `{ status: "updated",
version, tier }` — this is the value the dashboard shows, never the raw body
field.
---
## Read-time overlay merge rules
@@ -183,13 +223,15 @@ Every merged entry carries an `origin` field the UI renders as a badge:
## Local surfaces — never a feed proxy
Three local routes back the UI, all under `src/app/api/radar/`:
Five local routes back the UI, all under `src/app/api/radar/`:
| Route | Method | Purpose |
| --------------------- | ------ | ---------------------------------------------------------------------- |
| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. |
| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. |
| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. |
| Route | Method | Purpose |
| ----------------------- | ------ | -------------------------------------------------------------------------------------------------- |
| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. |
| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. |
| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. |
| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. |
| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. |
**Hard rule: these routes never proxy the feed service.** The browser only ever talks
to the local OmniRoute server; `syncRadar()` is the single module in the whole client
@@ -197,11 +239,118 @@ that touches the network for Radar (`src/lib/radar/sync.ts`), and it always runs
server-side, never client-side. This keeps the feed URL and any supporter key
out of client-facing network traffic entirely.
All three routes return `404` when `RADAR_ENABLED` is off (see
All five routes return `404` when `RADAR_ENABLED` is off (see
[Flag](#flag-radar_enabled-default-off) above), and route error responses through
`buildErrorBody()`/`sanitizeErrorMessage()` per the repo-wide error-sanitization rule
(`docs/security/ERROR_SANITIZATION.md`).
### Authentication
All five routes require authentication via `isAuthenticated()`
(`src/shared/utils/apiAuth.ts`) — a dashboard session cookie or a management-scoped
API key, the same gate that protects the rest of `/api/settings/*`. The flag-off
`404` check always runs **before** the auth check, so an install with `RADAR_ENABLED`
off stays byte-identical (no auth prompt just to learn the surface doesn't exist);
once the flag is on, an unauthenticated request gets `401` before any DB read or
write. `GET /api/radar/settings` never returns the raw supporter key regardless of
auth state — only the masked form and a `hasSupporterKey` boolean.
---
## Referral links (free credits)
The server-published feed carries a `referrals` section (server-side D28 work, already
in production — this section documents the **client** consumption only):
```ts
referrals: {
fixed: RadarReferral[], // present in EVERY tier, including community
campaigns: RadarReferral[], // only populated on the live (supporter) tier;
// the community artifact always publishes []
}
// RadarReferral = { provider, url, kind: "fixo" | "campanha", validUntil,
// requiredAction, isDefault }
```
The client never decides which tier it received or which referrals belong in which
tier — the server already publishes two artifacts (`live`/`community`) with
`campaigns` gated server-side, same principle as the [tiers](#tiers-community-and-live)
section above. `RadarFeedSchema` (`src/lib/radar/feedSchema.ts`) validates `referrals`
as a whole-object `.default({fixed:[],campaigns:[]})`, and `campaigns` defaults
independently inside it — so a feed cached before this section existed on the server
still parses cleanly, and `campaigns` alone can also be absent without failing
validation. Every `RadarReferral.url` must be `https://` — a `http://` url fails
schema validation.
### Accessor
`src/lib/radar/index.ts` exports two read-only accessors, both never throwing (same
defensive contract as `getRadarCatalog()` — flag off, no cache, or a corrupt/old cached
payload all resolve to the empty shape instead of an error):
- `getRadarReferrals()` → `{ fixed: RadarReferral[], campaigns: RadarReferral[] }`.
- `getDefaultReferralFor(provider)` → the `fixed` referral with `isDefault: true` for
that provider, or `null`. Only looks at `fixed` — a campaign is never used as a
provider's "default" link.
The actual "which referral is the default for a provider" rule lives in
`findDefaultReferral()` (`src/lib/radar/referrals.ts`), a small pure function with **no
DB import** — it is safe to import into a `"use client"` component. `getRadarReferrals`/
`getDefaultReferralFor` (in `index.ts`) pull in `@/lib/db/radar` and therefore stay
server-only; the providers dashboard imports `referrals.ts` directly instead of
`index.ts` (see below) to avoid bundling `better-sqlite3` into the browser.
### `GET /api/radar/referrals`
Follows the exact same gate order as every other Radar route: `RADAR_ENABLED` off →
`404` (checked first, byte-identical inertia); unauthenticated → `401`; otherwise `200`
with `{ fixed, campaigns, tier }` — `tier` comes straight from the cache row and is
purely informative (drives the UI's soft upsell copy below), the route does no
gating of its own. Never proxies the feed server — same local-cache-only contract as
`/api/radar/catalog`.
### Dashboard UI — "Free credits" tab on `/dashboard/radar`
Reuses the existing Radar page (`src/app/(dashboard)/dashboard/radar/page.tsx`) as a
second tab instead of a new route — less routing/i18n surface for a feature that is a
variation on data the page already fetches. Once opted in, the tab bar offers
**Catalog** (existing table) and **Free credits**:
- Fixed links are grouped by provider, each showing `requiredAction` (when present)
and a `target="_blank" rel="noopener noreferrer"` button to the referral URL.
- Campaigns show the same, plus `validUntil` when present.
- When `campaigns` is empty **and** the served tier is `community`, the UI shows a
short upsell note ("limited-time campaigns are a supporter extra") — this **never**
hides or gates the fixed links list, which stays fully populated for every tier. The
upsell is soft messaging only, never a block.
### Referral link on the provider name (providers dashboard)
`ProviderPageHeader` (`src/app/(dashboard)/dashboard/providers/[id]/components/`)
already linked the provider name to `providerInfo.website` when present, with one
precedent for a monetized link: the Kimi (Moonshot AI) partner-link note
(`providers.kimiPartnerLinkNote` i18n key). D28 reuses that exact same discreet-note
pattern for Radar default referrals instead of introducing a new key.
Loose coupling, by design:
- `resolveProviderHeaderLink()` (`src/app/(dashboard)/dashboard/providers/providerPageUtils.ts`)
is a **pure** function — `(staticWebsite, referralUrl) => { website, isReferralLink }`
— with no dependency on `@/lib/radar` or `@/lib/db/*`. `providerPageUtils.ts` as a
whole stays free of those imports (asserted by
`tests/unit/provider-header-referral-link.test.ts`).
- `ProviderDetailPageClient.tsx` (a `"use client"` component) is the one place allowed
to fetch Radar data — via `fetch("/api/radar/referrals")`, the same local-route
pattern the Radar dashboard page itself uses — and it computes the default referral
client-side with `findDefaultReferral()` from the DB-free `src/lib/radar/referrals.ts`.
- With `RADAR_ENABLED` off, the fetch 404s, `referralUrl` stays `null`, and
`resolveProviderHeaderLink()` returns the static catalog `website` unchanged — the
provider page is byte-identical to before this feature existed. Same outcome when
there is no cache yet or no default referral for that specific provider.
- When a default referral does apply, `ProviderPageHeader` receives `isReferralLink`
and shows the same discreet note/tooltip as the Kimi partner link (reusing the
`providers.kimiPartnerLinkNote` key) — never a new, separate visual treatment.
---
## How to self-host a feed
@@ -231,6 +380,6 @@ feed.
## Related docs
- [`docs/security/ERROR_SANITIZATION.md`](../security/ERROR_SANITIZATION.md) — the
error-response pattern the three `/api/radar/*` routes follow.
error-response pattern the five `/api/radar/*` routes follow.
- [`docs/reference/ENVIRONMENT.md`](../reference/ENVIRONMENT.md#27-radar-feed-self-hosting)
— `RADAR_FEED_URL` / `RADAR_FEED_PUBKEY` reference.

View File

@@ -645,7 +645,7 @@ for (const s of statuses) {
}
// Force re-check a specific proxy
invalidateProxyHealth("http://user:pass@1.2.3.4:8080");
invalidateProxyHealth("http://user:pass@203.0.113.7:8080");
```
Flaga `stale` jest `true`, gdy wpis cache przekroczył `HEALTH_CACHE_TTL_MS` i następne żądanie wywoła świeży check.
@@ -807,7 +807,7 @@ Gdy proxy systematycznie pada, oznacz je ręcznie, by rotator je pomijał:
```ts
import { failOneproxyProxy } from "omniroute/oneproxyRotator";
const removed = await failOneproxyProxy("1.2.3.4", 8080);
const removed = await failOneproxyProxy("203.0.113.7", 8080);
if (removed) {
console.log("Proxy marked as failed; rotator will skip it");
}

View File

@@ -645,7 +645,7 @@ for (const s of statuses) {
}
// Force re-check a specific proxy
invalidateProxyHealth("http://user:pass@1.2.3.4:8080");
invalidateProxyHealth("http://user:pass@203.0.113.7:8080");
```
The `stale` flag is `true` when the cache entry has exceeded `HEALTH_CACHE_TTL_MS` and the next request will trigger a fresh check.
@@ -807,7 +807,7 @@ When a proxy consistently fails, mark it manually so the rotator will skip it:
```ts
import { failOneproxyProxy } from "omniroute/oneproxyRotator";
const removed = await failOneproxyProxy("1.2.3.4", 8080);
const removed = await failOneproxyProxy("203.0.113.7", 8080);
if (removed) {
console.log("Proxy marked as failed; rotator will skip it");
}

View File

@@ -429,6 +429,7 @@ For deployments on small VPS instances (1 GB RAM or less):
- **Disable background services** — set `OMNIROUTE_DISABLE_BACKGROUND_SERVICES=1` to skip scheduler, MCP server, and periodic maintenance tasks. See `docs/reference/ENVIRONMENT.md`.
- **Use SQLite WAL mode** — enabled by default, reduces peak memory during concurrent reads.
- **Limit connection concurrency** — reduce `OMNIROUTE_MAX_POOL_SIZE` and `OMNIROUTE_DB_POOL_SIZE` in your environment.
- **Cap the V8 heap** — set `OMNIROUTE_MEMORY_MB` (e.g. `512`) so the runtime does not calibrate a ceiling larger than the VM. See `docs/reference/ENVIRONMENT.md`.
- **Limit concurrent heavy requests** — lower `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` (default `1`); excess requests get a retryable `503` with `Retry-After` instead of competing for memory.
- **Avoid `next build` on the VPS** — build locally and deploy the standalone output (`.next/standalone/`).
- **Monitor with `top` / `free -m`** — OmniRoute typically uses 200-400 MB RSS at idle on a 1 GB VM.

View File

@@ -1281,7 +1281,7 @@ self-hosted or forked feed instead of the default OmniRoute Radar feed. See
| Variable | Default | Source File | Description |
| -------------------- | ------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ |
| `RADAR_FEED_URL` | `https://radar.omniroute.dev` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. |
| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. |
| `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. |
---
@@ -1353,6 +1353,12 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro
| `OMNIROUTE_VNC_HARVEST_MS` | `20000` | `src/lib/vncSession/manifest.ts` | Harvest/cleanup timeout (ms). |
| `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | Directory for Notion thread session persistence. |
### Quality gate scripts (CI)
| Variable | Default | Description |
| --- | --- | --- |
| `FORGOTTEN_SIBLING_MAX_CHANGED` | `300` | Threshold for the forgotten-sibling-tests quality gate. When a PR changes more source files than this value, the consumer dependency scan is skipped (release PR or mass refactor). |
### Internal service auth
| Variable | Default | Description |

View File

@@ -12,27 +12,27 @@ export const PROVIDER_MODELS: Record<string, RegistryModel[]> = new Proxy(
{} as Record<string, RegistryModel[]>,
{
get(_, prop) {
if (typeof prop === 'symbol') return undefined;
if (typeof prop === "symbol") return undefined;
return Reflect.get(initModels(), prop, _models);
},
has(_, prop) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
return Reflect.has(initModels(), prop);
},
ownKeys() {
return Reflect.ownKeys(initModels());
},
getOwnPropertyDescriptor(_, prop) {
if (typeof prop === 'symbol') return undefined;
if (typeof prop === "symbol") return undefined;
return Object.getOwnPropertyDescriptor(initModels(), prop);
},
set(_, prop, value) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
(initModels() as Record<string, RegistryModel[]>)[prop] = value;
return true;
},
deleteProperty(_, prop) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
return Reflect.deleteProperty(initModels(), prop);
},
}
@@ -41,27 +41,27 @@ export const PROVIDER_ID_TO_ALIAS: Record<string, string> = new Proxy(
{} as Record<string, string>,
{
get(_, prop) {
if (typeof prop === 'symbol') return undefined;
if (typeof prop === "symbol") return undefined;
return Reflect.get(initAliases(), prop, _aliases);
},
has(_, prop) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
return Reflect.has(initAliases(), prop);
},
ownKeys() {
return Reflect.ownKeys(initAliases());
},
getOwnPropertyDescriptor(_, prop) {
if (typeof prop === 'symbol') return undefined;
if (typeof prop === "symbol") return undefined;
return Object.getOwnPropertyDescriptor(initAliases(), prop);
},
set(_, prop, value) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
(initAliases() as Record<string, string>)[prop] = value;
return true;
},
deleteProperty(_, prop) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
return Reflect.deleteProperty(initAliases(), prop);
},
}
@@ -116,7 +116,13 @@ export function findModelName(aliasOrId: string, modelId: string): string {
export function getModelTargetFormat(aliasOrId: string, modelId: string): string | null {
const models = PROVIDER_MODELS[aliasOrId];
const found = models?.find((m) => m.id === modelId);
// Strip provider prefix if present: "openai/gpt-5.6-luna" → "gpt-5.6-luna"
const prefix = aliasOrId + "/";
const bareModelId =
typeof modelId === "string" && modelId.startsWith(prefix)
? modelId.slice(prefix.length)
: modelId;
const found = models?.find((m) => m.id === bareModelId);
if (found?.targetFormat) return found.targetFormat;
// #5842: OpenAI "*-pro" reasoning models (o1-pro, gpt-5.x-pro) are only served by
// the native /v1/responses endpoint — /v1/chat/completions 404s ("only supported
@@ -124,7 +130,7 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string
// covers dynamically-synced ids that post-date the catalog (same spirit as the gh
// executor's /codex/i routing, 9router#102). Scoped to the openai alias so other
// providers shipping *-pro ids keep their own endpoint semantics.
if (aliasOrId === "openai" && /-pro$/i.test(modelId)) return "openai-responses";
if (aliasOrId === "openai" && /-pro$/i.test(bareModelId)) return "openai-responses";
return null;
}

View File

@@ -207,6 +207,7 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
maxMaxResults: 50,
timeoutMs: 10_000,
cacheTTLMs: 3 * 60 * 1000,
fallbackOnly: true,
},
"ollama-search": {

View File

@@ -1219,6 +1219,9 @@ export class CursorExecutor extends BaseExecutor {
if (isToolFollowUp) {
session = cursorSessionManager.acquire(conversationId);
// #9029: content-based session match when client lacks conversation_id.
if (!session && !body.conversation_id) session = cursorSessionManager.findByToolCallIds(
messages.filter(m => m.role === "tool" && m.tool_call_id).map(m => m.tool_call_id!));
}
if (session) {

View File

@@ -138,13 +138,149 @@ function isPartnerModel(model: string) {
return [...PARTNER_MODELS].some((prefix) => normalizedModel.startsWith(prefix));
}
// Anthropic models need their own branch: they use Vertex's native Anthropic Messages API
// (publishers/anthropic/.../rawPredict), not the generic OpenAI-compatible partner endpoint the
// other PARTNER_MODELS entries (DeepSeek, Qwen, Llama, Mistral, GLM) go through — the OpenAI-shaped
// endpoint 404s/"malformed argument"s for Claude models on at least some projects.
function isClaudeModel(model: string) {
return model.toLowerCase().startsWith("claude-");
}
// Defensive normalizer: target-format resolution for manually-added custom Claude models under
// "vertex"/"vertex-partner" was observed sending a Gemini-shaped body (contents/parts) to the
// Anthropic rawPredict endpoint instead of the configured "claude" format, causing a hard
// "messages: Field required" error upstream regardless of the stored per-model targetFormat. This
// converts a Gemini-shaped body to Anthropic Messages shape so the executor works either way,
// independent of that unresolved upstream resolution gap.
function toAnthropicBody(body: Record<string, unknown>): Record<string, unknown> {
const contents = body.contents as Array<{ role?: string; parts?: Array<{ text?: string }> }> | undefined;
if (!Array.isArray(contents)) return body;
const messages = contents.map((c) => ({
role: c.role === "model" ? "assistant" : "user",
content: (c.parts || []).map((p) => p.text || "").join(""),
}));
const generationConfig = body.generationConfig as { maxOutputTokens?: number } | undefined;
const systemInstruction = body.systemInstruction as { parts?: Array<{ text?: string }> } | undefined;
const converted: Record<string, unknown> = {
messages,
max_tokens: generationConfig?.maxOutputTokens || 4096,
};
if (systemInstruction?.parts?.length) {
converted.system = systemInstruction.parts.map((p) => p.text || "").join("");
}
return converted;
}
// rawPredict always returns a single complete JSON body, never real SSE framing (see buildUrl).
// When the caller actually requested a stream, synthesize a genuine Anthropic-native event
// sequence from that JSON so the existing claude-to-openai.ts (and sibling) response translators
// — which already parse real message_start/content_block_*/message_delta/message_stop events —
// can consume it correctly, instead of relying on the OpenAI-`choices`-only JSON→SSE fallback
// (open-sse/utils/jsonToSse.ts) which cannot represent Anthropic's native response shape at all.
function synthesizeClaudeSse(response: Record<string, unknown>): string {
const messageId = typeof response.id === "string" ? response.id : `msg_${Date.now()}`;
const model = typeof response.model === "string" ? response.model : "";
const usage = (response.usage as Record<string, unknown>) || {};
const stopReason = typeof response.stop_reason === "string" ? response.stop_reason : "end_turn";
const stopSequence = (response.stop_sequence as string | null | undefined) ?? null;
const content = Array.isArray(response.content) ? response.content : [];
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
events.push({
event: "message_start",
data: {
type: "message_start",
message: {
id: messageId,
type: "message",
role: "assistant",
content: [],
model,
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: usage.input_tokens || 0, output_tokens: 0 },
},
},
});
content.forEach((block: Record<string, unknown>, index: number) => {
if (block.type === "text") {
events.push({
event: "content_block_start",
data: { type: "content_block_start", index, content_block: { type: "text", text: "" } },
});
if (block.text) {
events.push({
event: "content_block_delta",
data: {
type: "content_block_delta",
index,
delta: { type: "text_delta", text: block.text },
},
});
}
events.push({ event: "content_block_stop", data: { type: "content_block_stop", index } });
} else if (block.type === "tool_use") {
events.push({
event: "content_block_start",
data: {
type: "content_block_start",
index,
content_block: { type: "tool_use", id: block.id, name: block.name, input: {} },
},
});
events.push({
event: "content_block_delta",
data: {
type: "content_block_delta",
index,
delta: { type: "input_json_delta", partial_json: JSON.stringify(block.input ?? {}) },
},
});
events.push({ event: "content_block_stop", data: { type: "content_block_stop", index } });
} else if (block.type === "thinking") {
events.push({
event: "content_block_start",
data: { type: "content_block_start", index, content_block: { type: "thinking", thinking: "" } },
});
if (block.thinking) {
events.push({
event: "content_block_delta",
data: {
type: "content_block_delta",
index,
delta: { type: "thinking_delta", thinking: block.thinking },
},
});
}
events.push({ event: "content_block_stop", data: { type: "content_block_stop", index } });
}
});
events.push({
event: "message_delta",
data: {
type: "message_delta",
delta: { stop_reason: stopReason, stop_sequence: stopSequence },
usage: { output_tokens: usage.output_tokens || 0 },
},
});
events.push({ event: "message_stop", data: { type: "message_stop" } });
return events.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`).join("");
}
export class VertexExecutor extends BaseExecutor {
constructor() {
super("vertex", PROVIDERS.vertex);
}
async execute(input: ExecuteInput) {
const { credentials, log } = input;
const { credentials, log, model, stream } = input;
// Defensive: trim stray surrounding whitespace from a pasted credential.
if (typeof credentials.apiKey === "string") {
credentials.apiKey = credentials.apiKey.trim();
@@ -160,7 +296,53 @@ export class VertexExecutor extends BaseExecutor {
throw err;
}
}
return super.execute(input);
if (isClaudeModel(model) && input.body && typeof input.body === "object") {
let body = input.body as Record<string, unknown>;
if (!Array.isArray(body.messages)) {
body = toAnthropicBody(body);
input.body = body;
}
// The rawPredict endpoint requires "anthropic_version" in the body (Vertex's substitute
// for the "anthropic-version" header used by Anthropic's direct API).
body.anthropic_version ??= "vertex-2023-10-16";
// Unlike Anthropic's direct API (which reads the model from the body), Vertex's
// rawPredict endpoint already encodes project/region/model in the URL and 400s with
// "model: Extra inputs are not permitted" if the translated request body still carries
// one (the openai→claude request translator copies the client's model field over).
delete body.model;
}
const result = await super.execute(input);
if (isClaudeModel(model) && stream) {
const response = result instanceof Response ? result : result?.response;
if (response?.ok) {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json") && !contentType.includes("text/event-stream")) {
const jsonText = await response.text();
let newBody = jsonText;
let newContentType = contentType;
try {
newBody = synthesizeClaudeSse(JSON.parse(jsonText));
newContentType = "text/event-stream";
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log?.warn?.("VERTEX", `Failed to synthesize Claude SSE stream: ${message}`);
}
const newHeaders = new Headers(response.headers);
newHeaders.set("content-type", newContentType);
newHeaders.delete("content-length");
const newResponse = new Response(newBody, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
return result instanceof Response ? newResponse : { ...result, response: newResponse };
}
}
}
return result;
}
buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) {
@@ -189,6 +371,13 @@ export class VertexExecutor extends BaseExecutor {
}
}
if (isClaudeModel(model)) {
// streamRawPredict?alt=sse was verified to return a single plain JSON body (not real SSE
// framing) rather than actual chunked events, which breaks the SSE parser upstream
// ("stream ended before producing a non-ping SSE event"). rawPredict is confirmed reliable
// for both streaming and non-streaming requests; always use it here.
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${region}/publishers/anthropic/models/${model}:rawPredict`;
}
if (isPartnerModel(model)) {
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/global/endpoints/openapi/chat/completions`;
}

View File

@@ -457,9 +457,12 @@ function transformYuanbaoStream(
if (event.type === "think" && event.content) {
ensureRole();
emit({ reasoning_content: event.content });
} else if (event.type === "text" && typeof event.msg === "string" && event.msg) {
ensureRole();
emit({ content: event.msg });
} else if (event.type === "text") {
const text = event.msg ?? event.content;
if (text) {
ensureRole();
emit({ content: text });
}
}
}
}
@@ -498,7 +501,10 @@ async function collectYuanbaoResponse(
const event = parseYuanbaoDataLine(line);
if (!event) continue;
if (event.type === "think" && event.content) reasoning += event.content;
else if (event.type === "text" && typeof event.msg === "string") content += event.msg;
else if (event.type === "text") {
const text = event.msg ?? event.content;
if (text) content += text;
}
}
}
} finally {

View File

@@ -2306,10 +2306,24 @@ export async function handleChatCore({
const nativeClaudeToolNameMap = isClaudePassthrough
? buildClaudePassthroughToolNameMap(body)
: null;
const toolNameMap =
let toolNameMap: Map<string, string> | null =
translatedToolNameMap instanceof Map && translatedToolNameMap.size > 0
? translatedToolNameMap
: nativeClaudeToolNameMap;
// For providers whose _toolNameMap was extracted as requestToolIdentityMap
// before the Kiro merge block (Gemini/Antigravity), merge it into the
// response toolNameMap so the response translator can restore tool names
// from their lowercased form (#9568). Only merge string-valued entries
// (tool name aliases), not object-valued namespace identities (#7936).
if (!toolNameMap && requestToolIdentityMap instanceof Map && requestToolIdentityMap.size > 0) {
const hasStringValues = [...requestToolIdentityMap.values()].every(
(v: unknown) => typeof v === "string"
);
if (hasStringValues) {
toolNameMap = requestToolIdentityMap;
}
}
delete translatedBody._toolNameMap;
delete translatedBody._disableToolPrefix;

View File

@@ -46,13 +46,15 @@ export function resolveChatCoreTargetFormat(opts: {
sourceFormat === FORMATS.CLAUDE)
? sourceFormat
: undefined;
// #8994: model-level targetFormat overrides (from registry or custom-model DB override)
// take precedence over apiFormat="responses" — otherwise Vertex Claude models with
// targetFormat="claude" get wrongly routed to OpenAI Responses format.
let targetFormat =
apiFormat === "responses"
modelTargetFormat ||
customModelTargetFormat ||
(apiFormat === "responses"
? FORMATS.OPENAI_RESPONSES
: modelTargetFormat ||
customModelTargetFormat ||
inferredAgentRouterTargetFormat ||
getTargetFormat(provider, providerSpecificData);
: inferredAgentRouterTargetFormat || getTargetFormat(provider, providerSpecificData));
if (nativeXaiResponsesPassthrough) targetFormat = FORMATS.OPENAI_RESPONSES;
return { alias, targetFormat };
}

View File

@@ -6,7 +6,10 @@ import {
import { normalizeOpenAICompatibleFinishReasonString } from "../utils/finishReason.ts";
import { containsTextualToolCallMarker } from "../utils/textualToolCall.ts";
import { getAnyReasoningValue } from "../utils/reasoningFields.ts";
import { restoreOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts";
import {
caseInsensitiveToolNameLookup,
restoreOpenAIToolNames,
} from "../translator/helpers/toolCallHelper.ts";
type JsonRecord = Record<string, unknown>;
@@ -206,7 +209,7 @@ export function translateNonStreamingResponse(
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit || {});
const rawName = toString(itemObj.name);
// Strip Claude OAuth proxy_ prefix using toolNameMap
const resolvedName = toolNameMap?.get(rawName) ?? rawName;
const resolvedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName;
toolCalls.push({
id: callId,
type: "function",
@@ -388,7 +391,8 @@ export function translateNonStreamingResponse(
if (partObj.functionCall) {
const fn = toRecord(partObj.functionCall);
const rawName = toString(fn.name);
const restoredName = toolNameMap?.get(rawName) ?? rawName;
const restoredName =
caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName;
const nativeId = toString(fn.id);
const toolCallId =
nativeId.length > 0
@@ -507,7 +511,7 @@ export function translateNonStreamingResponse(
thinkingContent += toString(blockObj.thinking);
} else if (blockObj.type === "tool_use") {
const rawName = toString(blockObj.name);
const strippedName = toolNameMap?.get(rawName) ?? rawName;
const strippedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName;
toolCalls.push({
id: toString(blockObj.id, `call_${Date.now()}_${toolCalls.length}`),
type: "function",
@@ -687,6 +691,35 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco
if (stopReason === "tool_calls") stopReason = "tool_use";
const usageSrc = toRecord(openaiResponse.usage);
const promptTokens = toNumber(usageSrc.prompt_tokens, 0);
const outputTokens = toNumber(usageSrc.completion_tokens, 0);
// Extract cache tokens from prompt_tokens_details (mirrors the streaming
// translator in open-sse/translator/response/openai-to-claude.ts lines 119-148).
const promptDetails = toRecord(usageSrc.prompt_tokens_details);
const cachedTokens = toNumber(promptDetails.cached_tokens, 0);
const cacheCreationTokens = toNumber(promptDetails.cache_creation_tokens, 0);
// OpenAI's prompt_tokens includes all prompt-side tokens (cached + non-cached).
// Claude expects input_tokens to be only non-cached tokens, with cached tokens
// exposed separately as cache_read_input_tokens.
const inputTokens = promptTokens - cachedTokens - cacheCreationTokens;
const usage: JsonRecord = {
input_tokens: inputTokens,
output_tokens: outputTokens,
};
// Add cache_read_input_tokens if present
if (cachedTokens > 0) {
usage.cache_read_input_tokens = cachedTokens;
}
// Add cache_creation_input_tokens if present
if (cacheCreationTokens > 0) {
usage.cache_creation_input_tokens = cacheCreationTokens;
}
const claudeResponse: JsonRecord = {
id: toString(openaiResponse.id, `msg_${Date.now()}`),
type: "message",
@@ -695,10 +728,7 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco
content,
stop_reason: stopReason,
stop_sequence: null,
usage: {
input_tokens: toNumber(usageSrc.prompt_tokens, 0),
output_tokens: toNumber(usageSrc.completion_tokens, 0),
},
usage,
};
return claudeResponse;

View File

@@ -22,9 +22,10 @@ describe("MCP Essential Tools", () => {
});
describe("Tool schema validation", () => {
it("should have exactly 11 essential tools (includes web_search + web_fetch + tool_search)", () => {
it("should have exactly 12 essential tools (includes web_search + web_fetch + tool_search)", () => {
// 11 -> 12: #8925 shipped omniroute_create_combo as a phase-1 tool.
const schemas = MCP_ESSENTIAL_TOOLS;
expect(schemas).toHaveLength(11);
expect(schemas).toHaveLength(12);
});
it("all tools should have omniroute_ prefix", () => {

View File

@@ -54,8 +54,21 @@ export function buildAccountSemaphoreKey({
return `${String(provider)}:${String(accountKey)}`;
}
/**
* Effective positive cap, or null when the semaphore is bypassed (unset/<=0).
*
* Narrowing companion of {@link isBypassed}: that one returns a plain boolean, so
* TypeScript cannot narrow `number | null` to `number` in its else-branch (a
* `x is null | undefined` predicate would be unsound — 0 bypasses too). Callers
* that need the VALUE after the guard go through here instead of casting.
*/
function resolveActiveCap(maxConcurrency?: number | null): number | null {
if (maxConcurrency == null || maxConcurrency <= 0) return null;
return maxConcurrency;
}
function isBypassed(maxConcurrency?: number | null): boolean {
return maxConcurrency == null || maxConcurrency <= 0;
return resolveActiveCap(maxConcurrency) === null;
}
function createNoopReleaseFn(): () => void {
@@ -192,7 +205,8 @@ export function acquire(
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
}: AcquireAccountSemaphoreOptions = {}
): Promise<() => void> {
if (isBypassed(maxConcurrency)) {
const activeCap = resolveActiveCap(maxConcurrency);
if (activeCap === null) {
return Promise.resolve(createNoopReleaseFn());
}
@@ -200,7 +214,7 @@ export function acquire(
return Promise.reject(makeAbortError(signal));
}
const gate = ensureGate(semaphoreKey, maxConcurrency);
const gate = ensureGate(semaphoreKey, activeCap);
clearCleanupTimer(gate);
if (gate.running < gate.maxConcurrency && !isBlocked(gate)) {

View File

@@ -259,7 +259,14 @@ async function captureViaCdp(opts: {
if (capturedAccessToken) return;
const request = params.request as
{ url?: string; headers?: Record<string, string> } | undefined;
if (!request?.url || !request.url.includes(FIREFLY_3P_HOST_SUFFIX)) return;
if (!request?.url) return;
let host: string;
try {
host = new URL(request.url).hostname.toLowerCase();
} catch {
return;
}
if (host !== FIREFLY_3P_HOST_SUFFIX && !host.endsWith(`.${FIREFLY_3P_HOST_SUFFIX}`)) return;
const headers = request.headers || {};
const auth = headers.Authorization || headers.authorization || headers.AUTHORIZATION || "";
const token = extractAdobeBearerTokenFromAuthorization(auth);

View File

@@ -22,6 +22,43 @@ import { updateProviderConnection } from "@/lib/db/providers";
* Best-effort / non-fatal by design: a persistence failure must never block
* the in-flight request, which already has the discovered id in hand.
*/
/**
* Selection-side companion of the persistence write path (#8894): given a pool
* of Antigravity/AGY connections, prefer the ones that already carry a stored
* projectId — they can serve a request without the `loadCodeAssist` discovery
* round-trip. "Prefer", not "require": when NO connection has a stored project
* the pool is returned unchanged, so a fresh install never empties its
* candidate list.
*
* Sync on purpose (called inside the quota-strategy connection expansion, which
* builds candidate lists without awaiting per-connection work). Tolerates
* `providerSpecificData` arriving either parsed or as the raw DB JSON string.
*/
export function preferAntigravityConnectionsWithStoredProject<T extends Record<string, unknown>>(
connections: T[]
): T[] {
if (!Array.isArray(connections) || connections.length === 0) return connections;
const hasStoredProject = (connection: T): boolean => {
if (typeof connection.projectId === "string" && connection.projectId) return true;
let psd = connection.providerSpecificData;
if (typeof psd === "string") {
try {
psd = JSON.parse(psd);
} catch {
return false;
}
}
return Boolean(
psd &&
typeof psd === "object" &&
typeof (psd as Record<string, unknown>).projectId === "string" &&
(psd as Record<string, unknown>).projectId
);
};
const withStoredProject = connections.filter(hasStoredProject);
return withStoredProject.length > 0 ? withStoredProject : connections;
}
export async function persistDiscoveredAntigravityProjectId(
connectionId: string | undefined | null,
discoveredProjectId: string | undefined | null,

View File

@@ -0,0 +1,13 @@
/**
* Re-export from `antigravityProjectPersist.ts` plus a connection-preference helper.
*/
import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts";
export { persistDiscoveredAntigravityProjectId };
export function preferAntigravityConnectionsWithStoredProject(
connections: Array<Record<string, unknown>>
): Array<Record<string, unknown>> {
return connections.filter(
(conn) => conn != null && typeof conn.projectId === "string" && conn.projectId.trim().length > 0
);
}

View File

@@ -2036,24 +2036,35 @@ export async function handleComboChat({
if (setTry < maxSetRetries) continue;
// All set retries exhausted — return the final error
if (!lastStatus) {
notifyWebhookEvent("request.failed", {
combo: combo.name,
reason: "ALL_ACCOUNTS_INACTIVE",
latencyMs,
fallbackCount,
});
// Silent-stop fix: bump the failure counter so the session pin clears on the 3rd
// consecutive all-inactive cascade; buildRecoveryHint emits `switch-combo` with a
// next-step that points the user at /dashboard/providers.
recordComboFailure(effectiveSessionId, combo.name);
return errorResponseWithComboDiagnostics(
503,
"Service temporarily unavailable: all upstream accounts are inactive",
buildComboDiag("all_accounts_inactive"),
{ code: "ALL_ACCOUNTS_INACTIVE", type: "service_unavailable" }
);
}
if (!lastStatus) {
if (recordedAttempts === 0) {
notifyWebhookEvent("request.failed", {
combo: combo.name,
reason: "ALL_TARGETS_SKIPPED",
latencyMs,
fallbackCount,
});
return errorResponseWithComboDiagnostics(
503,
"Service temporarily unavailable: all targets were skipped by pre-dispatch filters",
buildComboDiag("all_targets_skipped"),
{ code: "ALL_TARGETS_SKIPPED", type: "service_unavailable" }
);
}
notifyWebhookEvent("request.failed", {
combo: combo.name,
reason: "ALL_ACCOUNTS_INACTIVE",
latencyMs,
fallbackCount,
});
recordComboFailure(effectiveSessionId, combo.name);
return errorResponseWithComboDiagnostics(
503,
"Service temporarily unavailable: all upstream accounts are inactive",
buildComboDiag("all_accounts_inactive"),
{ code: "ALL_ACCOUNTS_INACTIVE", type: "service_unavailable" }
);
}
const status = lastStatus;
// Build aggregated error message with per-model failure details for diagnostics.
@@ -3004,18 +3015,30 @@ async function handleRoundRobinCombo({
});
}
if (!lastStatus) {
return new Response(
JSON.stringify({
error: {
message: "Service temporarily unavailable: all upstream accounts are inactive",
type: "service_unavailable",
code: "ALL_ACCOUNTS_INACTIVE",
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
if (!lastStatus) {
if (recordedAttempts === 0) {
return new Response(
JSON.stringify({
error: {
message: "Service temporarily unavailable: all targets were skipped by pre-dispatch filters",
type: "service_unavailable",
code: "ALL_TARGETS_SKIPPED",
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
return new Response(
JSON.stringify({
error: {
message: "Service temporarily unavailable: all upstream accounts are inactive",
type: "service_unavailable",
code: "ALL_ACCOUNTS_INACTIVE",
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
const status = lastStatus;
const msg = lastError || "All round-robin combo models unavailable";

View File

@@ -137,7 +137,9 @@ function normalizeRuntimeStep(
: {}),
weight,
label,
prompt: step.prompt || null,
// `prompt` is a per-step pipeline input and only exists on a model step —
// #8894 widened the union with ComboProviderWildcardStep, which has no prompt.
prompt: (step.kind === "model" ? step.prompt : null) || null,
} satisfies ResolvedComboTarget;
}
@@ -618,12 +620,6 @@ export type CompatFilterOptions = {
failOpen?: boolean;
};
const HARD_COMPAT_REASONS = new Set(["tools", "vision", "structured_output"]);
function hasHardCapabilityFailure(reasons: string[]): boolean {
return reasons.some((reason) => HARD_COMPAT_REASONS.has(reason));
}
/**
* Summarize a capability-filter exhaustion for a 400-class combo error (#8488).
* Returns null when the empty pool is not attributable to hard requirements.
@@ -727,7 +723,9 @@ export function filterTargetsByRequestCompatibility(
if (compatible.length === targets.length) return targets;
if (compatible.length === 0) {
const hardRejected = rejected.some((entry) => hasHardCapabilityFailure(entry.reasons));
const hardRejected = rejected.some((entry) =>
entry.reasons.some((r) => HARD_COMPAT_REASONS.has(r))
);
const failOpen = options?.failOpen === true;
log.debug?.(

View File

@@ -619,6 +619,7 @@ export async function tryRuntimeUnitDispatch(args: {
nesting: nestingContext,
baseOptions: buildBaseOptions(args),
runCombo: args.runCombo,
hiddenModelsByProvider: args.hiddenModelsByProvider,
});
recordRuntimeUnitStickySuccess({
strategy,

View File

@@ -10,7 +10,7 @@
* literal `auto/*` string panel member already behaves via the single-
* dispatch safety net in src/sse/handlers/chat.ts.
*/
import { normalizeComboStep } from "../../../src/lib/combos/steps.ts";
import { getComboModelString, normalizeComboStep } from "../../../src/lib/combos/steps.ts";
import { executeComboRefUnit } from "./runtimeUnits.ts";
import type {
ComboCollectionLike,
@@ -51,7 +51,11 @@ export function extractFusionPanelSpec(
panel.push(step.comboName);
return;
}
panel.push(step.model);
// #8894 widened ComboStep with ComboProviderWildcardStep, which carries a
// modelPattern instead of a model. getComboModelString() already resolves any
// step shape (and returns null for the ones with no concrete model id).
const modelStr = getComboModelString(step);
if (modelStr) panel.push(modelStr);
});
return { panel, comboRefUnits };
}

View File

@@ -45,7 +45,7 @@ import {
type QuotaFetchCacheConfig,
} from "./quotaScoring.ts";
import { rankByHeadroom, type HeadroomSaturation } from "./headroomRanking.ts";
import { preferAntigravityConnectionsWithStoredProject } from "../antigravityProjectPersistence.ts";
import { preferAntigravityConnectionsWithStoredProject } from "../antigravityProjectPersist.ts";
import { isQuotaExhaustedForRequest } from "../../../src/domain/quotaCache.ts";
const RESET_AWARE_CONNECTION_CACHE_TTL_MS = 30_000;

View File

@@ -9,7 +9,12 @@
import { isAccountSemaphoreFull } from "../accountSemaphore.ts";
import { resolveComboTargets } from "./comboStructure.ts";
import { lookupPositiveCap } from "./concurrencyCaps.ts";
import type { ComboCollectionLike, ComboLike, ResolvedComboUnit } from "./types.ts";
import type {
ComboCollectionLike,
ComboLike,
HiddenModelsByProvider,
ResolvedComboUnit,
} from "./types.ts";
type CapLookup = (connectionId: string) => Promise<number | null>;
@@ -45,7 +50,12 @@ async function isConnectionAtConcurrencyCap(
export async function isRuntimeUnitAtConcurrencyCap(
unit: ResolvedComboUnit,
allCombos: ComboCollectionLike,
lookupCap: CapLookup = lookupPositiveCap
lookupCap: CapLookup = lookupPositiveCap,
// Threaded from the caller so the hidden-model snapshot resolved once per
// request is reused. Without it resolveComboTargets falls back to its default
// getHiddenModelsByProvider(), i.e. a fresh full key_value read per nested
// combo-ref unit on EVERY request (#8878 threaded the other call sites).
hiddenModelsByProvider?: HiddenModelsByProvider
): Promise<boolean> {
if (unit.kind === "model") {
if (!unit.connectionId || !unit.provider) return false;
@@ -55,7 +65,7 @@ export async function isRuntimeUnitAtConcurrencyCap(
const childCombo = findComboByName(allCombos, unit.comboName);
if (!childCombo) return false;
const targets = resolveComboTargets(childCombo, allCombos, 1);
const targets = resolveComboTargets(childCombo, allCombos, 1, hiddenModelsByProvider);
const byConnection = new Map<string, { provider: string; connectionId: string }>();
for (const target of targets) {
if (!target.connectionId || !target.provider) continue;

View File

@@ -18,6 +18,7 @@ import type {
ComboNestingContext,
HandleComboChatOptions,
HandleSingleModel,
HiddenModelsByProvider,
IsModelAvailable,
ResolvedComboRefTarget,
ResolvedComboUnit,
@@ -186,6 +187,7 @@ export async function executeRuntimeUnitCombo(args: {
nesting: ComboNestingContext;
baseOptions: HandleComboChatOptions;
runCombo: RuntimeUnitRunner;
hiddenModelsByProvider?: HiddenModelsByProvider;
}): Promise<RuntimeUnitExecutionResult> {
const maxRetries = Number(args.config.maxRetries ?? 1);
const retryDelayMs = resolveDelayMs(args.config.retryDelayMs, 2000);
@@ -197,7 +199,14 @@ export async function executeRuntimeUnitCombo(args: {
let fallbackCount = 0;
for (const unit of orderedUnits) {
if (await isRuntimeUnitAtConcurrencyCap(unit, args.allCombos)) {
if (
await isRuntimeUnitAtConcurrencyCap(
unit,
args.allCombos,
undefined,
args.hiddenModelsByProvider
)
) {
args.log.info(
"COMBO",
`Skipping ${unit.kind} ${unitDisplayName(unit)} — concurrency cap reached`

View File

@@ -400,13 +400,24 @@ export function adaptBodyForCompression(
});
const cleanedInput = nextInput.filter((item) => {
if (!isRecord(item) || item.type !== "function_call") return true;
if (!isRecord(item)) return true;
const t = item.type;
if (
t !== "function_call" &&
t !== "custom_tool_call" &&
t !== "local_shell_call" &&
t !== "apply_patch_call"
) {
return true;
}
if (typeof item.call_id !== "string" || item.call_id.length === 0) return true;
const hadMappedOutput = mappings.some((mapping) => {
const original = mapping.item;
return (
(original.type === "function_call_output" ||
original.type === "custom_tool_call_output") &&
original.type === "custom_tool_call_output" ||
original.type === "local_shell_call_output" ||
original.type === "apply_patch_call_output") &&
original.call_id === item.call_id
);
});

View File

@@ -292,7 +292,8 @@ function rehydrateEntry(hash: string, principalId: string, now: number): CcrEntr
// Re-admit through the same budgets a fresh store would face. If the block no longer
// fits, it stays on disk and is served straight from the row instead of being cached.
if (enforcePrincipalBudget(entry.principalId, entry.bytes) && enforceGlobalBudget(entry.bytes)) {
const { principalId: owner, bytes } = entry;
if (enforcePrincipalBudget(owner, bytes) && enforceGlobalBudget(owner, bytes)) {
const key = buildStoreKey(hash, principalId === ANON ? undefined : principalId);
ccrStore.set(key, entry);
ccrTotalBytes += entry.bytes;

View File

@@ -192,6 +192,29 @@ export class CursorSessionManager {
if (oldest) this.close(oldest);
}
/**
* Find a session that has one of the specified tool call IDs pending.
* Only matches sessions in "awaiting_tool_result" state.
* Transitions the found session to "running" (same as acquire).
* This is used when the client doesn't provide conversation_id
* (OpenAI-compatible clients), so we match by content instead of key.
* Returns undefined if no session has any of the given IDs pending.
*/
findByToolCallIds(toolCallIds: string[]): CursorSession | undefined {
this.evictExpired();
for (const id of toolCallIds) {
for (const session of this.sessions.values()) {
if (session.state === "awaiting_tool_result" && session.pendingToolCalls.has(id)) {
this.clearIdleTimer(session);
session.state = "running";
session.lastActivityTs = Date.now();
return session;
}
}
}
return undefined;
}
// ─── Test / introspection helpers ────────────────────────────────────────
size(): number {

View File

@@ -245,6 +245,20 @@ export function classifyProviderError(
if (recoverableProject403) {
return PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR;
}
// #8813 — ChatGPT Web's Cloudflare Sentinel/Turnstile 403 is a TERMINAL
// block: the user's IP/session needs a browser Turnstile challenge, and
// retrying the same connection will keep 403ing. Classify as FORBIDDEN so
// the connection gets banned and combo routing falls back to other providers.
// Must be checked BEFORE the generic apikey-403→null return below, which
// is designed for normal API-key auth 403s that ARE recoverable.
if (
bodyStr.includes("SENTINEL_BLOCKED") ||
/\bSentinel\b[^\n]{0,80}\bblocked\b/i.test(bodyStr) ||
/\bTurnstile required\b/i.test(bodyStr)
) {
return PROVIDER_ERROR_TYPES.FORBIDDEN;
}
if (provider && getProviderCategory(provider) === "apikey") {
return null;
}

View File

@@ -110,7 +110,8 @@ export function getFirecrawlBaseUrl(connection?: Record<string, unknown>): strin
return envBase.replace(/\/+$/, "");
}
const providerData = toRecord(connection?.providerSpecificData);
const connBase = typeof connection?.baseUrl === "string" ? connection.baseUrl : providerData?.baseUrl;
const connBase =
typeof connection?.baseUrl === "string" ? connection.baseUrl : providerData?.baseUrl;
if (typeof connBase === "string" && connBase.trim() && !connBase.includes("api.firecrawl.dev")) {
return connBase.trim().replace(/\/+$/, "");
}
@@ -120,7 +121,11 @@ export function getFirecrawlBaseUrl(connection?: Record<string, unknown>): strin
export async function fetchFirecrawlQuota(
connectionId: string,
connection?: Record<string, unknown>
): Promise<QuotaInfo | null> {
// FirecrawlQuota, not the base QuotaInfo: every return here is a full credit
// breakdown (remainingCredits / planCredits / extraCreditsInferred / overPlan),
// and the narrower annotation made the custom-base literal below an excess-
// property error. FirecrawlQuota extends QuotaInfo, so callers are unaffected.
): Promise<FirecrawlQuota | null> {
const cached = quotaCache.get(connectionId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.quota;

View File

@@ -54,6 +54,10 @@ ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo";
ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp";
// agy/ is the short alias for antigravity provider.
ALIAS_TO_PROVIDER_ID["agy"] = "antigravity";
// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider.
// The canonical provider ID is "amazon-q". Register it so parseModel("aq/<model>")
// resolves provider = "amazon-q" instead of falling through to the identity fallback.
ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q";
// Provider-scoped legacy model aliases. Used to normalize provider/model inputs
// and keep backward compatibility when upstream IDs change.

View File

@@ -9,15 +9,23 @@
import { createHmac, createHash, randomUUID } from "node:crypto";
import { resolvePublicCred } from "../utils/publicCreds.ts";
export const RAYCAST_CHAT_URL = "https://backend.raycast.com/api/v1/ai/chat_completions";
export const RAYCAST_MODELS_URL = "https://backend.raycast.com/api/v1/ai/models";
export const RAYCAST_DEFAULT_USER_AGENT =
"Raycast/1.104.20 (macOS Version 26.5.1 (Build 25F80))";
export const RAYCAST_DEFAULT_USER_AGENT = "Raycast/1.104.20 (macOS Version 26.5.1 (Build 25F80))";
export const RAYCAST_DEFAULT_EXPERIMENTAL = "chatBranching, mcpHTTPServer";
/** Community-extracted default; override via providerSpecificData.sigSecret or RAYCAST_SIG_SECRET. */
export const RAYCAST_DEFAULT_SIG_SECRET =
"6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408";
/**
* Community-extracted default; override via providerSpecificData.sigSecret or
* RAYCAST_SIG_SECRET. Embedded through resolvePublicCred() per Hard Rule #11 —
* a public upstream credential must never be a string literal in the source
* (see docs/security/PUBLIC_CREDS.md).
*/
export const RAYCAST_DEFAULT_SIG_SECRET = resolvePublicCred(
"raycast_sig_secret",
"RAYCAST_SIG_SECRET"
);
export type RaycastCredentials = {
accessToken?: string;
@@ -69,9 +77,7 @@ export function raycastJwt(aid: string, secret: string): string {
const iat = Date.now() / 1000;
const header = base64UrlJson({ typ: "JWT", alg: "HS256" });
const payload = base64UrlJson({ aid, exp: iat + 60, iat });
const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
return `${header}.${payload}.${signature}`;
}
@@ -111,7 +117,10 @@ export function resolveRaycastSecrets(credentials: RaycastCredentials): {
return { bearerToken, deviceId, aid, sigSecret };
}
export function buildRaycastHeaders(payload: string, credentials: RaycastCredentials): Record<string, string> {
export function buildRaycastHeaders(
payload: string,
credentials: RaycastCredentials
): Record<string, string> {
const { bearerToken, deviceId, aid, sigSecret } = resolveRaycastSecrets(credentials);
const psd = credentials.providerSpecificData || {};
const timestamp = Math.floor(Date.now() / 1000).toString();
@@ -137,7 +146,12 @@ export function contentToText(content: unknown): string {
return content
.map((part) => {
if (typeof part === "string") return part;
if (part && typeof part === "object" && "type" in part && (part as { type?: string }).type === "text") {
if (
part &&
typeof part === "object" &&
"type" in part &&
(part as { type?: string }).type === "text"
) {
return String((part as { text?: string }).text || "");
}
return "";

View File

@@ -5,7 +5,11 @@
* credits into the standard `{ plan, quotas }` response.
*/
import { fetchFirecrawlQuota, getFirecrawlBaseUrl, type FirecrawlQuota } from "../firecrawlQuotaFetcher.ts";
import {
fetchFirecrawlQuota,
getFirecrawlBaseUrl,
type FirecrawlQuota,
} from "../firecrawlQuotaFetcher.ts";
import { createQuotaFromUsage, parseResetTime } from "./quota.ts";
function createFirecrawlPlanQuota(q: FirecrawlQuota) {
@@ -29,7 +33,11 @@ function createFirecrawlPlanQuota(q: FirecrawlQuota) {
};
}
export async function getFirecrawlUsage(connectionId: string, apiKey?: string, connection?: Record<string, unknown>) {
export async function getFirecrawlUsage(
connectionId: string,
apiKey?: string,
connection?: Record<string, unknown>
) {
if (!connectionId) {
return { message: "Firecrawl: connection id unavailable." };
}
@@ -44,7 +52,12 @@ export async function getFirecrawlUsage(connectionId: string, apiKey?: string, c
}
try {
const live = await fetchFirecrawlQuota(connectionId, connection);
// The explicit `apiKey` argument was silently dropped when #91bb6aa619 moved
// this to fetchFirecrawlQuota(connectionId, connection): the fetcher reads the
// key off the connection record, so a caller that passes the key directly —
// without a connection carrying it — always got "API key not available".
const resolvedConnection = apiKey ? { ...(connection || {}), apiKey } : connection;
const live = await fetchFirecrawlQuota(connectionId, resolvedConnection);
if (!live) {
return { message: "Firecrawl API key not available or credit usage unavailable." };
}

View File

@@ -96,6 +96,37 @@ export function normalizeOpenAIToolNames(body: unknown, maxLength: number): Tool
return aliases;
}
/**
* Case-insensitive fallback for tool name lookups from upstream responses.
*
* Many upstream providers/models return tool call names in lowercase (e.g., "bash")
* even when the tool definition used PascalCase ("Bash"). This helper tries an exact
* match first (fast path for well-behaved providers), then falls back to a
* case-insensitive scan over the map entries.
*
* Returns the mapped value on match, or `undefined` when no entry matches.
*/
export function caseInsensitiveToolNameLookup(
name: string,
map: Map<string, string> | null | undefined
): string | undefined {
if (!map || !name) return undefined;
// Fast path: exact match (PascalCase-preserving providers)
const exact = map.get(name);
if (exact !== undefined) return exact;
// Fallback: case-insensitive scan
const lowerName = name.toLowerCase();
for (const [key, value] of map) {
if (key.toLowerCase() === lowerName) {
return value;
}
}
return undefined;
}
/** Restore normalized function names in OpenAI Chat Completions responses. */
export function restoreOpenAIToolNames(body: unknown, aliases: unknown): boolean {
if (!(aliases instanceof Map) || aliases.size === 0) return false;
@@ -108,7 +139,7 @@ export function restoreOpenAIToolNames(body: unknown, aliases: unknown): boolean
for (const toolCall of calls) {
const fn = toRecord(toRecord(toolCall)?.function);
if (!fn || typeof fn.name !== "string") continue;
const original = aliases.get(fn.name);
const original = caseInsensitiveToolNameLookup(fn.name, aliases);
if (typeof original !== "string" || original === fn.name) continue;
fn.name = original;
changed = true;

View File

@@ -734,11 +734,24 @@ function wrapInCloudCodeEnvelope(model, cloudCodeRequest, credentials = null) {
envelope._toolNameMap = cloudCodeRequest._toolNameMap;
}
// #9030 — Client system content must NOT be combined with default in systemInstruction
//
// The upstream Antigravity / Cloud Code endpoint rejects oversized systemInstruction
// with 429 RESOURCE_EXHAUSTED. Keep only the lightweight ANTIGRAVITY_DEFAULT_SYSTEM
// in systemInstruction and relocate any client system content (which can be very
// large — Hermes ~125k tokens) to the first user message.
const defaultPart: GeminiPart = { text: ANTIGRAVITY_DEFAULT_SYSTEM };
if (envelope.request.systemInstruction?.parts) {
envelope.request.systemInstruction.parts.unshift(defaultPart);
} else {
envelope.request.systemInstruction = { role: "system", parts: [defaultPart] };
const clientParts = envelope.request.systemInstruction?.parts?.slice() ?? [];
envelope.request.systemInstruction = { role: "system", parts: [defaultPart] };
if (clientParts.length > 0) {
// Prepend client system parts to the first user message so they still guide
// the model's behavior early in the conversation.
if (envelope.request.contents && envelope.request.contents.length > 0) {
envelope.request.contents[0].parts.unshift(...clientParts);
} else {
envelope.request.contents = [{ role: "user", parts: [...clientParts] }];
}
}
// Strip Gemini built-in tool *names* out of functionDeclarations: Antigravity's

View File

@@ -37,10 +37,22 @@ type OpenAIToolCallLike = {
export function buildChangedToolNameMap(
toolNameMap: Map<string, string>
): Map<string, string> | null {
const changedEntries = [...toolNameMap.entries()].filter(
([sanitizedName, originalName]) => sanitizedName !== originalName
);
return changedEntries.length > 0 ? new Map(changedEntries) : null;
if (toolNameMap.size === 0) return null;
const result = new Map<string, string>();
for (const [sanitizedName, originalName] of toolNameMap.entries()) {
result.set(sanitizedName, originalName);
// Add lowercase-keyed alias so Gemini's lowercased tool names find the original.
// Gemini always lowercases tool names in functionCall responses, so even identity
// entries (Bash → Bash) need a lowercase key ("bash" → "Bash") for the response
// translator to look them up (#9568).
const lower = sanitizedName.toLowerCase();
if (lower !== sanitizedName && !result.has(lower)) {
result.set(lower, originalName);
}
}
return result;
}
export function extractClientThoughtSignature(toolCall: unknown): string | null {

View File

@@ -108,9 +108,11 @@ export function geminiToClaudeResponse(chunk, state) {
}
const fc = part.functionCall;
const rawToolName = fc.name;
const restoredToolName = normalizeToolName(
state.toolNameMap?.get(rawToolName) || rawToolName
);
const mappedName = state.toolNameMap?.get(rawToolName);
// When the toolNameMap provides a match (e.g., lowercase "bash" → "Bash"),
// use it directly without passing through normalizeToolName(), which would
// reverse TitleCase back to lowercase via REVERSE_MAP (#9568).
const restoredToolName = mappedName || normalizeToolName(rawToolName);
const idx = state.contentBlockIndex++;
const toolId = fc.id || `toolu_${Date.now()}_${idx}`;

View File

@@ -4,6 +4,7 @@ import {
buildGeminiThoughtSignatureKey,
storeGeminiThoughtSignature,
} from "../../services/geminiThoughtSignatureStore.ts";
import { caseInsensitiveToolNameLookup } from "../helpers/toolCallHelper.ts";
import {
parseTextualToolCallCandidate,
containsTextualToolCallMarker,
@@ -256,7 +257,7 @@ function emitFunctionCallPart(
results: Array<Record<string, unknown>>
) {
const rawToolName = part.functionCall.name;
const fcName = state.toolNameMap?.get(rawToolName) || rawToolName;
const fcName = caseInsensitiveToolNameLookup(rawToolName, state.toolNameMap) ?? rawToolName;
const fcArgs = normalizeToolCallArgs(part.functionCall.args || {});
const toolCallIndex = state.functionIndex++;
const toolCall = {

View File

@@ -1,6 +1,7 @@
import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import { CLAUDE_OAUTH_TOOL_PREFIX } from "../request/openai-to-claude.ts";
import { caseInsensitiveToolNameLookup } from "../helpers/toolCallHelper.ts";
import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallShim.ts";
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
import { isAbortFinishReason } from "../../utils/finishReason.ts";
@@ -284,7 +285,7 @@ export function openaiToClaudeResponse(chunk, state) {
// Strip the Claude OAuth prefix from an incoming tool name (if any).
const incomingName = (() => {
let n = tc.function?.name || "";
n = state.toolNameMap?.get(n) || n;
n = caseInsensitiveToolNameLookup(n, state.toolNameMap) ?? n;
if (n.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) n = n.slice(CLAUDE_OAUTH_TOOL_PREFIX.length);
return n;
})();

View File

@@ -382,6 +382,10 @@ export function resolveProxyForRequest(targetUrl) {
const contextProxy = proxyContext.getStore();
if (contextProxy) {
// #9551: NO_PROXY must bypass context-proxy too
if (target && noProxyMatch(targetUrl)) {
return { source: "direct", proxyUrl: null };
}
return { source: "context", proxyUrl: proxyConfigToUrl(contextProxy) };
}

View File

@@ -207,6 +207,15 @@ const EMBEDDED_DEFAULTS = {
// Firefly credits balance endpoint public x-api-key (`SunbreakWebUI1`) from
// GET firefly.adobe.io/v1/credits/balance browser traffic.
adobe_firefly_balance_api_key: [60, 24, 0, 11, 0, 10, 20, 31, 50, 72, 18, 32, 43, 93],
// Raycast Pro V2 request-signature secret (#8895). Community-extracted from the
// public Raycast macOS client — the SAME value ships to every install, so it is
// public by design, not a per-user credential. Overridable via RAYCAST_SIG_SECRET
// or providerSpecificData.sigSecret.
raycast_sig_secret: [
89, 15, 13, 93, 71, 90, 65, 67, 86, 24, 71, 67, 1, 9, 91, 0, 73, 64, 87, 88, 93, 90, 91, 68, 12,
20, 18, 3, 21, 70, 66, 3, 13, 11, 1, 72, 69, 87, 88, 95, 87, 88, 17, 94, 20, 67, 92, 27, 72, 68,
3, 10, 92, 6, 21, 21, 84, 95, 14, 15, 88, 70, 95, 77,
],
} as const;
export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS;

View File

@@ -70,7 +70,10 @@ import {
hasUnsupportedReasoningSignal,
} from "./reasoningFields.ts";
import { applyThinkTag, flushThink, initThinkState } from "./thinkTagParser.ts";
import { restoreOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts";
import {
caseInsensitiveToolNameLookup,
restoreOpenAIToolNames,
} from "../translator/helpers/toolCallHelper.ts";
import { normalizeFinalOpenAIStreamChunk } from "./openAIStreamChunk.ts";
/**
@@ -578,7 +581,7 @@ function restoreClaudePassthroughToolUseName(parsed: JsonRecord, toolNameMap: un
: null;
if (!block || block.type !== "tool_use" || typeof block.name !== "string") return false;
const restoredName = toolNameMap.get(block.name) ?? block.name;
const restoredName = caseInsensitiveToolNameLookup(block.name, toolNameMap) ?? block.name;
if (restoredName === block.name) return false;
block.name = restoredName;
return true;

View File

@@ -210,6 +210,7 @@
"backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts",
"env:sync": "node scripts/dev/sync-env.mjs",
"test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
"test:integration:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
"test:combo:matrix": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-matrix/*.test.ts\"",
"test:combo:live": "cross-env RUN_COMBO_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-live/*.live.test.ts\"",
"test:combo:live:vps": "node scripts/test/combo-live-vps.mjs",
@@ -229,6 +230,7 @@
"coverage:report": "cross-env NODE_OPTIONS=--max-old-space-size=8192 c8 report --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov",
"coverage:summary": "node scripts/check/test-report-summary.mjs --input coverage/coverage-summary.json --output coverage/coverage-report.md",
"check:pr-test-policy": "node scripts/check/check-pr-test-policy.mjs",
"check:forgotten-sibling-tests": "node scripts/check/check-forgotten-sibling-tests.mjs",
"coverage:report:legacy": "c8 report --output-dir coverage --exclude=open-sse --reporter=text --reporter=text-summary",
"test:all": "npm run test:unit && npm run test:vitest && npm run test:vitest:ui && npm run test:ecosystem && npm run test:e2e",
"check": "npm run lint && npm run test",

View File

@@ -63,6 +63,7 @@ export const INTENTIONALLY_INTERNAL = new Set([
"optimizationSettings", // db-internal: imported by db/core.ts for SQLite PRAGMA application helpers that require the live adapter
"pluginMetrics", // DEAD? (production): write path não foi conectado ainda (documentado no cabeçalho do módulo); testado por tests/unit/plugins-metrics.test.ts
"prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts
"probeUtils", // db-internal: importado so por db/core.ts (retryProbeIfTransient no caminho da corruption-probe, #9541); testado por tests/unit/probe-9541-repro.test.ts
"providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421)
"providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts
"proxyLatency", // intentionally-internal: imported directly by src/lib/db/proxies.ts (anti-barrel, #6798)

View File

@@ -0,0 +1,241 @@
#!/usr/bin/env node
// scripts/check/check-forgotten-sibling-tests.mjs
// Gate: when a PR changes source file Y, detects if any production consumer Z of Y
// has a test sibling (Z.test.ts or Z/index.ts → Z/test.ts) that is NOT included in
// the same PR diff. Prevents the "forgotten sibling test" pattern (7 occurrences
// fixed in PR #9529).
//
// Usage:
// node scripts/check/check-forgotten-sibling-tests.mjs
//
// Environment (PR context):
// GITHUB_BASE_SHA or GITHUB_BASE_REF — base of the PR diff
// FORGOTTEN_SIBLING_MAX_CHANGED — threshold for release-PR skip (default 300)
//
// No PR context → no-ops (exit 0, no output).
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
import { globSync } from "tinyglobby";
import { ROOT, IMPORT_RE, EXTS, SRC_ROOTS, resolveImport } from "./lib/importResolution.mjs";
// ─── Constants ────────────────────────────────────────────────────────────────
const SOURCE_ROOTS = ["src/", "open-sse/", "bin/"];
const EXCLUDED_PATTERNS = [
/\/tests\//,
/\/migrations\//,
/\/__tests__\//,
/\.test\./,
/\.spec\./,
/\/node_modules\//,
/\/config\/(?:quality|eslint|tsconfig)/,
];
const DEFAULT_MAX_CHANGED = 300;
// ─── Helpers (exported for testing) ───────────────────────────────────────────
export function runGit(args) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
export function isSourceFile(filePath) {
if (EXCLUDED_PATTERNS.some((p) => p.test(filePath))) return false;
return (
SOURCE_ROOTS.some((root) => filePath.startsWith(root)) && EXTS.some((e) => filePath.endsWith(e))
);
}
export function resolveBase() {
if (process.env.GITHUB_BASE_SHA) return process.env.GITHUB_BASE_SHA;
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
return null;
}
/**
* Test sibling of a production file Z.
* Convention: Z.ts → Z.test.ts, or Z/index.ts → Z/test.ts.
* Returns the repo-relative path of the test sibling, or null if none.
*/
export function testSiblingOf(relPath) {
const abs = path.join(ROOT, relPath);
const dir = path.dirname(abs);
const base = path.basename(abs).replace(/\.(ts|tsx|mts|js|mjs)$/, "");
const candidates = [
path.join(dir, `${base}.test.ts`),
path.join(dir, `${base}.test.tsx`),
path.join(dir, `${base}.test.mjs`),
];
// Also try Z/test/ subdirectory
const testDir = path.join(dir.replace(/\/?$/, ""), "test");
candidates.push(
path.join(testDir, `${base}.test.ts`),
path.join(testDir, `${base}.test.tsx`),
path.join(testDir, `${base}.test.mjs`)
);
// For Z/index.ts or Z/route.ts, also try Z/test.ts
if (base === "index" || base === "route") {
candidates.push(
path.join(dir, `${base}.test.ts`),
path.join(dir, `${base}.test.tsx`),
path.join(dir, `${base}.test.mjs`),
path.join(dir, "test.ts"),
path.join(dir, "test.tsx"),
path.join(dir, "test.mjs")
);
}
for (const c of candidates) {
if (fs.existsSync(c)) return path.relative(ROOT, c);
}
return null;
}
/**
* For a given changed source file (repo-relative path), find all production
* files under SOURCE_ROOTS that directly import from it. Uses the pre-built
* reverse dependency map.
*/
export function findConsumers(changedRelPath, prodFileMap) {
const abs = path.join(ROOT, changedRelPath);
const consumers = [];
for (const [consumerRel, deps] of Object.entries(prodFileMap)) {
if (deps.has(abs)) consumers.push(consumerRel);
}
return consumers.sort();
}
/**
* Build a map of all production files → their resolved direct import deps (Set of absolute paths).
*/
export function buildProdFileMap() {
const map = {};
const prodFiles = globSync(
SRC_ROOTS.map((r) => `${r}/**/*.{ts,tsx,mts,js,mjs}`),
{ cwd: ROOT, ignore: ["**/node_modules/**", "**/tests/**", "**/__tests__/**"] }
);
for (const f of prodFiles) {
if (!isSourceFile(f)) continue;
const fullPath = path.join(ROOT, f);
let code;
try {
code = fs.readFileSync(fullPath, "utf8");
} catch {
continue;
}
const deps = new Set();
for (const m of code.matchAll(IMPORT_RE)) {
const spec = m[1] || m[2] || m[3];
if (!spec) continue;
const r = resolveImport(spec, fullPath);
if (!r) continue;
deps.add(r);
}
map[f] = deps;
}
return map;
}
/**
* Check if an allowlist entry exempts a (changedFile, consumerWithMissingTest) pair.
*/
export function isAllowlisted(changedFile, missingTest, allowlist) {
for (const entry of allowlist) {
if (entry.sourcePath === changedFile && entry.forgottenSibling === missingTest) return true;
if (entry.sourcePath === "*" && entry.forgottenSibling === missingTest) return true;
}
return false;
}
// ─── Main ─────────────────────────────────────────────────────────────────────
function main() {
const base = resolveBase();
if (!base) {
console.log("[forgotten-sibling] no base ref (not a PR context) — skipping check.");
return;
}
// Read allowlist
let allowlist = [];
try {
const raw = JSON.parse(
fs.readFileSync(path.join(ROOT, "config/quality/forgotten-sibling-allowlist.json"), "utf8")
);
allowlist = Array.isArray(raw) ? raw : [];
} catch {
// No allowlist file or parse error — treat as empty
}
// Get changed files
const changedFiles = runGit(["diff", "--name-only", "--diff-filter=ACM", `${base}...HEAD`])
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
const changedSources = changedFiles.filter(isSourceFile);
const changedTestFiles = new Set(
changedFiles.filter((f) => /\.(?:test|spec)\.(?:ts|tsx|mjs)$/.test(f))
);
// Release PR skip: if too many changed files, skip the detailed consumer walk.
const maxChanged = Number(process.env.FORGOTTEN_SIBLING_MAX_CHANGED) || DEFAULT_MAX_CHANGED;
if (maxChanged > 0 && changedSources.length > maxChanged) {
console.log(
`[forgotten-sibling] ${changedSources.length} source file(s) changed exceeds ` +
`threshold (${maxChanged}) — skipping consumer scan (release PR).\n` +
` A diff this large is a release PR or mass refactor; each file already ` +
`passed this gate on its own PR during the cycle.`
);
return;
}
if (changedSources.length === 0) {
console.log("[forgotten-sibling] no changed source files — OK.");
return;
}
// Build the production dependency map (source → consumers)
const prodFileMap = buildProdFileMap();
const flags = [];
for (const changedSource of changedSources) {
const consumers = findConsumers(changedSource, prodFileMap);
if (consumers.length === 0) continue;
for (const consumer of consumers) {
const testSibling = testSiblingOf(consumer);
if (!testSibling) continue;
if (changedTestFiles.has(testSibling)) continue;
if (isAllowlisted(changedSource, testSibling, allowlist)) continue;
flags.push(
`${changedSource}: \`${consumer}\` imports this file and has a test sibling ` +
`(\`${testSibling}\`) that is NOT in the current diff. ` +
"When the public API of the changed source changes, consumer tests " +
"may need updating too."
);
}
}
if (flags.length) {
console.error(
`[forgotten-sibling] ${flags.length} forgotten sibling test(s) detected:\n` +
flags.map((f) => `${f}`).join("\n") +
`\n → Add the missing test files to this PR or add an allowlist entry ` +
`(config/quality/forgotten-sibling-allowlist.json) with a justification.`
);
process.exit(1);
}
console.log(
`[forgotten-sibling] OK — ${changedSources.length} source file(s) changed, ` +
`no forgotten sibling tests.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) main();

View File

@@ -107,6 +107,11 @@ export const COLLECTORS = [
glob: "open-sse/services/__tests__/antigravity-quota-family.test.ts",
sources: ["vitest.mcp.config.ts"],
},
// #8890 landed this suite here without wiring a runner, so it had never run once.
{
glob: "open-sse/services/__tests__/fail-fast-concurrency-gate.test.ts",
sources: ["vitest.mcp.config.ts"],
},
{ glob: "tests/unit/autoCombo/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/lib/memory/__tests__/generic-backend.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "tests/unit/encryption.spec.ts", sources: ["vitest.mcp.config.ts"] },

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
// scripts/check/lib/importResolution.mjs
// Shared import resolution logic extracted from build-test-impact-map.mjs.
// Provides resolveImport(), sourceDepsOf(), IMPORT_RE, EXTS, SRC_ROOTS, ROOT.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
export const SRC_ROOTS = ["src", "open-sse"];
export const IMPORT_RE =
/(?:import|export)[^'"]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g;
export const EXTS = [".ts", ".tsx", ".mts", ".js", ".mjs"];
/**
* Resolve an import specifier to an absolute file path.
* Handles `@/` aliases, `@omniroute/open-sse` aliases, and relative paths.
* Returns null for external/npm imports or unresolvable specs.
*/
export function resolveImport(spec, fromFile) {
let base;
if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2));
else if (spec.startsWith("@omniroute/open-sse"))
base = path.join(ROOT, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, ""));
else if (spec.startsWith(".")) base = path.resolve(path.dirname(fromFile), spec);
else return null;
for (const e of EXTS) {
if (fs.existsSync(base + e)) return base + e;
}
for (const e of EXTS) {
const idx = path.join(base, "index" + e);
if (fs.existsSync(idx)) return idx;
}
return fs.existsSync(base) && fs.statSync(base).isFile() ? base : null;
}
/**
* Walk the transitive import graph of a file and return all source-relative
* paths it depends on (files under src/ or open-sse/).
*/
export function sourceDepsOf(entry) {
const seen = new Set();
const stack = [entry];
const sources = new Set();
while (stack.length) {
const f = stack.pop();
if (seen.has(f)) continue;
seen.add(f);
let code;
try {
code = fs.readFileSync(f, "utf8");
} catch {
continue;
}
for (const m of code.matchAll(IMPORT_RE)) {
const spec = m[1] || m[2] || m[3];
if (!spec) continue;
const r = resolveImport(spec, f);
if (!r) continue;
const rel = path.relative(ROOT, r);
if (SRC_ROOTS.some((s) => rel.startsWith(s + path.sep))) sources.add(rel);
stack.push(r);
}
}
return sources;
}

View File

@@ -1,57 +1,14 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { globSync } from "tinyglobby";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const SRC_ROOTS = ["src", "open-sse"];
const IMPORT_RE =
/(?:import|export)[^'"]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g;
const EXTS = [".ts", ".tsx", ".mts", ".js", ".mjs"];
function resolveImport(spec, fromFile) {
let base;
if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2));
else if (spec.startsWith("@omniroute/open-sse"))
base = path.join(ROOT, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, ""));
else if (spec.startsWith(".")) base = path.resolve(path.dirname(fromFile), spec);
else return null;
for (const e of EXTS) {
if (fs.existsSync(base + e)) return base + e;
}
for (const e of EXTS) {
const idx = path.join(base, "index" + e);
if (fs.existsSync(idx)) return idx;
}
return fs.existsSync(base) && fs.statSync(base).isFile() ? base : null;
}
function sourceDepsOf(entry) {
const seen = new Set();
const stack = [entry];
const sources = new Set();
while (stack.length) {
const f = stack.pop();
if (seen.has(f)) continue;
seen.add(f);
let code;
try {
code = fs.readFileSync(f, "utf8");
} catch {
continue;
}
for (const m of code.matchAll(IMPORT_RE)) {
const spec = m[1] || m[2] || m[3];
if (!spec) continue;
const r = resolveImport(spec, f);
if (!r) continue;
const rel = path.relative(ROOT, r);
if (SRC_ROOTS.some((s) => rel.startsWith(s + path.sep))) sources.add(rel);
stack.push(r);
}
}
return sources;
}
import {
ROOT,
IMPORT_RE,
EXTS,
SRC_ROOTS,
resolveImport,
sourceDepsOf,
} from "../check/lib/importResolution.mjs";
// Mirror EXACTLY the `npm run test:unit` glob — the curated set of node:test files.
// The TIA step runs the selected subset via `node --test`, so it must NOT include
@@ -79,7 +36,10 @@ for (const tf of testFiles) {
}
for (const k of Object.keys(map)) map[k].sort();
const out = path.join(ROOT, "config/quality/test-impact-map.json");
fs.writeFileSync(out, JSON.stringify({ generatedFrom: "import-graph", sources: map }, null, 2) + "\n");
fs.writeFileSync(
out,
JSON.stringify({ generatedFrom: "import-graph", sources: map }, null, 2) + "\n"
);
console.log(
`test-impact-map: ${Object.keys(map).length} source files mapped from ${testFiles.length} test files`
);

View File

@@ -27,7 +27,8 @@ import { normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
import { resolveDashboardProviderInfo } from "../providerPageUtils";
import { resolveDashboardProviderInfo, resolveProviderHeaderLink } from "../providerPageUtils";
import { findDefaultReferral } from "@/lib/radar/referrals";
import { type ConnectionRowConnection } from "./components/ConnectionRow";
import { useProviderConnections } from "./hooks/useProviderConnections";
import { useProviderSettings } from "./hooks/useProviderSettings";
@@ -213,6 +214,39 @@ export default function ProviderDetailPageClient() {
openAiCompatibleName: t("openaiCompatibleName"),
},
});
// D28 — Radar default referral link ("Pegue seus créditos grátis"). Fetched
// from the LOCAL /api/radar/referrals route only (never talks to the
// private feed server directly) — same client-fetch pattern the Radar
// dashboard page already uses for its own data. This keeps the providers
// page decoupled from @/lib/radar (DB-touching, Node-only): a 404 (flag
// off) or 401/network failure just leaves `referralUrl` null, and
// `resolveProviderHeaderLink` below then falls back to the static catalog
// website — byte-identical to before this feature existed.
const [referralUrl, setReferralUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/radar/referrals");
if (!res.ok) return;
const data = await res.json();
if (cancelled) return;
const fixed = Array.isArray(data?.fixed) ? data.fixed : [];
const match = findDefaultReferral(fixed, providerId);
setReferralUrl(match?.url ?? null);
} catch {
// Best-effort only — never blocks rendering of the provider page.
}
})();
return () => {
cancelled = true;
};
}, [providerId]);
const { website: providerHeaderWebsite, isReferralLink } = resolveProviderHeaderLink(
providerInfo?.website,
referralUrl
);
const providerSupportsOAuth =
providerInfo?.toggleAuthType === "oauth" || providerInfo?.toggleAuthType === "free";
const subscriptionRisk = providerInfo?.subscriptionRisk === true;
@@ -463,12 +497,13 @@ export default function ProviderDetailPageClient() {
<div className="flex flex-col gap-8">
<ProviderPageHeader
providerId={providerId}
providerInfo={providerInfo}
providerInfo={{ ...providerInfo, website: providerHeaderWebsite }}
connectionsCount={connections.length}
isOpenAICompatible={isOpenAICompatible}
isAnthropicProtocolCompatible={isAnthropicProtocolCompatible}
onOpenTutorial={() => setShowTutorialModal(true)}
t={t}
isReferralLink={isReferralLink}
/>
{providerId === "zed" && (

View File

@@ -27,6 +27,14 @@ interface ProviderPageHeaderProps {
isAnthropicProtocolCompatible: boolean;
onOpenTutorial: () => void;
t: ProviderMessageTranslator;
/**
* True when `providerInfo.website` was overridden with a Radar default
* referral link (D28 — referral links / free credits), rather than the
* static catalog `website`. Reuses the same discreet "Partner link" note
* as the pre-existing Kimi partnership link — both are the same kind of
* "this link supports OmniRoute" disclosure.
*/
isReferralLink?: boolean;
}
export default function ProviderPageHeader({
@@ -37,6 +45,7 @@ export default function ProviderPageHeader({
isAnthropicProtocolCompatible,
onOpenTutorial,
t,
isReferralLink = false,
}: ProviderPageHeaderProps) {
// Kimi (Moonshot AI) official-partnership aff links (2026-07): the header
// website link doubles as the CTA for kimi-coding/kimi-web/moonshot's
@@ -45,6 +54,9 @@ export default function ProviderPageHeader({
// reads as a monetized link, not just "visit provider website" like every
// other card. UI-only — never affects routing/fallback (featuredProviders.ts).
const isKimiPartnerLink = isKimiPartnerProviderId(providerInfo.id);
// D28: any Radar-driven default referral gets the exact same discreet
// disclosure treatment as the Kimi partner link.
const showPartnerNote = isKimiPartnerLink || isReferralLink;
const kimiPartnerLinkNote = providerText(
t,
"kimiPartnerLinkNote",
@@ -88,9 +100,9 @@ export default function ProviderPageHeader({
rel="noopener noreferrer"
className="text-3xl font-semibold tracking-tight hover:underline inline-flex items-center gap-2"
style={{ color: providerInfo.color }}
title={isKimiPartnerLink ? kimiPartnerLinkNote : undefined}
title={showPartnerNote ? kimiPartnerLinkNote : undefined}
aria-label={
isKimiPartnerLink ? `${providerInfo.name}${kimiPartnerLinkNote}` : undefined
showPartnerNote ? `${providerInfo.name}${kimiPartnerLinkNote}` : undefined
}
>
{providerInfo.name}
@@ -103,7 +115,7 @@ export default function ProviderPageHeader({
<p className="text-text-muted">
{t("connectionCountLabel", { count: connectionsCount })}
</p>
{isKimiPartnerLink && providerInfo.website && (
{showPartnerNote && providerInfo.website && (
<span className="text-[10px] font-medium uppercase tracking-wide text-text-muted/70">
{kimiPartnerLinkNote}
</span>

View File

@@ -511,6 +511,43 @@ export function buildCompactProviderEntries<TProvider>(
return visibleEntries;
}
/**
* Result of `resolveProviderHeaderLink` — decides whether the provider name
* link on the detail page header (`ProviderPageHeader`) points at the
* static catalog `website` or at a Radar referral link.
*/
export interface ProviderHeaderLink {
/** Effective URL for the header link, or `undefined` for no link at all. */
website: string | undefined;
/** True when `website` came from a Radar default referral, not the static catalog. */
isReferralLink: boolean;
}
/**
* Pure decision function for the provider-name link (D28 — referral links).
* Deliberately DB-free and Radar-module-free: it takes the already-resolved
* referral URL (or `undefined`/`null` when none applies) as a plain string
* so this file — and the providers dashboard that depends on it — never has
* to import `@/lib/radar` (which pulls in `better-sqlite3`, Node-only) to
* render. The caller (`ProviderDetailPageClient`) is the one place allowed
* to fetch the referral, via the local `/api/radar/referrals` route — same
* pattern the Radar dashboard page already uses for its own data.
*
* With `RADAR_ENABLED` off, or no cache, or no default referral for the
* provider, `referralUrl` is `null`/`undefined` and this returns the exact
* same `website` the catalog already provided — byte-identical to today's
* behavior.
*/
export function resolveProviderHeaderLink(
staticWebsite: string | null | undefined,
referralUrl: string | null | undefined
): ProviderHeaderLink {
if (referralUrl) {
return { website: referralUrl, isReferralLink: true };
}
return { website: staticWebsite ?? undefined, isReferralLink: false };
}
export function resolveDashboardProviderInfo(
providerId: string,
options?: {

View File

@@ -1,10 +1,11 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { notFound } from "next/navigation";
import Link from "next/link";
import { Card } from "@/shared/components";
import { shouldAutoSyncOnOpen } from "@/lib/radar/autoSync";
// ---------------------------------------------------------------------------
// Types
@@ -39,6 +40,24 @@ interface RadarMergedEntry {
type PageState = "flag_off" | "optin_pending" | "empty" | "populated";
/** D28 — referral links / free credits. Client-side mirror of RadarReferral. */
interface RadarReferralItem {
provider: string;
url: string;
kind: "fixo" | "campanha";
validUntil: string | null;
requiredAction: string | null;
isDefault: boolean;
}
interface RadarReferralsState {
fixed: RadarReferralItem[];
campaigns: RadarReferralItem[];
tier: string | null;
}
type RadarTabId = "catalog" | "referrals";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -100,6 +119,12 @@ export default function RadarPage() {
const [optIn, setOptIn] = useState<boolean | null>(null);
const [activating, setActivating] = useState(false);
const [syncing, setSyncing] = useState(false);
const [activeTab, setActiveTab] = useState<RadarTabId>("catalog");
const [referrals, setReferrals] = useState<RadarReferralsState>({
fixed: [],
campaigns: [],
tier: null,
});
// Fetch catalog
const fetchCatalog = useCallback(async () => {
@@ -126,32 +151,53 @@ export default function RadarPage() {
}
}, [t]);
// Fetch settings to determine opt-in state
// D28 — fetch the referral links section ("Pegue seus créditos grátis").
// Best-effort: flag off => 404, no cache => empty shape; either way this
// never blocks rendering of the rest of the page.
const fetchReferrals = useCallback(async () => {
try {
const res = await fetch("/api/radar/referrals");
if (!res.ok) return;
const data = await res.json();
setReferrals({
fixed: Array.isArray(data.fixed) ? data.fixed : [],
campaigns: Array.isArray(data.campaigns) ? data.campaigns : [],
tier: data.tier ?? null,
});
} catch {
// Best-effort only.
}
}, []);
// Fetch settings to determine opt-in state (GET /api/radar/settings — FIX 3:
// previously there was no settings GET, so an already-opted-in operator saw
// the activation screen on every reload).
const fetchSettings = useCallback(async () => {
try {
// We don't have a GET /api/radar/settings — infer from catalog response:
// If catalog returns meta=null and entries are baseline-only, user hasn't opted in.
// A 404 means flag is off.
const res = await fetch("/api/radar/catalog");
if (res.status === 404) {
const settingsRes = await fetch("/api/radar/settings");
if (settingsRes.status === 404) {
// Flag off
setOptIn(false);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEntries(data.entries || []);
setMeta(data.meta || null);
// If meta is null, the user hasn't synced yet (or hasn't opted in).
// We need to check opt-in state. Since there's no GET endpoint for settings,
// we infer: if flag is on and we got baseline, user may or may not be opted in.
// The activation flow handles this — we show the activation screen if meta is null.
setOptIn(null); // unknown — will determine from user action
if (!settingsRes.ok) throw new Error(`HTTP ${settingsRes.status}`);
const settingsData = await settingsRes.json();
setOptIn(settingsData.optIn === true);
if (settingsData.optIn === true) {
// Already opted in — load the catalog now so the populated/empty
// state renders immediately instead of waiting for a manual sync.
await fetchCatalog();
// D28 — load the referral links in parallel; best-effort, never
// blocks the catalog state above.
void fetchReferrals();
}
} catch {
setOptIn(null);
} finally {
setLoading(false);
}
}, []);
}, [fetchCatalog, fetchReferrals]);
useEffect(() => {
fetchSettings();
@@ -167,7 +213,11 @@ export default function RadarPage() {
const data = await res.json();
if (data.status === "updated" || data.status === "stale") {
await fetchCatalog();
} else if (data.status === "error") {
void fetchReferrals();
} else if (data.status === "error" || data.status === "too_large") {
// "too_large" reuses the generic sync-failed copy — the feed exceeded the
// client-side size cap (10MB), which is operationally the same as any
// other sync failure from the operator's point of view.
setError(data.reason || t("syncFailed"));
} else if (data.status === "disabled") {
setError(t("flagDisabled"));
@@ -179,7 +229,20 @@ export default function RadarPage() {
} finally {
setSyncing(false);
}
}, [t, fetchCatalog]);
}, [t, fetchCatalog, fetchReferrals]);
// Auto-sync on open: when the operator is already opted in and the cached
// feed is stale (or absent), refresh it automatically once per mount so the
// page always shows current data without requiring the manual Sync button
// (spec: dados atualizados a cada abrir da página). The ref guards against
// re-firing when `meta` updates after the sync itself.
const autoSyncFiredRef = useRef(false);
useEffect(() => {
if (loading || syncing || optIn !== true || autoSyncFiredRef.current) return;
if (!shouldAutoSyncOnOpen(meta?.fetchedAt ?? null, Date.now())) return;
autoSyncFiredRef.current = true;
void handleSync();
}, [loading, syncing, optIn, meta, handleSync]);
// Activate opt-in
const handleActivate = useCallback(async () => {
@@ -293,8 +356,120 @@ export default function RadarPage() {
</Card>
)}
{/* D28 — tab bar. Only shown once opted in (empty/populated) — the
activation gate above is a single full-screen step, not a tab. */}
{(pageState === "empty" || pageState === "populated") && (
<div className="flex gap-2 border-b border-border" role="tablist">
<button
role="tab"
aria-selected={activeTab === "catalog"}
onClick={() => setActiveTab("catalog")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "catalog"
? "border-violet-500 text-violet-400"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
{t("catalogTab")}
</button>
<button
role="tab"
aria-selected={activeTab === "referrals"}
onClick={() => setActiveTab("referrals")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "referrals"
? "border-violet-500 text-violet-400"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
{t("freeCreditsTab")}
</button>
</div>
)}
{/* Free credits tab (D28 — referral links) */}
{(pageState === "empty" || pageState === "populated") && activeTab === "referrals" && (
<div className="flex flex-col gap-6">
<p className="text-sm text-text-muted">{t("freeCreditsSubtitle")}</p>
{referrals.fixed.length === 0 ? (
<Card>
<p className="text-text-muted text-center py-6">{t("fixedLinksEmpty")}</p>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{referrals.fixed.map((referral) => (
<Card key={`${referral.provider}:fixed`} padding="sm">
<div className="flex flex-col gap-2">
<span className="font-medium">{referral.provider}</span>
{referral.requiredAction && (
<p className="text-xs text-text-muted">
{t("requiredActionLabel")} {referral.requiredAction}
</p>
)}
<a
href={referral.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm font-medium text-violet-400 hover:underline w-fit"
>
{t("claimButton")}
<span className="material-symbols-outlined text-sm">open_in_new</span>
</a>
</div>
</Card>
))}
</div>
)}
<div>
<h3 className="text-lg font-semibold mb-3">{t("campaignsTitle")}</h3>
{referrals.campaigns.length === 0 ? (
<Card>
<p className="text-text-muted text-center py-6">
{referrals.tier === "community"
? t("campaignsUpsellCommunity")
: t("campaignsEmpty")}
</p>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{referrals.campaigns.map((referral, idx) => (
<Card key={`${referral.provider}:campaign:${idx}`} padding="sm">
<div className="flex flex-col gap-2">
<span className="font-medium">{referral.provider}</span>
{referral.requiredAction && (
<p className="text-xs text-text-muted">
{t("requiredActionLabel")} {referral.requiredAction}
</p>
)}
{referral.validUntil && (
<p className="text-xs text-amber-400">
{t("campaignsValidUntil", {
date: new Date(referral.validUntil).toLocaleDateString(),
})}
</p>
)}
<a
href={referral.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm font-medium text-violet-400 hover:underline w-fit"
>
{t("claimButton")}
<span className="material-symbols-outlined text-sm">open_in_new</span>
</a>
</div>
</Card>
))}
</div>
)}
</div>
</div>
)}
{/* Empty cache — opted in but no data yet */}
{pageState === "empty" && (
{pageState === "empty" && activeTab === "catalog" && (
<Card>
<div className="flex flex-col items-center gap-4 py-12 text-center">
<p className="text-text-muted">{t("emptyState")}</p>
@@ -310,7 +485,7 @@ export default function RadarPage() {
)}
{/* Populated catalog table */}
{pageState === "populated" && (
{pageState === "populated" && activeTab === "catalog" && (
<Card>
<div className="overflow-x-auto">
<table className="w-full">

View File

@@ -217,11 +217,15 @@ export async function POST(request: Request) {
testStatus: "active",
isActive: true,
};
const connection: any = await upsertImportedKiroConnection(targetProvider, record, {
profileArn: resolvedProfileArn,
clientId: providerSpecificData.clientId,
email,
});
// Only include clientId in the identity for IDC imports where it is genuinely
// unique per account (#2059). For Builder ID / social imports the OIDC clientId
// comes from a machine-wide cached OIDC registration (shared across all accounts
// on the same machine), so using it for identity matching would cause different
// accounts to overwrite each other (#9435). Without clientId, the identity
// matching falls through to the email field, which correctly distinguishes imports.
const identity: Record<string, unknown> = { profileArn: resolvedProfileArn, email };
if (isIdc) identity.clientId = providerSpecificData.clientId;
const connection: any = await upsertImportedKiroConnection(targetProvider, record, identity);
// Auto sync to Cloud if enabled
await syncToCloudIfEnabled();

View File

@@ -111,6 +111,14 @@ export const OAUTH_TEST_CONFIG = {
// Validate using token presence/expiry as a lightweight auth check.
checkExpiry: true,
},
raycast: {
// #8895 — Raycast Pro is an `import_token` provider: the token is imported
// from the local Raycast install, `refreshToken` is always null and the
// stored `expiresIn` defaults to 30 days. There is nothing to refresh, so
// the test is the expiry check on the imported token; without an entry here
// Test Connection persists testStatus="error" on a healthy account (#8408).
checkExpiry: true,
},
cline: CLINE_OAUTH_TEST_CONFIG,
// ClinePass reuses the same WorkOS OAuth flow and token lifecycle as Cline.
clinepass: CLINE_OAUTH_TEST_CONFIG,

View File

@@ -4,12 +4,16 @@
* NEVER proxies the private feed server. The browser talks only to this
* local endpoint; sync happens server-side via POST /api/radar/sync.
*
* Flag off => 404 (the surface doesn't exist when disabled).
* Flag off => 404 (the surface doesn't exist when disabled), checked BEFORE
* auth so flag-off inertia stays byte-identical (no auth required to learn
* the surface doesn't exist). Unauthenticated access once the flag is on
* => 401 (management route — dashboard session or a management-scoped key).
*/
import { NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { getRadarCatalog } from "@/lib/radar";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
@@ -20,8 +24,8 @@ export async function OPTIONS() {
return handleCorsOptions();
}
export async function GET() {
// Flag gate — surface doesn't exist when disabled
export async function GET(request: Request) {
// Flag gate — surface doesn't exist when disabled. MUST run before auth.
if (!isFeatureFlagEnabled("RADAR_ENABLED")) {
return NextResponse.json(
buildErrorBody(404, "Not found"),
@@ -29,6 +33,13 @@ export async function GET() {
);
}
if (!(await isAuthenticated(request))) {
return NextResponse.json(
buildErrorBody(401, "Unauthorized"),
{ status: 401, headers: CORS_HEADERS },
);
}
try {
const result = getRadarCatalog();
return NextResponse.json(

View File

@@ -0,0 +1,67 @@
/**
* GET /api/radar/referrals — return the referral links section ("Pegue seus
* créditos grátis", D28) of the locally cached Radar feed.
*
* NEVER proxies the private feed server. Like GET /api/radar/catalog, the
* browser talks only to this local endpoint; sync happens server-side via
* POST /api/radar/sync, and this route only reads the cache that sync
* already wrote.
*
* `fixed` referrals are present in every tier (community included, gated
* server-side); `campaigns` only comes populated on the `live` (supporter)
* tier — the community artifact publishes `campaigns: []` — so this route
* never needs to decide tier gating itself, it just relays what the cached
* feed already contains. `tier` is informative (from the cache row), used
* by the UI to show a soft upsell note when campaigns is empty.
*
* Flag off => 404 (the surface doesn't exist when disabled), checked BEFORE
* auth. Unauthenticated access once the flag is on => 401 (management route
* — dashboard session or a management-scoped key).
*/
import { NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { getRadarReferrals } from "@/lib/radar";
import { getRadarCache } from "@/lib/db/radar";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export async function OPTIONS() {
return handleCorsOptions();
}
export async function GET(request: Request) {
// Flag gate — surface doesn't exist when disabled. MUST run before auth.
if (!isFeatureFlagEnabled("RADAR_ENABLED")) {
return NextResponse.json(
buildErrorBody(404, "Not found"),
{ status: 404, headers: CORS_HEADERS },
);
}
if (!(await isAuthenticated(request))) {
return NextResponse.json(
buildErrorBody(401, "Unauthorized"),
{ status: 401, headers: CORS_HEADERS },
);
}
try {
const { fixed, campaigns } = getRadarReferrals();
const cache = getRadarCache();
return NextResponse.json(
{ fixed, campaigns, tier: cache?.tier ?? null },
{ headers: { ...CORS_HEADERS, "Cache-Control": "no-store" } },
);
} catch (err: unknown) {
const { sanitizeErrorMessage } = await import("@omniroute/open-sse/utils/error");
return NextResponse.json(
buildErrorBody(500, sanitizeErrorMessage(err) || "Failed to load Radar referrals"),
{ status: 500, headers: CORS_HEADERS },
);
}
}

View File

@@ -1,18 +1,27 @@
/**
* GET /api/radar/settings — read the current Radar opt-in + supporter-key
* snapshot. Powers the dashboard page's "am I already opted in?" check so
* a reload doesn't re-show the activation screen (see FIX 3).
*
* POST /api/radar/settings — set Radar opt-in and/or supporter key.
*
* Zod-validated body: { optIn?: boolean, supporterKey?: string|null }
* Key shape: "omr_" + 40 hex chars.
*
* NEVER echoes the key back returns a masked form instead.
* Flag off => 404.
* NEVER echoes the raw key back on either verb — GET returns a masked form
* ("omr_****" + last 4 hex chars) and a `hasSupporterKey` boolean; POST
* returns the same masked form.
*
* Flag off => 404, checked BEFORE auth (byte-identical flag-off inertia).
* Unauthenticated access once the flag is on => 401.
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { setRadarOptIn, setRadarKey } from "@/lib/db/radar";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { setRadarOptIn, setRadarKey, getRadarSettings } from "@/lib/db/radar";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
export const dynamic = "force-dynamic";
@@ -43,8 +52,8 @@ export async function OPTIONS() {
return handleCorsOptions();
}
export async function POST(request: Request) {
// Flag gate
export async function GET(request: Request) {
// Flag gate — MUST run before auth (byte-identical flag-off inertia).
if (!isFeatureFlagEnabled("RADAR_ENABLED")) {
return NextResponse.json(
buildErrorBody(404, "Not found"),
@@ -52,6 +61,48 @@ export async function POST(request: Request) {
);
}
if (!(await isAuthenticated(request))) {
return NextResponse.json(
buildErrorBody(401, "Unauthorized"),
{ status: 401, headers: CORS_HEADERS },
);
}
try {
const settings = getRadarSettings();
return NextResponse.json(
{
optIn: settings.optIn,
hasSupporterKey: settings.supporterKey !== null,
supporterKeyMasked: maskKey(settings.supporterKey),
},
{ headers: { ...CORS_HEADERS, "Cache-Control": "no-store" } },
);
} catch (err: unknown) {
const { sanitizeErrorMessage } = await import("@omniroute/open-sse/utils/error");
return NextResponse.json(
buildErrorBody(500, sanitizeErrorMessage(err) || "Failed to load Radar settings"),
{ status: 500, headers: CORS_HEADERS },
);
}
}
export async function POST(request: Request) {
// Flag gate — MUST run before auth (byte-identical flag-off inertia).
if (!isFeatureFlagEnabled("RADAR_ENABLED")) {
return NextResponse.json(
buildErrorBody(404, "Not found"),
{ status: 404, headers: CORS_HEADERS },
);
}
if (!(await isAuthenticated(request))) {
return NextResponse.json(
buildErrorBody(401, "Unauthorized"),
{ status: 401, headers: CORS_HEADERS },
);
}
let body: unknown;
try {
body = await request.json();
@@ -83,6 +134,17 @@ export async function POST(request: Request) {
try {
if (optIn !== undefined) {
setRadarOptIn(optIn);
if (optIn) {
// Opting in is the moment the daily background sync becomes wanted —
// arm the scheduler lazily so a flag-off/opt-out install never even
// creates the timer (Radar inertia contract). Never fatal.
try {
const { ensureRadarSyncScheduler } = await import("@/lib/radar/scheduler");
ensureRadarSyncScheduler();
} catch {
// Scheduler is best-effort; manual sync keeps working without it.
}
}
}
if (supporterKey !== undefined) {
setRadarKey(supporterKey);

View File

@@ -5,13 +5,15 @@
* verification, schema validation, version floor). Returns the status
* object. Never proxies the feed URL to the client.
*
* Flag off => 404.
* Flag off => 404, checked BEFORE auth (byte-identical flag-off inertia).
* Unauthenticated access once the flag is on => 401.
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { syncRadar } from "@/lib/radar/sync";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
@@ -26,7 +28,7 @@ export async function OPTIONS() {
}
export async function POST(request: Request) {
// Flag gate
// Flag gate — MUST run before auth (byte-identical flag-off inertia).
if (!isFeatureFlagEnabled("RADAR_ENABLED")) {
return NextResponse.json(
buildErrorBody(404, "Not found"),
@@ -34,6 +36,13 @@ export async function POST(request: Request) {
);
}
if (!(await isAuthenticated(request))) {
return NextResponse.json(
buildErrorBody(401, "Unauthorized"),
{ status: 401, headers: CORS_HEADERS },
);
}
// Validate body (must be empty or absent)
let body: unknown;
try {

View File

@@ -5,6 +5,7 @@ import { getRuntimePorts } from "@/lib/runtime/ports";
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings";
import {
validateProxyUrl,
@@ -220,6 +221,12 @@ export async function GET(request: Request) {
cloudConfigured: Boolean(cloudUrl),
cloudUrl,
machineId,
// Sidebar.tsx has no server-side feature-flag access (client component);
// this piggy-backs the RADAR_ENABLED gate onto the settings payload the
// sidebar already fetches on mount, so the "radar" item can hide itself
// without a dedicated round trip. See sidebarVisibility.ts's
// `isSidebarItemVisibleForFlags()`.
radarEnabled: isFeatureFlagEnabled("RADAR_ENABLED"),
...(cliproxyapiModelMapping !== null
? { cliproxyapi_model_mapping: cliproxyapiModelMapping }
: {}),

View File

@@ -12223,7 +12223,17 @@
"disabledByFeed": "معطل بواسطة تغذية الرادار",
"capTools": "الأدوات",
"capVision": "الرؤية",
"capThinking": "التفكير"
"capThinking": "التفكير",
"catalogTab": "Catalog",
"freeCreditsTab": "Free credits",
"freeCreditsSubtitle": "Referral links from Radar-tracked providers — using them costs you nothing extra and helps support OmniRoute.",
"fixedLinksEmpty": "No referral links available yet. Sync Radar to check for updates.",
"requiredActionLabel": "What to do:",
"claimButton": "Claim credits",
"campaignsTitle": "Limited-time campaigns",
"campaignsEmpty": "No active campaigns right now — check back later.",
"campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.",
"campaignsValidUntil": "Valid until {date}"
},
"radarSetupPage": {
"title": "إعداد المزود",

View File

@@ -12223,7 +12223,17 @@
"disabledByFeed": "Radar axını tərəfindən deaktiv edilmişdir",
"capTools": "Alətlər",
"capVision": "Görmə",
"capThinking": "Düşünmə"
"capThinking": "Düşünmə",
"catalogTab": "Catalog",
"freeCreditsTab": "Free credits",
"freeCreditsSubtitle": "Referral links from Radar-tracked providers — using them costs you nothing extra and helps support OmniRoute.",
"fixedLinksEmpty": "No referral links available yet. Sync Radar to check for updates.",
"requiredActionLabel": "What to do:",
"claimButton": "Claim credits",
"campaignsTitle": "Limited-time campaigns",
"campaignsEmpty": "No active campaigns right now — check back later.",
"campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.",
"campaignsValidUntil": "Valid until {date}"
},
"radarSetupPage": {
"title": "Təchizatçı Quraşdırması",

View File

@@ -12223,7 +12223,17 @@
"disabledByFeed": "Деактивирано от фийда на радара",
"capTools": "Инструменти",
"capVision": "Визия",
"capThinking": "Мислене"
"capThinking": "Мислене",
"catalogTab": "Catalog",
"freeCreditsTab": "Free credits",
"freeCreditsSubtitle": "Referral links from Radar-tracked providers — using them costs you nothing extra and helps support OmniRoute.",
"fixedLinksEmpty": "No referral links available yet. Sync Radar to check for updates.",
"requiredActionLabel": "What to do:",
"claimButton": "Claim credits",
"campaignsTitle": "Limited-time campaigns",
"campaignsEmpty": "No active campaigns right now — check back later.",
"campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.",
"campaignsValidUntil": "Valid until {date}"
},
"radarSetupPage": {
"title": "Настройка на доставчика",

View File

@@ -12223,7 +12223,17 @@
"disabledByFeed": "রাডার ফিড দ্বারা নিষ্ক্রিয়",
"capTools": "টুলস",
"capVision": "ভিশন",
"capThinking": "চিন্তা"
"capThinking": "চিন্তা",
"catalogTab": "Catalog",
"freeCreditsTab": "Free credits",
"freeCreditsSubtitle": "Referral links from Radar-tracked providers — using them costs you nothing extra and helps support OmniRoute.",
"fixedLinksEmpty": "No referral links available yet. Sync Radar to check for updates.",
"requiredActionLabel": "What to do:",
"claimButton": "Claim credits",
"campaignsTitle": "Limited-time campaigns",
"campaignsEmpty": "No active campaigns right now — check back later.",
"campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.",
"campaignsValidUntil": "Valid until {date}"
},
"radarSetupPage": {
"title": "প্রদানকারী সেটআপ",

View File

@@ -12223,7 +12223,17 @@
"disabledByFeed": "Zakázáno zdrojem Radar",
"capTools": "Nástroje",
"capVision": "Vize",
"capThinking": "Přemýšlení"
"capThinking": "Přemýšlení",
"catalogTab": "Catalog",
"freeCreditsTab": "Free credits",
"freeCreditsSubtitle": "Referral links from Radar-tracked providers — using them costs you nothing extra and helps support OmniRoute.",
"fixedLinksEmpty": "No referral links available yet. Sync Radar to check for updates.",
"requiredActionLabel": "What to do:",
"claimButton": "Claim credits",
"campaignsTitle": "Limited-time campaigns",
"campaignsEmpty": "No active campaigns right now — check back later.",
"campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.",
"campaignsValidUntil": "Valid until {date}"
},
"radarSetupPage": {
"title": "Nastavení poskytovatele",

View File

@@ -12223,7 +12223,17 @@
"disabledByFeed": "Deaktiveret af Radar feed",
"capTools": "Værktøjer",
"capVision": "Vision",
"capThinking": "Tænkning"
"capThinking": "Tænkning",
"catalogTab": "Catalog",
"freeCreditsTab": "Free credits",
"freeCreditsSubtitle": "Referral links from Radar-tracked providers — using them costs you nothing extra and helps support OmniRoute.",
"fixedLinksEmpty": "No referral links available yet. Sync Radar to check for updates.",
"requiredActionLabel": "What to do:",
"claimButton": "Claim credits",
"campaignsTitle": "Limited-time campaigns",
"campaignsEmpty": "No active campaigns right now — check back later.",
"campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.",
"campaignsValidUntil": "Valid until {date}"
},
"radarSetupPage": {
"title": "Udbyder Opsætning",

View File

@@ -12223,7 +12223,17 @@
"disabledByFeed": "Durch den Radar-Feed deaktiviert",
"capTools": "Werkzeuge",
"capVision": "Vision",
"capThinking": "Denken"
"capThinking": "Denken",
"catalogTab": "Catalog",
"freeCreditsTab": "Free credits",
"freeCreditsSubtitle": "Referral links from Radar-tracked providers — using them costs you nothing extra and helps support OmniRoute.",
"fixedLinksEmpty": "No referral links available yet. Sync Radar to check for updates.",
"requiredActionLabel": "What to do:",
"claimButton": "Claim credits",
"campaignsTitle": "Limited-time campaigns",
"campaignsEmpty": "No active campaigns right now — check back later.",
"campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.",
"campaignsValidUntil": "Valid until {date}"
},
"radarSetupPage": {
"title": "Anbieter-Einrichtung",

View File

@@ -12272,7 +12272,17 @@
"disabledByFeed": "Disabled by Radar feed",
"capTools": "Tools",
"capVision": "Vision",
"capThinking": "Thinking"
"capThinking": "Thinking",
"catalogTab": "Catalog",
"freeCreditsTab": "Free credits",
"freeCreditsSubtitle": "Referral links from Radar-tracked providers — using them costs you nothing extra and helps support OmniRoute.",
"fixedLinksEmpty": "No referral links available yet. Sync Radar to check for updates.",
"requiredActionLabel": "What to do:",
"claimButton": "Claim credits",
"campaignsTitle": "Limited-time campaigns",
"campaignsEmpty": "No active campaigns right now — check back later.",
"campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.",
"campaignsValidUntil": "Valid until {date}"
},
"radarSetupPage": {
"title": "Provider Setup",

Some files were not shown because too many files have changed in this diff Show More