mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 08:12:20 +03:00
Compare commits
32 Commits
fix/9534-m
...
babysit/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eddf330cbf | ||
|
|
9d8985880a | ||
|
|
976d670ff3 | ||
|
|
ff679ab86e | ||
|
|
ebf151e057 | ||
|
|
7d3dc0bc35 | ||
|
|
5e2429ce15 | ||
|
|
6629a9b698 | ||
|
|
c40d4b17ee | ||
|
|
d86ea99713 | ||
|
|
1e15583f29 | ||
|
|
aec0de28be | ||
|
|
5a6787f0f9 | ||
|
|
33f0336d39 | ||
|
|
976c8fa054 | ||
|
|
9995bc4893 | ||
|
|
c9a3361e5a | ||
|
|
c9debe92bd | ||
|
|
fad3539a69 | ||
|
|
919f9acd80 | ||
|
|
8a573c56e3 | ||
|
|
f338363cd3 | ||
|
|
28dc5af7ba | ||
|
|
616175a93e | ||
|
|
e4e0c254ea | ||
|
|
2e9abab944 | ||
|
|
4cc9cf8123 | ||
|
|
6c3aea6ba6 | ||
|
|
9edefd4572 | ||
|
|
8fbd331567 | ||
|
|
6c5329bcfe | ||
|
|
535c75b60a |
@@ -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
1
.eslintcache-probe
Normal file
File diff suppressed because one or more lines are too long
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 15–95%, ~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 15–95%, ~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'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's docs."/>
|
||||
|
||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & 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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
1
changelog.d/fixes/8739-yuanbao-sse-parser.md
Normal file
1
changelog.d/fixes/8739-yuanbao-sse-parser.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(yuanbao-web): accept `content` field in SSE text events (upstream format change) (#8739)
|
||||
1
changelog.d/fixes/8813-chatgpt-sentinel.md
Normal file
1
changelog.d/fixes/8813-chatgpt-sentinel.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(errorClassifier): classify ChatGPT Web SENTINEL_BLOCKED 403 as terminal FORBIDDEN, enabling proper combo fallback (#8813)
|
||||
1
changelog.d/fixes/8946-no-tool-output.md
Normal file
1
changelog.d/fixes/8946-no-tool-output.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946)
|
||||
1
changelog.d/fixes/8994-vertex-partner-claude.md
Normal file
1
changelog.d/fixes/8994-vertex-partner-claude.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(vertex): route Claude models to native rawPredict endpoint and respect custom targetFormat overrides (#8994)
|
||||
1
changelog.d/fixes/9029-cursor-narration.md
Normal file
1
changelog.d/fixes/9029-cursor-narration.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cursor): preserve tool context across multi-turn conversations when client lacks conversation_id (#9029)
|
||||
1
changelog.d/fixes/9030-antigravity-system-429s.md
Normal file
1
changelog.d/fixes/9030-antigravity-system-429s.md
Normal 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)
|
||||
1
changelog.d/fixes/9435-kiro-import-overwrite.md
Normal file
1
changelog.d/fixes/9435-kiro-import-overwrite.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(oauth): Kiro import token endpoint no longer overwrites existing connection when using shared cached OIDC clientId (#9435)
|
||||
1
changelog.d/fixes/9531-ci-combo-matrix.md
Normal file
1
changelog.d/fixes/9531-ci-combo-matrix.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(ci): include combo-matrix tests in test-integration job (#9531)
|
||||
1
changelog.d/fixes/9534-modelsdevsync-timers.md
Normal file
1
changelog.d/fixes/9534-modelsdevsync-timers.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(test): prevent flaky modelsDevSync timer assertions by serializing test execution within the file (#9534)
|
||||
1
changelog.d/fixes/9536-usage-misreporting.md
Normal file
1
changelog.d/fixes/9536-usage-misreporting.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)
|
||||
1
changelog.d/fixes/9541-fastpath-db-corruption.md
Normal file
1
changelog.d/fixes/9541-fastpath-db-corruption.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): add transient-error retry to corruption probe to prevent data loss under concurrent load (#9541)
|
||||
1
changelog.d/fixes/9543-searxng-auto-select.md
Normal file
1
changelog.d/fixes/9543-searxng-auto-select.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(search): mark searxng-search as fallbackOnly to prevent auto-select without instance (#9543)
|
||||
1
changelog.d/fixes/9545-gpt56-effort-tools.md
Normal file
1
changelog.d/fixes/9545-gpt56-effort-tools.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): strip provider prefix in getModelTargetFormat to route GPT-5.6 models to /v1/responses (#9545)
|
||||
1
changelog.d/fixes/9550-amazon-q-alias.md
Normal file
1
changelog.d/fixes/9550-amazon-q-alias.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(model): add "aq" alias for amazon-q provider so parseModel resolves it instead of falling back to OpenAI (#9550)
|
||||
1
changelog.d/fixes/9551-proxyfetch-context-bypass.md
Normal file
1
changelog.d/fixes/9551-proxyfetch-context-bypass.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(proxy): NO_PROXY now bypasses context-level proxy in resolveProxyForRequest (#9551)
|
||||
1
changelog.d/fixes/9560-turbopack-nft-guard.md
Normal file
1
changelog.d/fixes/9560-turbopack-nft-guard.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(build): lazy-resolve module-level fs paths to avoid Turbopack NFT whole-source trace (#9560)
|
||||
1
changelog.d/fixes/9567-chatcore-sse-flaky.md
Normal file
1
changelog.d/fixes/9567-chatcore-sse-flaky.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): replace timer-based waits with polling to fix flaky chatCore/SSE tests under CI load (#9567)
|
||||
1
changelog.d/fixes/9568-gemini-tool-casing.md
Normal file
1
changelog.d/fixes/9568-gemini-tool-casing.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** restore original tool name casing in Gemini/Antigravity response translators ([#9568](https://github.com/diegosouzapw/OmniRoute/issues/9568))
|
||||
1
changelog.d/fixes/9575-tool-call-case.md
Normal file
1
changelog.d/fixes/9575-tool-call-case.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)
|
||||
1
changelog.d/fixes/9630-combo-false-503.md
Normal file
1
changelog.d/fixes/9630-combo-false-503.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(combo): distinguish pre-dispatch skips from genuine failures to prevent false 503 ALL_ACCOUNTS_INACTIVE (#9630)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
10
config/quality/forgotten-sibling-allowlist.json
Normal file
10
config/quality/forgotten-sibling-allowlist.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| :--------------- | :-------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
|
||||
maxMaxResults: 50,
|
||||
timeoutMs: 10_000,
|
||||
cacheTTLMs: 3 * 60 * 1000,
|
||||
fallbackOnly: true,
|
||||
},
|
||||
|
||||
"ollama-search": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
13
open-sse/services/antigravityProjectPersistence.ts
Normal file
13
open-sse/services/antigravityProjectPersistence.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
@@ -619,6 +619,7 @@ export async function tryRuntimeUnitDispatch(args: {
|
||||
nesting: nestingContext,
|
||||
baseOptions: buildBaseOptions(args),
|
||||
runCombo: args.runCombo,
|
||||
hiddenModelsByProvider: args.hiddenModelsByProvider,
|
||||
});
|
||||
recordRuntimeUnitStickySuccess({
|
||||
strategy,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
241
scripts/check/check-forgotten-sibling-tests.mjs
Normal file
241
scripts/check/check-forgotten-sibling-tests.mjs
Normal 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();
|
||||
@@ -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"] },
|
||||
|
||||
67
scripts/check/lib/importResolution.mjs
Normal file
67
scripts/check/lib/importResolution.mjs
Normal 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;
|
||||
}
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
67
src/app/api/radar/referrals/route.ts
Normal file
67
src/app/api/radar/referrals/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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": "إعداد المزود",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "Настройка на доставчика",
|
||||
|
||||
@@ -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": "প্রদানকারী সেটআপ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user