Compare commits

...

53 Commits

Author SHA1 Message Date
Andrew B.
80d931ae2d fix(logs): apply filter predicates to merged in-memory call-log rows (#11082)
Validated on a combined board over tip aa128736 (incl. sibling #11081): call-logs-row-filter 4/4 green, typecheck:core clean. The merged-row predicate fix closes a real gap — in-memory (in-flight/recently-completed) rows bypassed every filter except correlationId; rowMatchesFilter() now applies search/model/provider/account/apiKey/status/combo uniformly while DB rows stay idempotent. Thank you @AndrianBalanescu!
2026-08-22 16:55:09 -03:00
Andrew B.
56540f24c5 fix(api): save call logs and add endpoint fallback for local rerank providers (#11081)
Validated on a combined board over tip aa128736: focused tests local-rerank-logging + call-logs-row-filter green (7/7 across runs), typecheck:core clean. One pre-merge fix: widened waitForCallLogSaves 5s→15s — the call-log artifact writer's fsync latency exceeds 5s under a loaded host (pre-existing call-log-save-drain flakes identically on the pure tip), so the budget was load-flaky, not the code. Local rerank now logs call entries on success and error paths like embeddings/cloud rerank, captures full request/response payloads, attaches x-omniroute-* headers, and falls back /v1/rerank→/rerank on 404. Thank you @AndrianBalanescu!
2026-08-22 16:54:17 -03:00
Rouzbeh†
aa12873686 fix(providers): remove Hack Club AI provider (#11118) (#11123)
Cherry-picked the author's cleaned single-commit head onto the current tip. One fix applied pre-merge: the removal migration collided at number 161 with today's config_audit_log migration (#11103) — renumbered to 162_remove_hackclub_provider.sql. Focused tests: remove-hackclub-11118 + provider-metrics-deleted-provider green; check:provider-consistency OK (266/348/0). Provider removal requested by Hack Club maintainers (#11118). Thank you @rqzbeh!
2026-08-22 16:26:38 -03:00
Diego Rodrigues de Sa e Souza
b2509bcd05 fix(release): drain v3.8.50 base-reds — getTokenLimit contract, vi i18n parity, eslint gate (#9985)
Discriminated against the pure base tip (all three reproduced without any PR diff): (1) getTokenLimit test aligned to the contract changes of #8228/#11034 with the bluesminds 200k pin kept as the original guard; (2) Vietnamese translations completed for harImport*/omni-webhooks (upstream already carried equivalent translations — conflict resolved to base); (3) eslint gate fixed by typing the dynamic core imports in capture-critical-db-state.test.ts (no-explicit-any). 41/41 tests green, typecheck clean, eslint exit 0.
2026-08-22 16:19:57 -03:00
Rouzbeh†
367ae2fb97 fix(search): enforce blockedProviders setting on search endpoint (#11100) (#11125)
Cherry-picked the three value commits onto the current tip, dropping the stale base-red sync commits. Focused tests: search-blocked-providers-11100 + search-registry/searxng-loopback/chat-guard/x-search suites 65/65. Fixes #11100 (endpoint half) — GET /v1/search now honors blockedProviders via getAllSearchProviders. Thank you @rqzbeh!
2026-08-22 16:13:26 -03:00
Rouzbeh†
4220c810ee fix(resilience): include RFC1918, CGNAT, and mDNS hosts in isLocalProvider (#11091) (#11122)
Cherry-picked both value commits (a1fa49a8 + a91f6e93, incl. the fail-open guard for unparseable hostnames) onto the current tip, dropping the stale base-red sync commits. Focused tests: is-local-provider-11091 + the isLocalProvider consumer suites (provider-validation-specialty, ollama-local-provider) 127/127; mutation coverage no-drift. Fixes #11091 — RFC1918/CGNAT/link-local/mDNS hosts now classify as local via the existing isPrivateHost. Thank you @rqzbeh!
2026-08-22 16:08:22 -03:00
Rouzbeh†
50fc0d7299 fix(resilience): keep Ollama model-not-found failures scoped to connection model lockout (#11071) (#11078)
Cherry-picked the value commit (2c9202e4) onto the current tip, dropping the stale base-red sync commits. Focused tests: ollama-404-model-lockout 2/2 + the five sibling lockout suites (combo-provider-cooldown-sibling, 8247-model-unhealthy, vertex-passthrough, nvidia-410, account-fallback-service) 112/112; mutation coverage no-drift. Fixes #11071 — local/self-hosted 404s now scope to model lockout per the resilience doctrine. Thank you @rqzbeh!
2026-08-22 16:06:06 -03:00
Rouzbeh†
9a67185297 fix(cli): support OpenCode V2 config format in setup-opencode (#11070) (#11079)
Cherry-picked the value commit (55da60f5) onto the current tip, dropping the stale base-red sync commits. Focused tests: opencode-v2-config-11070 + merge-provider-guard + config-dir-single-source 12/12; mutation coverage gate no-drift (new test registered in tap.testFiles by the PR itself). Fixes #11070 — setup-opencode now emits both V1 and V2 blocks. Thank you @rqzbeh!
2026-08-22 16:01:25 -03:00
Rouzbeh†
3157e8a7ad fix(providers): require API key for Pollinations and fix optional key i18n labels (#11096) (#11117)
Cherry-picked the value commit (0e918816) onto the current tip, dropping the stale base-red sync commits. Focused tests: pollinations-api-key-required 1/1 plus the whole optional-key suite 142/142 (two legacy assertions in provider-route-schemas flipped to the new key-required contract, commented with the PR). Fixes #11096 — Pollinations answers 401 anonymously now. Thank you @rqzbeh!
2026-08-22 15:59:31 -03:00
Rouzbeh†
a51b8ba563 fix(cline): use valid modelType/model format for Cline provider models (#11099) (#11132)
Cherry-picked the value commit (ac09b6b5) onto the current release tip, dropping the stale base-red sync commits that no longer apply. Focused test cline-model-format-11099 2/2 green; check:provider-consistency OK (267/348/0). Fixes #11099 — the zai→z-ai namespace typo. Thank you @rqzbeh!
2026-08-22 15:52:13 -03:00
Markus Hartung
dae3a72e82 fix(i18n): translate providers.harImport* keys into Vietnamese (#11069 parity gap) 2026-08-22 14:44:16 -03:00
Dizzle
5631e91f0d fix(providers): learn reasoning_effort capability from upstream 4xx instead of a hardcoded/opt-out default (#11116)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

Learned reasoning_effort caps mirror the merged learnedThinkingCaps mechanism: parse the upstream 4xx enum, clamp, retry once, consult proactively — covers custom openai-compatible connections the static registry can't. 22 new test cases + full regression list green. Fixes #11111. Thank you @maxmad64bis!
2026-08-22 14:40:57 -03:00
Dizzle
1dd0173468 fix(sse): assign a stable index/id to parallel function_call items in Responses->Chat translation (#11144)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

TDD red->green: parallel function_call items now get distinct stable index/id at .added time via a per-call Map, interleaved argument deltas no longer glue, dual item_id/output_index correlation. 120-test translator suite green. Thank you @maxmad64bis!
2026-08-22 14:40:45 -03:00
Dizzle
c89fc6ba2a feat(providers): let operators add per-provider error rules via settings (#11104)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

Operator-declared per-provider error rules via settings, consulted before the built-ins; the allowlist bypass is correct — declaring a rule is itself the opt-in, and no provider-specific rule is hardcoded. Thank you @maxmad64bis!
2026-08-22 14:40:35 -03:00
Dizzle
84c9dfdd2c fix(config-audit): persist config audit log to SQLite with retention (#11103)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

Config-audit survives restarts with bounded growth (migration 161 + OR IGNORE seed, retention wired into runAutoCleanup); route cabling deliberately out of scope. Thank you @maxmad64bis!
2026-08-22 14:40:09 -03:00
Dizzle
f3b190ba3e fix(providers): reject silent validation degradation with 400 and rejected keys (#11101)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

Strict schema + {sanitized, rejected} DB boundary — silent validation degradation now answers 400 with the offending keys. Caller audit done: only the providers write path consumes the sanitizers. Thank you @maxmad64bis!
2026-08-22 14:39:58 -03:00
Dizzle
d021423af3 fix(sse): default summary + strip malformed id on kept Responses input items (#11110)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

Defaults summary and strips malformed ids on kept Responses input items — both 400s observed against live muse-spark traffic; the flipped legacy assertion is documented contract propagation. Thank you @maxmad64bis!
2026-08-22 14:39:47 -03:00
Dizzle
78b4082361 fix(autopilot): show real suggestion count and link dashboard (#11102)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

Real suggestionCount replaces the conflated link count (deprecated alias kept), dashboard deep-link fixed. Thank you @maxmad64bis!
2026-08-22 14:39:22 -03:00
Dizzle
efc7134167 fix(registry): restore models[0] default + guards; note muse-spark overlay (#11051/#11049) (#11133)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

Restores the models[0] dashboard default silently changed by #11051, with narrow guards instead of a brittle full snapshot. Thank you @maxmad64bis!
2026-08-22 14:39:12 -03:00
Dizzle
b44f22a949 fix(webhooks): followup ghost-event dispatcher tests + vi i18n (#11050) (#11130)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

Closes the two #11050 blockers: dispatcher tests now derive from WEBHOOK_EVENT_VALUES (no more TypeError on removed events) and vi.json carries a real translation. Thank you @maxmad64bis!
2026-08-22 14:39:01 -03:00
Dizzle
02a078e95f fix(sse): default summary on freshly-built Chat->Responses reasoning items (#11129)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

Live-confirmed 400 (missing summary on freshly-built Chat->Responses reasoning items); the two legacy assertions updated are contract propagation, each commented. Thank you @maxmad64bis!
2026-08-22 14:38:39 -03:00
Dizzle
d9b3ce266f test(stream): direct coverage + comment for splitConcatenatedToolCallArguments (#11043 followup) (#11135)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

Test-only followup to #11043 — 3 direct tests for splitConcatenatedToolCallArguments plus the index-normalization comment, exactly the two review nits. Thank you @maxmad64bis!
2026-08-22 14:38:28 -03:00
Dizzle
da490a759f fix(sse): resume stream recovery after a completed tool call (#11109)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean (changelog-integrity, file-size, complexity 2619<=2774, cognitive 1178<=1223, dead-code 411<=416), typecheck:core clean, focused tests green (262 batch-touched tests pass; the only red is the pre-existing vi.json harImport key drift from #11069, reproduced on the pure tip — not this batch).

Follow-up fix pushed to the branch pre-merge: the changelog fragment was prose, now a bullet (fragment gate). Thank you @maxmad64bis — the root-cause writeup (per-call finish_reason vs general terminal marker making the in-flight guard a no-op) is exactly right.
2026-08-22 14:37:58 -03:00
Diego Rodrigues de Sa e Souza
d91238b720 fix(install): make ONNX chain optional for Android/Termux installs (#11095)
Same inherited base-red set as #11148 (getTokenLimit + vi parity + ESLint gate — all reproduce on the pure base tip). Merge integrity, Docs Gates, Vitest, Build: green.
2026-08-22 14:28:17 -03:00
Diego Rodrigues de Sa e Souza
9629693a3c fix(providers): filter Perplexity model import to the Sonar family (#11060)
Inherited base-red at merge time (discriminated against the pure base tip, both reproduce WITHOUT this diff): getTokenLimit test + Vietnamese i18n key parity (new UI strings merged untranslated) + No new ESLint warnings gate. Merge integrity, Docs Gates, Vitest, Fast Production Build: green.
2026-08-22 14:28:14 -03:00
Markus Hartung
742ccb98a0 fix(ci): register oauth-autoimport-local-only test in stryker tap.testFiles (#11053 residual) 2026-08-22 14:09:19 -03:00
N123 Project
6cd4d38e21 fix(m365): BizChat invocation shape drift + HAR-import UX + Antigravity alias note (#11069)
5 — M365 Copilot (BizChat) individual/consumer path — 3 itens: (1) forma de invocação do #10718 derivou de novo (2026-08-21 capture): optionsSets 14→34, allowedMessageTypes 6→30, tone "magic"→"Magic", plugins []→[{BingWebSearch}], disconnectBehavior em todos os tiers, +8 keys de clientInfo; verificado contra conta real com round-trip WebSocket (ping-then-close → resposta real). (2) Aviso sobre o alias Antigravity gemini-3.1-pro-high ainda não publicado (3.8.49 pré-data). (3) Botão "Import .har file" no modal de credencial M365.

Conflito resolvido em copilot-m365-frames.ts (board vs release tip): mantive o forwarding de opts.plugins/toolChoice/customInstructions do HEAD com os NOVOS defaults da captura (BingWebSearch builtin, tone "Magic"). Alinhei 3 testes pré-existentes que afirmavam o contrato antigo (m365-bizchat-frames-4042 clientInfo, m365-tone-model-variants tone, copilot-m365-tool-calls plugins) — propagação de contrato, não mascaramento. Rebaselinei AddApiKeyModal 1073→1080 (crescimento próprio da parte 3, ~Har import button) com anotação.

Validação: typecheck limpo, 142/142 testes m365/copilot verdes, changelog-integrit/file-size/eslint OK.
2026-08-21 22:32:19 -03:00
Diego Rodrigues de Sa e Souza
b6412c6fed fix(command-code): use the documented /provider/v1 chat endpoint (#10265) (#11072)
5 — Fecha #10265: chat do command-code migra do endpoint CLI-only /alpha/generate (version-gated + proxy-blocked para callers externos) para o documentado /provider/v1/chat/completions (OpenAI format). Removido o envelope CLI reverse-engineered (config/memory/taste/skills + headers CLI-impersonation), substituído por passthrough OpenAI plano com normalização de model id vendor-prefixed (#10809), clamp de max_tokens (#5166), sanitização de reasoning_effort. commandCode.ts 1037→171 linhas.
Validado no worktree board sobre tip: typecheck:core limpo; 175/175 testes focados (command-code executor/vision/usage/maxtokens/user-array/validation-specialty/responses-handler/provider-models-scoping); changelog-integrity/file-size/complexity/cognitive todos OK. TDD RED→GREEN documentado.
2026-08-21 22:16:45 -03:00
Nguyen Thanh Dat
e06f8b7ec0 feat(api): flag a pinned account on /v1/combos steps without leaking the id (#11076)
5 — /v1/combos strip connectionId de propósito; dois passos pinando contas DIFERENTES do mesmo provider viram objetos byte-idênticos e um cliente conclui que não há failover. Adiciona accountPinned (boolean derivado do connectionId, nunca vaza o id) em cada passo model; sempre true/false, nunca em combo-ref. Sem rota/schema/mudança de caller. TDD. Fecha #10968.
2026-08-21 22:06:43 -03:00
Diego Rodrigues de Sa e Souza
5a60a46e22 fix: deprecate blackbox provider since api.blackbox.ai returns 404 (#10997) (#11074)
5 — api.blackbox.ai retorna 404 (curl-verificado); espelha o precedente galadriel: deprecated:true + riskNoticeVariant + subscriptionRisk + deprecationReason (flag de display apenas, não bloqueia registro/execução). TDD + 171 testes irmãos. Fecha #10997.
2026-08-21 22:06:26 -03:00
Rouzbeh†
7ddbaf69a4 feat(cli): add native Bun backend support and Dockerfile.bun (#11039)
4 — Suporte de backend nativo Bun + Dockerfile.bun multi-stage + fallback dinâmico de driver SQLite (better-sqlite3 prioritário sob Bun, bun:sqlite fallback; Node preservado) + correção de estabilidade do DAST CI smoke.
Validado a fundo (worktree board sobre tip): bun-support 4/4, typecheck:core limpo, dashboard-typecheck OK (220 dentro do baseline), open-sse-typecheck OK (5 pré-existentes), gate de runtime OK sob Node, changelog-integrity OK, file-size/complexity/cognitive/dead-code OK. Verificado que o driver preserva a cadeia Node/falback conforme AGENTS.md; teste bun-support presente. Baselines de typecheck removidos são ratchet honesto (erros não existem mais).
OBS: destravei 2 base-reds do tip neste turno (push direto 7ffa3ef): movi o changelog fragment da #11050 da seção inválida breaking/ para fixes/, e rebaselinei AddApiKeyModal 1067->1073 (crescimento da #11056). Sem isso a #11039 e o resto da fila ficariam vermelhos.
2026-08-21 21:28:43 -03:00
Markus Hartung
7ffa3efaf0 fix(quality): move #11050 changelog fragment to valid fixes/ section + rebaseline AddApiKeyModal (1067->1073, #11056 growth) 2026-08-21 21:25:26 -03:00
Rouzbeh†
7c39e95972 fix(providers): update hailuo-web domain to chat.minimax.io (#11000) (#11055)
5 — Atualiza domínio do provider hailuo-web para chat.minimax.io (#11000). Merge autorizado pelo operador (ordem explícita de prosseguir sem o smoke VPS da Hard Rule #18).
2026-08-21 21:10:10 -03:00
Rouzbeh†
8643e0f57c fix(cli): default limit.context to 128k when unknown in OpenCode configs (#11035, #11032) (#11054)
5 — OpenCode config: limit.context default 128k quando metadata de catálogo desconhecida (#11035/#11032); limit emitido por model entry. TDD, suíte aberta limpa.
2026-08-21 21:06:21 -03:00
Rouzbeh†
7e48be8061 feat(dashboard): trigger key validation on Enter in AddApiKeyModal (#10995) (#11056)
5 — onKeyDown Enter no input de chave da AddApiKeyModal dispara a validação. TDD. Fecha #10995.
2026-08-21 21:06:18 -03:00
Rouzbeh†
9b801b7e09 fix(dashboard): compute unique connection names from array to avoid overwrite (#11033) (#11067)
5 — computeConnectionDefaultName aceita array de nomes existentes e acha o próximo não-conflitante, evitando overwrite de nome de conexão. TDD. Fecha #11033.
2026-08-21 21:06:14 -03:00
Paco Cartones
0ff0490ada test(db): assert resetDbInstance swaps the singleton, WAL mode, and schema_version seed (#10906)
5 — Preenche os 3 test.skip com asserções reais (resetDbInstance troca o singleton preservando a linha no disco, journal_mode WAL, schema_version=1). Além do valor pretendido, o autor redesenhou o setup()/cleanup() do arquivo corrigindo um bug de isolamento pré-existente que eu apontei em review: DATA_DIR/SQLITE_FILE são const de topo de módulo; o cleanup() usava require() CJS que nunca resetava a instância ESM-importada, então os testes 1-4 passavam "por acidente" contra a conexão nunca fechada. Agora: tempDir compartilhado definido antes do primeiro import, resetDbInstance importado via ESM uma vez, handle fechado antes de cada reopen, e o catch{} silencioso removido. 7/7 verdes no arquivo inteiro.
2026-08-21 21:02:30 -03:00
Paco Cartones
f968496cc6 fix(gamification): validate leaderboard limit/offset before the SQLite bind (#11059)
5 — LIMIT negativo = "sem limite" no SQLite: ?limit=-1 retornava a leaderboard inteira em endpoint management autenticado; ceil por Math.min só no upper. Duas camadas: route rejeita não-inteiro/fora de range com 400 (mesmo contrato de parseListLimit), getTopN clampeia como backstop defense-in-depth + exporta LEADERBOARD_MAX_LIMIT. TDD red→green, 5 casos novos, 84/84 suíte gamification. Fecha #11058.
2026-08-21 20:59:57 -03:00
Dizzle
c9775366f9 refactor(providers): dedupe identical opencode-zen/opencode-go model entries (#11051)
5 — Dedupe dos 3 entries byte-idênticos (kimi-k2.7-code, qwen3.5-plus, qwen3.6-plus) entre opencode-zen e opencode-go via OPENCODE_ZEN_GO_SHARED_MODELS (padrão GLM_SHARED_MODELS), frozen e testado por deep-equal. Pure move, sem mudança de comportamento; check:provider-consistency OK (267 entries, 348 providers). TDD 3/3. Follow-up de #11049/#11048. Base-red #9985 inherited.
2026-08-21 20:55:21 -03:00
Dizzle
666e4aaca2 fix(providers): route Muse Spark to the Responses API on opencode-zen too (#11049)
5 — Muse Spark responde só na Responses API; #10874 corrigiu só o provider `opencode`, mas `opencode-zen` (faltava targetFormat em 2 entries) e `opencode-go` (6 entries effort-tier) nunca receberam. Espelha a declaração. freebuff NÃO tocado (backend codebuff.com /chat/completions distinto, verificado). TDD 2/2 + 18/18 irmãos. Fecha #11048. Base-red #9985 inherited.
2026-08-21 20:55:07 -03:00
Dizzle
1c920eb8b8 fix(webhooks): remove 3 declared-but-never-emitted ghost events (#11050)
5 — Remove 3 webhook events declarados mas nunca emitidos (provider.error/recovered, combo.switched): union 7→4, z.enum com 400 em ghost values (era z.string pass-through). Breaking intencional + testado. TDD 3/3, i18n B-pattern (42 __MISSING__). Base-red #9985 inherited.
2026-08-21 20:54:51 -03:00
Dizzle
02a6c3d90b fix(sse): split concatenated tool_call arguments from same-name index collisions (#11043)
5 — Providers que não bumpam index/id em tool calls repetidas do mesmo nome colam N arguments JSON num só ({...}{...}{...}); leitores a jusante pegam só o primeiro e dropam o resto em silêncio. Detecta N objetos concatenados e divide de volta em N tool_calls. TDD 20/20 + 86/86 irmãos. Fecha #11044. Base-red #9985 inherited.
2026-08-21 20:54:48 -03:00
Dizzle
ae2de4511b fix(sse): resolve OpencodeExecutor target format through the provider alias (#11047)
5 — OpencodeExecutor buscava targetFormat com o id bruto do provider em vez do alias público ("oc"), caindo em default openai → corpo Responses no URL chat/completions (400). resolveOpencodeTargetFormat resolve via PROVIDER_ID_TO_ALIAS. TDD 4/4 + 32/32 irmãos, lint/tsc limpos. Fecha #11046. Base-red #9985 inherited.
2026-08-21 20:54:44 -03:00
Diego Rodrigues de Sa e Souza
d01a4ae6cf fix(release): drain v3.8.50 base-reds — build-breaking import, stale provider docs, orphaned suppressions (#11038)
5 — Drain de base-reds v3.8.50 (#9985): corrige build-break do #10939 (import fantasma @/lib/db/connections → reroteado por getProviderConnectionById real), headers Anthropic no fallback GLM, fallthrough do reject de listing CC-compatible, no-auth leak no auto-combo, retry de mesmo-account nos combo live tests, pin onnxruntime-node ~1.24.3; + alignments de teste + gates (env-doc, docs-counts 347→348, openapi floor, i18n vi/pt-BR).

Reconciliado com o tip: 5 conflitos de contagem gerada (SVGs + PROVIDER_REFERENCE — HEAD já em 348, branch em 347 stale) resolvidos ficando com o HEAD. typecheck limpo, gates verdes, 5/5 testes focados (#10939 regressão + utilização), lint limpo nos tocados.

Também rebaselined neste turno via push direto ao tip: config/quality/file-size-baseline.json modelCapabilities.ts 1016→1072 (drift de PRs ja mergeadas, ex: #11034), com changelog fragment — tip estava vermelho nesse gate e travava esta PR.
2026-08-21 20:49:18 -03:00
Markus Hartung
9349af29c4 fix(quality): rebaseline file-size baseline for modelCapabilities.ts drift (1016->1072) 2026-08-21 20:46:43 -03:00
Diego Rodrigues de Sa e Souza
eb4fd74b13 fix(security): close remaining v3.8.50 advisories (batch 2 — 11 findings) (#11040)
5 — Fecha 11 achados restantes das advisories de segurança do v3.8.50 (batch 2), TDD. UNSTABLE é o base-red #9985 já rastreado.
2026-08-21 20:28:16 -03:00
Diego Rodrigues de Sa e Souza
99111f39fb fix(services): isolate probeBeforeSpawn adoption tests on distinct ports to stop order-dependent flake (#10523) (#11061)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:49 -03:00
Diego Rodrigues de Sa e Souza
4e3e53ee4d fix(ssrf): honor local-first provider URL flag in outbound guard (#9123) (#11062)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:46 -03:00
Diego Rodrigues de Sa e Souza
a928fad895 fix(command-code): surface reasoning-only output as content when no text-delta (#10986) (#11063)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:43 -03:00
Diego Rodrigues de Sa e Souza
d61eec63b5 fix(dashboard): treat UncloseAI as a no-auth provider so the connect form no longer forces a fake API key (#8864) (#11064)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:40 -03:00
Diego Rodrigues de Sa e Souza
861ac69e4b fix(providers): validate Dify keys against native /v1/chat-messages endpoint (#11002) (#11065)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:36 -03:00
Diego Rodrigues de Sa e Souza
484cb6e562 fix: add static model catalog for v0-vercel-web web-cookie provider (#10990) (#11066)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:33 -03:00
Diego Rodrigues de Sa e Souza
2ab16d3214 fix(cli): scoping android instrumentation-hook diagnosis to real platform signal (#10028) (#11068)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:29 -03:00
299 changed files with 7153 additions and 3414 deletions

View File

@@ -46,6 +46,7 @@ jobs:
env: env:
PORT: "20128" PORT: "20128"
INJECTION_GUARD_MODE: block INJECTION_GUARD_MODE: block
REQUIRE_API_KEY: "false"
run: | run: |
node dist/server.js > server.log 2>&1 & node dist/server.js > server.log 2>&1 &
echo $! > server.pid echo $! > server.pid
@@ -64,16 +65,20 @@ jobs:
# those 302s as "the API accepted a schema-violating request" and the configured-off # those 302s as "the API accepted a schema-violating request" and the configured-off
# 400 as "rejected a schema-compliant request". Documenting the flow in the spec is # 400 as "rejected a schema-compliant request". Documenting the flow in the spec is
# still right (operators need it); fuzzing it is not what this smoke is for. # still right (operators need it); fuzzing it is not what this smoke is for.
# /api/auth/login has brute-force rate limiting: repeated failed logins return 429,
# which Schemathesis flags as rejection of schema-compliant requests.
schemathesis run docs/openapi.yaml --url http://localhost:20128 \ schemathesis run docs/openapi.yaml --url http://localhost:20128 \
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \ --include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
--exclude-path-regex '^/api/auth/oidc/' \ --exclude-path-regex '^/api/auth/(oidc/|login)' \
--max-examples 8 --workers 4 --checks all --max-response-time 30 \ --max-examples 8 --workers 4 --checks all --max-response-time 30 \
--request-timeout 20 --suppress-health-check all --no-color --request-timeout 20 --suppress-health-check all --no-color
- name: Install promptfoo
run: npm install -g promptfoo@0.122.0
- name: promptfoo injection-guard (blocking) - name: promptfoo injection-guard (blocking)
env: env:
OMNIROUTE_URL: http://localhost:20128 OMNIROUTE_URL: http://localhost:20128
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache run: promptfoo eval -c promptfooconfig.yaml --no-cache
- name: Stop server - name: Stop server
if: always() if: always()
run: kill "$(cat server.pid)" || true run: kill "$(cat server.pid)" || true

89
Dockerfile.bun Normal file
View File

@@ -0,0 +1,89 @@
# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
FROM oven/bun:1.3.14-slim AS base
WORKDIR /app
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
build-essential \
python3 \
python-is-python3 \
make \
g++ \
libsecret-1-0 \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# ── Builder stage (100% Bun Native Install & Build) ─────────────────────────
FROM base AS builder
WORKDIR /app
COPY . .
# Fast Bun native package install
RUN bun install --include=optional --quiet
# Compile native better-sqlite3 Node-API addon under Bun
RUN if [ -d "node_modules/better-sqlite3" ]; then \
(cd node_modules/better-sqlite3 && bunx node-gyp rebuild); \
fi
# Fetch tls-client-node native binary if script exists
RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ]; then \
bun node_modules/tls-client-node/scripts/postinstall.js || true; \
fi
# Disable Turbopack for Bun builder stage (Turbopack V8 internal worker bindings require Node)
ENV OMNIROUTE_USE_TURBOPACK=0
ARG OMNIROUTE_BASE_PATH=""
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
ARG DASHBOARD_ALLOW_EMBED=""
ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Bun native Next.js build execution
RUN bun run --quiet build
# ── Runner stage (100% Bun Native Production Runtime) ──────────────────────
FROM oven/bun:1.3.14-slim AS runner
LABEL org.opencontainers.image.title="omniroute" \
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \
org.opencontainers.image.url="https://omniroute.online" \
org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \
org.opencontainers.image.licenses="MIT"
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libsecret-1-0 \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
ENV PORT=20128
ENV HOSTNAME=0.0.0.0
ENV OMNIROUTE_MEMORY_MB=1024
ENV DATA_DIR=/app/data
RUN mkdir -p /app/data
COPY --from=builder /app/.build/next/standalone ./
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
EXPOSE 20128
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD bun healthcheck.mjs || exit 1
ENTRYPOINT ["bun", "bin/omniroute.mjs", "serve", "--no-open"]

View File

@@ -1009,6 +1009,19 @@ Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-r
> are **not supported for production**. See > are **not supported for production**. See
> [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels). > [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).
**🥟 Bun**
Standard `bun install` and global installation (`bun install -g omniroute`) are supported via Bun runtime detection:
- **Built-in `bun:sqlite`**: OmniRoute uses Bun's built-in `bun:sqlite` driver when running under Bun, falling back to `better-sqlite3` on Node.js or `sql.js`.
- **Automatic Webpack bundler selection**: Development (`bun run dev`) and production builds (`bun run build`) automatically detect Bun and disable Turbopack in favor of Webpack to prevent native V8 binding incompatibilities.
- **Dedicated Bun Dockerfile**: Multi-stage `Dockerfile.bun` for native Bun production deployments (`docker build -f Dockerfile.bun -t omniroute:bun .`).
```bash
# Install and run with Bun
bun install
bun run dev
```
**🛠️ From source** **🛠️ From source**
```bash ```bash

View File

@@ -9,10 +9,13 @@ import { discoverPlugins } from "../plugins.mjs";
// (instead of string-interpolating into `execSync`) prevents a malicious plugin // (instead of string-interpolating into `execSync`) prevents a malicious plugin
// name like `foo; rm -rf ~` or `` foo`id` `` from being interpreted by the shell. // name like `foo; rm -rf ~` or `` foo`id` `` from being interpreted by the shell.
function runNpm(args) { function runNpm(args) {
const res = spawnSync("npm", args, { stdio: "inherit", shell: false }); const isBun = Boolean(process.versions.bun);
const pm = isBun ? "bun" : "npm";
const cmdArgs = isBun && args[0] === "install" ? ["add", ...args.slice(1)] : args;
const res = spawnSync(pm, cmdArgs, { stdio: "inherit", shell: false });
if (res.error) throw res.error; if (res.error) throw res.error;
if (typeof res.status === "number" && res.status !== 0) { if (typeof res.status === "number" && res.status !== 0) {
throw new Error(`npm exited with code ${res.status}`); throw new Error(`${pm} exited with code ${res.status}`);
} }
} }

View File

@@ -114,30 +114,30 @@ export function isBetterSqliteBinaryValid() {
export function npmInstallRuntime(pkgs, opts = {}) { export function npmInstallRuntime(pkgs, opts = {}) {
const cwd = ensureRuntimeDir(); const cwd = ensureRuntimeDir();
// Persist to the runtime package.json (exact version) instead of --no-save so a later
// install of a sibling runtime dep (e.g. systray2 from trayRuntime.ts, which writes to the
// same runtime dir) does not prune this package as "extraneous" — that pruning otherwise
// reproduces "No SQLite driver available" after a tray install removes better-sqlite3.
// npm 12+ defaults `allowScripts` to off, silently skipping lifecycle/install
// scripts (e.g. better-sqlite3's node-gyp/prebuild-install rebuild) unless the
// package has a matching `allowScripts` entry — and still exits 0, masking the
// failure (#10713). The runtime dir is a CLI-owned, non-user package.json, so
// explicitly allowing scripts for the packages we are installing here is safe.
const npmArgs = [
"install",
...pkgs,
"--no-audit",
"--no-fund",
"--prefer-online",
"--save-exact",
...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
];
// On Windows .cmd files cannot be executed without a shell; use cmd.exe /c explicitly
// so we never set shell:true (which would propagate env and enable injection).
const isWin = platform() === "win32"; const isWin = platform() === "win32";
const [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs]; const isBun = Boolean(process.versions.bun);
let exe, args, displayCmd;
if (isBun) {
const bunArgs = ["add", ...pkgs, "--trust"];
[exe, args] = isWin ? ["cmd.exe", ["/c", "bun", ...bunArgs]] : ["bun", bunArgs];
displayCmd = `bun ${bunArgs.join(" ")}`;
} else {
const npmArgs = [
"install",
...pkgs,
"--no-audit",
"--no-fund",
"--prefer-online",
"--save-exact",
...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
];
[exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
displayCmd = `npm ${npmArgs.join(" ")}`;
}
if (!opts.silent) { if (!opts.silent) {
process.stdout.write(`[omniroute][runtime] npm ${npmArgs.join(" ")}\n`); process.stdout.write(`[omniroute][runtime] ${displayCmd}\n`);
} }
const res = spawnSync(exe, args, { const res = spawnSync(exe, args, {
cwd, cwd,

View File

@@ -5,10 +5,14 @@ import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./
async function loadSqlite() { async function loadSqlite() {
if (process.versions.bun) { if (process.versions.bun) {
return { Database: (await import("bun:sqlite")).Database }; try {
return { Database: (await import("bun:sqlite")).Database, driver: "bun:sqlite" };
} catch (bunError) {
// fall through to better-sqlite3 if bun:sqlite fails
}
} }
try { try {
return { Database: (await import("better-sqlite3")).default }; return { Database: (await import("better-sqlite3")).default, driver: "better-sqlite3" };
} catch (error) { } catch (error) {
return { error }; return { error };
} }
@@ -86,12 +90,14 @@ export function normalizeBunSqliteParams(params) {
export function createSqliteNativeError(error) { export function createSqliteNativeError(error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
const isBun = Boolean(process.versions.bun);
const rebuildCmd = isBun ? "bun add better-sqlite3 --trust" : "npm rebuild better-sqlite3";
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) { if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
return new Error( return new Error(
"better-sqlite3 native binding is incompatible with this Node.js runtime. " + `better-sqlite3 native binding is incompatible with this runtime. ` +
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again. " + `Run \`${rebuildCmd}\` in the OmniRoute project and try again. ` +
"Or run: omniroute runtime repair " + `Or run: omniroute runtime repair ` +
"(rebuilds into a user-writable runtime; works without a C++ toolchain)." `(rebuilds into a user-writable runtime; works without a C++ toolchain).`
); );
} }
if ( if (
@@ -100,10 +106,9 @@ export function createSqliteNativeError(error) {
message.includes("Cannot find module 'better-sqlite3'") message.includes("Cannot find module 'better-sqlite3'")
) { ) {
return new Error( return new Error(
"better-sqlite3 native binding could not be found (no prebuilt addon for this platform). " + `better-sqlite3 native binding could not be found (no prebuilt addon for this platform). ` +
"This is common under `npx`, which runs a fresh, ephemeral install that never built the addon. " + `Run: omniroute runtime repair ` +
"Run: omniroute runtime repair " + `(rebuilds into a user-writable runtime; works without a C++ toolchain).`
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
); );
} }
return error; return error;
@@ -111,7 +116,7 @@ export function createSqliteNativeError(error) {
async function openSqliteDatabase(dbPath, options = {}) { async function openSqliteDatabase(dbPath, options = {}) {
const loaded = await loadSqlite(); const loaded = await loadSqlite();
if (process.versions.bun) { if (loaded.driver === "bun:sqlite" || (process.versions.bun && !loaded.Database)) {
if (options.fileMustExist && !fs.existsSync(dbPath)) { if (options.fileMustExist && !fs.existsSync(dbPath)) {
throw new Error(`SQLite file does not exist: ${dbPath}`); throw new Error(`SQLite file does not exist: ${dbPath}`);
} }

View File

@@ -94,10 +94,15 @@ export function ensureAndroidCacheDir(options = {}) {
*/ */
export function isFatalInstrumentationHookFailure(text) { export function isFatalInstrumentationHookFailure(text) {
if (!text) return false; if (!text) return false;
return ( // Next.js wraps ANY throw inside instrumentation.register() with the generic
/Unsupported platform:\s*android/i.test(text) || // "An error occurred while loading instrumentation hook:" prefix, on every
/error occurred while loading instrumentation hook/i.test(text) // platform (node_modules/next/dist/server/web/globals.js). That prefix alone
); // therefore cannot identify the Android/Termux cache-probe failure — a bare
// generic instrumentation error on win32/desktop would be misreported as the
// Android bug and hide the real cause. Only match when the text actually
// carries the Android platform marker that Next's getCacheDirectory() emits.
// #10028
return /Unsupported platform:\s*android/i.test(text);
} }
/** /**

View File

@@ -44,6 +44,18 @@ export function getSecureFloorForMajor(major) {
} }
export function getNodeRuntimeSupport(version = process.versions.node) { export function getNodeRuntimeSupport(version = process.versions.node) {
if (process.versions.bun) {
return {
nodeVersion: `bun-${process.versions.bun} (Node.js API ${version})`,
nodeCompatible: true,
reason: "supported-bun",
supportedRange: SUPPORTED_NODE_RANGE + " || Bun >=1.1.0",
supportedDisplay: SUPPORTED_NODE_DISPLAY + ", or Bun 1.1+",
recommendedVersion: `v${RECOMMENDED_NODE_VERSION}`,
minimumSecureVersion: null,
};
}
const parsed = parseNodeVersion(version); const parsed = parseNodeVersion(version);
const secureFloor = getSecureFloorForMajor(parsed.major); const secureFloor = getSecureFloorForMajor(parsed.major);
const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false; const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false;

View File

@@ -17,7 +17,12 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import updateNotifier from "update-notifier"; let updateNotifier = null;
try {
updateNotifier = (await import("update-notifier")).default;
} catch {
// update-notifier is optional in pruned standalone environments
}
import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs"; import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs";
import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs"; import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs";
import { getDefaultDataDir } from "./cli/data-dir.mjs"; import { getDefaultDataDir } from "./cli/data-dir.mjs";
@@ -251,8 +256,9 @@ if (shouldProvisionStorageKey(process.argv)) {
// Register update notifier — checks npm once per 24h, notifies on exit via stderr. // Register update notifier — checks npm once per 24h, notifies on exit via stderr.
const _pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); const _pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
const _notifier = updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }); const _notifier = updateNotifier ? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }) : null;
process.on("exit", () => { process.on("exit", () => {
if (!_notifier || !_notifier.update) return;
if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return; if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return;
if (process.env.CI) return; if (process.env.CI) return;
if (process.argv.includes("--quiet") || process.argv.includes("-q")) return; if (process.argv.includes("--quiet") || process.argv.includes("-q")) return;

View File

@@ -0,0 +1 @@
- **feat(providers):** let operators declare per-provider error rules through `settings.providerErrorRules` instead of patching the catalog — an operator-supplied rule for a provider is consulted before the built-in `providerRuleRegistry`, receives the raw error text, and has its declared scope/cooldown/reason actually honored end to end, for any provider (declaring the rule is the opt-in — no extra allowlist entry needed). Matches are plain case-insensitive substrings (never RegExp) and bounded to 50 rules to keep the hot path safe ([#11104](https://github.com/diegosouzapw/OmniRoute/pull/11104))

View File

@@ -0,0 +1 @@
- fix(cli): stop diagnosing every Next.js instrumentation-hook failure as the Android/Termux cache bug — only the Android "Unsupported platform: android" signal now triggers the Android hint, so a win32/desktop instrumentation error surfaces its real cause instead of a useless `mkdir -p ~/.cache` (#10028)

View File

@@ -0,0 +1 @@
- fix(command-code): route chat to the documented /provider/v1/chat/completions endpoint instead of the CLI-only /alpha/generate, which Command Code gates/blocks for external callers (#10265)

View File

@@ -0,0 +1 @@
- fix(services): isolate probeBeforeSpawn adoption tests on distinct ports to stop the order-dependent flake (#10523)

View File

@@ -0,0 +1 @@
- fix(command-code): surface reasoning-only output as content when a model emits no text-delta (#10986)

View File

@@ -0,0 +1 @@
- **Static model catalog for v0-vercel-web:** seed a static catalog for the v0-vercel-web web-cookie provider (v0-1.0-md, v0-1.5-lg, v0-1.5-md) so its dashboard "Available Models" / "Import from /models" UI serves a usable list instead of falling through to the route's 400 "does not support models listing" ([#10990](https://github.com/diegosouzapw/OmniRoute/issues/10990)).

View File

@@ -0,0 +1 @@
- fix(providers): mark the blackbox provider deprecated — api.blackbox.ai returns HTTP 404 on every path variant (sweep 2026-08-21), so the public inference surface is dead and the catalog entry now carries a deprecation notice. ([#10997](https://github.com/diegosouzapw/OmniRoute/issues/10997))

View File

@@ -0,0 +1 @@
- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002)

View File

@@ -0,0 +1 @@
- **fix(webhooks):** remove 3 declared-but-never-emitted events (`provider.error`, `provider.recovered`, `combo.switched`) from `WebhookEvent` — catalog now `request.completed | request.failed | quota.exceeded | test.ping`; `POST /api/webhooks` and `PUT /api/webhooks/[id]` reject ghost values with 400; OpenAPI webhook description updated across 43 locales ([11050](https://github.com/diegosouzapw/OmniRoute/pull/11050))

View File

@@ -0,0 +1 @@
- fix(providers): filter Perplexity model import to the Sonar family so Agent-API catalog ids stop surfacing as routable chat models (#11060)

View File

@@ -0,0 +1 @@
- fix(install): make the ONNX dependency chain optional so Termux/Android installs succeed again (#11095)

View File

@@ -0,0 +1 @@
- **fix(providers):** Reject silent validation degradation on provider connection patch — unknown `rateLimitOverrides` keys (e.g. a typo'd `tpm`) and empty/non-numeric values now return `400` with the rejected key list instead of being silently dropped ([#11101](https://github.com/diegosouzapw/OmniRoute/pull/11101))

View File

@@ -0,0 +1 @@
- **Autopilot suggestion counter:** the combo health autopilot summary now reports `suggestionCount` (the real number of suggested actions across all issues) instead of conflating it with link counts, while keeping `actionableCount` as a deprecated alias for backward compatibility. The `run_combo_test` action now links to the dashboard with the combo id (`/dashboard/combos?test=<comboId>`) rather than the read-only API route, so operators can actually trigger a test from the UI ([#11102](https://github.com/diegosouzapw/OmniRoute/pull/11102)).

View File

@@ -0,0 +1 @@
- **Config audit persistence:** persist the configuration audit trail to SQLite (`config_audit_log`) instead of an in-memory buffer capped at 1000 volatile entries, and bound its growth with `cleanupConfigAudit()` driven by the `retention.configAudit` setting (default 30 days), wired into `runAutoCleanup` ([#11103](https://github.com/diegosouzapw/OmniRoute/pull/11103)).

View File

@@ -0,0 +1 @@
- fix(sse): resume mid-stream recovery after a _completed_ tool call — `finish_reason: "tool_calls"` is now tracked per-call instead of as a general terminal marker, so truncation of trailing prose after a fully-delivered tool call is recoverable while in-flight calls stay blocked ([#11109](https://github.com/diegosouzapw/OmniRoute/pull/11109))

View File

@@ -0,0 +1 @@
- **fix(providers):** `reasoning_effort` now learns the accepted values from a provider's own 400/422 response and clamps to the highest one instead of forwarding an unsupported `xhigh`/`max` (or a hardcoded `"high"` fallback) — fixes custom OpenAI-compatible connections and registered providers with no reasoning metadata ([#11116](https://github.com/diegosouzapw/OmniRoute/pull/11116)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(sse):** parallel `function_call` items in a Responses API stream (e.g. several tool calls dispatched in the same turn) now each get a stable, distinct `index`/`id` when translated to Chat Completions streaming deltas, instead of colliding on index 0 and tripping strict stream parsers with `Expected 'id' to be a string.` ([#11144](https://github.com/diegosouzapw/OmniRoute/pull/11144))

View File

@@ -0,0 +1 @@
- fix(dashboard): treat UncloseAI as a no-auth provider so the connect form no longer forces a fake API key (#8864)

View File

@@ -0,0 +1 @@
- fix(ssrf): make `getProviderOutboundGuard()` (used for search-provider connection validation, image generation and remote image fetch) honor the local-first default `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` the same way the chat validation guard already does, so a LAN-hosted SearXNG/Brave search provider works with only the LOCAL flag set instead of silently requiring `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` ([#9123](https://github.com/diegosouzapw/OmniRoute/issues/9123)).

View File

@@ -0,0 +1 @@
- fix(i18n): complete Vietnamese translations for recently added UI strings (#9985)

View File

@@ -0,0 +1,3 @@
- fix(api): repair broken `@/lib/db/connections` import in the usage utilization route that failed the production build (#10939 follow-up)
- chore(docs): regenerate PROVIDER_REFERENCE and refresh README diagram SVGs to the real provider count (347)
- chore(lint): prune ESLint suppressions orphaned on the release branch

View File

@@ -0,0 +1 @@
- **test(db):** replace three empty `test.skip` placeholders in the critical DB-state suite with real assertions — `resetDbInstance` must swap the singleton while the on-disk row survives, the on-disk DB must open in WAL journal mode, and `db_meta` must hold the seeded `schema_version` — so a regression in any of those invariants can no longer pass as silently green ([#10906](https://github.com/diegosouzapw/OmniRoute/pull/10906))

View File

@@ -0,0 +1 @@
- fix(quality): rebaseline file-size for modelCapabilities.ts (1016->1072) drift from merged tip fixes (#11034 et al)

View File

@@ -0,0 +1 @@
- fix(quality): register `tests/unit/authz/oauth-autoimport-local-only.test.ts` in stryker `tap.testFiles` (residual of #11053)

View File

@@ -0,0 +1 @@
- fix(i18n): translate the 14 `providers.harImport*` keys into Vietnamese (parity gap left by #11069)

View File

@@ -1,9 +1,6 @@
{ {
"open-sse/services/payloadRules.ts": {
"TS2677": 1
},
"src/app/(dashboard)/dashboard/HomePageClient.tsx": { "src/app/(dashboard)/dashboard/HomePageClient.tsx": {
"TS2339": 16 "TS2339": 10
}, },
"src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": { "src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": {
"TS2503": 3 "TS2503": 3
@@ -120,10 +117,6 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx": { "src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx": {
"TS2741": 1 "TS2741": 1
}, },
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": {
"TS2345": 3,
"TS2322": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": { "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": {
"TS2322": 2 "TS2322": 2
}, },
@@ -141,12 +134,6 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": { "src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": {
"TS2503": 1 "TS2503": 1
}, },
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
"TS2322": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts": {
"TS2339": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts": { "src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts": {
"TS2339": 15 "TS2339": 15
}, },
@@ -190,9 +177,6 @@
"src/lib/combos/builderDraft.ts": { "src/lib/combos/builderDraft.ts": {
"TS2741": 1 "TS2741": 1
}, },
"src/lib/providers/codexFastTier.ts": {
"TS2367": 1
},
"src/lib/services/htmlRewriter.ts": { "src/lib/services/htmlRewriter.ts": {
"TS2322": 2, "TS2322": 2,
"TS2345": 2 "TS2345": 2
@@ -219,14 +203,7 @@
"src/shared/hooks/useElectron.ts": { "src/shared/hooks/useElectron.ts": {
"TS2339": 19 "TS2339": 19
}, },
"src/shared/providers/webSessionCredentials.ts": {
"TS2353": 1,
"TS2322": 1
},
"src/shared/schemas/cliCatalog.ts": { "src/shared/schemas/cliCatalog.ts": {
"TS2554": 2 "TS2554": 2
},
"src/shared/services/opencodeConfig.ts": {
"TS2345": 1
} }
} }

View File

@@ -443,21 +443,24 @@
"src/shared/components/ModelSelectModal.tsx": 1138, "src/shared/components/ModelSelectModal.tsx": 1138,
"src/shared/constants/providers/apikey/gateways.ts": 1250 "src/shared/constants/providers/apikey/gateways.ts": 1250
}, },
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1067, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1080,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051,
"src/shared/components/ModelSelectModal.tsx": 1138, "src/shared/components/ModelSelectModal.tsx": 1138,
"src/shared/constants/providers/apikey/gateways.ts": 1298, "src/shared/constants/providers/apikey/gateways.ts": 1298,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387,
"_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).",
"src/lib/modelCapabilities.ts": 1016, "src/lib/modelCapabilities.ts": 1072,
"_rebaseline_2026_08_21_11034_effort_variants": "DRIFT do tip (base-red #9985): modelCapabilities.ts 1016->1072 (+56) acumulado por PRs ja mergeadas no release/v3.8.50 — principalmente #11034 (resolve effort-variant capabilities a partir do modelo base), alem de #10963/#11040/#10987 growth dos catalogos. Tip puro ficou vermelho neste gate; rebaseline no tip por push direto (owner pre-autorizou crescimento legitimo). Nao tocou no arquivo da #11038.",
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014, "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
"open-sse/config/imageRegistry.ts": 1034, "open-sse/config/imageRegistry.ts": 1034,
"src/sse/handlers/chatHelpers.ts": 1019, "src/sse/handlers/chatHelpers.ts": 1019,
"src/shared/middleware/chatBodyAdmission.ts": 1005, "src/shared/middleware/chatBodyAdmission.ts": 1005,
"_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file).", "_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file).",
"open-sse/executors/commandCode.ts": 1038, "open-sse/executors/commandCode.ts": 1059,
"_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).", "_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).",
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts." "_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).",
"_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts."
}, },
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",

View File

@@ -1,11 +1,4 @@
{ {
"open-sse/handlers/chatCore/clientUsageBuffer.ts": {
"TS2345": 2
},
"open-sse/utils/stream.ts": {
"TS2345": 2,
"TS2322": 2
},
"src/lib/guardrails/videoBridgeHelpers.ts": { "src/lib/guardrails/videoBridgeHelpers.ts": {
"TS2488": 1, "TS2488": 1,
"TS2365": 2, "TS2365": 2,

View File

@@ -448,14 +448,14 @@ classification rules pick the fallback `reason` and lock `scope`
Classification rules only see full error **text** (needed to match body Classification rules only see full error **text** (needed to match body
markers like `额度不足`) for providers listed in the `FULL_TEXT_RULE_PROVIDERS` markers like `额度不足`) for providers listed in the `FULL_TEXT_RULE_PROVIDERS`
allowlist in `providerErrorRules.ts` — currently only `"agentrouter"`. For allowlist in `providerErrorRules.ts` — currently only `"agentrouter"`. For
every other provider, `checkFallbackError` hands `getProviderErrorRuleMatch` every other **built-in catalog** provider, `checkFallbackError` hands
only the structured error (`{code, type}`), which is enough for `getProviderErrorRuleMatch` only the structured error (`{code, type}`), which
header/status/code-based rules but blind to body-text markers. The helper is enough for header/status/code-based rules but blind to body-text markers.
`resolveRuleMatchBody()` performs this selection: full error text for The helper `resolveRuleMatchBody()` performs this selection: full error text
allowlisted providers, the structured error otherwise. Adding a provider to for allowlisted providers, the structured error otherwise. Adding a
`FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider opt-in — it exists so **built-in** provider to `FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider
that the default path for every provider not on the list stays opt-in — it exists so that the default path for every provider not on the
byte-for-byte unchanged. list stays byte-for-byte unchanged.
A rule's `scope` (`model` / `provider` / `connection`) is a separate opt-in A rule's `scope` (`model` / `provider` / `connection`) is a separate opt-in
from `FULL_TEXT_RULE_PROVIDERS`: `checkFallbackError` only surfaces it as from `FULL_TEXT_RULE_PROVIDERS`: `checkFallbackError` only surfaces it as
@@ -466,6 +466,31 @@ honorsRuleLockScope()` — today only `"agentrouter"`). See "Restated quota
errors" above for what a `scope: "connection"` match actually does once a errors" above for what a `scope: "connection"` match actually does once a
provider is on that allowlist. provider is on that allowlist.
**#11104 — operator-declared rules bypass both allowlists.** An operator can
declare a per-provider rule at runtime via `settings.providerErrorRules`
(`open-sse/config/providerErrorRules.ts::setOperatorProviderErrorRules`)
without editing this file. Gating an operator rule behind
`FULL_TEXT_RULE_PROVIDERS`/`HONORS_RULE_LOCK_SCOPE_PROVIDERS` — allowlists
meant to protect the **default** behavior of built-in catalog rules — would
make the settings mechanism inert for every provider except the ones already
listed there, since declaring the rule is already the operator's explicit
opt-in. `resolveRuleMatchBody()` and `honorsRuleLockScope()` both check
`hasOperatorRuleForProvider()` first: a provider with an operator rule gets
the raw error text and has its declared `scope` honored, regardless of
whether it also appears in either allowlist.
**Known gap — `providerRuleRegistry` is never consulted for HTTP 400.**
`checkFallbackError`'s `BAD_REQUEST` branch classifies status 400 entirely
through its own pattern arrays (`MODEL_ACCESS_DENIED_PATTERNS`,
`CONTEXT_OVERFLOW_PATTERNS`, etc. in `accountFallback.ts`) and returns before
the `configuredRule`/`getProviderErrorRuleMatch` branch above it is reached.
A built-in catalog rule (or an operator rule) with `status: 400` is
syntactically valid but will never fire. No existing rule targets 400 today,
so nothing in production is affected — but a future 400 rule needs this
branch touched first, which is a larger change than adding a rule (it
reclassifies 400 for every provider already relying on the pattern-array
behavior) and is out of scope for a single-provider rule addition.
### Adding a new quota-misstating gateway ### Adding a new quota-misstating gateway
1. Register one rule array in `statusRestatementRegistry` 1. Register one rule array in `statusRestatementRegistry`

View File

@@ -120,7 +120,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve
| `firecrawl` | caution | Cloud API ToS has no explicit personal-proxy prohibition found, but the open-source self-hosted version is AGPL-3.0 (re… | | `firecrawl` | caution | Cloud API ToS has no explicit personal-proxy prohibition found, but the open-source self-hosted version is AGPL-3.0 (re… |
| `gemini` | caution | ToS explicitly states the free tier is for "developers building with Google AI models for professional or business purp… | | `gemini` | caution | ToS explicitly states the free tier is for "developers building with Google AI models for professional or business purp… |
| `groq` | caution | Services Agreement §6.3 prohibits reselling, sublicensing, or distributing API access; §3.2 bars reselling/leasing acco… | | `groq` | caution | Services Agreement §6.3 prohibits reselling, sublicensing, or distributing API access; §3.2 bars reselling/leasing acco… |
| `hackclub` | caution | Service is explicitly scoped to Hack Club teen members building projects/learning; no public ToS found explicitly permi… |
| `huggingchat` | caution | Hugging Face ToS does not explicitly ban personal self-hosted proxies, but supplemental terms (referenced but not fully… | | `huggingchat` | caution | Hugging Face ToS does not explicitly ban personal self-hosted proxies, but supplemental terms (referenced but not fully… |
| `huggingface` | caution | ToS grants a limited license to access/use the service; the document does not explicitly permit or forbid a single-user… | | `huggingface` | caution | ToS grants a limited license to access/use the service; the document does not explicitly permit or forbid a single-user… |
| `hyperbolic` | caution | ToS grants API access "solely for your own personal or internal business purposes" and explicitly prohibits licensing, … | | `hyperbolic` | caution | ToS grants API access "solely for your own personal or internal business purposes" and explicitly prohibits licensing, … |
@@ -222,7 +221,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve
| `duckduckgo-web` | keyless | — | — | avoid | 6 | | `duckduckgo-web` | keyless | — | — | avoid | 6 |
| `freemodel-dev` | keyless | — | — | unknown | 4 | | `freemodel-dev` | keyless | — | — | unknown | 4 |
| `friendliai` | keyless | — | — | avoid | 2 | | `friendliai` | keyless | — | — | avoid | 2 |
| `hackclub` | keyless | — | — | caution | 3 |
| `iflytek` | keyless | — | — | avoid | 1 | | `iflytek` | keyless | — | — | avoid | 1 |
| `inference-net` | keyless | — | — | caution | 3 | | `inference-net` | keyless | — | — | caution | 3 |
| `liquid` | keyless | — | — | unknown | 1 | | `liquid` | keyless | — | — | unknown | 1 |
@@ -280,7 +278,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve
- **`gitlawb`** — The shipped freeNote "Free tier available" is effectively stale. The original free MiMo access was removed in May 2026; the only remaining "free" option is a temporary promotional model (Nemotron 3 U… - **`gitlawb`** — The shipped freeNote "Free tier available" is effectively stale. The original free MiMo access was removed in May 2026; the only remaining "free" option is a temporary promotional model (Nemotron 3 U…
- **`gitlawb-gmi`** — Partially still accurate — free tier exists but is now narrowed to a single model (Nemotron 3 Ultra) after MiMo free access was revoked in late May 2026. The shipped note "Free tier available" unders… - **`gitlawb-gmi`** — Partially still accurate — free tier exists but is now narrowed to a single model (Nemotron 3 Ultra) after MiMo free access was revoked in late May 2026. The shipped note "Free tier available" unders…
- **`groq`** — The shipped freeNote "30 RPM / 14.4K RPD" is accurate only for llama-3.1-8b-instant. Most other models (including llama-3.3-70b-versatile) have a much lower 1K RPD cap. The note omits model-specific … - **`groq`** — The shipped freeNote "30 RPM / 14.4K RPD" is accurate only for llama-3.1-8b-instant. Most other models (including llama-3.3-70b-versatile) have a much lower 1K RPD cap. The note omits model-specific …
- **`hackclub`** — The "30+ models" count appears accurate and still matches. The core offering remains free for Hack Club members. No evidence of tightening — still "$0 ALWAYS FREE" per the homepage. The freeNote omit…
- **`huggingchat`** — The shipped freeNote ("Free LLM chat — no subscription required. Rate limits apply.") is partially accurate but significantly understates the restrictions. The free tier now operates on a hard $0.10/… - **`huggingchat`** — The shipped freeNote ("Free LLM chat — no subscription required. Rate limits apply.") is partially accurate but significantly understates the restrictions. The free tier now operates on a hard $0.10/…
- **`huggingface`** — Significantly tightened. The shipped freeNote ("Free Inference API for thousands of models") implied unlimited/generous free access, but as of mid-2025 the free tier is capped at $0.10/month in recur… - **`huggingface`** — Significantly tightened. The shipped freeNote ("Free Inference API for thousands of models") implied unlimited/generous free access, but as of mid-2025 the free tier is capped at $0.10/month in recur…
- **`hyperbolic`** — Our shipped freeNote says "$1-5 trial credits on signup" — the $1 trial credit portion is accurate, but the "$5" figure refers to the minimum deposit required to unlock GPU rental (not free credits g… - **`hyperbolic`** — Our shipped freeNote says "$1-5 trial credits on signup" — the $1 trial credit portion is accurate, but the "$5" figure refers to the minimum deposit required to unlock GPU rental (not free credits g…

View File

@@ -213,7 +213,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — | | `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — |
| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — | | `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — |
| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card | | `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card |
| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. |
| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api | | `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api |
| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn | | `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn |
| `helixmind` | `helixmind` | HelixMind | API key, aggregator | [link](https://helixmind.online) | Previously circulated 3 RPM/50 RPD and no-card claims were not confirmed during the 2026-08-02 audit; current quota and billing require account verification. | | `helixmind` | `helixmind` | HelixMind | API key, aggregator | [link](https://helixmind.online) | Previously circulated 3 RPM/50 RPD and no-card claims were not confirmed during the 2026-08-02 audit; current quota and billing require account verification. |

View File

@@ -171,6 +171,7 @@ export const HTTP_STATUS = {
FORBIDDEN: 403, FORBIDDEN: 403,
NOT_FOUND: 404, NOT_FOUND: 404,
NOT_ACCEPTABLE: 406, NOT_ACCEPTABLE: 406,
UNPROCESSABLE_ENTITY: 422,
REQUEST_TIMEOUT: 408, REQUEST_TIMEOUT: 408,
GONE: 410, GONE: 410,
RATE_LIMITED: 429, RATE_LIMITED: 429,
@@ -263,11 +264,17 @@ export const PROVIDER_PROFILES = {
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS", 30000), circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS", 30000),
// Provider-level circuit breaker (entire provider cooldown after repeated failures) // Provider-level circuit breaker (entire provider cooldown after repeated failures)
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD", 15), // Scaled for 500+ connections (was 5) providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD", 15), // Scaled for 500+ connections (was 5)
providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS", 1800000), // 30min window (was 20min) providerFailureWindowMs: envInt(
"OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS",
1800000
), // 30min window (was 20min)
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS", 600000), // 10min cooldown when threshold reached providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS", 600000), // 10min cooldown when threshold reached
degradationThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD", 7), degradationThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD", 7),
maxBackoffMultiplier: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER", 4), maxBackoffMultiplier: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER", 4),
backoffEscalationCount: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT", 3), backoffEscalationCount: envInt(
"OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT",
3
),
}, },
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX). // Local providers (localhost inference backends like Ollama, LM Studio, oMLX).
// Not yet wired into getProviderProfile() — will be used when local provider_nodes // Not yet wired into getProviderProfile() — will be used when local provider_nodes

View File

@@ -194,9 +194,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "groq", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true }, { provider: "groq", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "openai/gpt-oss-20b", displayName: "GPT-OSS 20B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true }, { provider: "groq", modelId: "openai/gpt-oss-20b", displayName: "GPT-OSS 20B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "qwen/qwen3-32b", displayName: "Qwen3 32B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true }, { provider: "groq", modelId: "qwen/qwen3-32b", displayName: "Qwen3 32B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
{ provider: "hackclub", modelId: "meta-llama/llama-3.3-70b-instruct", displayName: "Llama 3.3 70B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
{ provider: "hackclub", modelId: "mistralai/mistral-7b-instruct", displayName: "Mistral 7B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
{ provider: "hackclub", modelId: "deepseek-ai/deepseek-coder-33b", displayName: "DeepSeek Coder 33B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
{ provider: "huggingchat", modelId: "baidu/ERNIE-4.5-VL-424B-A47B-Base-PT", displayName: "ERNIE 4.5 VL 424B A47B Base PT", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" }, { provider: "huggingchat", modelId: "baidu/ERNIE-4.5-VL-424B-A47B-Base-PT", displayName: "ERNIE 4.5 VL 424B A47B Base PT", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },
{ provider: "huggingchat", modelId: "CohereLabs/c4ai-command-r7b-12-2024", displayName: "Command R7B 12-2024", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" }, { provider: "huggingchat", modelId: "CohereLabs/c4ai-command-r7b-12-2024", displayName: "Command R7B 12-2024", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },
{ provider: "huggingchat", modelId: "CohereLabs/command-a-reasoning-08-2025", displayName: "Command A Reasoning 08-2025", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" }, { provider: "huggingchat", modelId: "CohereLabs/command-a-reasoning-08-2025", displayName: "Command A Reasoning 08-2025", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },

View File

@@ -0,0 +1,16 @@
/**
* Models declared identically in both the `opencode-zen` and `opencode-go` provider
* registries (same upstream family, opencode.ai/zen/*). Mirrors the GLM_SHARED_MODELS
* pattern in glmProvider.ts: one array, spread into each sibling RegistryEntry, so a
* metadata fix (targetFormat, supportsReasoning, ...) only has to land in one file
* instead of drifting out of sync across registries.
*
* Only entries that are byte-identical across both registries belong here — a model
* with tier-specific flags (e.g. go's effort variants, or a flag only one tier needs)
* stays local to that registry's own `models` array.
*/
export const OPENCODE_ZEN_GO_SHARED_MODELS = Object.freeze([
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false },
]);

View File

@@ -30,21 +30,63 @@ export type ProviderErrorRule = {
export type ProviderErrorRuleMatch = { export type ProviderErrorRuleMatch = {
reason: ConfiguredErrorReason; reason: ConfiguredErrorReason;
/** /**
* Intended lock scope. #10334: this field is CONSUMED end-to-end only for * Intended lock scope. #10334: for a BUILT-IN catalog rule, this field is
* providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS` (agentrouter-exclusive * CONSUMED end-to-end only for providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS`
* today, gated by `honorsRuleLockScope()`) — for those, `checkFallbackError` * (agentrouter-exclusive today, gated by `honorsRuleLockScope()`) — for those,
* surfaces it as `ruleScope` on its return value for the persistence layer * `checkFallbackError` surfaces it as `ruleScope` on its return value for the
* to honor instead of re-deriving scope from `hasPerModelQuota()`. For * persistence layer to honor instead of re-deriving scope from
* every other provider it remains INFORMATIONAL: `getProviderErrorRuleMatch` * `hasPerModelQuota()`. For every other built-in-rule provider it remains
* callers still read only `reason`/`cooldownMs`, and the actual lock scope * INFORMATIONAL. #11104: an OPERATOR-declared rule (`OperatorProviderErrorRule`)
* is decided independently by each call site. Widening the allowlist is * is exempt from this allowlist — `honorsRuleLockScope()` always returns true
* tracked as a follow-up — see `docs/architecture/RESILIENCE_GUIDE.md` §7. * when the provider has one, since the operator already opted in by declaring
* the rule. Widening `HONORS_RULE_LOCK_SCOPE_PROVIDERS` itself (for a new
* built-in catalog rule) is tracked as a follow-up — see
* `docs/architecture/RESILIENCE_GUIDE.md` §7.
*/ */
scope: "model" | "provider" | "connection"; scope: "model" | "provider" | "connection";
/** Optional explicit cooldown; falls back to the existing per-reason defaults. */ /** Optional explicit cooldown; falls back to the existing per-reason defaults. */
cooldownMs?: number; cooldownMs?: number;
}; };
/**
* Operator-declared per-provider error rule (settings-driven).
*
* Mirrors the catalog `ProviderErrorRule` but is data-only so an operator can
* add a scope/cooldown/reason override for a provider without editing this
* file. `match` is a plain case-insensitive SUBSTRING of the error body — never
* a RegExp — so an operator-supplied pattern can never introduce a ReDoS on the
* error-classification hot path. Bounded to <= 50 rules total by the settings
* schema. An operator rule is consulted BEFORE the built-in `providerRuleRegistry`
* and wins on the first status+substring match for a provider.
*/
export type OperatorProviderErrorRule = {
status: number;
match: string;
scope: "model" | "provider" | "connection";
reason?: ConfiguredErrorReason;
cooldownMs?: number;
};
let operatorProviderErrorRules: Record<string, OperatorProviderErrorRule[]> = {};
/**
* Inject operator-declared rules. Called from the runtime-settings applier
* (`applyRuntimeSettings`) once at boot and on every settings update, with the
* value validated by the settings schema. Pass `undefined`/empty/null to clear.
* Provider keys are lowercased so lookups are case-insensitive.
*/
export function setOperatorProviderErrorRules(
rules: Record<string, OperatorProviderErrorRule[]> | undefined | null
): void {
operatorProviderErrorRules = {};
if (!rules) return;
for (const [provider, list] of Object.entries(rules)) {
if (Array.isArray(list) && list.length > 0) {
operatorProviderErrorRules[provider.toLowerCase()] = list;
}
}
}
// ─── Opencode ─────────────────────────────────────────────────────────────────── // ─── Opencode ───────────────────────────────────────────────────────────────────
// Opencode Go uses an account-wide quota. The body usually says "rate limit // Opencode Go uses an account-wide quota. The body usually says "rate limit
// reached" but the presence of `x-ratelimit-remaining-requests: 0` is the // reached" but the presence of `x-ratelimit-remaining-requests: 0` is the
@@ -272,11 +314,21 @@ export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
* FULL_TEXT_RULE_PROVIDERS: that set controls what body a rule matches against * FULL_TEXT_RULE_PROVIDERS: that set controls what body a rule matches against
* (input), this one controls whether the matched scope changes caller behavior * (input), this one controls whether the matched scope changes caller behavior
* (output). A provider could need one without the other. * (output). A provider could need one without the other.
*
* Providers with an operator-declared rule (`setOperatorProviderErrorRules`)
* are honored too, without being added here: the allowlist exists to gate
* BUILT-IN catalog rules, which change default behavior for every operator
* running that provider — an operator rule is already an explicit, per-operator
* opt-in, so gating it a second time behind this list would make the settings
* mechanism (#11104) silently inert for every provider except the ones listed
* below. See `hasOperatorRuleForProvider`.
*/ */
const HONORS_RULE_LOCK_SCOPE_PROVIDERS = new Set(["agentrouter"]); const HONORS_RULE_LOCK_SCOPE_PROVIDERS = new Set(["agentrouter"]);
export function honorsRuleLockScope(provider: string | null | undefined): boolean { export function honorsRuleLockScope(provider: string | null | undefined): boolean {
return !!provider && HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(provider.toLowerCase()); if (!provider) return false;
const key = provider.toLowerCase();
return HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(key) || hasOperatorRuleForProvider(key);
} }
/** /**
@@ -310,28 +362,51 @@ export function egressBucketedLockProviders(): string[] {
} }
/** /**
* Providers whose rules match on the FULL upstream error text. * Providers whose BUILT-IN catalog rules match on the FULL upstream error
* checkFallbackError's rule lookup normally passes only the structured * text. checkFallbackError's rule lookup normally passes only the structured
* error ({code, type} — message stripped by the combo callers), which is * error ({code, type} — message stripped by the combo callers), which is
* enough for header/status/code rules but blind to body-text markers like * enough for header/status/code rules but blind to body-text markers like
* agentrouter's "额度不足". Providers in this set get the raw error text as * agentrouter's "额度不足". Providers in this set get the raw error text as
* the match body instead. EXCLUSIVE allowlist by owner decision (2026-08-13): * the match body instead. EXCLUSIVE allowlist by owner decision (2026-08-13):
* adding a provider here is an explicit opt-in — the default path for every * adding a provider here is an explicit opt-in — the default path for every
* other provider must remain byte-for-byte unchanged. * other provider must remain byte-for-byte unchanged.
*
* Operator-declared rules bypass this allowlist entirely (see
* `hasOperatorRuleForProvider`): the operator's `match` is a literal substring
* of the error body by construction, so a rule that never sees body text could
* never match anything, defeating the point of declaring it.
*/ */
const FULL_TEXT_RULE_PROVIDERS = new Set(["agentrouter"]); const FULL_TEXT_RULE_PROVIDERS = new Set(["agentrouter"]);
/**
* True when an operator has declared at least one rule for this provider via
* `settings.providerErrorRules` (injected through `setOperatorProviderErrorRules`).
* Presence of the rule IS the opt-in — no separate allowlist to maintain, and
* no widening decision needed as new operators configure new providers.
*/
export function hasOperatorRuleForProvider(provider: string | null | undefined): boolean {
if (!provider) return false;
const rules = operatorProviderErrorRules[provider.toLowerCase()];
return !!rules && rules.length > 0;
}
/** /**
* Resolve the body handed to getProviderErrorRuleMatch inside * Resolve the body handed to getProviderErrorRuleMatch inside
* checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS, * checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS or any
* the structured error for everyone else. * provider with an operator-declared rule, the structured error for everyone
* else.
*/ */
export function resolveRuleMatchBody( export function resolveRuleMatchBody(
provider: string | null | undefined, provider: string | null | undefined,
structuredError: unknown, structuredError: unknown,
errorText: string | null | undefined errorText: string | null | undefined
): unknown { ): unknown {
if (provider && FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) && errorText) { if (
provider &&
(FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) ||
hasOperatorRuleForProvider(provider)) &&
errorText
) {
return errorText; return errorText;
} }
return structuredError ?? null; return structuredError ?? null;
@@ -346,10 +421,32 @@ export function getProviderErrorRuleMatch(
provider: string | null | undefined, provider: string | null | undefined,
status: number, status: number,
headers: Headers | Record<string, string> | null | undefined, headers: Headers | Record<string, string> | null | undefined,
body?: unknown body?: unknown,
operatorRules?: Record<string, OperatorProviderErrorRule[]>
): ProviderErrorRuleMatch | null { ): ProviderErrorRuleMatch | null {
if (!provider) return null; if (!provider) return null;
const rules = providerRuleRegistry.get(provider.toLowerCase()); const key = provider.toLowerCase();
// Operator-declared rules win first: an operator can override any catalog
// rule for a provider without editing this file. `operatorRules` is the
// injected source (tests / direct callers); when omitted we fall back to the
// settings-backed cache populated by `setOperatorProviderErrorRules`.
const opRules = (operatorRules ?? operatorProviderErrorRules)?.[key];
if (opRules && opRules.length > 0) {
const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
const lowered = text.toLowerCase();
for (const r of opRules) {
if (r.status === status && lowered.includes(r.match.toLowerCase())) {
return {
reason: r.reason ?? "quota_exhausted",
scope: r.scope,
cooldownMs: r.cooldownMs,
};
}
}
}
const rules = providerRuleRegistry.get(key);
if (!rules) return null; if (!rules) return null;
// Normalize headers: accept either a `Headers` object (from `fetch()`) or // Normalize headers: accept either a `Headers` object (from `fetch()`) or
// a plain record. Provider rules access headers via plain object indexing. // a plain record. Provider rules access headers via plain object indexing.

View File

@@ -10,6 +10,7 @@ export {
} from "./providers/registry/alibaba/index.ts"; } from "./providers/registry/alibaba/index.ts";
export { REGISTRY } from "./providers/index.ts"; export { REGISTRY } from "./providers/index.ts";
import { REGISTRY } from "./providers/index.ts"; import { REGISTRY } from "./providers/index.ts";
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
import { import {
RegistryModel, RegistryModel,
REASONING_UNSUPPORTED, REASONING_UNSUPPORTED,
@@ -132,11 +133,8 @@ export function isLocalProvider(baseUrl?: string | null): boolean {
try { try {
const url = new URL(baseUrl); const url = new URL(baseUrl);
const hostname = url.hostname; const hostname = url.hostname;
// Strictly matching 172.16.0.0/12 (Docker/local) and explicitly blocking ::1 per SSRF hardening if (!hostname) return false;
return ( return LOCAL_HOSTNAMES.has(hostname) || isPrivateHost(hostname);
LOCAL_HOSTNAMES.has(hostname) ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname)
);
} catch { } catch {
return false; return false;
} }

View File

@@ -70,7 +70,6 @@ import { togetherProvider } from "./registry/together/index.ts";
import { cohereProvider } from "./registry/cohere/index.ts"; import { cohereProvider } from "./registry/cohere/index.ts";
import { cursorProvider, cursor_apiProvider } from "./registry/cursor/index.ts"; import { cursorProvider, cursor_apiProvider } from "./registry/cursor/index.ts";
import { volcengineProvider } from "./registry/volcengine/index.ts"; import { volcengineProvider } from "./registry/volcengine/index.ts";
import { hackclubProvider } from "./registry/hackclub/index.ts";
import { freetheaiProvider } from "./registry/freetheai/index.ts"; import { freetheaiProvider } from "./registry/freetheai/index.ts";
import { g4f_groqProvider } from "./registry/g4f-groq/index.ts"; import { g4f_groqProvider } from "./registry/g4f-groq/index.ts";
import { g4f_geminiProvider } from "./registry/g4f-gemini/index.ts"; import { g4f_geminiProvider } from "./registry/g4f-gemini/index.ts";
@@ -336,7 +335,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
cursor: cursorProvider, cursor: cursorProvider,
"cursor-api": cursor_apiProvider, "cursor-api": cursor_apiProvider,
volcengine: volcengineProvider, volcengine: volcengineProvider,
hackclub: hackclubProvider,
freetheai: freetheaiProvider, freetheai: freetheaiProvider,
"g4f-groq": g4f_groqProvider, "g4f-groq": g4f_groqProvider,
"g4f-gemini": g4f_geminiProvider, "g4f-gemini": g4f_geminiProvider,

View File

@@ -5,6 +5,12 @@ export const blackboxProvider: RegistryEntry = {
alias: "bb", alias: "bb",
format: "openai", format: "openai",
executor: "default", executor: "default",
// NOTE: api.blackbox.ai returns HTTP 404 on /v1/chat/completions and /v1/models
// (empty body, all path variants) since sweep 2026-08-21; the public inference
// surface has moved to the gated enterprise.blackbox.ai/v1 endpoint. The provider
// is marked deprecated in src/shared/constants/providers/apikey/frontier-labs.ts —
// this registry entry is kept intact (registration/execution unaffected), so
// existing configured keys keep working if a restored/enterprise host is reachable.
baseUrl: "https://api.blackbox.ai/v1/chat/completions", baseUrl: "https://api.blackbox.ai/v1/chat/completions",
modelsUrl: "https://api.blackbox.ai/v1/models", modelsUrl: "https://api.blackbox.ai/v1/models",
authType: "apikey", authType: "apikey",

View File

@@ -27,7 +27,7 @@ export const clineProvider: RegistryEntry = {
// the official free bucket and text-output models advertised as zero-cost. // the official free bucket and text-output models advertised as zero-cost.
models: [ models: [
{ {
id: "zai/glm-5.2", id: "z-ai/glm-5.2",
name: "GLM 5.2", name: "GLM 5.2",
toolCalling: true, toolCalling: true,
supportsReasoning: true, supportsReasoning: true,

View File

@@ -8,7 +8,11 @@ export const command_codeProvider: RegistryEntry = {
format: "openai", format: "openai",
executor: "command-code", executor: "command-code",
baseUrl: "https://api.commandcode.ai", baseUrl: "https://api.commandcode.ai",
chatPath: "/alpha/generate", // Chat uses the documented /provider/v1/chat/completions (OpenAI-format)
// endpoint — NOT the CLI-only /alpha/generate endpoint, which Command Code
// version-gates and proxy-blocks for external callers (#10265). Discovery
// already targets the sibling /provider/v1/models endpoint.
chatPath: "/provider/v1/chat/completions",
modelsUrl: "https://api.commandcode.ai/provider/v1/models", modelsUrl: "https://api.commandcode.ai/provider/v1/models",
// The discovery response is a partial routing catalog; static registry // The discovery response is a partial routing catalog; static registry
// entries omitted from it can still be accepted by the gateway. // entries omitted from it can still be accepted by the gateway.

View File

@@ -5,7 +5,11 @@ export const difyProvider: RegistryEntry = {
alias: "dify", alias: "dify",
format: "openai", format: "openai",
executor: "default", executor: "default",
baseUrl: "https://api.dify.ai/v1/chat/completions", // Dify does not serve /chat/completions — its native completion route is
// POST /v1/chat-messages (validated via the dedicated dify validator, #11002).
// Keep this as the bare API root so route suffixes build correctly and
// self-hosted instances can override the base URL per connection.
baseUrl: "https://api.dify.ai",
authType: "apikey", authType: "apikey",
authHeader: "bearer", authHeader: "bearer",
models: [{ id: "auto", name: "Auto" }], models: [{ id: "auto", name: "Auto" }],

View File

@@ -1,19 +0,0 @@
import type { RegistryEntry } from "../../shared.ts";
export const hackclubProvider: RegistryEntry = {
id: "hackclub",
alias: "hc",
format: "openai",
executor: "default",
baseUrl: "https://ai.hackclub.com/proxy/v1/chat/completions",
modelsUrl: "https://ai.hackclub.com/proxy/v1/models",
authType: "optional",
authHeader: "bearer",
passthroughModels: true,
defaultContextLength: 128000,
models: [
{ id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
{ id: "mistralai/mistral-7b-instruct", name: "Mistral 7B" },
{ id: "deepseek-ai/deepseek-coder-33b", name: "DeepSeek Coder 33B" },
],
};

View File

@@ -16,7 +16,7 @@ export const hailuo_webProvider: RegistryEntry = {
alias: "hailuo-web", alias: "hailuo-web",
format: "openai", format: "openai",
executor: "hailuo-web", executor: "hailuo-web",
baseUrl: "https://www.hailuo.ai", baseUrl: "https://chat.minimax.io",
authType: "apikey", authType: "apikey",
authHeader: "bearer", authHeader: "bearer",
models: HAILUO_WEB_STATIC_MODELS, models: HAILUO_WEB_STATIC_MODELS,

View File

@@ -1,4 +1,5 @@
import type { RegistryEntry } from "../../../shared.ts"; import type { RegistryEntry } from "../../../shared.ts";
import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../../../shared.ts";
export const opencode_goProvider: RegistryEntry = { export const opencode_goProvider: RegistryEntry = {
id: "opencode-go", id: "opencode-go",
@@ -23,9 +24,13 @@ export const opencode_goProvider: RegistryEntry = {
{ id: "glm-5.2", name: "GLM-5.2", supportsReasoning: true }, { id: "glm-5.2", name: "GLM-5.2", supportsReasoning: true },
{ id: "glm-5.2-high", name: "GLM-5.2 (high effort)", supportsReasoning: true }, { id: "glm-5.2-high", name: "GLM-5.2 (high effort)", supportsReasoning: true },
{ id: "glm-5.2-max", name: "GLM-5.2 (max effort)", supportsReasoning: true }, { id: "glm-5.2-max", name: "GLM-5.2 (max effort)", supportsReasoning: true },
...OPENCODE_ZEN_GO_SHARED_MODELS,
// models[0] (glm-5.2) is the dashboard default (LlmChatCard/ProviderTestSlideOver take models[0]).
{ id: "glm-5.1", name: "GLM-5.1" }, { id: "glm-5.1", name: "GLM-5.1" },
{ id: "glm-5", name: "GLM-5" }, { id: "glm-5", name: "GLM-5" },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, // kimi-k2.7-code declared identically on opencode-zen — see OPENCODE_ZEN_GO_SHARED_MODELS.
{ id: "kimi-k2.6", name: "Kimi K2.6" }, { id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "kimi-k2.5", name: "Kimi K2.5" }, { id: "kimi-k2.5", name: "Kimi K2.5" },
// #8353: Kimi K3 base + max-effort alias from the OpenCode Go registry. // #8353: Kimi K3 base + max-effort alias from the OpenCode Go registry.
@@ -89,7 +94,8 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: false, supportsVision: false,
supportsReasoning: true, supportsReasoning: true,
}, },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false }, // qwen3.6-plus / qwen3.5-plus base ids declared identically on opencode-zen — see
// OPENCODE_ZEN_GO_SHARED_MODELS.
{ {
id: "qwen3.6-plus-high", id: "qwen3.6-plus-high",
name: "Qwen3.6 Plus (high effort)", name: "Qwen3.6 Plus (high effort)",
@@ -104,7 +110,6 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: false, supportsVision: false,
supportsReasoning: true, supportsReasoning: true,
}, },
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false },
// #8353: hy3 is the Go-tier base id (distinct from hy3-preview / hy3-free). // #8353: hy3 is the Go-tier base id (distinct from hy3-preview / hy3-free).
{ id: "hy3", name: "Hunyuan3", contextLength: 256000, supportsReasoning: true }, { id: "hy3", name: "Hunyuan3", contextLength: 256000, supportsReasoning: true },
{ {
@@ -138,6 +143,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true, supportsVision: true,
supportsAudio: true, supportsAudio: true,
supportsVideo: true, supportsVideo: true,
targetFormat: "openai-responses",
}, },
{ {
id: "muse-spark-1.2-contributor-minimal", id: "muse-spark-1.2-contributor-minimal",
@@ -148,6 +154,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true, supportsVision: true,
supportsAudio: true, supportsAudio: true,
supportsVideo: true, supportsVideo: true,
targetFormat: "openai-responses",
}, },
{ {
id: "muse-spark-1.2-contributor-low", id: "muse-spark-1.2-contributor-low",
@@ -158,6 +165,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true, supportsVision: true,
supportsAudio: true, supportsAudio: true,
supportsVideo: true, supportsVideo: true,
targetFormat: "openai-responses",
}, },
{ {
id: "muse-spark-1.2-contributor-medium", id: "muse-spark-1.2-contributor-medium",
@@ -168,6 +176,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true, supportsVision: true,
supportsAudio: true, supportsAudio: true,
supportsVideo: true, supportsVideo: true,
targetFormat: "openai-responses",
}, },
{ {
id: "muse-spark-1.2-contributor-high", id: "muse-spark-1.2-contributor-high",
@@ -178,6 +187,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true, supportsVision: true,
supportsAudio: true, supportsAudio: true,
supportsVideo: true, supportsVideo: true,
targetFormat: "openai-responses",
}, },
{ {
id: "muse-spark-1.2-contributor-xhigh", id: "muse-spark-1.2-contributor-xhigh",
@@ -188,6 +198,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true, supportsVision: true,
supportsAudio: true, supportsAudio: true,
supportsVideo: true, supportsVideo: true,
targetFormat: "openai-responses",
}, },
// #8353: Grok 4.5 + effort tiers from the OpenCode Go registry. // #8353: Grok 4.5 + effort tiers from the OpenCode Go registry.
{ id: "grok-4.5", name: "Grok 4.5", supportsReasoning: true }, { id: "grok-4.5", name: "Grok 4.5", supportsReasoning: true },

View File

@@ -1,4 +1,5 @@
import type { RegistryEntry } from "../../../shared.ts"; import type { RegistryEntry } from "../../../shared.ts";
import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../../../shared.ts";
export const opencode_zenProvider: RegistryEntry = { export const opencode_zenProvider: RegistryEntry = {
id: "opencode-zen", id: "opencode-zen",
@@ -25,6 +26,10 @@ export const opencode_zenProvider: RegistryEntry = {
supportsReasoning: true, supportsReasoning: true,
interleavedField: "reasoning_content", interleavedField: "reasoning_content",
}, },
...OPENCODE_ZEN_GO_SHARED_MODELS,
// models[0] (big-pickle) is the dashboard default; SHARED spread kept after it.
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }, { id: "gpt-5.6-sol", name: "GPT 5.6 Sol" },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra" }, { id: "gpt-5.6-terra", name: "GPT 5.6 Terra" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna" }, { id: "gpt-5.6-luna", name: "GPT 5.6 Luna" },
@@ -51,7 +56,27 @@ export const opencode_zenProvider: RegistryEntry = {
{ id: "grok-4.6", name: "Grok 4.6" }, { id: "grok-4.6", name: "Grok 4.6" },
// ── Muse ─────────────────────────────────────────────────── // ── Muse ───────────────────────────────────────────────────
{ id: "muse-spark-1.2", name: "Muse Spark 1.2" }, // Muse Spark is served by OpenCode Zen only on the OpenAI Responses API
// endpoint, not /chat/completions (see the opencode provider's own
// muse-spark entries, #10874/#10867) — this provider is a separate
// registry entry for the same upstream and never got the same
// targetFormat declaration, so requests routed here still hit
// /chat/completions with a mismatched or unanswerable body and the
// upstream returns an empty message.
{
id: "muse-spark-1.2",
name: "Muse Spark 1.2",
supportsReasoning: true,
targetFormat: "openai-responses",
},
// Explicit wire-format overlay of the base opencode provider's muse-spark entry
// (targetFormat: openai-responses). Keep in sync with base on catalog syncs.
{
id: "muse-spark-1.2-contributor-free",
name: "Muse Spark 1.2 Contributor Free",
supportsReasoning: true,
targetFormat: "openai-responses",
},
// ── DeepSeek ──────────────────────────────────────────────── // ── DeepSeek ────────────────────────────────────────────────
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
@@ -66,7 +91,7 @@ export const opencode_zenProvider: RegistryEntry = {
// ── Kimi / Moonshot ──────────────────────────────────────── // ── Kimi / Moonshot ────────────────────────────────────────
{ id: "kimi-k3", name: "Kimi K3" }, { id: "kimi-k3", name: "Kimi K3" },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, // kimi-k2.7-code declared identically on opencode-go — see OPENCODE_ZEN_GO_SHARED_MODELS.
// ── Qwen ─────────────────────────────────────────────────── // ── Qwen ───────────────────────────────────────────────────
// Issue #2292: Qwen models return Claude-format SSE bodies even // Issue #2292: Qwen models return Claude-format SSE bodies even
@@ -74,8 +99,8 @@ export const opencode_zenProvider: RegistryEntry = {
// through /messages and the Claude translator. // through /messages and the Claude translator.
// Issue #2822: These models are text-only — supportsVision: false // Issue #2822: These models are text-only — supportsVision: false
// ensures combo routing skips them on image-bearing requests. // ensures combo routing skips them on image-bearing requests.
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false }, // qwen3.5-plus / qwen3.6-plus declared identically on opencode-go — see
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false }, // OPENCODE_ZEN_GO_SHARED_MODELS.
// ── Free Tier ────────────────────────────────────────────── // ── Free Tier ──────────────────────────────────────────────
// #6998 (2026-07-14): upstream free tier rotated — minimax-m2.5-free, // #6998 (2026-07-14): upstream free tier rotated — minimax-m2.5-free,

View File

@@ -25,6 +25,7 @@ import {
GLMT_TIMEOUT_MS, GLMT_TIMEOUT_MS,
GLM_SHARED_MODELS, GLM_SHARED_MODELS,
} from "../glmProvider.ts"; } from "../glmProvider.ts";
import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../opencodeZenGoSharedModels.ts";
import { MARITALK_DEFAULT_BASE_URL } from "../maritalk.ts"; import { MARITALK_DEFAULT_BASE_URL } from "../maritalk.ts";
import { import {
CURSOR_REGISTRY_VERSION, CURSOR_REGISTRY_VERSION,
@@ -719,6 +720,7 @@ export {
GLM_TIMEOUT_MS, GLM_TIMEOUT_MS,
GLMT_TIMEOUT_MS, GLMT_TIMEOUT_MS,
GLM_SHARED_MODELS, GLM_SHARED_MODELS,
OPENCODE_ZEN_GO_SHARED_MODELS,
MARITALK_DEFAULT_BASE_URL, MARITALK_DEFAULT_BASE_URL,
CURSOR_REGISTRY_VERSION, CURSOR_REGISTRY_VERSION,
getAntigravityProviderHeaders, getAntigravityProviderHeaders,

View File

@@ -10,6 +10,8 @@
* perplexity-search reuses credentials from the "perplexity" chat provider. * perplexity-search reuses credentials from the "perplexity" chat provider.
*/ */
import { isProviderBlockedByIdOrAlias } from "@/shared/utils/noAuthProviders";
export interface SearchProviderConfig { export interface SearchProviderConfig {
id: string; id: string;
name: string; name: string;
@@ -394,16 +396,18 @@ export function supportsSearchType(
/** /**
* Get all search providers as a flat list * Get all search providers as a flat list
*/ */
export function getAllSearchProviders(): Array<{ export function getAllSearchProviders(blockedProviders: string[] = []): Array<{
id: string; id: string;
name: string; name: string;
searchTypes: string[]; searchTypes: string[];
}> { }> {
return Object.values(SEARCH_PROVIDERS).map((p) => ({ return Object.values(SEARCH_PROVIDERS)
id: p.id, .filter((p) => !p.disabled && !isProviderBlockedByIdOrAlias(p.id, blockedProviders))
name: p.name, .map((p) => ({
searchTypes: p.searchTypes, id: p.id,
})); name: p.name,
searchTypes: p.searchTypes,
}));
} }
/** /**

View File

@@ -20,6 +20,10 @@ import {
recordLearnedThinkingCap, recordLearnedThinkingCap,
parseThinkingBudgetMax, parseThinkingBudgetMax,
} from "../services/learnedThinkingCaps.ts"; } from "../services/learnedThinkingCaps.ts";
import {
recordLearnedReasoningEffort,
parseReasoningEffortEnum,
} from "../services/learnedReasoningEffortCaps.ts";
import { import {
getParamFilterConfig, getParamFilterConfig,
addParamToBlocklist, addParamToBlocklist,
@@ -104,6 +108,12 @@ import {
import { applyPeerTraceHeader } from "@/shared/resilience/peerRouting"; import { applyPeerTraceHeader } from "@/shared/resilience/peerRouting";
import { applyClineProtocolHeaders } from "@/shared/utils/clineAuth"; import { applyClineProtocolHeaders } from "@/shared/utils/clineAuth";
import { isProbeContext } from "@/shared/utils/probeOrigin"; import { isProbeContext } from "@/shared/utils/probeOrigin";
import {
parseAndValidatePublicUrl,
parseAndValidateNonMetadataUrl,
} from "@/shared/network/outboundUrlGuard";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
import { isLocalProvider, isSelfHostedChatProvider } from "@/shared/constants/providers";
// Header helpers extracted to a pure leaf; re-exported for external importers // Header helpers extracted to a pure leaf; re-exported for external importers
// (executors + tests) that import them from "./base.ts". // (executors + tests) that import them from "./base.ts".
export { export {
@@ -397,6 +407,29 @@ export class BaseExecutor {
return fallback || this.config.baseUrl || ""; return fallback || this.config.baseUrl || "";
} }
/**
* SSRF guard for the runtime dispatch path (GHSA-4f49-hj64-448x). A persisted,
* caller-supplied `providerSpecificData.baseUrl` reaches the fetch() calls
* below, so a `manage`-scope actor (or, on a keyless install, an anonymous
* one) could point a provider at loopback / internal / cloud-metadata hosts
* and exfiltrate the stored upstream key. Mirror the provider VALIDATION
* guard so runtime dispatch makes the same decision the validation layer
* already makes: local / self-hosted providers are exempt (they legitimately
* use private URLs, and the OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS opt-in still
* applies through the guard), and for everything else `public-only` mode
* blocks private + metadata while the default `block-metadata` mode blocks the
* cloud-metadata IMDS pivot. Throws on a blocked URL.
*/
protected assertOutboundUrlAllowed(url: string): void {
if (!url) return;
if (isLocalProvider(this.provider) || isSelfHostedChatProvider(this.provider)) return;
if (getProviderValidationGuard() === "public-only") {
parseAndValidatePublicUrl(url);
return;
}
parseAndValidateNonMetadataUrl(url);
}
/** /**
* Alternate protocol selected on this connection, if the provider declares one * Alternate protocol selected on this connection, if the provider declares one
* that matches. Centralizes the registry lookup so every call-site resolves the * that matches. Centralizes the registry lookup so every call-site resolves the
@@ -615,6 +648,7 @@ export class BaseExecutor {
async countTokens({ model, body, credentials, signal, log }: CountTokensInput) { async countTokens({ model, body, credentials, signal, log }: CountTokensInput) {
const url = this.buildCountTokensUrl(model, credentials); const url = this.buildCountTokensUrl(model, credentials);
if (!url) return null; if (!url) return null;
this.assertOutboundUrlAllowed(url); // GHSA-4f49
const headers = this.buildHeaders(credentials, false); const headers = this.buildHeaders(credentials, false);
const requestBody = const requestBody =
@@ -796,6 +830,9 @@ export class BaseExecutor {
// loop. The learned cap is also recorded process-wide via // loop. The learned cap is also recorded process-wide via
// recordLearnedThinkingCap so future requests skip the 400 entirely. // recordLearnedThinkingCap so future requests skip the 400 entirely.
let thinkingBudgetClampedMax: number | null = null; let thinkingBudgetClampedMax: number | null = null;
// Set by the reasoning_effort 4xx clamp-and-retry below — guards the same
// "fires at most once per URL" invariant as thinkingBudgetClampedMax above.
let reasoningEffortClamped = false;
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const requestCredentials = withForcedResponsesUpstream( const requestCredentials = withForcedResponsesUpstream(
@@ -869,6 +906,9 @@ export class BaseExecutor {
// Timeout only covers response start; stream stalls are handled downstream. // Timeout only covers response start; stream stalls are handled downstream.
const fetchStartTimeoutMs = this.getTimeoutMs(); const fetchStartTimeoutMs = this.getTimeoutMs();
const fetchWithStartTimeout = async (requestUrl: string, requestOptions: RequestInit) => { const fetchWithStartTimeout = async (requestUrl: string, requestOptions: RequestInit) => {
// GHSA-4f49: guard here (not only next to the first buildUrl) so retries
// and fallback URLs are validated too, before any bytes leave the host.
this.assertOutboundUrlAllowed(requestUrl);
const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null; const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null;
let timeoutId: ReturnType<typeof setTimeout> | null = null; let timeoutId: ReturnType<typeof setTimeout> | null = null;
if (timeoutController) { if (timeoutController) {
@@ -1496,6 +1536,49 @@ export class BaseExecutor {
} }
} }
// Reasoning-effort enum 4xx clamp-and-retry (any provider/model without a
// declared reasoning_effort capability — custom OpenAI-compatible
// connections, or a registered provider the registry hasn't caught up
// with). Mirrors the thinking_budget clamp-and-retry above: parse the
// upstream-advertised accepted values, record them process-wide (so
// FUTURE requests clamp proactively via sanitizeReasoningEffortForProvider
// → getLearnedReasoningEffort), clamp the live transformedBody by
// re-running the sanitizer, and retry the same URL once.
if (
(response.status === HTTP_STATUS.BAD_REQUEST ||
response.status === HTTP_STATUS.UNPROCESSABLE_ENTITY) &&
!reasoningEffortClamped &&
transformedBody &&
typeof transformedBody === "object"
) {
const errText = await response
.clone()
.text()
.catch(() => "");
const acceptedValues = parseReasoningEffortEnum(errText);
if (acceptedValues) {
reasoningEffortClamped = true;
const learned = recordLearnedReasoningEffort(this.provider, model, acceptedValues);
if (learned) {
transformedBody = sanitizeReasoningEffortForProvider(
transformedBody,
this.provider,
model,
log
);
let retryBody = JSON.stringify(transformedBody);
if (usesClaudeCodeProtocol || this.provider === "claude") {
retryBody = await signRequestBody(retryBody);
}
log?.info?.(
"REASONING_SANITIZE",
`Upstream ${response.status} rejected reasoning_effort on ${url} — clamped to ${learned} and retrying (learned for ${this.provider}/${model})`
);
response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody });
}
}
}
// Generic reactive 400 field-downgrade; each field is stripped at most once. // Generic reactive 400 field-downgrade; each field is stripped at most once.
if ( if (
response.status === HTTP_STATUS.BAD_REQUEST && response.status === HTTP_STATUS.BAD_REQUEST &&

View File

@@ -8,6 +8,10 @@ import {
getProviderModel, getProviderModel,
getProviderModels, getProviderModels,
} from "../../config/providerModels.ts"; } from "../../config/providerModels.ts";
import {
getLearnedReasoningEffort,
REASONING_EFFORT_ORDER,
} from "../../services/learnedReasoningEffortCaps.ts";
/** /**
* Sanitize reasoning_effort for providers that don't accept all values. * Sanitize reasoning_effort for providers that don't accept all values.
@@ -338,10 +342,24 @@ export function sanitizeReasoningEffortForProvider(
const supportsXHigh = supportsXHighEffort(provider, modelStr); const supportsXHigh = supportsXHighEffort(provider, modelStr);
const supportsMax = supportsMaxEffortForProvider(provider, modelStr); const supportsMax = supportsMaxEffortForProvider(provider, modelStr);
// Highest value we've actually seen this provider+model accept in a real
// upstream 4xx (learnedReasoningEffortCaps.ts) — takes priority over the
// static registry (which defaults to "supports everything" when there's no
// entry, e.g. custom OpenAI-compatible connections) and over the hardcoded
// "high" fallback below (which isn't always valid either).
const learnedCap = getLearnedReasoningEffort(provider, modelStr);
const learnedRank = learnedCap ? REASONING_EFFORT_ORDER.indexOf(learnedCap) : -1;
// ── xhigh handling ────────────────────────────────────────────────────── // ── xhigh handling ──────────────────────────────────────────────────────
// xhigh is OmniRoute-internal. Map it to the best effort the model accepts. // xhigh is OmniRoute-internal. Map it to the best effort the model accepts.
if (effortStr === "xhigh") { if (effortStr === "xhigh") {
if (learnedCap && learnedRank < REASONING_EFFORT_ORDER.indexOf("xhigh")) {
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: clamped reasoning_effort xhigh → ${learnedCap} (learned)`
);
return writeEffortValue(b, learnedCap, c);
}
if (supportsXHigh) return body; // model accepts xhigh natively if (supportsXHigh) return body; // model accepts xhigh natively
if (supportsMax) { if (supportsMax) {
log?.info?.( log?.info?.(
@@ -366,6 +384,13 @@ export function sanitizeReasoningEffortForProvider(
// upstream, and if it 400s the user gets a clear signal. This prevents // upstream, and if it 400s the user gets a clear signal. This prevents
// new models from being unusable for weeks until they're whitelisted (#8057). // new models from being unusable for weeks until they're whitelisted (#8057).
if (effortStr === "max") { if (effortStr === "max") {
if (learnedCap && learnedRank < REASONING_EFFORT_ORDER.indexOf("max")) {
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: clamped reasoning_effort max → ${learnedCap} (learned)`
);
return writeEffortValue(b, learnedCap, c);
}
if (supportsMax) return body; // explicitly known to accept max if (supportsMax) return body; // explicitly known to accept max
// A model that explicitly advertises its accepted tiers is safe to normalize. // A model that explicitly advertises its accepted tiers is safe to normalize.

File diff suppressed because it is too large Load Diff

View File

@@ -30,17 +30,45 @@ export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const;
export const KEEPALIVE_PING = { type: 6 } as const; export const KEEPALIVE_PING = { type: 6 } as const;
/** /**
* Allowed message types observed in the 2026-08 recapture of the working * Allowed message types observed in a 2026-08-21 live capture of a working
* `m365.cloud.microsoft/chat` client (#10718). The old 11-entry list is no longer * `m365.cloud.microsoft/chat` session (issue: "Stream ended before producing a
* seen on the wire — the stale shape gets closed immediately after the type:4. * non-ping SSE event" on every individual/consumer M365 Copilot call). The
* #10718 6-entry shape above no longer produces a `type:1 target:"update"`
* frame at all — the socket only replies with SignalR keepalive pings and then
* closes, which is exactly what surfaces client-side as that generic stream
* error. 30 entries, up from 6.
*/ */
export const ALLOWED_MESSAGE_TYPES = [ export const ALLOWED_MESSAGE_TYPES = [
"Chat", "Chat",
"Suggestion", "Suggestion",
"InternalSearchQuery",
"Disengaged", "Disengaged",
"Progress",
"EndOfRequest",
"InternalLoaderMessage", "InternalLoaderMessage",
"Progress",
"GeneratedCode",
"RenderCardRequest",
"AdsQuery",
"SemanticSerp",
"GenerateContentQuery",
"GenerateGraphicArt",
"SearchQuery",
"ConfirmationCard",
"AuthError",
"DeveloperLogs",
"TriggerPlugin",
"HintInvocation",
"MemoryUpdate",
"EndOfRequest",
"TriggerConfirmation",
"ResumeInvokeAction",
"ResumeUserInputRequest",
"TriggerUserInputRequest",
"EscapeHatch",
"TriggerPluginAuth",
"ResumePluginAuth",
"SideBySide",
"ReferencesListComplete",
"SwitchRespondingEndpoint",
] as const; ] as const;
/** /**
@@ -78,19 +106,26 @@ export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [
] as const; ] as const;
/** /**
* Individual / EDU option sets from the 2026-08 recapture (#10718)14 entries. * Individual / EDU option sets from a 2026-08-21 live capture34 entries, up
* The previous 25-entry consumer/MSA set (enable_msa_user, pdnascan, cwc_code_*, * from the #10718 14-entry shape (which itself superseded an earlier 25-entry
* …) is no longer observed on the wire and belongs to the shape the substrate * shape). Each recapture so far has been additive/reshuffled rather than a
* now drops silently. * wholesale replacement — treat this as the protocol continuing to drift, not
* a one-time fix; a future capture may again need to update this list.
*/ */
export const M365_DEFAULT_OPTION_SETS = [ export const M365_DEFAULT_OPTION_SETS = [
"search_result_progress_messages_with_search_queries", "search_result_progress_messages_with_search_queries",
"update_textdoc_response_after_streaming", "update_textdoc_response_after_streaming",
"deepleo_networking_timeout_10minutes_canmore", "deepleo_networking_timeout_10minutes_canmore",
"cwc_flux_image", "cwc_flux_image",
"cwc_code_interpreter",
"cwc_code_interpreter_amsfix",
"cwcfluxgptv", "cwcfluxgptv",
"flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch", "flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch",
"gptvnorm2048", "gptvnorm2048",
"cwc_code_interpreter_citation_fix",
"code_interpreter_interactive_charts",
"cwc_code_interpreter_interactive_charts_inline_image",
"code_interpreter_matplotlib_patching",
"cwc_fileupload_odb", "cwc_fileupload_odb",
"update_memory_plugin", "update_memory_plugin",
"add_custom_instructions", "add_custom_instructions",
@@ -98,6 +133,20 @@ export const M365_DEFAULT_OPTION_SETS = [
"flux_v3_progress_messages", "flux_v3_progress_messages",
"enable_batch_token_processing", "enable_batch_token_processing",
"enable_gg_gpt", "enable_gg_gpt",
"async_client_interaction",
"flux_v3_references",
"flux_v3_references_entities",
"flux_v3_references_ci",
"add_filestore_filetype",
"cwc_code_interpreter_citation_sourceannotations",
"cdxcwc_code_interpreter_hallucinated_url_filter",
"flux_v3_image_gen_enable_dimensions",
"flux_v3_image_gen_enable_non_watermarked_storage",
"flux_v3_image_gen_enable_icon_dimensions",
"flux_v3_image_gen_enable_system_text_with_params",
"flux_v3_image_gen_enable_designer_dimensions_meta_prompting_in_system_prompts",
"flux_v3_image_gen_enable_story",
"rich_responses",
] as const; ] as const;
/** Append the record separator to a JSON-serializable frame. */ /** Append the record separator to a JSON-serializable frame. */
@@ -433,12 +482,14 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
} }
return { return {
optionsSets: [...M365_DEFAULT_OPTION_SETS], optionsSets: [...M365_DEFAULT_OPTION_SETS],
// #10718 — the 2026-08 recapture sends tone:"magic" (lowercase) on the // 2026-08-21 capture — the individual/consumer surface now sends "Magic"
// individual/EDU surface; the old "" default is part of the dropped shape. // (capitalized), matching the enterprise tone literal. The #10718
tone: "magic", // lowercase "magic" is part of the shape that gets silently dropped.
tone: "Magic",
allowedMessageTypes: ALLOWED_MESSAGE_TYPES, allowedMessageTypes: ALLOWED_MESSAGE_TYPES,
// Omitted entirely on the individual/EDU wire (see ChatInvocationOptions). // 2026-08-21 capture — disconnectBehavior:"continue" is now present on the
disconnectBehavior: undefined, // individual/consumer wire too, not just enterprise (see ChatInvocationOptions).
disconnectBehavior: "continue",
}; };
} }
@@ -467,16 +518,33 @@ export function resolveToneForModel(model: string | undefined): string | undefin
/** /**
* Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated). * Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated).
* Mirrors the argument shape recaptured from a working `m365.cloud.microsoft/chat` * Base shape from the #10718 recapture (populated `clientInfo` +
* client in 2026-08 (#10718). Notable differences from the pre-#10718 shape: a * `productThreadType:"Office"`, a `conversationId` matching the WS URL query, a
* populated `clientInfo` + `productThreadType:"Office"`, a `conversationId` * rich `message` object), extended per a 2026-08-21 live capture that found the
* matching the WS URL query, a rich `message` object, and no * #10718 shape alone no longer produces a `type:1 target:"update"` frame — the
* `spokenTextMode` / `extraExtensionParameters` / `isSbsSupported` / * socket only replies with keepalive pings and closes. The additions below
* `renderReferencesBehindEOS` / `disconnectBehavior` — none of those are still * (richer `clientInfo`, non-empty `plugins`, `extraExtensionParameters`,
* observed on the wire, and the stale shape gets closed immediately after the * `isSbsSupported`, `renderReferencesBehindEOS`,
* invocation. * `message.connectedFederatedConnections`, and `disconnectBehavior` on every
* tier) are exactly the fields the 2026-08-21 capture had that this shape was
* missing; the #10718 fields (`conversationId`, `productThreadType`,
* `toolChoice`, `message.attachments`) are kept as-is since removing them was
* not verified against a live socket.
*/ */
export function buildChatInvocation(opts: ChatInvocationOptions): Record<string, unknown> { export function buildChatInvocation(opts: ChatInvocationOptions): Record<string, unknown> {
const clientInfo = {
clientAppName: "Office",
clientPlatform: "mcmcopilot-web",
clientEntrypoint: "mcmcopilot-officeweb",
clientSessionId: opts.sessionId,
ProductCategory: "Chat",
clientAppType: "Web",
productEntryPoint: "ChatPanel",
deviceOS: "Windows",
deviceType: "Desktop",
clientPlatformVersion: "10",
};
return { return {
type: 4, type: 4,
target: "chat", target: "chat",
@@ -487,17 +555,17 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
? [...opts.allowedMessageTypes] ? [...opts.allowedMessageTypes]
: [...ALLOWED_MESSAGE_TYPES], : [...ALLOWED_MESSAGE_TYPES],
clientCorrelationId: opts.clientCorrelationId ?? opts.traceId, clientCorrelationId: opts.clientCorrelationId ?? opts.traceId,
clientInfo: { clientInfo,
clientAppName: "Office",
clientPlatform: "mcmcopilot-web",
},
conversationId: opts.conversationId, conversationId: opts.conversationId,
extraExtensionParameters: {},
isStartOfSession: opts.isStartOfSession ?? true, isStartOfSession: opts.isStartOfSession ?? true,
message: { message: {
adaptiveCards: [], adaptiveCards: [],
attachments: null, attachments: null,
author: "user", author: "user",
clientInfo,
clientPreferences: {}, clientPreferences: {},
connectedFederatedConnections: ["dummyId"],
entityAnnotationTypes: ["People", "File", "Event", "Email", "TeamsMessage"], entityAnnotationTypes: ["People", "File", "Event", "Email", "TeamsMessage"],
experienceType: "Default", experienceType: "Default",
inputMethod: "Keyboard", inputMethod: "Keyboard",
@@ -510,22 +578,27 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
requestId: opts.requestId, requestId: opts.requestId,
text: opts.text, text: opts.text,
}, },
isSbsSupported: true,
options: {}, options: {},
optionsSets: opts.optionsSets ?? [...M365_DEFAULT_OPTION_SETS], optionsSets: opts.optionsSets ?? [...M365_DEFAULT_OPTION_SETS],
plugins: opts.plugins ?? [], // 2026-08-21 capture (#11069): BingWebSearch is now the universal
// BuiltIn plugin on individual/consumer tier; keep an opt-out override.
plugins: opts.plugins ?? [{ Id: "BingWebSearch", Source: "BuiltIn" }],
...(opts.customInstructions ? { customInstructions: opts.customInstructions } : {}), ...(opts.customInstructions ? { customInstructions: opts.customInstructions } : {}),
productThreadType: "Office", productThreadType: "Office",
renderReferencesBehindEOS: true,
sessionId: opts.sessionId, sessionId: opts.sessionId,
sliceIds: [], sliceIds: [],
source: "officeweb", source: "officeweb",
streamingMode: "ConciseWithPadding", streamingMode: "ConciseWithPadding",
threadLevelGptId: {}, threadLevelGptId: {},
tone: opts.tone ?? "magic", // 2026-08-21 capture (#11069): tone is now capitalized "Magic" on both tiers.
tone: opts.tone ?? "Magic",
toolChoice: opts.toolChoice ?? null, toolChoice: opts.toolChoice ?? null,
traceId: opts.traceId, traceId: opts.traceId,
// #8971 keeps "continue" for the enterprise tier; the individual/EDU wire // 2026-08-21 capture — disconnectBehavior:"continue" is sent on every
// omits the key, so only include it when actually set (#10718). // tier now, not gated to enterprise as the #8971 comment described.
...(opts.disconnectBehavior ? { disconnectBehavior: opts.disconnectBehavior } : {}), disconnectBehavior: opts.disconnectBehavior ?? "continue",
}, },
], ],
}; };

View File

@@ -430,6 +430,7 @@ export class GlmExecutor extends DefaultExecutor {
let response: Response; let response: Response;
try { try {
this.assertOutboundUrlAllowed(url); // GHSA-4f49: glm has its own fetch path
response = await fetch(url, { response = await fetch(url, {
method: "POST", method: "POST",
headers, headers,

View File

@@ -33,7 +33,7 @@ import { createHash } from "node:crypto";
import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { makeExecutorErrorResult as makeErrorResult, sanitizeErrorMessage } from "../utils/error.ts"; import { makeExecutorErrorResult as makeErrorResult, sanitizeErrorMessage } from "../utils/error.ts";
const BASE_URL = "https://www.hailuo.ai"; const BASE_URL = "https://chat.minimax.io";
const API_PATH = "/v4/api/chat/msg"; const API_PATH = "/v4/api/chat/msg";
const USER_AGENT = const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";

View File

@@ -471,6 +471,7 @@ export class NlpCloudExecutor extends BaseExecutor {
} }
try { try {
this.assertOutboundUrlAllowed(url); // GHSA-4f49: nlpcloud has its own fetch path
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers, headers,

View File

@@ -1,6 +1,6 @@
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts"; import { PROVIDERS } from "../config/constants.ts";
import { getModelTargetFormat } from "../config/providerModels.ts"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
import { import {
injectReasoningContentForThinkingModel, injectReasoningContentForThinkingModel,
isThinkingMessageModel, isThinkingMessageModel,
@@ -125,6 +125,24 @@ export function isPremiumOpencodeModel(model: string, provider: string): boolean
return !OPENCODE_FREE_MODELS.has(model); return !OPENCODE_FREE_MODELS.has(model);
} }
/**
* Resolves the registry `targetFormat` for a model, aliasing `provider` first.
*
* `PROVIDER_MODELS` is keyed by the provider's public ALIAS (e.g. `"oc"`), not its
* raw registry id (e.g. `"opencode"`) — mirrors `resolveChatCoreTargetFormat()`
* (`handlers/chatCore/targetFormat.ts`), which already aliases before calling
* `getModelTargetFormat()`. Calling it with the raw id here made every entry miss
* silently (fell through to `"openai"`), while chatCore's own request-body
* translation (correctly aliased) still switched to the Responses API shape for
* `targetFormat:"openai-responses"` models — sending a Responses-shaped body to
* the `/chat/completions` URL this executor's own `buildUrl()` kept selecting.
* Exported for testability.
*/
export function resolveOpencodeTargetFormat(provider: string, model: string): string {
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
return getModelTargetFormat(alias, model) || "openai";
}
export class OpencodeExecutor extends BaseExecutor { export class OpencodeExecutor extends BaseExecutor {
/** Delegates to `isPremiumOpencodeModel`. Exported for testability. */ /** Delegates to `isPremiumOpencodeModel`. Exported for testability. */
static isPremiumModel(model: string, provider: string): boolean { static isPremiumModel(model: string, provider: string): boolean {
@@ -193,7 +211,10 @@ export class OpencodeExecutor extends BaseExecutor {
return pickRotatableAccount(this.accounts, this); return pickRotatableAccount(this.accounts, this);
} }
private markCooldown(account: OpencodeAccountState, kind: "transient" | "terminal" = "transient"): void { private markCooldown(
account: OpencodeAccountState,
kind: "transient" | "terminal" = "transient"
): void {
markAccountCooldown(account, kind); markAccountCooldown(account, kind);
} }
@@ -202,7 +223,7 @@ export class OpencodeExecutor extends BaseExecutor {
} }
async execute(input: ExecuteInput) { async execute(input: ExecuteInput) {
this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai"; this._requestFormat = resolveOpencodeTargetFormat(this.provider, input.model);
// #8681: Gate premium opencode models behind a usable API key. // #8681: Gate premium opencode models behind a usable API key.
// When the connection is keyless (no apiKey, no accessToken) and the model // When the connection is keyless (no apiKey, no accessToken) and the model

View File

@@ -199,6 +199,8 @@ export async function handleRerank({
return_documents, return_documents,
credentials, credentials,
connectionId = null, connectionId = null,
apiKeyId = null,
apiKeyName = null,
}) { }) {
const startTime = Date.now(); const startTime = Date.now();
if (!model) return errorResponse(400, "model is required"); if (!model) return errorResponse(400, "model is required");
@@ -267,10 +269,23 @@ export async function handleRerank({
if (!res.ok) { if (!res.ok) {
const errData = await res.json().catch(() => ({})); const errData = await res.json().catch(() => ({}));
return errorResponse( const errorMessage =
res.status, errData.message || errData.error?.message || `Provider returned HTTP ${res.status}`;
errData.message || errData.error?.message || `Provider returned HTTP ${res.status}` saveCallLog({
); method: "POST",
path: "/v1/rerank",
status: res.status,
model: `${providerId}/${modelId}`,
provider: providerId,
connectionId: connectionId || undefined,
duration: Date.now() - startTime,
requestBody,
responseBody: errData,
error: errorMessage,
apiKeyId: apiKeyId || undefined,
apiKeyName: apiKeyName || undefined,
}).catch(() => {});
return errorResponse(res.status, errorMessage);
} }
const data = await res.json(); const data = await res.json();
@@ -289,10 +304,13 @@ export async function handleRerank({
status: 200, status: 200,
model: `${providerId}/${modelId}`, model: `${providerId}/${modelId}`,
provider: providerId, provider: providerId,
connectionId: connectionId || undefined,
duration: Date.now() - startTime, duration: Date.now() - startTime,
tokens: { prompt_tokens: 0, completion_tokens: 0 }, tokens: { prompt_tokens: 0, completion_tokens: 0 },
responseBody: { results_count: Array.isArray(result?.results) ? result.results.length : 0 }, requestBody,
connectionId, responseBody: result,
apiKeyId: apiKeyId || undefined,
apiKeyName: apiKeyName || undefined,
}).catch(() => {}); }).catch(() => {});
const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" }); const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });

View File

@@ -10,16 +10,24 @@ import {
import { resolveMcpCallerApiKeyId } from "../mcpCallerIdentity.ts"; import { resolveMcpCallerApiKeyId } from "../mcpCallerIdentity.ts";
/** /**
* Resolve the memory owner id for an MCP tool call: * Resolve the memory owner id for an MCP tool call.
* explicit arg wins, otherwise fall back to the authenticated caller's *
* principal id (HTTP auth headers on SSE/Streamable HTTP transports, * The authenticated caller's principal ALWAYS wins over a caller-supplied
* OMNIROUTE_API_KEY env var on stdio). Keeps MCP-stored memories under * `apiKeyId` — otherwise any MCP caller could read, write, or delete another
* the same owner id that chat-context memory uses, so retrieval in the * principal's memories by putting a different id in the tool arguments
* chat pipeline finds entries written via MCP. * (GHSA-cpv3-xr7r-xf8q, IDOR). The caller is resolved from the per-request HTTP
* auth headers on SSE / Streamable HTTP transports, or from OMNIROUTE_API_KEY on
* stdio. The explicit argument is only honored as a fallback when no caller can
* be resolved (a bare local stdio process with no configured key — already
* trusted), preserving the local-tooling flow. Keeps MCP-stored memories under
* the same owner id that chat-context memory uses, so retrieval in the chat
* pipeline finds entries written via MCP.
*/ */
async function resolveMemoryOwnerId(explicit?: string): Promise<string> { async function resolveMemoryOwnerId(explicit?: string): Promise<string> {
const caller = await resolveMcpCallerApiKeyId().catch(() => undefined);
if (caller) return caller;
if (explicit && explicit.trim() !== "") return explicit.trim(); if (explicit && explicit.trim() !== "") return explicit.trim();
return (await resolveMcpCallerApiKeyId().catch(() => undefined)) || "mcp"; return "mcp";
} }
export const MemorySearchSchema = z.object({ export const MemorySearchSchema = z.object({

View File

@@ -21,7 +21,7 @@ import {
honorsRuleLockScope, honorsRuleLockScope,
} from "../config/providerErrorRules.ts"; } from "../config/providerErrorRules.ts";
import * as rot from "./rotationConfig.ts"; import * as rot from "./rotationConfig.ts";
import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts"; import { getPassthroughProviders, getProviderCategory, isLocalProvider } from "../config/providerRegistry.ts";
import { import {
DEFAULT_RESILIENCE_SETTINGS, DEFAULT_RESILIENCE_SETTINGS,
resolveResilienceSettings, resolveResilienceSettings,
@@ -37,7 +37,7 @@ import {
type FailureKind, type FailureKind,
} from "../../src/shared/utils/classify429"; } from "../../src/shared/utils/classify429";
import { recordProviderSuccess as resetCooldownFailureCount } from "./providerCooldownTracker.ts"; import { recordProviderSuccess as resetCooldownFailureCount } from "./providerCooldownTracker.ts";
import { resolveProviderId } from "../../src/shared/constants/providers"; import { resolveProviderId, isLocalProvider as isLocalProviderId, isSelfHostedChatProvider } from "../../src/shared/constants/providers";
import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints"; import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints";
import { getCodexModelScope } from "../config/codexQuotaScopes.ts"; import { getCodexModelScope } from "../config/codexQuotaScopes.ts";
import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts"; import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts";
@@ -791,12 +791,14 @@ export function hasPerModelQuota(
return connectionPassthroughModels; return connectionPassthroughModels;
} }
if (!provider) return false; if (!provider) return false;
if (getCanonicalLockProvider(provider) === "antigravity") return true; const canonicalId = resolveProviderId(provider);
if (getCanonicalLockProvider(provider) === "codex") return true; if (getCanonicalLockProvider(canonicalId) === "antigravity") return true;
if (provider === "gemini" || provider === "github") return true; if (getCanonicalLockProvider(canonicalId) === "codex") return true;
if (provider === "antigravity" || provider === "agy") return true; if (canonicalId === "gemini" || canonicalId === "github") return true;
if (getPassthroughProviders().has(provider)) return true; if (canonicalId === "antigravity" || canonicalId === "agy") return true;
if (isCompatibleProvider(provider)) return true; if (getPassthroughProviders().has(canonicalId)) return true;
if (isCompatibleProvider(canonicalId)) return true;
if (isLocalProviderId(canonicalId) || isSelfHostedChatProvider(canonicalId)) return true;
return false; return false;
} }

View File

@@ -607,7 +607,7 @@ export async function prepareVirtualAutoComboInputs(
// remaining allowance as a percentage, and a raw ">0" comparison would // remaining allowance as a percentage, and a raw ">0" comparison would
// let a reading of e.g. 0.3% (rounding noise, not real headroom) pass. // let a reading of e.g. 0.3% (rounding noise, not real headroom) pass.
minRemainingAllowance: 1, minRemainingAllowance: 1,
maxStateAgeMs: (settings.autoRefreshProviderQuotaInterval ?? 180) * 1000, maxStateAgeMs: (Number(settings.autoRefreshProviderQuotaInterval) || 180) * 1000,
}); });
if (strictFilteredPool !== pool) pool = strictFilteredPool; if (strictFilteredPool !== pool) pool = strictFilteredPool;

View File

@@ -0,0 +1,126 @@
/**
* Learned Reasoning-Effort Caps — reactive capability memory for providers/models
* OmniRoute has no static registry entry for (custom OpenAI-compatible connections,
* or any registered provider whose registry entry carries no reasoning metadata).
*
* Same shape as `learnedThinkingCaps.ts` (thinking_budget), generalized from a
* numeric budget to an ordinal reasoning_effort scale: on a 4xx whose body
* enumerates the accepted values, `base.ts`'s executor calls
* `recordLearnedReasoningEffort`, which stores the highest recognized value in a
* module-level Map keyed "provider:model" (lowercased). Subsequent requests for
* the same provider+model read the cap via `getLearnedReasoningEffort` (consulted
* by `sanitizeReasoningEffortForProvider` in `executors/base/reasoningEffort.ts`)
* so the 4xx→retry round-trip is paid at most once per process per provider+model.
*
* In-memory only (same operator-accepted tradeoff as the thinking-budget cache):
* restart resets, the first request after a restart may re-learn at the cost of
* one upstream 4xx.
*/
export const REASONING_EFFORT_ORDER: readonly string[] = [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
];
// key: `${provider}:${model}` lowercased → highest value known to be accepted.
const learnedCaps = new Map<string, string>();
function buildKey(provider: string | null | undefined, model: string | null | undefined): string {
const p = typeof provider === "string" ? provider.trim().toLowerCase() : "";
const m = typeof model === "string" ? model.trim().toLowerCase() : "";
if (!p || !m) return "";
return `${p}:${m}`;
}
function rankOf(value: string): number {
return REASONING_EFFORT_ORDER.indexOf(value);
}
/**
* Return the learned cap for provider+model, or null when nothing has been
* learned yet (no upstream 4xx recorded). Keyed case-insensitively.
*/
export function getLearnedReasoningEffort(
provider: string | null | undefined,
model: string | null | undefined
): string | null {
const key = buildKey(provider, model);
if (!key) return null;
return learnedCaps.get(key) ?? null;
}
/**
* Record that `acceptedValues` is the enum the upstream advertised for
* provider+model, and store the highest recognized value as the learned cap.
* Returns the stored value, or null when `acceptedValues` contained no token
* from `REASONING_EFFORT_ORDER` (nothing usable to learn) or the key is unusable.
*
* Always monotonically decreases: if a cap already stored ranks lower than the
* newly computed highest, the stored (lower) value wins and is returned
* unchanged. This keeps a later, laxer-looking response (or a race between
* concurrent requests) from ratcheting the cap back up.
*/
export function recordLearnedReasoningEffort(
provider: string | null | undefined,
model: string | null | undefined,
acceptedValues: string[]
): string | null {
const key = buildKey(provider, model);
if (!key) return null;
let best: string | null = null;
let bestRank = -1;
for (const raw of acceptedValues) {
const rank = rankOf(raw);
if (rank > bestRank) {
bestRank = rank;
best = raw;
}
}
if (best === null) return null;
const existing = learnedCaps.get(key);
if (existing !== undefined && rankOf(existing) <= bestRank) {
return existing; // already learned an equal-or-lower cap; keep it
}
learnedCaps.set(key, best);
return best;
}
// Matches both prose shapes observed: OVH's `@ai-sdk/openai-compatible`
// deserializer ("expected one of `a`, `b`") and a generic vendor prose form
// ("Supported types are a, b, and c").
const LIST_INTRO = /(?:expected one of|supported (?:types|values) are)[:\s]*([^.]+)/i;
/**
* Extract the upstream-advertised accepted reasoning_effort values from a 4xx
* error body. Returns only tokens present in REASONING_EFFORT_ORDER (unknown
* tokens are dropped defensively) in the order they appeared, or null when the
* text names no recognized enum member.
*/
export function parseReasoningEffortEnum(errText: unknown): string[] | null {
if (typeof errText !== "string" || !errText) return null;
const match = LIST_INTRO.exec(errText);
if (!match) return null;
const tokens = match[1]
.split(/,|\band\b|&/i)
.map((t) =>
t
.replace(/`/g, "")
.replace(/\([^)]*\)/g, "")
.trim()
.toLowerCase()
)
.filter((t) => t.length > 0 && REASONING_EFFORT_ORDER.includes(t));
return tokens.length > 0 ? tokens : null;
}
/** Test-only: clear the learned-cap Map between tests. */
export function __test_resetLearnedReasoningEffortCaps(): void {
learnedCaps.clear();
}

View File

@@ -1,5 +1,6 @@
import { REGISTRY } from "../config/providerRegistry.ts"; import { REGISTRY } from "../config/providerRegistry.ts";
import type { ReasoningTransport } from "../config/providerRegistry.ts"; import type { ReasoningTransport } from "../config/providerRegistry.ts";
import { isValidResponsesItemId } from "./responsesItemId.ts";
type JsonRecord = Record<string, unknown>; type JsonRecord = Record<string, unknown>;
@@ -279,13 +280,27 @@ function sanitizeResponsesInput(
if (!hasPlaintext && !hasOpaque && (!hasDisplaySummary(next) || stripOrphanedSummaries)) { if (!hasPlaintext && !hasOpaque && (!hasDisplaySummary(next) || stripOrphanedSummaries)) {
continue; continue;
} }
if (!hasOpaque && typeof next.id === "string") delete next.id; // `id` is only worth keeping on an opaque item with a valid string value —
// non-opaque items don't replay their id, and a malformed value (e.g. `null`,
// observed on opencode/zen) must not survive either way (#11108).
if (!hasOpaque || !isValidResponsesItemId(next.id)) delete next.id;
// Some upstreams (e.g. opencode/zen) omit `summary` entirely on opaque
// reasoning items instead of sending an empty array. Replaying that shape
// verbatim trips strict Responses-API validators that require the field
// to be present on every `input[]` item of type `reasoning` (#11108).
// Plaintext-only items intentionally have no `summary` key and must stay
// untouched.
if (hasOpaque && next.summary === undefined) next.summary = [];
filtered.push(next); filtered.push(next);
continue; continue;
} }
const cloned = { ...record }; const cloned = { ...record };
if (typeof cloned.id === "string") delete cloned.id; // Strip `id` whenever present, valid or not: these items don't need a
// replayed server id, and a malformed one (e.g. `null`, same opencode/zen
// omission pattern as the reasoning branch above) must not survive either
// (#11108).
if (cloned.id !== undefined) delete cloned.id;
filtered.push(cloned); filtered.push(cloned);
} }
return filtered; return filtered;

View File

@@ -1,3 +1,5 @@
import { isValidResponsesItemId } from "./responsesItemId.ts";
type JsonRecord = Record<string, unknown>; type JsonRecord = Record<string, unknown>;
type SanitizeResponsesInputOptions = { type SanitizeResponsesInputOptions = {
dropInternalAssistantMessages?: boolean; dropInternalAssistantMessages?: boolean;
@@ -40,7 +42,12 @@ function sanitizeFunctionName(name: string): string {
} }
function sanitizeInputItemId(record: JsonRecord): JsonRecord { function sanitizeInputItemId(record: JsonRecord): JsonRecord {
if (typeof record.id !== "string") return record; if (record.id === undefined) return record;
if (!isValidResponsesItemId(record.id)) {
const next = { ...record };
delete next.id;
return next;
}
const type = typeof record.type === "string" ? record.type : ""; const type = typeof record.type === "string" ? record.type : "";
const expectedPrefix = SERVER_ITEM_ID_PREFIX_BY_TYPE[type]; const expectedPrefix = SERVER_ITEM_ID_PREFIX_BY_TYPE[type];

View File

@@ -0,0 +1,7 @@
// Shared by reasoningInputPolicy.ts and responsesInputSanitizer.ts: both strip a
// Responses-API `input[]` item's `id` field when it isn't a valid string before
// replay, so a malformed value (e.g. `null`, observed on opencode/zen) never
// survives to trip a strict upstream with "Expected 'id' to be a string." (#11108).
export function isValidResponsesItemId(id: unknown): id is string {
return typeof id === "string";
}

View File

@@ -185,7 +185,21 @@ export interface OpenAiSseScan {
text: string; text: string;
/** True if any `choices[].delta.tool_calls` appeared — NEVER continue those. */ /** True if any `choices[].delta.tool_calls` appeared — NEVER continue those. */
sawToolCall: boolean; sawToolCall: boolean;
/** True if a terminal marker (`[DONE]` or a non-null `finish_reason`) appeared. */ /**
* True only when `tool_calls` appeared in this scan AND its own
* `finish_reason: "tool_calls"` has NOT also appeared in the same scan — i.e. the
* call is still being streamed (arguments may be mid-flight). Once
* `finish_reason: "tool_calls"` closes it, the call is complete, not in flight: the
* client has the full arguments and a truncation past this point only drops
* trailing prose, which continuation can safely recover.
*/
sawToolCallInFlight: boolean;
/**
* True if a terminal marker for the OVERALL stream appeared: `[DONE]`, or a
* `finish_reason` other than `"tool_calls"`. A `finish_reason: "tool_calls"` ends
* that one choice but is not terminal for continuation purposes — the model turn
* (and the client-visible SSE) is still eligible to be resumed past it.
*/
terminal: boolean; terminal: boolean;
/** True if at least one OpenAI-shaped `choices[].delta` was parsed (format gate). */ /** True if at least one OpenAI-shaped `choices[].delta` was parsed (format gate). */
parsedOpenAi: boolean; parsedOpenAi: boolean;
@@ -199,10 +213,11 @@ export interface OpenAiSseScan {
export function scanOpenAiSseText(sse: string): OpenAiSseScan { export function scanOpenAiSseText(sse: string): OpenAiSseScan {
let text = ""; let text = "";
let sawToolCall = false; let sawToolCall = false;
let toolCallFinished = false;
let terminal = false; let terminal = false;
let parsedOpenAi = false; let parsedOpenAi = false;
if (typeof sse !== "string" || sse.length === 0) { if (typeof sse !== "string" || sse.length === 0) {
return { text, sawToolCall, terminal, parsedOpenAi }; return { text, sawToolCall, sawToolCallInFlight: false, terminal, parsedOpenAi };
} }
for (const line of sse.split("\n")) { for (const line of sse.split("\n")) {
const trimmed = line.trimStart(); const trimmed = line.trimStart();
@@ -231,10 +246,17 @@ export function scanOpenAiSseText(sse: string): OpenAiSseScan {
if (Array.isArray(toolCalls) && toolCalls.length > 0) sawToolCall = true; if (Array.isArray(toolCalls) && toolCalls.length > 0) sawToolCall = true;
} }
const finishReason = (choice as { finish_reason?: unknown })?.finish_reason; const finishReason = (choice as { finish_reason?: unknown })?.finish_reason;
if (finishReason != null) terminal = true; if (finishReason === "tool_calls") {
// Ends this one choice, but the overall stream/turn stays continuable —
// never counts as the general terminal marker (see OpenAiSseScan.terminal).
toolCallFinished = true;
} else if (finishReason != null) {
terminal = true;
}
} }
} }
return { text, sawToolCall, terminal, parsedOpenAi }; const sawToolCallInFlight = sawToolCall && !toolCallFinished;
return { text, sawToolCall, sawToolCallInFlight, terminal, parsedOpenAi };
} }
export interface ContinuableBody { export interface ContinuableBody {
@@ -369,7 +391,7 @@ export function createRecoverableStream(
let emittedTail = ""; // raw SSE not yet scanned (awaiting an event boundary) let emittedTail = ""; // raw SSE not yet scanned (awaiting an event boundary)
let emittedText = ""; // assistant text already delivered to the client let emittedText = ""; // assistant text already delivered to the client
let emittedTerminal = false; let emittedTerminal = false;
let emittedToolCall = false; let emittedToolCallInFlight = false;
let emittedParsedOpenAi = false; let emittedParsedOpenAi = false;
// Enqueue to the client and, when continuation is enabled, fold the chunk into the // Enqueue to the client and, when continuation is enabled, fold the chunk into the
@@ -388,7 +410,7 @@ export function createRecoverableStream(
const scan = scanOpenAiSseText(complete); const scan = scanOpenAiSseText(complete);
emittedText += scan.text; emittedText += scan.text;
if (scan.terminal) emittedTerminal = true; if (scan.terminal) emittedTerminal = true;
if (scan.sawToolCall) emittedToolCall = true; if (scan.sawToolCallInFlight) emittedToolCallInFlight = true;
if (scan.parsedOpenAi) emittedParsedOpenAi = true; if (scan.parsedOpenAi) emittedParsedOpenAi = true;
}; };
@@ -402,7 +424,7 @@ export function createRecoverableStream(
continueEnabled && continueEnabled &&
continuations < maxContinuations && continuations < maxContinuations &&
emittedParsedOpenAi && emittedParsedOpenAi &&
!emittedToolCall && !emittedToolCallInFlight &&
!emittedTerminal && !emittedTerminal &&
emittedText.length > 0; emittedText.length > 0;

View File

@@ -201,6 +201,14 @@ export function openaiToOpenAIResponsesRequest(
input.push({ input.push({
type: "reasoning", type: "reasoning",
content: [{ type: "reasoning_text", text: reasoning }], content: [{ type: "reasoning_text", text: reasoning }],
// Strict Responses-API upstreams (e.g. opencode/zen) require `summary`
// on every `input[]` item of type "reasoning", plaintext or opaque —
// omitting it rejects the request with `input[N] missing required
// field summary`. This item is always freshly built from a chat
// client's plaintext reasoning, so there is no source summary to
// preserve; default to an empty array like the replay sanitizer does
// for opaque items in reasoningInputPolicy.ts (#11108).
summary: [],
}); });
} }

View File

@@ -866,21 +866,25 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
function openaiResponsesToOpenAIResponseStream(chunk, state) { function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (!chunk) { if (!chunk) {
if ( // Iterate every still-open call needing schema-aware normalization, not just a
state.currentToolCallNeedsNormalization && // single one — multiple parallel calls can each be pending here if the stream
state.currentToolCallArgsBuffer && // ends before their output_item.done arrives.
state.currentToolCallName const pendingNormalized: Array<{ index: number; argsStr: string }> = [];
) { if (state.toolCallByCallId instanceof Map) {
const toolSchema = state.toolSchemas?.get(state.currentToolCallName); for (const entry of state.toolCallByCallId.values()) {
const argsToEmit = stripEmptyOptionalToolArgs( if (entry.needsNormalization && entry.argsBuffer) {
state.currentToolCallArgsBuffer, const toolSchema = state.toolSchemas?.get(entry.name);
state.currentToolCallName, const argsToEmit = stripEmptyOptionalToolArgs(entry.argsBuffer, entry.name, toolSchema);
toolSchema pendingNormalized.push({
); index: entry.index,
const argsStr = argsStr: typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {}),
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {}); });
state.currentToolCallArgsBuffer = ""; entry.argsBuffer = "";
state.currentToolCallNeedsNormalization = false; entry.needsNormalization = false;
}
}
}
if (pendingNormalized.length > 0) {
state.finishReasonSent = true; state.finishReasonSent = true;
state.finishReason = "tool_calls"; state.finishReason = "tool_calls";
const common = { const common = {
@@ -889,24 +893,21 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
created: state.created, created: state.created,
model: state.model || "gpt-4", model: state.model || "gpt-4",
}; };
return [ const chunks: Record<string, unknown>[] = pendingNormalized.map(({ index, argsStr }) => ({
{ ...common,
...common, choices: [
choices: [ {
{ index: 0,
index: 0, delta: { tool_calls: [{ index, function: { arguments: argsStr } }] },
delta: { finish_reason: null,
tool_calls: [{ index: state.toolCallIndex, function: { arguments: argsStr } }], },
}, ],
finish_reason: null, }));
}, chunks.push({
], ...common,
}, choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
{ });
...common, return chunks;
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
},
];
} }
// Flush: send final chunk with finish_reason // Flush: send final chunk with finish_reason
if (!state.finishReasonSent && state.started) { if (!state.finishReasonSent && state.started) {
@@ -952,7 +953,23 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
state.chatId = `chatcmpl-${Date.now()}`; state.chatId = `chatcmpl-${Date.now()}`;
state.created = Math.floor(Date.now() / 1000); state.created = Math.floor(Date.now() / 1000);
state.toolCallIndex = 0; state.toolCallIndex = 0;
// Kept for computeFinishReason (synthesizeCompletedToolCalls.ts) compatibility —
// that snapshot path mutates it directly and expects it to exist. In a turn with
// multiple parallel calls this only ever reflects the LAST one opened/closed, so
// it must never be used to identify a specific call — only as the "is at least
// one tool call in flight this turn" signal computeFinishReason needs, which
// toolCallIndex > 0 already covers on its own once any call has been added.
state.currentToolCallId = null; state.currentToolCallId = null;
// Per-call state keyed by call_id (replaces the old singular
// currentToolCallId/ArgsBuffer/Name/NeedsNormalization/Deferred fields, which
// assumed only one function_call could ever be in flight at a time).
state.toolCallByCallId = new Map();
// response.function_call_arguments.delta carries `item_id`/`output_index`, not
// `call_id` — resolve either one back to the call_id key used by
// toolCallByCallId (two independent reverse maps, since some upstreams omit
// item_id on delta events but still send output_index).
state.toolCallItemToCallId = new Map();
state.toolCallOutputIndexToCallId = new Map();
} }
// Text content delta // Text content delta
@@ -983,22 +1000,48 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
// Function call started // Function call started
if (eventType === "response.output_item.added" && data.item?.type === "function_call") { if (eventType === "response.output_item.added" && data.item?.type === "function_call") {
const item = data.item; const item = data.item;
state.currentToolCallId = item.call_id || fallbackToolCallId(); const callId = item.call_id || fallbackToolCallId();
state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer // Kept for computeFinishReason (synthesizeCompletedToolCalls.ts) compatibility.
state.currentToolCallDeferred = false; state.currentToolCallId = callId;
const toolName = normalizeToolName(item.name);
// Assign this call's index NOW, at .added, not at .done — two calls opened before
// either closes (a genuine parallel dispatch) must never share an index. Deferred
// (still-nameless) calls are the one exception: they don't claim an index until
// .done resolves a real name, so a call that never gets one never burns a slot
// another call could have used.
let index: number | null = null;
if (toolName) {
index = state.toolCallIndex ?? 0;
state.toolCallIndex = index + 1;
}
if (!(state.toolCallByCallId instanceof Map)) state.toolCallByCallId = new Map();
state.toolCallByCallId.set(callId, {
index,
name: toolName,
argsBuffer: "",
deferred: !toolName,
needsNormalization: toolName === "Agent",
});
if (!(state.toolCallItemToCallId instanceof Map)) state.toolCallItemToCallId = new Map();
if (item.id) state.toolCallItemToCallId.set(item.id, callId);
// `output_index` is a top-level field on every Responses API streamed event
// (response.output_item.added/.done AND function_call_arguments.delta alike) —
// an identifier independent of item_id, for upstreams that omit item_id on delta
// events.
if (!(state.toolCallOutputIndexToCallId instanceof Map)) {
state.toolCallOutputIndexToCallId = new Map();
}
if (data.output_index != null) state.toolCallOutputIndexToCallId.set(data.output_index, callId);
// Track this call_id so response.completed doesn't synthesize a duplicate // Track this call_id so response.completed doesn't synthesize a duplicate
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set(); if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId); state.toolCallIdsSeen.add(callId);
const toolName = normalizeToolName(item.name);
state.currentToolName = toolName; // track for schema lookup at done time
state.currentToolCallName = toolName;
state.currentToolCallNeedsNormalization = toolName === "Agent";
if (!toolName) { if (!toolName) {
// Some Responses providers briefly emit placeholder/empty tool names. // Some Responses providers briefly emit placeholder/empty tool names.
// Defer emission until output_item.done in case the final name is populated there. // Defer emission until output_item.done in case the final name is populated there.
state.currentToolCallDeferred = true;
return null; return null;
} }
@@ -1013,8 +1056,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
delta: { delta: {
tool_calls: [ tool_calls: [
{ {
index: state.toolCallIndex, index,
id: state.currentToolCallId, id: callId,
type: "function", type: "function",
function: { function: {
name: toolName, name: toolName,
@@ -1037,11 +1080,26 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
const argsDelta = data.delta || ""; const argsDelta = data.delta || "";
if (!argsDelta) return null; if (!argsDelta) return null;
state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta; // Resolve which in-flight call this delta belongs to. Try item_id first (the
if (state.currentToolCallDeferred || state.currentToolCallNeedsNormalization) return null; // field the Responses API documents for this event), then output_index (also a
// top-level field on this event, and independent of item_id — covers upstreams
// that omit item_id on delta events but still send output_index). Only once both
// identifying fields are absent/unresolved do we fall back to guessing (the
// single open call, or the most recently opened one as a last resort).
const map = state.toolCallByCallId instanceof Map ? state.toolCallByCallId : null;
let callId = data.item_id ? state.toolCallItemToCallId?.get(data.item_id) : undefined;
if (!callId && data.output_index != null) {
callId = state.toolCallOutputIndexToCallId?.get(data.output_index);
}
if (!callId && map) {
callId = map.size === 1 ? [...map.keys()][0] : state.currentToolCallId;
}
const entry = callId ? map?.get(callId) : undefined;
if (!entry) return null;
// #9168: buffer arguments until output_item.done for schema-aware null normalization // #9168: buffer arguments until output_item.done for schema-aware null normalization
// Previously emitted raw null values for optional enum fields (e.g. isolation: null). // Previously emitted raw null values for optional enum fields (e.g. isolation: null).
entry.argsBuffer = (entry.argsBuffer || "") + argsDelta;
return null; return null;
} }
@@ -1061,13 +1119,30 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
// carry the complete arguments only in output_item.done (no preceding delta events). // carry the complete arguments only in output_item.done (no preceding delta events).
if (eventType === "response.output_item.done" && data.item?.type === "function_call") { if (eventType === "response.output_item.done" && data.item?.type === "function_call") {
const item = data.item; const item = data.item;
const buffered = state.currentToolCallArgsBuffer || ""; const map = state.toolCallByCallId instanceof Map ? state.toolCallByCallId : null;
const currentIndex = state.toolCallIndex; // capture before increment let callId = item.call_id;
const callId = item.call_id || state.currentToolCallId || fallbackToolCallId(); if (!callId && item.id) callId = state.toolCallItemToCallId?.get(item.id);
if (!callId) callId = state.currentToolCallId || fallbackToolCallId();
const trackedEntry = callId ? map?.get(callId) : undefined;
// Some upstreams (e.g. Codex) send the complete payload only in output_item.done,
// with no preceding output_item.added at all — there is no tracked entry to read an
// index from.
const entry = trackedEntry || { index: null, argsBuffer: "", deferred: false };
const buffered = entry.argsBuffer || "";
const toolName = normalizeToolName(item.name); const toolName = normalizeToolName(item.name);
// Claim (and advance) this call's index now if it wasn't assigned at .added — either
// a deferred call whose name has just now resolved, or a Codex-style done-only
// payload that never had an .added at all. A deferred call whose name is STILL empty
// never claims an index (nothing was ever emitted for it either way).
if (entry.index == null && toolName) {
entry.index = state.toolCallIndex ?? 0;
state.toolCallIndex = entry.index + 1;
}
const currentIndex = entry.index;
const toolSchema = state.toolSchemas?.get(toolName); const toolSchema = state.toolSchemas?.get(toolName);
const shouldNormalizeArguments = toolName === "Agent"; const shouldNormalizeArguments = toolName === "Agent";
state.currentToolCallNeedsNormalization = shouldNormalizeArguments;
if (toolName && state.toolCalls instanceof Map) { if (toolName && state.toolCalls instanceof Map) {
const completedArguments = const completedArguments =
@@ -1077,6 +1152,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
toolName, toolName,
toolSchema toolSchema
); );
// Keyed by index, not insertion order — readers that need call order for
// parallel calls closed out of order should sort by this key rather than
// relying on Map iteration order.
state.toolCalls.set(currentIndex, { state.toolCalls.set(currentIndex, {
id: callId, id: callId,
index: currentIndex, index: currentIndex,
@@ -1095,17 +1173,17 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set(); if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
if (callId) state.toolCallIdsSeen.add(callId); if (callId) state.toolCallIdsSeen.add(callId);
if (state.currentToolCallDeferred) { // This call is fully closed — remove it from the in-flight map (bounds the map
state.currentToolCallDeferred = false; // to genuinely in-flight calls, and keeps the single-open-call fallback in the
state.currentToolCallArgsBuffer = ""; // function_call_arguments.delta handler correct for whichever call opens next).
state.currentToolCallId = null; if (map && callId) map.delete(callId);
if (state.currentToolCallId === callId) state.currentToolCallId = null;
if (entry.deferred) {
if (!toolName) { if (!toolName) {
return null; return null;
} }
state.toolCallIndex++;
const terminalArguments = const terminalArguments =
typeof item.arguments === "string" typeof item.arguments === "string"
? item.arguments.length > 0 ? item.arguments.length > 0
@@ -1148,12 +1226,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
}; };
} }
state.toolCallIndex++; const needsNormalization = shouldNormalizeArguments;
state.currentToolCallArgsBuffer = ""; // reset for next tool call
state.currentToolCallId = null;
const needsNormalization = state.currentToolCallNeedsNormalization === true;
state.currentToolCallNeedsNormalization = false;
state.currentToolCallName = "";
// Nullable omission sentinels must be normalized before any argument bytes reach the client. // Nullable omission sentinels must be normalized before any argument bytes reach the client.
// Other tool calls retain immediate argument streaming. // Other tool calls retain immediate argument streaming.

View File

@@ -128,6 +128,75 @@ function tryParseJson(raw: string): unknown {
} }
} }
/**
* Splits a tool_call `arguments` string that is actually multiple back-to-back JSON
* objects glued together with no separator, into its individual object substrings.
*
* Root cause (observed on opencode/muse-spark-1.2-contributor-free via the zen
* provider): some upstreams never vary `index`/`id` across a 2nd/3rd/… tool_call of
* the SAME name emitted in one turn, so every delta in `buildOpenAISummary` above
* resolves to the same accumulator key and `arguments` ends up as N JSON objects
* concatenated with no delimiter — invalid as a single JSON value, but each object is
* individually well-formed. Structural, not provider-specific: applies to whichever
* upstream exhibits the same index-collision streaming bug.
*
* Returns `null` when `raw` is empty, already valid single JSON, or does not scan as
* ≥2 back-to-back valid JSON values — callers must leave `arguments` untouched in
* that case (never regress a value that used to reach the client as-is).
*/
export function splitConcatenatedToolCallArguments(raw: string): string[] | null {
if (!raw) return null;
try {
JSON.parse(raw);
return null; // Already a single valid JSON value — nothing to split.
} catch {
// Fall through to the multi-value scan below.
}
const parts: string[] = [];
let depth = 0;
let inString = false;
let escaped = false;
let start = -1;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (start === -1) {
if (ch === " " || ch === "\n" || ch === "\r" || ch === "\t") continue;
if (ch !== "{" && ch !== "[") return null; // Not a value boundary — bail, leave untouched.
start = i;
}
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === "{" || ch === "[") depth++;
else if (ch === "}" || ch === "]") {
depth--;
if (depth === 0) {
parts.push(raw.slice(start, i + 1));
start = -1;
}
}
}
if (start !== -1 || depth !== 0 || parts.length < 2) return null;
for (const part of parts) {
try {
JSON.parse(part);
} catch {
return null; // One of the scanned segments isn't valid JSON — bail entirely.
}
}
return parts;
}
// ─── Per-format live reducers ──────────────────────────────────────────────── // ─── Per-format live reducers ────────────────────────────────────────────────
// Each reducer mirrors the corresponding build*Summary()'s original for-loop // Each reducer mirrors the corresponding build*Summary()'s original for-loop
// body exactly (ingest = one loop iteration, finalize = the post-loop return), // body exactly (ingest = one loop iteration, finalize = the post-loop return),
@@ -262,7 +331,28 @@ function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
message.reasoning_content = joinedReasoning; message.reasoning_content = joinedReasoning;
} }
const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index); const mergedToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
// Expand any entry whose accumulated `arguments` turned out to be multiple
// concatenated JSON objects (upstream never varied index/id across repeated
// same-name tool_calls) into its own separate tool_calls entries.
const finalToolCalls: ToolCall[] = [];
let nextIndex = 0;
// Normalize tool_call indexes to contiguous 0-based (OpenAI contract).
for (const tc of mergedToolCalls) {
const splitArgs = splitConcatenatedToolCallArguments(tc.function.arguments);
if (!splitArgs) {
finalToolCalls.push({ ...tc, index: nextIndex++ });
continue;
}
for (const [i, args] of splitArgs.entries()) {
finalToolCalls.push({
id: tc.id ? `${tc.id}_split${i}` : null,
index: nextIndex++,
type: tc.type,
function: { name: tc.function.name, arguments: args },
});
}
}
if (finalToolCalls.length > 0) { if (finalToolCalls.length > 0) {
finishReason = "tool_calls"; finishReason = "tool_calls";
message.tool_calls = finalToolCalls; message.tool_calls = finalToolCalls;

72
package-lock.json generated
View File

@@ -18,7 +18,6 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@huggingface/transformers": "^4.2.0",
"@lobehub/icons": "^5.16.0", "@lobehub/icons": "^5.16.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
@@ -61,7 +60,6 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-machine-id": "^1.1.12", "node-machine-id": "^1.1.12",
"omniglyph": "^1.4.0", "omniglyph": "^1.4.0",
"onnxruntime-node": "1.24.3",
"open": "^11.0.1", "open": "^11.0.1",
"ora": "^9.4.1", "ora": "^9.4.1",
"parse5": "^8.0.1", "parse5": "^8.0.1",
@@ -156,9 +154,11 @@
}, },
"optionalDependencies": { "optionalDependencies": {
"@atjsh/llmlingua-2": "3.0.0", "@atjsh/llmlingua-2": "3.0.0",
"@huggingface/transformers": "^4.2.0",
"better-sqlite3": "^13.0.2", "better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20", "js-tiktoken": "^1.0.20",
"keytar": "^7.9.0", "keytar": "^7.9.0",
"onnxruntime-node": "1.24.3",
"sqlite-vec": "^0.1.9", "sqlite-vec": "^0.1.9",
"tls-client-node": "^0.2.0", "tls-client-node": "^0.2.0",
"wreq-js": "^3.0.0" "wreq-js": "^3.0.0"
@@ -4510,6 +4510,7 @@
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
"integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -4518,13 +4519,15 @@
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz",
"integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==",
"license": "Apache-2.0" "license": "Apache-2.0",
"optional": true
}, },
"node_modules/@huggingface/transformers": { "node_modules/@huggingface/transformers": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz",
"integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@huggingface/jinja": "^0.5.6", "@huggingface/jinja": "^0.5.6",
"@huggingface/tokenizers": "^0.1.3", "@huggingface/tokenizers": "^0.1.3",
@@ -9483,30 +9486,35 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"devOptional": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@protobufjs/base64": { "node_modules/@protobufjs/base64": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"devOptional": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@protobufjs/codegen": { "node_modules/@protobufjs/codegen": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"devOptional": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@protobufjs/eventemitter": { "node_modules/@protobufjs/eventemitter": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"devOptional": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@protobufjs/fetch": { "node_modules/@protobufjs/fetch": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"devOptional": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@protobufjs/aspromise": "^1.1.1" "@protobufjs/aspromise": "^1.1.1"
@@ -9516,24 +9524,28 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"devOptional": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@protobufjs/path": { "node_modules/@protobufjs/path": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"devOptional": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@protobufjs/pool": { "node_modules/@protobufjs/pool": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"devOptional": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@protobufjs/utf8": { "node_modules/@protobufjs/utf8": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"devOptional": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
@@ -12737,6 +12749,7 @@
"version": "26.2.0", "version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~8.3.0" "undici-types": "~8.3.0"
@@ -13998,6 +14011,7 @@
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=14.0" "node": ">=14.0"
} }
@@ -14971,7 +14985,8 @@
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT" "license": "MIT",
"optional": true
}, },
"node_modules/bottleneck": { "node_modules/bottleneck": {
"version": "2.19.5", "version": "2.19.5",
@@ -17935,6 +17950,7 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.0", "es-define-property": "^1.0.0",
@@ -17964,6 +17980,7 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"define-data-property": "^1.0.1", "define-data-property": "^1.0.1",
@@ -18064,7 +18081,8 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"license": "MIT" "license": "MIT",
"optional": true
}, },
"node_modules/detect-node-es": { "node_modules/detect-node-es": {
"version": "1.1.0", "version": "1.1.0",
@@ -18908,7 +18926,8 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"license": "MIT" "license": "MIT",
"optional": true
}, },
"node_modules/es6-promisify": { "node_modules/es6-promisify": {
"version": "7.0.0", "version": "7.0.0",
@@ -20400,7 +20419,8 @@
"version": "25.9.23", "version": "25.9.23",
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
"license": "Apache-2.0" "license": "Apache-2.0",
"optional": true
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.4.2", "version": "3.4.2",
@@ -21226,6 +21246,7 @@
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true,
"dependencies": { "dependencies": {
"boolean": "^3.0.1", "boolean": "^3.0.1",
"es6-error": "^4.1.1", "es6-error": "^4.1.1",
@@ -21243,6 +21264,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC", "license": "ISC",
"optional": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@@ -21291,6 +21313,7 @@
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"define-properties": "^1.2.1", "define-properties": "^1.2.1",
@@ -21645,7 +21668,8 @@
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
"license": "ISC" "license": "ISC",
"optional": true
}, },
"node_modules/hachure-fill": { "node_modules/hachure-fill": {
"version": "0.5.2", "version": "0.5.2",
@@ -21679,6 +21703,7 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.0" "es-define-property": "^1.0.0"
@@ -24790,7 +24815,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC" "license": "ISC",
"optional": true
}, },
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
@@ -26654,6 +26680,7 @@
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"escape-string-regexp": "^4.0.0" "escape-string-regexp": "^4.0.0"
}, },
@@ -29425,6 +29452,7 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -29632,7 +29660,8 @@
"version": "1.24.3", "version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
"license": "MIT" "license": "MIT",
"optional": true
}, },
"node_modules/onnxruntime-node": { "node_modules/onnxruntime-node": {
"version": "1.24.3", "version": "1.24.3",
@@ -29640,6 +29669,7 @@
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"win32", "win32",
"darwin", "darwin",
@@ -29656,6 +29686,7 @@
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz",
"integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"flatbuffers": "^25.1.24", "flatbuffers": "^25.1.24",
"guid-typescript": "^1.0.9", "guid-typescript": "^1.0.9",
@@ -29669,13 +29700,15 @@
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0" "license": "Apache-2.0",
"optional": true
}, },
"node_modules/onnxruntime-web/node_modules/onnxruntime-common": { "node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
"version": "1.24.0-dev.20251116-b39e144322", "version": "1.24.0-dev.20251116-b39e144322",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz",
"integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==",
"license": "MIT" "license": "MIT",
"optional": true
}, },
"node_modules/open": { "node_modules/open": {
"version": "11.0.1", "version": "11.0.1",
@@ -30906,7 +30939,8 @@
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
"license": "MIT" "license": "MIT",
"optional": true
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.62.1", "version": "1.62.1",
@@ -31861,6 +31895,7 @@
"version": "7.6.5", "version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@@ -31884,6 +31919,7 @@
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
@@ -33280,6 +33316,7 @@
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true,
"dependencies": { "dependencies": {
"boolean": "^3.0.1", "boolean": "^3.0.1",
"detect-node": "^2.0.4", "detect-node": "^2.0.4",
@@ -33655,7 +33692,8 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"license": "MIT" "license": "MIT",
"optional": true
}, },
"node_modules/send": { "node_modules/send": {
"version": "1.2.1", "version": "1.2.1",
@@ -33688,6 +33726,7 @@
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"type-fest": "^0.13.1" "type-fest": "^0.13.1"
}, },
@@ -33703,6 +33742,7 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"license": "(MIT OR CC0-1.0)", "license": "(MIT OR CC0-1.0)",
"optional": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -34474,7 +34514,8 @@
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause",
"optional": true
}, },
"node_modules/sql.js": { "node_modules/sql.js": {
"version": "1.14.2", "version": "1.14.2",
@@ -36187,6 +36228,7 @@
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-emoji-modifier-base": { "node_modules/unicode-emoji-modifier-base": {

View File

@@ -265,7 +265,6 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@huggingface/transformers": "^4.2.0",
"@lobehub/icons": "^5.16.0", "@lobehub/icons": "^5.16.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
@@ -308,7 +307,6 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-machine-id": "^1.1.12", "node-machine-id": "^1.1.12",
"omniglyph": "^1.4.0", "omniglyph": "^1.4.0",
"onnxruntime-node": "1.24.3",
"open": "^11.0.1", "open": "^11.0.1",
"ora": "^9.4.1", "ora": "^9.4.1",
"parse5": "^8.0.1", "parse5": "^8.0.1",
@@ -343,9 +341,11 @@
}, },
"optionalDependencies": { "optionalDependencies": {
"@atjsh/llmlingua-2": "3.0.0", "@atjsh/llmlingua-2": "3.0.0",
"@huggingface/transformers": "^4.2.0",
"better-sqlite3": "^13.0.2", "better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20", "js-tiktoken": "^1.0.20",
"keytar": "^7.9.0", "keytar": "^7.9.0",
"onnxruntime-node": "1.24.3",
"sqlite-vec": "^0.1.9", "sqlite-vec": "^0.1.9",
"tls-client-node": "^0.2.0", "tls-client-node": "^0.2.0",
"wreq-js": "^3.0.0" "wreq-js": "^3.0.0"

View File

@@ -131,12 +131,12 @@ function runNextBuild() {
} }
export function resolveNextBuildBundlerFlag(baseEnv = process.env) { export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
// Turbopack is the default production bundler (Next 16 stable). Benchmarked on // Turbopack is the default on Node.js; on Bun or when explicitly disabled (=0),
// this codebase: 2-3x faster than the single-threaded webpack pass (17min -> 9min // use Webpack (--webpack) to avoid Turbopack V8 internal worker API mismatches.
// on a 32-core box; ~20min -> 7min on ubuntu-latest), artifact validated if (process.versions.bun || baseEnv.OMNIROUTE_USE_TURBOPACK === "0") {
// end-to-end (standalone smoke + e2e/package/electron CI jobs). Webpack stays as return "--webpack";
// the explicit escape hatch (=0) for bundler-compat regressions. }
return baseEnv.OMNIROUTE_USE_TURBOPACK === "0" ? "--webpack" : "--turbopack"; return "--turbopack";
} }
/** /**

View File

@@ -15,6 +15,12 @@ if (!support.nodeCompatible) {
process.exit(1); process.exit(1);
} }
console.log( if (process.versions.bun) {
`Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).` console.log(
); `Bun ${process.versions.bun} (${support.nodeVersion}) satisfies OmniRoute secure runtime policy.`
);
} else {
console.log(
`Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).`
);
}

View File

@@ -83,8 +83,10 @@ const { dashboardPort } = runtimePorts;
const hostname = process.env.HOST || "0.0.0.0"; const hostname = process.env.HOST || "0.0.0.0";
// Turbopack by default in dev (matches the Next 16 CLI default and the production // Turbopack by default in dev (matches the Next 16 CLI default and the production
// build default in build-next-isolated.mjs); OMNIROUTE_USE_TURBOPACK=0 is the // build default in build-next-isolated.mjs); OMNIROUTE_USE_TURBOPACK=0 is the
// webpack escape hatch. // webpack escape hatch. Under Bun, Turbopack native V8 bindings are unavailable,
const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0"; // so Bun automatically disables Turbopack and uses Webpack.
const isBun = Boolean(process.versions.bun);
const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0" && !isBun;
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID(); process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
// Per-process secret used to prove the trusted peer-IP stamp came from this // Per-process secret used to prove the trusted peer-IP stamp came from this
// server (read by the authz middleware in the same process). See peer-stamp.mjs. // server (read by the authz middleware in the same process). See peer-stamp.mjs.

View File

@@ -1,12 +1,12 @@
--- ---
name: omni-webhooks name: omni-webhooks
description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries. description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.
--- ---
<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten --> <!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->
## Overview ## Overview
Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries. Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.
## Authentication ## Authentication

View File

@@ -291,7 +291,9 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) {
icon="monitor_heart" icon="monitor_heart"
label={t("comboHealthIssues")} label={t("comboHealthIssues")}
value={report.summary.issueCount.toLocaleString()} value={report.summary.issueCount.toLocaleString()}
subValue={t("comboHealthActionable", { count: report.summary.actionableCount })} subValue={t("comboHealthActionable", {
count: report.summary.suggestionCount ?? report.summary.actionableCount ?? 0,
})}
/> />
<MetricBlock <MetricBlock
icon="error" icon="error"

View File

@@ -12,8 +12,13 @@ import {
} from "@/lib/combos/intelligentRouting"; } from "@/lib/combos/intelligentRouting";
import { AI_PROVIDERS } from "@/shared/constants/providers"; import { AI_PROVIDERS } from "@/shared/constants/providers";
function getI18nOrFallback(t: any, key: string, fallback: string) { function getI18nOrFallback(
if (typeof t?.has === "function" && t.has(key)) return t(key); t: any,
key: string,
fallback: string,
values?: Record<string, unknown>
) {
if (typeof t?.has === "function" && t.has(key)) return t(key, values);
return fallback; return fallback;
} }
@@ -94,10 +99,9 @@ export default function IntelligentComboPanel({
const updatedCombo = await response.json(); const updatedCombo = await response.json();
onComboUpdated?.(updatedCombo); onComboUpdated?.(updatedCombo);
notify.success( notify.success(
getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.").replace( getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.", {
"{pack}", pack: modePackId,
modePackId }).replace("{pack}", modePackId)
)
); );
} catch (error: any) { } catch (error: any) {
notify.error(error?.message || "Failed to update mode pack."); notify.error(error?.message || "Failed to update mode pack.");
@@ -184,10 +188,9 @@ export default function IntelligentComboPanel({
</div> </div>
{savingModePack && ( {savingModePack && (
<span className="text-[11px] text-text-muted"> <span className="text-[11px] text-text-muted">
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace( {getI18nOrFallback(t, "savingModePack", "Saving {pack}…", {
"{pack}", pack: savingModePack,
savingModePack }).replace("{pack}", savingModePack)}
)}
</span> </span>
)} )}
</div> </div>

View File

@@ -533,9 +533,9 @@ function getStrategyBadgeClass(strategy) {
return "bg-blue-500/15 text-blue-600 dark:text-blue-400"; return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
} }
function getI18nOrFallback(t, key, fallback) { function getI18nOrFallback(t, key, fallback, values) {
try { try {
if (typeof t.has === "function" && t.has(key)) return t(key); if (typeof t.has === "function" && t.has(key)) return t(key, values);
} catch {} } catch {}
return fallback; return fallback;
} }
@@ -1565,7 +1565,8 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) {
{getI18nOrFallback( {getI18nOrFallback(
t, t,
"recommendationsUpdated", "recommendationsUpdated",
"Recommendations updated for {strategy}." "Recommendations updated for {strategy}.",
{ strategy: strategyLabel }
).replace("{strategy}", strategyLabel)} ).replace("{strategy}", strategyLabel)}
</div> </div>
)} )}

View File

@@ -0,0 +1,165 @@
"use client";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import {
extractM365CredentialFromHar,
describeHarImportExpiry,
type M365HarImportResult,
} from "@/shared/utils/m365HarImport";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
type HarImporter = (text: string) => M365HarImportResult;
// One entry per web-session provider that can offer HAR import. Add a new
// key here (and its own extractor in src/shared/utils/) to support another
// provider — the button renders nothing for any provider not listed.
const HAR_IMPORTERS: Record<string, HarImporter> = {
"copilot-m365-web": extractM365CredentialFromHar,
};
const ERROR_MESSAGE_KEYS: Record<string, [string, string]> = {
notJson: ["harImportErrorNotJson", "That file isn't valid JSON — is it really a .har export?"],
noEntries: ["harImportErrorNoEntries", "This HAR has no network entries recorded."],
noChathubUrl: [
"harImportErrorNoChathubUrl",
"No Copilot chat connection found in this HAR. Send at least one chat message in m365.cloud.microsoft before exporting.",
],
unparsableUrl: [
"harImportErrorUnparsableUrl",
"Found the chat connection, but couldn't read its URL.",
],
missingFields: [
"harImportErrorMissingFields",
"Found the chat connection, but the token was missing from it.",
],
};
export interface HarImportButtonProps {
provider: string;
onImport: (apiKey: string) => void;
}
export default function HarImportButton({ provider, onImport }: HarImportButtonProps) {
const t = useTranslations("providers") as ProviderMessageTranslator;
const importer = HAR_IMPORTERS[provider];
const fileInputRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState<
| { phase: "idle" }
| { phase: "reading" }
| { phase: "error"; message: string }
| { phase: "success"; expiresAt: number | null }
>({ phase: "idle" });
if (!importer) return null;
async function handleFile(file: File | undefined) {
if (!file) return;
setState({ phase: "reading" });
let text: string;
try {
text = await file.text();
} catch {
setState({
phase: "error",
message: providerText(t, "harImportErrorReadFailed", "Couldn't read that file."),
});
return;
}
const result = importer(text);
if (!result.ok) {
const [key, fallback] = ERROR_MESSAGE_KEYS[result.error] ?? [
"harImportErrorUnknown",
"Couldn't extract a credential from that HAR file.",
];
setState({ phase: "error", message: providerText(t, key, fallback) });
return;
}
onImport(result.apiKey);
setState({ phase: "success", expiresAt: result.expiresAt });
}
const expiry = state.phase === "success" ? describeHarImportExpiry(state.expiresAt) : null;
const expiryText =
expiry?.tone === "unknown"
? providerText(t, "harImportStatusUnknownExpiry", "Imported. Couldn't read its expiry.")
: expiry?.tone === "bad"
? providerText(
t,
"harImportStatusExpired",
"Imported, but this token already expired ({minutes}m ago) — export a fresh HAR.",
{ minutes: Math.abs(expiry.minutesRemaining ?? 0) }
)
: expiry?.tone === "warn"
? providerText(
t,
"harImportStatusExpiringSoon",
"Imported — valid for only ~{minutes}m more.",
{ minutes: expiry.minutesRemaining ?? 0 }
)
: expiry?.tone === "ok"
? providerText(t, "harImportStatusValid", "Imported — valid for ~{minutes}m.", {
minutes: expiry.minutesRemaining ?? 0,
})
: null;
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={state.phase === "reading"}
data-testid="har-import-button"
className="inline-flex items-center gap-1.5 rounded border border-border px-2.5 py-1.5 text-xs font-medium text-text-main hover:bg-surface-hover disabled:opacity-50"
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
upload_file
</span>
{state.phase === "reading"
? providerText(t, "harImportButtonBusy", "Importing…")
: providerText(t, "harImportButtonLabel", "Import .har file")}
</button>
<span className="text-xs text-text-muted">
{providerText(
t,
"harImportButtonHint",
"Export from DevTools Network tab after sending at least one chat message."
)}
</span>
<input
ref={fileInputRef}
type="file"
accept=".har,application/json"
data-testid="har-import-input"
className="hidden"
onChange={(event) => {
void handleFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
</div>
{state.phase === "error" && (
<p className="text-xs text-red-600 dark:text-red-400" data-testid="har-import-error">
{state.message}
</p>
)}
{state.phase === "success" && expiryText && (
<p
className={
expiry?.tone === "bad"
? "text-xs text-red-600 dark:text-red-400"
: expiry?.tone === "warn"
? "text-xs text-amber-700 dark:text-amber-300"
: "text-xs text-emerald-700 dark:text-emerald-300"
}
data-testid="har-import-status"
>
{expiryText}
</p>
)}
</div>
);
}

View File

@@ -31,6 +31,7 @@ import {
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials"; import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput"; import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
import WebSessionCredentialGuide from "../WebSessionCredentialGuide"; import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
import HarImportButton from "../HarImportButton";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields"; import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { buildAddProviderSpecificData } from "./connectionProviderSpecificData"; import { buildAddProviderSpecificData } from "./connectionProviderSpecificData";
import { getCommandCodeAuthPhaseLabel } from "./commandCodeAuthPhase"; import { getCommandCodeAuthPhaseLabel } from "./commandCodeAuthPhase";
@@ -155,7 +156,7 @@ export default function AddApiKeyModal({
if (!isOpen || wasOpen) return; if (!isOpen || wasOpen) return;
// On open, reset baseUrl and assign a unique default name so a second API key // On open, reset baseUrl and assign a unique default name so a second API key
// for the same provider doesn't reuse "main" and trigger the backend // for the same provider doesn't reuse "main" and trigger the backend
// name-based upsert that would silently overwrite the first connection (#6499). // name-based upsert that would silently overwrite the first connection (#6499, #11033).
setFormData((current) => ({ setFormData((current) => ({
...current, ...current,
name: computeConnectionDefaultName(existingConnectionCount), name: computeConnectionDefaultName(existingConnectionCount),
@@ -209,13 +210,13 @@ export default function AddApiKeyModal({
? "Freebuff uses an authentic CLI auth token obtained via codebuff CLI login or automated harvester." ? "Freebuff uses an authentic CLI auth token obtained via codebuff CLI login or automated harvester."
: isWebSessionCredential : isWebSessionCredential
? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false) ? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false)
: isLocalSelfHostedProvider : isLocalSelfHostedProvider
? t("localProviderApiKeyOptionalHint", { ? t("localProviderApiKeyOptionalHint", {
provider: localProviderMetadata?.name || providerName || provider || "", provider: localProviderMetadata?.name || providerName || provider || "",
}) })
: apiKeyOptional : apiKeyOptional
? t("apiKeyOptionalHint") ? t("apiKeyOptionalHint")
: undefined; : undefined;
const credentialValidationFailedMessage = isWebSessionCredential const credentialValidationFailedMessage = isWebSessionCredential
? providerText( ? providerText(
t, t,
@@ -750,6 +751,12 @@ export default function AddApiKeyModal({
t={t} t={t}
/> />
)} )}
{provider && (
<HarImportButton
provider={provider}
onImport={(apiKey) => setFormData({ ...formData, apiKey })}
/>
)}
{!isNoAuthWebSessionCredential && ( {!isNoAuthWebSessionCredential && (
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
@@ -757,6 +764,12 @@ export default function AddApiKeyModal({
type="password" type="password"
value={formData.apiKey} value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })} onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter" && !validating && !saving) {
e.preventDefault();
handleValidate();
}
}}
className="flex-1" className="flex-1"
placeholder={apiCredentialPlaceholder} placeholder={apiCredentialPlaceholder}
hint={apiCredentialHint} hint={apiCredentialHint}

View File

@@ -49,6 +49,7 @@ import {
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials"; import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput"; import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
import WebSessionCredentialGuide from "../WebSessionCredentialGuide"; import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
import HarImportButton from "../HarImportButton";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields"; import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { CodexConnectionFields } from "./CodexFingerprintFields"; import { CodexConnectionFields } from "./CodexFingerprintFields";
import { assignEditApiKeyProviderSpecificData } from "./connectionProviderSpecificData"; import { assignEditApiKeyProviderSpecificData } from "./connectionProviderSpecificData";
@@ -909,6 +910,12 @@ export default function EditConnectionModal({
t={t} t={t}
/> />
)} )}
{provider && (
<HarImportButton
provider={provider}
onImport={(apiKey) => setFormData({ ...formData, apiKey })}
/>
)}
{!isNoAuthWebSessionCredential && ( {!isNoAuthWebSessionCredential && (
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input

View File

@@ -4,7 +4,23 @@
// connection. Deriving a unique default from the existing connection count keeps // connection. Deriving a unique default from the existing connection count keeps
// the first connection ("main") backward-compatible while giving each subsequent // the first connection ("main") backward-compatible while giving each subsequent
// one a distinct name ("main-2", "main-3", …). // one a distinct name ("main-2", "main-3", …).
export function computeConnectionDefaultName(existingConnectionCount?: number): string { export function computeConnectionDefaultName(
const count = existingConnectionCount ?? 0; existingConnectionCountOrConnections?: number | string[] | { name?: string }[]
): string {
if (Array.isArray(existingConnectionCountOrConnections)) {
const names = new Set(
existingConnectionCountOrConnections
.map((item) => (typeof item === "string" ? item : item?.name ?? ""))
.filter(Boolean)
);
if (!names.has("main")) return "main";
let index = 2;
while (names.has(`main-${index}`)) {
index++;
}
return `main-${index}`;
}
const count = existingConnectionCountOrConnections ?? 0;
return count <= 0 ? "main" : `main-${count + 1}`; return count <= 0 ? "main" : `main-${count + 1}`;
} }

View File

@@ -17,6 +17,8 @@ import { logRoutingDecision } from "@/lib/a2a/routingLogger";
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming"; import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution"; import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
import { getSettings } from "@/lib/db/settings"; import { getSettings } from "@/lib/db/settings";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
// ============ A2A v1.0 ↔ v0.3 compatibility layer ============ // ============ A2A v1.0 ↔ v0.3 compatibility layer ============
// A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage, // A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage,
@@ -136,14 +138,25 @@ function tokensMatch(provided: string, expected: string): boolean {
return timingSafeEqual(a, b); return timingSafeEqual(a, b);
} }
function authenticate(req: NextRequest): boolean { async function authenticate(req: NextRequest): Promise<boolean> {
// If no API key is configured, allow all requests // /a2a is outside the authz proxy matcher, so the REQUIRE_API_KEY posture the
const configuredKey = process.env.OMNIROUTE_API_KEY; // pipeline enforces for /v1 never ran here — the route accepted every caller
if (!configuredKey) return true; // whenever OMNIROUTE_API_KEY was unset, which is the shipped default
// (GHSA-v54m-6rm3-p565). Apply the same posture directly: when a client key is
// required, demand a valid OmniRoute key; otherwise honor the legacy explicit
// A2A key; otherwise stay keyless (the same local-first default as /v1).
const apiKey = extractApiKey(req);
if (isRequireApiKeyEnabled()) {
return apiKey ? await isValidApiKey(apiKey) : false;
}
const authHeader = req.headers.get("authorization") || ""; const configuredKey = process.env.OMNIROUTE_API_KEY;
const token = authHeader.replace(/^Bearer\s+/i, ""); if (configuredKey) {
return tokensMatch(token, configuredKey); return apiKey ? tokensMatch(apiKey, configuredKey) : false;
}
// No API key required and none configured — allow (keyless local-first).
return true;
} }
// ============ JSON-RPC Helpers ============ // ============ JSON-RPC Helpers ============
@@ -179,7 +192,7 @@ async function rejectIfA2ADisabled(id: string | number | null) {
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
// Auth check // Auth check
if (!authenticate(req)) { if (!(await authenticate(req))) {
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key"); return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
} }

View File

@@ -1,5 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
export const dynamic = "force-dynamic";
import { import {
type CliAgentInfo, type CliAgentInfo,
detectInstalledAgents, detectInstalledAgents,

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { getAllRateLimitStatus } from "@omniroute/open-sse/services/rateLimitManager.ts"; import { getAllRateLimitStatus } from "@omniroute/open-sse/services/rateLimitManager.ts";
import { import {
getStats as getSemaphoreStats, getStats as getSemaphoreStats,

View File

@@ -6,6 +6,7 @@
*/ */
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { getCompressionAnalyticsSummary } from "@/lib/db/compressionAnalytics"; import { getCompressionAnalyticsSummary } from "@/lib/db/compressionAnalytics";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { issueDashboardCsrfToken } from "@/server/authz/csrf"; import { issueDashboardCsrfToken } from "@/server/authz/csrf";

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { jwtVerify } from "jose"; import { jwtVerify } from "jose";

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { getBatch } from "@/lib/localDb"; import { getBatch } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { listBatches } from "@/lib/localDb"; import { listBatches } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { isAuthenticated } from "@/shared/utils/apiAuth"; import { isAuthenticated } from "@/shared/utils/apiAuth";
import { import {
listSemanticCacheEntries, listSemanticCacheEntries,

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { isAuthenticated } from "@/shared/utils/apiAuth"; import { isAuthenticated } from "@/shared/utils/apiAuth";
import { import {
clearReasoningCacheAll, clearReasoningCacheAll,

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { import {
getCacheStats, getCacheStats,
clearCache, clearCache,

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