Compare commits

...

34 Commits

Author SHA1 Message Date
Xiangzhe
a763666c3a chore(skills): regenerate omni-webhooks after provider discovery changes (#11060) 2026-08-22 14:13:55 -03:00
Xiangzhe
b4d7433253 fix(providers): perplexity import filters to Sonar-family models (#11060) 2026-08-22 13:13:32 -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
mymusicmyspace
3caa59107e feat(sse): add STRICT_ZERO_COST opt-in free-access policy (#10965)
5 — freeAccessPolicy "strict" opt-in (default off): verifica candidatos de auto-combo contra estado de quota ao vivo + segurança econômica por conexão antes do dispatch (fail-closed — estado desconhecido/stale/incompleto é excluído). Zero mudança de comportamento com o default "off". 82 testes focados, eslint/prettier/typecheck limpos, docs em docs/routing/STRICT_ZERO_COST.md.
2026-08-21 14:59:11 -03:00
Harvey Doan
9469b9c79e fix(sse): surface bare upstream close as response.failed for Responses clients (#10980)
5 — resolveSilentCloseOutcome tratava bare upstream close para clientes Claude (#7699) e OpenAI chat-completions (#10443), mas clientes Responses-format caíam por ambos os branches e recebiam um close silencioso no meio do stream. Estende o veredito para OPENAI_RESPONSES/OPENAI_RESPONSE: emite response.failed sintético preservando o conteúdo já entregue. 3 novos + 92 testes-irmãos (streamHandler) verdes.
2026-08-21 14:59:07 -03:00
Nguyen Thanh Dat
4ac157b76d fix(opencode): guard the provider block when merging an existing config (#11004)
5 — mergeOpenCodeConfig aplicava guard de objeto na raiz mas espalhava safeConfig.provider sem guard, produzindo configs estruturalmente válidas mas semanticamente corrompidas para provider não-objeto (array vira índices numéricos, string vira caracteres). Aplica o mesmo guard já usado na raiz. TDD, 7 casos novos + 19 testes-irmãos verdes, typecheck/lint limpos.
2026-08-21 14:59:03 -03:00
lamcn1k9
8f0d0a0d03 fix: strip unsupported message metadata for Groq (#11026)
5 — Groq rejeita chat multi-turn quando mensagens de assistant carregam metadata não suportada (model/messageId/sender). stripGroqUnsupportedFields agora remove esses campos, preservando role/content/tool_calls. Fix pequeno e focado, teste novo cobrindo o caso.
2026-08-21 14:58:59 -03:00
Jonathan Bailey
00dfdadf93 fix(combo): resolve effort variants via base model capabilities (#11034)
5 — Variantes de modelo com sufixo de effort (ex: model-max/model-xhigh) são sintetizadas no catálogo a partir de supportedThinkingEfforts do modelo base, sem linha própria no registry — getResolvedModelCapabilities resolvia NULL para tool/vision/context, o que fazia combos com tools reordenar silenciosamente essas variantes para trás. Fix: quando todas as fontes diretas falham, resolve as capacidades a partir do modelo base após strip do sufixo. TDD, 65 testes focados verdes.
2026-08-21 14:58:53 -03:00
233 changed files with 5687 additions and 3066 deletions

View File

@@ -46,6 +46,7 @@ jobs:
env:
PORT: "20128"
INJECTION_GUARD_MODE: block
REQUIRE_API_KEY: "false"
run: |
node dist/server.js > server.log 2>&1 &
echo $! > server.pid
@@ -64,16 +65,20 @@ jobs:
# 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
# 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 \
--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 \
--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)
env:
OMNIROUTE_URL: http://localhost:20128
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
if: always()
run: kill "$(cat server.pid)" || true

View File

@@ -2,6 +2,18 @@
## [Unreleased]
### ✨ New Features
- **feat(sse): STRICT_ZERO_COST** — opt-in, off-by-default `freeAccessPolicy: "strict"` setting
that hard-verifies every auto-combo candidate against live quota state and per-connection
economic safety before it can be dispatched, going beyond `hidePaidModels`'s static catalog
check. Adds curated `hardStopGuaranteed` metadata to `FREE_MODEL_BUDGETS`, a short-TTL quota
cache reusing `getUsageForProvider()`, and a connection-safety guarantee: a candidate backed
by multiple accounts has its `allowedConnectionIds` narrowed to exactly the connections
independently verified `SAFE`, so dispatch can never use an unverified account. An
`excludeTosAvoid` guard (default `false`) is available separately for contractual risk. See
`docs/routing/STRICT_ZERO_COST.md`.
---
## [3.8.50] — TBD

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
> [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**
```bash

View File

@@ -9,10 +9,13 @@ import { discoverPlugins } from "../plugins.mjs";
// (instead of string-interpolating into `execSync`) prevents a malicious plugin
// name like `foo; rm -rf ~` or `` foo`id` `` from being interpreted by the shell.
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 (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 = {}) {
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 [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) {
process.stdout.write(`[omniroute][runtime] npm ${npmArgs.join(" ")}\n`);
process.stdout.write(`[omniroute][runtime] ${displayCmd}\n`);
}
const res = spawnSync(exe, args, {
cwd,

View File

@@ -5,10 +5,14 @@ import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./
async function loadSqlite() {
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 {
return { Database: (await import("better-sqlite3")).default };
return { Database: (await import("better-sqlite3")).default, driver: "better-sqlite3" };
} catch (error) {
return { error };
}
@@ -86,12 +90,14 @@ export function normalizeBunSqliteParams(params) {
export function createSqliteNativeError(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")) {
return new Error(
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again. " +
"Or run: omniroute runtime repair " +
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
`better-sqlite3 native binding is incompatible with this runtime. ` +
`Run \`${rebuildCmd}\` in the OmniRoute project and try again. ` +
`Or run: omniroute runtime repair ` +
`(rebuilds into a user-writable runtime; works without a C++ toolchain).`
);
}
if (
@@ -100,10 +106,9 @@ export function createSqliteNativeError(error) {
message.includes("Cannot find module 'better-sqlite3'")
) {
return new Error(
"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 " +
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
`better-sqlite3 native binding could not be found (no prebuilt addon for this platform). ` +
`Run: omniroute runtime repair ` +
`(rebuilds into a user-writable runtime; works without a C++ toolchain).`
);
}
return error;
@@ -111,7 +116,7 @@ export function createSqliteNativeError(error) {
async function openSqliteDatabase(dbPath, options = {}) {
const loaded = await loadSqlite();
if (process.versions.bun) {
if (loaded.driver === "bun:sqlite" || (process.versions.bun && !loaded.Database)) {
if (options.fileMustExist && !fs.existsSync(dbPath)) {
throw new Error(`SQLite file does not exist: ${dbPath}`);
}

View File

@@ -94,10 +94,15 @@ export function ensureAndroidCacheDir(options = {}) {
*/
export function isFatalInstrumentationHookFailure(text) {
if (!text) return false;
return (
/Unsupported platform:\s*android/i.test(text) ||
/error occurred while loading instrumentation hook/i.test(text)
);
// Next.js wraps ANY throw inside instrumentation.register() with the generic
// "An error occurred while loading instrumentation hook:" prefix, on every
// 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) {
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 secureFloor = getSecureFloorForMajor(parsed.major);
const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false;

View File

@@ -17,7 +17,12 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
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 { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.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.
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", () => {
if (!_notifier || !_notifier.update) return;
if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return;
if (process.env.CI) return;
if (process.argv.includes("--quiet") || process.argv.includes("-q")) return;

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(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(combo): resolve effort-suffixed command-code variants (e.g. `deepseek-v4-flash-max`) to their base model for capability lookups, so tool-bearing combo requests keep the declared priority order instead of reordering behind models with confirmed capabilities

View File

@@ -0,0 +1 @@
- **OpenCode config merge:** stop `mergeOpenCodeConfig` splaying a malformed `provider` block into index keys. The root was already guarded against a non-object; the `provider` branch it spreads one level down was not, so an existing `"provider": ["a", "b"]` merged to `{"0": "a", "1": "b", …}`. Its sibling `mergeOpenCodeConfigText` already refuses the same input.

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

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

View File

@@ -443,21 +443,24 @@
"src/shared/components/ModelSelectModal.tsx": 1138,
"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/shared/components/ModelSelectModal.tsx": 1138,
"src/shared/constants/providers/apikey/gateways.ts": 1298,
"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).",
"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,
"open-sse/config/imageRegistry.ts": 1034,
"src/sse/handlers/chatHelpers.ts": 1019,
"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).",
"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_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_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": {
"TS2488": 1,
"TS2365": 2,

View File

@@ -0,0 +1,146 @@
---
title: "STRICT_ZERO_COST"
version: 3.8.50
lastUpdated: 2026-08-20
---
# STRICT_ZERO_COST
> Opt-in, off by default (`settings.freeAccessPolicy !== "strict"` leaves every `auto/*`
> candidate pool byte-identical). A stricter sibling of `hidePaidModels`
> (`open-sse/services/autoCombo/paidModelFilter.ts`, #6512) for operators who need a hard
> guarantee against ANY incremental monetary spend, not just "documented as free".
## Why this exists, and why `hidePaidModels` alone isn't enough
`hidePaidModels` answers "is this model classified free in `FREE_MODEL_BUDGETS` right now?" —
a point-in-time catalog fact, checked via `isFreeModel()`/`providerHasFreeModels()`
(`src/shared/utils/freeModels.ts`). It says nothing about two real risks:
1. A `recurring-*`/`one-time-initial` free tier's allowance can be **exhausted** — the catalog
still lists the model as free, but the account behind it has no headroom left.
2. Exceeding a free tier is not always a hard stop. Some providers document explicitly that no
payment method can ever be attached ("no credit card required"); others don't say, and a
handful bill automatically past the free allowance.
`hidePaidModels` cannot distinguish these — it was never meant to. STRICT_ZERO_COST adds exactly
these two checks, evaluated per candidate, **before** category/tier ranking and **before**
dispatch — never after a request has already gone out.
## Candidate classification
For every candidate in the pool (`open-sse/services/autoCombo/virtualFactory.ts::buildPreparedPool`,
right after `filterPaidOnlyCandidates`):
1. **Not in `FREE_MODEL_BUDGETS` at all** → excluded. This covers genuinely paid models and any
provider/model OmniRoute hasn't classified yet — new candidates start excluded, not included.
2. **`freeType: "keyless"`** → passes immediately, **but only for a candidate that genuinely
arrived via the no-auth path** (`connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID`,
`open-sse/services/autoCombo/resilienceCandidateFilter.ts`). No credential exists for that
candidate, so no request against it can ever be billed — no runtime check is needed or
possible. The same catalogued `keyless` provider/model reached through a **real** DB
connection (`connectionId` is an actual connection id, or the candidate carries
`allowedConnectionIds`) does **not** get this shortcut — `keyless` metadata describes the
no-auth path specifically, not the provider in general, and never authorizes a real,
credentialed account. Such a candidate falls through to check 3 like any other, where it is
excluded unless the catalog entry separately carries `hardStopGuaranteed: true` (real
`keyless` entries never do — the shortcut was their only path to safety).
3. **Any other `freeType`** (`recurring-daily`, `recurring-monthly`, `recurring-credit`,
`recurring-uncapped`, `one-time-initial`, and any future type this module doesn't
special-case) → passes only if **all** of the following hold:
- `hardStopGuaranteed: true` is set on the catalog entry (`FreeModelBudget.hardStopGuaranteed`,
`open-sse/config/freeModelCatalog.ts`) — a **curated, hand-set fact** about the provider's
own published terms (e.g. an explicit "no credit card required" claim), never derived from
`freeType` or from a live API response. Unset (`undefined`) and `false` are both treated as
"not guaranteed".
- A usage adapter exists for the provider in `USAGE_FETCHER_PROVIDERS`
(`open-sse/services/usage.ts`) — the same registry that already backs the quota dashboard and
`getUsageForProvider()`. No adapter → excluded, permanently, until one is added.
- The live, cached `FreeAccessState` for **the specific connection actually being
evaluated** is `status: "SAFE"`, was checked within
`settings.autoRefreshProviderQuotaInterval` (default 180s — the existing setting, not a new
number), and reports `remainingFreeAllowance` above a small safety margin.
4. **`freeType: "discontinued"`** → always excluded.
## Connection safety (per-connection verification, never per-candidate)
A candidate in the auto-combo pool is not always tied to one connection. A "logical" candidate
(`connectionId: null`) carries an `allowedConnectionIds` allowlist — one or more actual
provider connections/accounts any of which could serve the request — and the account actually
used is decided later, at dispatch time, by `open-sse/services/combo/autoStrategy.ts`
(intersecting `allowedConnectionIds` against its own connection-selection logic, ~line 315-331).
STRICT_ZERO_COST verifies the free-access state of **each connection in that allowlist
individually** (`evaluateCandidateConnections()` in `strictZeroCostFilter.ts`) and rewrites
`allowedConnectionIds` down to exactly the subset that came back `SAFE` — never the full
original list, and never a single arbitrarily-chosen member. Concretely:
- Account A `SAFE`, account B `UNKNOWN`/exhausted/billable → only A remains selectable.
- All accounts `UNKNOWN` → the candidate is dropped entirely (empty safe set).
- A single-connection candidate (`connectionId` set directly, no allowlist) that fails is
dropped outright, never returned with an empty `allowedConnectionIds`.
Because `autoStrategy.ts` already enforces `allowedConnectionIds` as a hard allowlist before
selecting a connection to dispatch to, rewriting it to the verified-SAFE subset is sufficient to
guarantee the connection actually used at dispatch is always one this filter itself verified —
never a different, unverified account on the same candidate. See
`tests/unit/autoCombo/strict-zero-cost-connection-safety.test.ts` for the regression proof
(keyless-bypass cases A/B/C, multi-account cases 1-5).
`discovered automatically`: a provider/model shipped tomorrow with the right metadata (in the
catalog, with a usage adapter, `hardStopGuaranteed: true`) is usable the moment OmniRoute knows
about it — no code change, no whitelist entry, nothing to edit in this module. One removed from
the catalog disappears the same way. See
`tests/unit/autoCombo/strict-zero-cost-autodiscovery.test.ts` for the regression proof (via
injectable fixtures, not by mutating the real catalog).
## Quota caching (`open-sse/services/autoCombo/freeAccessQuota.ts`)
Reuses `getUsageForProvider()` — no second quota system. A short, in-memory,
process-lifetime cache sits in front of it (TTL equal to the default
`autoRefreshProviderQuotaInterval`) so a Telegram-scale request rate never triggers a live
billing-API call per candidate per request. Reads are synchronous: a cache miss returns
`undefined` (→ excluded, fail-closed) and kicks off a background refresh for the _next_ read —
nothing in the candidate-pool build path ever awaits a network call.
`invalidateFreeAccessState(provider, connectionId)` is called from
`src/sse/services/auth.ts::markAccountUnavailable()` the moment a connection fails for any
reason, so the very next pool build reads a clean cache miss instead of a stale `SAFE` entry —
no waiting out the TTL after a 402/403/quota-exhausted response.
## ToS guard (independent of economic safety)
`excludeTosAvoid` (default `false`) drops any candidate whose curated `tos` verdict
(`FreeModelBudget.tos`) is `"avoid"` — reuses the same field `hidePaidModels`'s sibling docs
(`docs/reference/FREE_TIERS.md`) already populate. Deliberately separate from
`freeAccessPolicy`: a candidate can be economically `SAFE` and still excluded here for
contractual reasons, or left in when this guard is off even with `freeAccessPolicy: "strict"` on.
## What passes today
Run `npx tsx scripts/ad-hoc/dry-run-strict-zero-cost.ts` against a live instance's
`GET /v1/auto-combo/{channel}/candidates` output for a real before/after — the script now reads
each candidate's real `connectionId`, so it also proves the connection-safety fix live, not just
in unit tests. As of 2026-08-20, only `freeType: "keyless"` candidates pass in practice (7 of 29
live candidates on this instance: `opencode/big-pickle`, `opencode/deepseek-v4-flash-free`, and
5 `felo-web` models — all confirmed arriving with the genuine no-auth `connectionId`, never a
real connection) — no currently-catalogued `recurring-*` provider both has a usage adapter
registered in `USAGE_FETCHER_PROVIDERS` **and** `hardStopGuaranteed: true` declared (e.g. `groq`
has neither the adapter registered here nor is fetched offline in this dry run; `kiro` lacks
`hardStopGuaranteed`). This is not a bug: it's the honest state of two independently-curated
metadata sets that happen not to overlap yet, not a limitation of the filter itself.
With `excludeTosAvoid: true` added on top of the same live pool, the count drops from 7 to 0 —
every one of the 7 surviving candidates is curated `tos: "avoid"` today (`felo-web`, `opencode`).
This is a real, expected trade-off of turning the ToS guard on, not a bug: the guard is
`false` by default for exactly this reason (see "ToS guard" above).
## Enabling
```json
PUT /api/settings
{ "freeAccessPolicy": "strict", "excludeTosAvoid": false }
```
Both new settings default to their pre-feature values (`"off"` / `false`) — enabling neither
changes any existing `auto/*` routing behavior.

View File

@@ -108,8 +108,9 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "bytez", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
{ provider: "bytez", modelId: "mistralai/Mistral-7B-Instruct-v0.3", displayName: "mistralai/Mistral-7B-Instruct-v0.3", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
{ provider: "bytez", modelId: "Qwen/Qwen2.5-72B-Instruct", displayName: "Qwen/Qwen2.5-72B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
{ provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution" },
{ provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution" },
// hardStopGuaranteed: Cerebras pricing page states "Free Trial: 1M tokens/day... no credit card" (open-sse/services/../providers/apikey/inference-hosts.ts:74-84).
{ provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true },
{ provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true },
// #8717: drop dead Workers AI ids (400/403/410). Keep Neurons/day budget on fp8-fast.
{ provider: "cloudflare-ai", modelId: "@cf/mistral/mistral-7b-instruct-v0.2-lora", displayName: "Mistral 7B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" },
{ provider: "cloudflare-ai", modelId: "@cf/qwen/qwen2.5-coder-32b-instruct", displayName: "Qwen 2.5 Coder 32B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" },
@@ -187,11 +188,12 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "glm-cn", modelId: "glm-4.5-flash", displayName: "GLM-4.5-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" },
{ provider: "glm-cn", modelId: "glm-4.7-flash", displayName: "GLM-4.7-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" },
{ provider: "glm-cn", modelId: "glm-signup-bonus", displayName: "Z.AI — 20M signup bonus", monthlyTokens: 0, creditTokens: 20000000, freeType: "one-time-initial", poolKey: "zhipu-signup", tos: "ok" },
{ provider: "groq", modelId: "meta-llama/llama-4-scout-17b-16e-instruct", displayName: "Llama 4 Scout", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution" },
{ provider: "groq", modelId: "llama-3.3-70b-versatile", displayName: "Llama 3.3 70B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution" },
{ provider: "groq", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution" },
{ provider: "groq", modelId: "openai/gpt-oss-20b", displayName: "GPT-OSS 20B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution" },
{ provider: "groq", modelId: "qwen/qwen3-32b", displayName: "Qwen3 32B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution" },
// hardStopGuaranteed: Groq pricing page states "Free tier: 30 RPM / 14.4K RPD — no credit card" (open-sse/services/../providers/apikey/frontier-labs.ts:71-81).
{ provider: "groq", modelId: "meta-llama/llama-4-scout-17b-16e-instruct", displayName: "Llama 4 Scout", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "llama-3.3-70b-versatile", displayName: "Llama 3.3 70B", 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: "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" },

View File

@@ -26,6 +26,20 @@ export interface FreeModelBudget {
* reports this per model as `mayTrainOnYourPrompts` on its public catalog.
*/
trainsOnPrompts?: boolean;
/**
* True only when the provider's own published terms document that exceeding
* the free allowance is a hard stop (request refused / rate-limited) and NOT
* automatic pay-as-you-go billing — e.g. an explicit "no credit card
* required" claim on the provider's pricing page. This is a curated fact
* about the upstream provider, not something derivable from `freeType` or
* from any live API response, so it must be set by hand per entry with the
* source of the claim in a comment. Leave unset (undefined) whenever this
* isn't independently documented — `undefined` and `false` are both treated
* as "not guaranteed" by `strictZeroCostFilter.ts`; never default to `true`
* to grow the catalog. See STRICT_ZERO_COST in
* `open-sse/services/autoCombo/strictZeroCostFilter.ts`.
*/
hardStopGuaranteed?: boolean;
}
export interface FreeModelTotals {
@@ -80,7 +94,7 @@ function fmt(n: number): string {
function dedupedSum(
models: FreeModelBudget[],
pick: (m: FreeModelBudget) => number,
include: (m: FreeModelBudget) => boolean,
include: (m: FreeModelBudget) => boolean
): number {
const poolMax = new Map<string, number>();
let loose = 0;
@@ -100,30 +114,30 @@ export function computeFreeModelTotals(opts: { excludeTosAvoid?: boolean } = {})
const steadyRecurringTokens = dedupedSum(
models,
(m) => m.monthlyTokens,
(m) => RECURRING.has(m.freeType),
(m) => RECURRING.has(m.freeType)
);
const recurringCredits = dedupedSum(
models,
(m) => m.creditTokens,
(m) => m.freeType === "recurring-credit",
(m) => m.freeType === "recurring-credit"
);
const oneTimeCredits = dedupedSum(
models,
(m) => m.creditTokens,
(m) => m.freeType === "one-time-initial",
(m) => m.freeType === "one-time-initial"
);
const steadyWithRecurringCreditsTokens = steadyRecurringTokens + recurringCredits;
const firstMonthRealisticTokens = steadyWithRecurringCreditsTokens + oneTimeCredits;
const poolCount = new Set(
models.filter((m) => RECURRING.has(m.freeType) && m.poolKey).map((m) => m.poolKey),
models.filter((m) => RECURRING.has(m.freeType) && m.poolKey).map((m) => m.poolKey)
).size;
// Deposit-unlock boost: sum the FREE_TIER_BOOSTS whose pool still has a live
// recurring model in the (optionally ToS-filtered) set.
const livePools = new Set(
models.filter((m) => RECURRING.has(m.freeType) && m.poolKey).map((m) => m.poolKey),
models.filter((m) => RECURRING.has(m.freeType) && m.poolKey).map((m) => m.poolKey)
);
const boostMonthlyTokens = Object.entries(FREE_TIER_BOOSTS)
.filter(([pool]) => livePools.has(pool))

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

@@ -56,12 +56,21 @@ export function stripGroqUnsupportedFields<T extends Record<string, unknown>>(bo
delete next.top_logprobs;
if (Array.isArray(next.messages)) {
next.messages = next.messages.map((m) => {
if (m && typeof m === "object" && "name" in m) {
const { name: _name, ...rest } = m as Record<string, unknown>;
if (m && typeof m === "object") {
const {
name: _name,
model: _model,
messageId: _msgId,
sender: _sender,
...rest
} = m as Record<string, unknown>;
return rest;
}
return m;
});
}
return next as T;
}

View File

@@ -5,6 +5,12 @@ export const blackboxProvider: RegistryEntry = {
alias: "bb",
format: "openai",
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",
modelsUrl: "https://api.blackbox.ai/v1/models",
authType: "apikey",

View File

@@ -8,7 +8,11 @@ export const command_codeProvider: RegistryEntry = {
format: "openai",
executor: "command-code",
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",
// The discovery response is a partial routing catalog; static registry
// entries omitted from it can still be accepted by the gateway.

View File

@@ -5,7 +5,11 @@ export const difyProvider: RegistryEntry = {
alias: "dify",
format: "openai",
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",
authHeader: "bearer",
models: [{ id: "auto", name: "Auto" }],

View File

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

View File

@@ -1,4 +1,5 @@
import type { RegistryEntry } from "../../../shared.ts";
import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../../../shared.ts";
export const opencode_goProvider: RegistryEntry = {
id: "opencode-go",
@@ -13,6 +14,8 @@ export const opencode_goProvider: RegistryEntry = {
authPrefix: "Bearer",
defaultContextLength: 200000,
models: [
...OPENCODE_ZEN_GO_SHARED_MODELS,
// Port from decolua/9router 8efacc11: align with official Go endpoints —
// glm-5.2 is now advertised and Kimi chat traffic must route through
// `kimi-k2.7-code` (the live API rejects the plain `kimi-k2.7` alias for
@@ -25,7 +28,7 @@ export const opencode_goProvider: RegistryEntry = {
{ id: "glm-5.2-max", name: "GLM-5.2 (max effort)", supportsReasoning: true },
{ id: "glm-5.1", name: "GLM-5.1" },
{ 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.5", name: "Kimi K2.5" },
// #8353: Kimi K3 base + max-effort alias from the OpenCode Go registry.
@@ -89,7 +92,8 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: false,
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",
name: "Qwen3.6 Plus (high effort)",
@@ -104,7 +108,6 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: false,
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).
{ id: "hy3", name: "Hunyuan3", contextLength: 256000, supportsReasoning: true },
{
@@ -138,6 +141,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-minimal",
@@ -148,6 +152,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-low",
@@ -158,6 +163,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-medium",
@@ -168,6 +174,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-high",
@@ -178,6 +185,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-xhigh",
@@ -188,6 +196,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
// #8353: Grok 4.5 + effort tiers from the OpenCode Go registry.
{ id: "grok-4.5", name: "Grok 4.5", supportsReasoning: true },

View File

@@ -1,4 +1,5 @@
import type { RegistryEntry } from "../../../shared.ts";
import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../../../shared.ts";
export const opencode_zenProvider: RegistryEntry = {
id: "opencode-zen",
@@ -15,6 +16,8 @@ export const opencode_zenProvider: RegistryEntry = {
// from the live API response so new models work without a code deploy.
passthroughModels: true,
models: [
...OPENCODE_ZEN_GO_SHARED_MODELS,
// ── Chat / Coding ──────────────────────────────────────────
// #2900: big-pickle's upstream runs DeepSeek thinking mode — declare the
// interleaved reasoning_content contract so follow-up/tool-use turns replay
@@ -51,7 +54,25 @@ export const opencode_zenProvider: RegistryEntry = {
{ id: "grok-4.6", name: "Grok 4.6" },
// ── 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",
},
{
id: "muse-spark-1.2-contributor-free",
name: "Muse Spark 1.2 Contributor Free",
supportsReasoning: true,
targetFormat: "openai-responses",
},
// ── DeepSeek ────────────────────────────────────────────────
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
@@ -66,7 +87,7 @@ export const opencode_zenProvider: RegistryEntry = {
// ── Kimi / Moonshot ────────────────────────────────────────
{ 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 ───────────────────────────────────────────────────
// Issue #2292: Qwen models return Claude-format SSE bodies even
@@ -74,8 +95,8 @@ export const opencode_zenProvider: RegistryEntry = {
// through /messages and the Claude translator.
// Issue #2822: These models are text-only — supportsVision: false
// ensures combo routing skips them on image-bearing requests.
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false },
// qwen3.5-plus / qwen3.6-plus declared identically on opencode-go — see
// OPENCODE_ZEN_GO_SHARED_MODELS.
// ── Free Tier ──────────────────────────────────────────────
// #6998 (2026-07-14): upstream free tier rotated — minimax-m2.5-free,

View File

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

View File

@@ -104,6 +104,12 @@ import {
import { applyPeerTraceHeader } from "@/shared/resilience/peerRouting";
import { applyClineProtocolHeaders } from "@/shared/utils/clineAuth";
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
// (executors + tests) that import them from "./base.ts".
export {
@@ -397,6 +403,29 @@ export class BaseExecutor {
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
* that matches. Centralizes the registry lookup so every call-site resolves the
@@ -615,6 +644,7 @@ export class BaseExecutor {
async countTokens({ model, body, credentials, signal, log }: CountTokensInput) {
const url = this.buildCountTokensUrl(model, credentials);
if (!url) return null;
this.assertOutboundUrlAllowed(url); // GHSA-4f49
const headers = this.buildHeaders(credentials, false);
const requestBody =
@@ -869,6 +899,9 @@ export class BaseExecutor {
// Timeout only covers response start; stream stalls are handled downstream.
const fetchStartTimeoutMs = this.getTimeoutMs();
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;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
if (timeoutController) {

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;
/**
* Allowed message types observed in the 2026-08 recapture of the working
* `m365.cloud.microsoft/chat` client (#10718). The old 11-entry list is no longer
* seen on the wire — the stale shape gets closed immediately after the type:4.
* Allowed message types observed in a 2026-08-21 live capture of a working
* `m365.cloud.microsoft/chat` session (issue: "Stream ended before producing a
* 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 = [
"Chat",
"Suggestion",
"InternalSearchQuery",
"Disengaged",
"Progress",
"EndOfRequest",
"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;
/**
@@ -78,19 +106,26 @@ export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [
] as const;
/**
* Individual / EDU option sets from the 2026-08 recapture (#10718)14 entries.
* The previous 25-entry consumer/MSA set (enable_msa_user, pdnascan, cwc_code_*,
* …) is no longer observed on the wire and belongs to the shape the substrate
* now drops silently.
* Individual / EDU option sets from a 2026-08-21 live capture34 entries, up
* from the #10718 14-entry shape (which itself superseded an earlier 25-entry
* shape). Each recapture so far has been additive/reshuffled rather than a
* 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 = [
"search_result_progress_messages_with_search_queries",
"update_textdoc_response_after_streaming",
"deepleo_networking_timeout_10minutes_canmore",
"cwc_flux_image",
"cwc_code_interpreter",
"cwc_code_interpreter_amsfix",
"cwcfluxgptv",
"flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch",
"gptvnorm2048",
"cwc_code_interpreter_citation_fix",
"code_interpreter_interactive_charts",
"cwc_code_interpreter_interactive_charts_inline_image",
"code_interpreter_matplotlib_patching",
"cwc_fileupload_odb",
"update_memory_plugin",
"add_custom_instructions",
@@ -98,6 +133,20 @@ export const M365_DEFAULT_OPTION_SETS = [
"flux_v3_progress_messages",
"enable_batch_token_processing",
"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;
/** Append the record separator to a JSON-serializable frame. */
@@ -433,12 +482,14 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
}
return {
optionsSets: [...M365_DEFAULT_OPTION_SETS],
// #10718 — the 2026-08 recapture sends tone:"magic" (lowercase) on the
// individual/EDU surface; the old "" default is part of the dropped shape.
tone: "magic",
// 2026-08-21 capture — the individual/consumer surface now sends "Magic"
// (capitalized), matching the enterprise tone literal. The #10718
// lowercase "magic" is part of the shape that gets silently dropped.
tone: "Magic",
allowedMessageTypes: ALLOWED_MESSAGE_TYPES,
// Omitted entirely on the individual/EDU wire (see ChatInvocationOptions).
disconnectBehavior: undefined,
// 2026-08-21 capture — disconnectBehavior:"continue" is now present on the
// 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).
* Mirrors the argument shape recaptured from a working `m365.cloud.microsoft/chat`
* client in 2026-08 (#10718). Notable differences from the pre-#10718 shape: a
* populated `clientInfo` + `productThreadType:"Office"`, a `conversationId`
* matching the WS URL query, a rich `message` object, and no
* `spokenTextMode` / `extraExtensionParameters` / `isSbsSupported` /
* `renderReferencesBehindEOS` / `disconnectBehavior` — none of those are still
* observed on the wire, and the stale shape gets closed immediately after the
* invocation.
* Base shape from the #10718 recapture (populated `clientInfo` +
* `productThreadType:"Office"`, a `conversationId` matching the WS URL query, a
* rich `message` object), extended per a 2026-08-21 live capture that found the
* #10718 shape alone no longer produces a `type:1 target:"update"` frame — the
* socket only replies with keepalive pings and closes. The additions below
* (richer `clientInfo`, non-empty `plugins`, `extraExtensionParameters`,
* `isSbsSupported`, `renderReferencesBehindEOS`,
* `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> {
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 {
type: 4,
target: "chat",
@@ -487,17 +555,17 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
? [...opts.allowedMessageTypes]
: [...ALLOWED_MESSAGE_TYPES],
clientCorrelationId: opts.clientCorrelationId ?? opts.traceId,
clientInfo: {
clientAppName: "Office",
clientPlatform: "mcmcopilot-web",
},
clientInfo,
conversationId: opts.conversationId,
extraExtensionParameters: {},
isStartOfSession: opts.isStartOfSession ?? true,
message: {
adaptiveCards: [],
attachments: null,
author: "user",
clientInfo,
clientPreferences: {},
connectedFederatedConnections: ["dummyId"],
entityAnnotationTypes: ["People", "File", "Event", "Email", "TeamsMessage"],
experienceType: "Default",
inputMethod: "Keyboard",
@@ -510,22 +578,27 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
requestId: opts.requestId,
text: opts.text,
},
isSbsSupported: true,
options: {},
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 } : {}),
productThreadType: "Office",
renderReferencesBehindEOS: true,
sessionId: opts.sessionId,
sliceIds: [],
source: "officeweb",
streamingMode: "ConciseWithPadding",
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,
traceId: opts.traceId,
// #8971 keeps "continue" for the enterprise tier; the individual/EDU wire
// omits the key, so only include it when actually set (#10718).
...(opts.disconnectBehavior ? { disconnectBehavior: opts.disconnectBehavior } : {}),
// 2026-08-21 capture — disconnectBehavior:"continue" is sent on every
// tier now, not gated to enterprise as the #8971 comment described.
disconnectBehavior: opts.disconnectBehavior ?? "continue",
},
],
};

View File

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

View File

@@ -33,7 +33,7 @@ import { createHash } from "node:crypto";
import { BaseExecutor, type ExecuteInput } from "./base.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 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";

View File

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

View File

@@ -1,6 +1,6 @@
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
import { getModelTargetFormat } from "../config/providerModels.ts";
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
import {
injectReasoningContentForThinkingModel,
isThinkingMessageModel,
@@ -125,6 +125,24 @@ export function isPremiumOpencodeModel(model: string, provider: string): boolean
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 {
/** Delegates to `isPremiumOpencodeModel`. Exported for testability. */
static isPremiumModel(model: string, provider: string): boolean {
@@ -193,7 +211,10 @@ export class OpencodeExecutor extends BaseExecutor {
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);
}
@@ -202,7 +223,7 @@ export class OpencodeExecutor extends BaseExecutor {
}
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.
// When the connection is keyless (no apiKey, no accessToken) and the model

View File

@@ -10,16 +10,24 @@ import {
import { resolveMcpCallerApiKeyId } from "../mcpCallerIdentity.ts";
/**
* 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,
* OMNIROUTE_API_KEY env var on stdio). 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.
* Resolve the memory owner id for an MCP tool call.
*
* The authenticated caller's principal ALWAYS wins over a caller-supplied
* `apiKeyId` — otherwise any MCP caller could read, write, or delete another
* principal's memories by putting a different id in the tool arguments
* (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> {
const caller = await resolveMcpCallerApiKeyId().catch(() => undefined);
if (caller) return caller;
if (explicit && explicit.trim() !== "") return explicit.trim();
return (await resolveMcpCallerApiKeyId().catch(() => undefined)) || "mcp";
return "mcp";
}
export const MemorySearchSchema = z.object({

View File

@@ -0,0 +1,209 @@
/**
* Live wiring for STRICT_ZERO_COST's quota-based branch.
*
* Reuses the existing `getUsageForProvider()` (`open-sse/services/usage.ts`)
* instead of building a second quota system — this module only adds a short
* TTL cache in front of it (so a Telegram-scale request rate never triggers a
* live billing-API call per candidate per request) and an invalidation hook
* for the resilience layer to call the moment a 402/403/quota-exhausted
* response is observed (`accountFallback.ts`).
*
* The cache is intentionally synchronous to read: `resolveFreeAccessState()`
* never awaits. A cache miss returns `undefined` (→ UNKNOWN → excluded,
* fail-closed) and kicks off a background refresh for the *next* read —
* nothing here can make `strictZeroCostFilter.ts`'s pool build block on a
* network call.
*/
import {
getUsageForProvider,
USAGE_FETCHER_PROVIDERS,
type UsageFetcherProvider,
} from "./../usage.ts";
import { getCachedProviderConnections } from "@/lib/db/readCache";
import { defaultLogger as log } from "@omniroute/open-sse/utils/logger";
import type { FreeAccessState } from "./strictZeroCostFilter";
const USAGE_FETCHER_PROVIDER_SET = new Set<string>(USAGE_FETCHER_PROVIDERS);
/** Default cache TTL, reused verbatim from the already-shipped
* `settings.autoRefreshProviderQuotaInterval` (180s default,
* `src/lib/db/settings.ts`) instead of inventing a new number. */
const FALLBACK_TTL_MS = 180_000;
/** Cold-cache thundering-herd guard: after a process restart every candidate
* in a pool build is a simultaneous cache miss, which without a cap would
* fire one `getUsageForProvider()` call per distinct (provider, connection)
* pair in the same tick. Capping concurrent background refreshes spreads
* that burst out — a skipped refresh here just means this candidate stays
* UNKNOWN (excluded, fail-closed) until a later pool build tries again, never
* a correctness issue. */
const MAX_CONCURRENT_REFRESHES = 4;
/** Entries older than this are pruned outright even if nothing ever triggers
* a fresh refresh for that exact key again (e.g. the connection was deleted
* and no candidate references it anymore, so a normal stale-triggered
* refresh — which self-heals inside one TTL window — never fires). A sweep,
* not a timer: piggybacks on `resolveFreeAccessState` calls so this module
* never owns its own background interval. */
const HARD_EVICTION_AGE_MS = FALLBACK_TTL_MS * 20; // 1 hour at the default TTL
const SWEEP_EVERY_N_CALLS = 200;
interface CacheEntry {
state: FreeAccessState;
fetchedAtMs: number;
}
// Keyed by `${provider}::${connectionId}` — module-level, process-lifetime
// cache. Cleared per-entry by `invalidateFreeAccessState`, on a failed
// refresh, or by the periodic sweep below; never wholesale.
const cache = new Map<string, CacheEntry>();
const inFlight = new Set<string>();
let resolveCallCount = 0;
function cacheKey(provider: string, connectionId: string): string {
return `${provider}::${connectionId}`;
}
// `getSettings()` is async (DB-backed); reading it synchronously here isn't
// possible without changing `resolveFreeAccessState`'s synchronous contract.
// Using the fallback unconditionally is equivalent in practice: it's the same
// number as `settings.autoRefreshProviderQuotaInterval`'s own default
// (`src/lib/db/settings.ts`), and `strictZeroCostFilter.ts`'s own
// `maxStateAgeMs` (passed the real, live setting from `virtualFactory.ts`)
// is the check that actually gates staleness for the STRICT_ZERO_COST
// decision — this cache TTL only bounds how long a background refresh is
// skipped, a looser, non-safety-critical concern.
function ttlMs(): number {
return FALLBACK_TTL_MS;
}
/** Opportunistic sweep of very stale entries, run every N calls instead of on
* a timer. Cheap (a single Map iteration) and only ever removes entries no
* live candidate can plausibly still be waiting on. */
function sweepIfDue(): void {
resolveCallCount += 1;
if (resolveCallCount % SWEEP_EVERY_N_CALLS !== 0) return;
const now = Date.now();
for (const [key, entry] of cache) {
if (now - entry.fetchedAtMs > HARD_EVICTION_AGE_MS) cache.delete(key);
}
}
/**
* Best-effort, provider-agnostic extraction of "how much free allowance is
* left" from whatever shape `getUsageForProvider()` returns for this
* provider today. Adapters were written for a human-readable quota display,
* not for this filter, so their payloads are heterogeneous; this function
* recognizes the two shapes already used by other read paths in this
* codebase (`quotas.*.remainingPercentage` / `.total`+`.remaining`, mirroring
* `quota_omniroute.py`'s own parsing) and returns `null` — never a guess —
* for anything else. `null` is treated as "not proven safe" by the filter.
*/
function extractRemainingAllowance(usage: unknown): number | null {
if (!usage || typeof usage !== "object") return null;
const quotas = (usage as Record<string, unknown>).quotas;
if (!quotas || typeof quotas !== "object") return null;
let worstPercent: number | null = null;
for (const raw of Object.values(quotas as Record<string, unknown>)) {
if (!raw || typeof raw !== "object") continue;
const q = raw as Record<string, unknown>;
if (q.unlimited === true) continue;
let pct: number | null =
typeof q.remainingPercentage === "number" ? q.remainingPercentage : null;
if (
pct === null &&
typeof q.total === "number" &&
typeof q.remaining === "number" &&
q.total > 0
) {
pct = (100 * q.remaining) / q.total;
}
if (pct === null) continue;
worstPercent = worstPercent === null ? pct : Math.min(worstPercent, pct);
}
return worstPercent; // percentage points; the filter's threshold is compared against this unit
}
async function refresh(provider: string, connectionId: string): Promise<void> {
const key = cacheKey(provider, connectionId);
if (inFlight.has(key)) return;
if (inFlight.size >= MAX_CONCURRENT_REFRESHES) return; // thundering-herd guard — see const doc above
inFlight.add(key);
try {
const connections = await getCachedProviderConnections();
const connection = connections.find(
(c): c is Record<string, unknown> =>
!!c &&
typeof c === "object" &&
(c as Record<string, unknown>).id === connectionId &&
(c as Record<string, unknown>).provider === provider
);
if (!connection) {
cache.delete(key);
return;
}
const usage = await getUsageForProvider(
connection as unknown as Parameters<typeof getUsageForProvider>[0],
{ forceRefresh: false }
);
const remaining = extractRemainingAllowance(usage);
const state: FreeAccessState = {
status: remaining === null ? "UNKNOWN" : remaining > 0 ? "SAFE" : "EXHAUSTED",
remainingFreeAllowance: remaining,
resetAt:
usage &&
typeof usage === "object" &&
typeof (usage as Record<string, unknown>).resetAt === "string"
? ((usage as Record<string, unknown>).resetAt as string)
: null,
checkedAt: new Date().toISOString(),
};
cache.set(key, { state, fetchedAtMs: Date.now() });
} catch (err) {
// A failed lookup must never leave a stale SAFE entry behind — drop it so
// the next read is a clean cache miss (UNKNOWN), not a lucky reuse.
cache.delete(key);
log.warn("AUTO", "STRICT_ZERO_COST: usage refresh failed, treating as UNKNOWN", {
provider,
err: err instanceof Error ? err.message : String(err),
});
} finally {
inFlight.delete(key);
}
}
/**
* Synchronous read for `strictZeroCostFilter.ts`. Returns `undefined` when
* there's no usage adapter for this provider at all (a permanent UNKNOWN, no
* point ever refreshing), or on a cold/stale cache — in both cases a
* background refresh is kicked off (fire-and-forget, subject to the
* concurrency cap above) so a *later* read can benefit, but this call itself
* never blocks or throws.
*/
export function resolveFreeAccessState(
provider: string,
connectionId: string | undefined
): FreeAccessState | undefined {
sweepIfDue();
if (!USAGE_FETCHER_PROVIDER_SET.has(provider as UsageFetcherProvider)) return undefined;
if (!connectionId) return undefined;
const key = cacheKey(provider, connectionId);
const entry = cache.get(key);
const fresh = entry && Date.now() - entry.fetchedAtMs <= ttlMs();
if (!fresh) {
void refresh(provider, connectionId);
}
return fresh ? entry.state : undefined;
}
/** Called by `accountFallback.ts` the moment a 402/403/quota-exhausted
* response is classified for a connection — drops the cached entry
* immediately instead of waiting out the TTL, so the very next candidate-pool
* build reads a clean cache miss (UNKNOWN) rather than a stale SAFE. */
export function invalidateFreeAccessState(provider: string, connectionId: string): void {
cache.delete(cacheKey(provider, connectionId));
}
export const __testing = { cache, extractRemainingAllowance, sweepIfDue };

View File

@@ -0,0 +1,283 @@
/**
* STRICT_ZERO_COST — an opt-in, stricter sibling of `hidePaidModels`
* (`paidModelFilter.ts`) for operators who need a hard guarantee against ANY
* incremental monetary spend, not just "documented as free".
*
* `hidePaidModels` answers "is this model classified free in FREE_MODEL_BUDGETS
* right now?" — a point-in-time catalog fact. It says nothing about whether a
* `recurring-*`/`one-time-initial` candidate's allowance has since been
* consumed, and nothing about whether exceeding it is a hard stop or silent
* pay-as-you-go billing. STRICT_ZERO_COST adds exactly those two checks,
* before ranking, before dispatch — never after.
*
* Design, kept deliberately close to `filterPaidOnlyCandidates`'s own stated
* goal: "a pure, dependency-light function so the filter is unit-testable in
* isolation". The live quota lookup (`getUsageForProvider`, cached with a TTL)
* lives in `freeAccessQuota.ts` and is injected here as a plain function —
* this file never imports the DB or makes a network call itself.
*
* No provider or model name appears anywhere in this file. A candidate passes
* or fails purely on the metadata it carries (`freeType`, `tos`,
* `hardStopGuaranteed`) plus, for quota-based types, a `FreeAccessState`
* resolved elsewhere. A future provider that ships correct metadata is
* handled automatically; one that doesn't is excluded automatically — see
* `docs/routing/STRICT_ZERO_COST.md`.
*
* ## Connection safety (fixed after code review, see `docs/routing/STRICT_ZERO_COST.md`)
*
* A candidate from `virtualFactory.ts`'s connection-based pool represents ONE
* provider/model pair with a set of *eligible* connections
* (`allowedConnectionIds`) — the actual connection used at dispatch is chosen
* later (session stickiness/LKGP), not by this filter. Two invariants follow:
*
* 1. The `keyless` shortcut (no live check needed, because no credential
* exists) is valid ONLY for candidates that genuinely came from the
* no-auth path — identified by `connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID`
* (`resilienceCandidateFilter.ts`). A `keyless`-catalogued model reached
* through a real DB connection (the same provider also has a
* credentialed connection) does NOT get the shortcut — it falls through
* to the normal quota-based check like any other freeType, and is
* excluded unless that specific connection independently proves SAFE.
* 2. For a multi-account candidate (`connectionId: null`,
* `allowedConnectionIds: [...]`), each connection is checked
* INDIVIDUALLY. The returned candidate's `allowedConnectionIds` is
* REWRITTEN to exactly the subset proven SAFE — never the full original
* list. `autoStrategy.ts` (`open-sse/services/combo/autoStrategy.ts:315-331`)
* already intersects further routing against `allowedConnectionIds`
* before connection selection, so rewriting it here is enough to make
* "verified this connection" and "dispatch used this connection" the
* same set, by construction — no new enforcement point needed.
*/
import {
FREE_MODEL_BUDGETS,
type FreeModelBudget,
} from "@omniroute/open-sse/config/freeModelCatalog.ts";
import { SYNTHETIC_NOAUTH_CONNECTION_ID } from "./resilienceCandidateFilter";
/** Types whose allowance needs no runtime verification: no credential exists
* for the candidate at all, so no request against it can ever be billed. */
const KEYLESS_FREE_TYPES = new Set<FreeModelBudget["freeType"]>(["keyless"]);
export type FreeAccessStatus = "SAFE" | "EXHAUSTED" | "UNKNOWN";
/** Live-checked allowance state for one (provider, connection) pair. Resolved
* and cached by `freeAccessQuota.ts`; passed in here as plain data so this
* module stays free of DB/network dependencies. */
export interface FreeAccessState {
status: FreeAccessStatus;
/** Remaining free allowance in the provider's own unit (tokens, requests, or
* USD-equivalent) — whatever `getUsageForProvider()` reports. `null` when
* the provider's usage payload doesn't expose a numeric remaining figure. */
remainingFreeAllowance: number | null;
/** When the allowance next resets, if the provider reports it. */
resetAt: string | null;
/** When this state was fetched (ISO 8601) — used to detect staleness. */
checkedAt: string;
}
/** A candidate as this module needs to see it — a structural subset of
* `VirtualAutoComboCandidate` (`virtualFactory.ts`) so this file has no
* dependency on that module's full type. */
export interface StrictZeroCostCandidate {
provider: string;
model: string;
connectionId: string | null;
allowedConnectionIds?: string[];
}
export interface StrictZeroCostOptions {
/** Master switch — mirrors `hidePaidModels`'s own off-by-default shape. */
enabled: boolean;
/**
* Resolves the live allowance state for ONE specific (provider, connection)
* pair. Returns `undefined` when no usage capability exists for the
* provider at all (no adapter registered in `USAGE_FETCHER_PROVIDERS`), or
* when the cache has nothing fresh for this exact connection — both are a
* meaningful, terminal UNKNOWN for that connection, not an error to retry.
*
* Synchronous by design: the caller (`virtualFactory.ts`) resolves and
* caches state per candidate up front, once per pool build, so this filter
* itself never awaits a network call and stays trivially testable.
*/
resolveFreeAccessState: (provider: string, connectionId: string) => FreeAccessState | undefined;
/** Minimum remaining allowance (in the unit `resolveFreeAccessState` reports
* — percentage points for the built-in `freeAccessQuota.ts` resolver) a
* quota-based connection must exceed to pass. Must be >= 0; a fully-exhausted
* account (`remainingFreeAllowance === 0`) fails at any non-negative
* threshold via the strict `>` comparison below. */
minRemainingAllowance: number;
/** Maximum age, in ms, a `FreeAccessState.checkedAt` may have before it's
* treated as stale (→ UNKNOWN, excluded). */
maxStateAgeMs: number;
/** `now` injection for deterministic tests; defaults to `Date.now`. */
now?: () => number;
/**
* The free-model catalog to look candidates up against. Defaults to the
* real, live `FREE_MODEL_BUDGETS` — overridable so tests can prove the
* autodiscovery contract (a provider/model that appears in the catalog is
* automatically considered; one that's removed automatically disappears)
* with synthetic fixtures instead of mutating global state. Production
* callers should never pass this. Threaded through by
* `filterStrictZeroCostCandidates` (previously accepted but silently
* ignored — fixed alongside the connection-safety review).
*/
catalog?: readonly FreeModelBudget[];
}
export function findBudgetEntry(
candidate: Pick<StrictZeroCostCandidate, "provider" | "model">,
catalog: readonly FreeModelBudget[] = FREE_MODEL_BUDGETS
): FreeModelBudget | undefined {
return catalog.find((m) => m.provider === candidate.provider && m.modelId === candidate.model);
}
function isConnectionStateSafe(
provider: string,
connectionId: string,
resolveFreeAccessState: StrictZeroCostOptions["resolveFreeAccessState"],
options: Pick<StrictZeroCostOptions, "minRemainingAllowance" | "maxStateAgeMs" | "now">
): boolean {
const state = resolveFreeAccessState(provider, connectionId);
if (!state) return false; // no usage adapter for this provider, or lookup never ran/is stale
if (state.status !== "SAFE") return false;
const now = (options.now ?? Date.now)();
const checkedAtMs = Date.parse(state.checkedAt);
if (!Number.isFinite(checkedAtMs) || now - checkedAtMs > options.maxStateAgeMs) return false;
if (state.remainingFreeAllowance === null) return false;
// A negative threshold would let a negative/garbage reading pass; a caller
// that genuinely wants "any allowance greater than zero" should pass 0.
if (options.minRemainingAllowance < 0) return false;
return state.remainingFreeAllowance > options.minRemainingAllowance;
}
/**
* Decide which of a candidate's connections satisfy STRICT_ZERO_COST. Pure —
* `resolveFreeAccessState` is the only injected side-effecting dependency,
* and it's a synchronous cache read (see `StrictZeroCostOptions` above).
*
* Returns the list of connection ids proven SAFE right now:
* - `[SYNTHETIC_NOAUTH_CONNECTION_ID]` for a genuine no-auth candidate whose
* catalog entry is `keyless` — no live check needed or possible.
* - a (possibly empty) subset of the candidate's real connection id(s) for
* every other case, each individually verified.
* An empty array means the caller must exclude the candidate entirely.
*/
export function evaluateCandidateConnections(
candidate: StrictZeroCostCandidate,
budgetEntry: FreeModelBudget | undefined,
resolveFreeAccessState: StrictZeroCostOptions["resolveFreeAccessState"],
options: Pick<StrictZeroCostOptions, "minRemainingAllowance" | "maxStateAgeMs" | "now">
): string[] {
if (!budgetEntry) return []; // not in the catalog at all → paid, or genuinely unknown
const isGenuineNoAuthCandidate = candidate.connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID;
if (KEYLESS_FREE_TYPES.has(budgetEntry.freeType)) {
// The keyless shortcut is trustworthy ONLY when this specific candidate
// instance actually has no credential behind it. A `keyless`-catalogued
// model reached through a real DB connection (connectionId is a real id,
// or the candidate carries allowedConnectionIds at all) must NOT take
// this shortcut — it falls through to the quota-based check below like
// any other freeType, and is excluded there unless hardStopGuaranteed is
// also set for it (which the curated catalog does not do for keyless
// entries today, so it will correctly exclude).
if (isGenuineNoAuthCandidate) return [SYNTHETIC_NOAUTH_CONNECTION_ID];
}
if (budgetEntry.freeType === "discontinued") return [];
if (isGenuineNoAuthCandidate) return []; // no-auth path but a non-keyless catalog entry: contradictory metadata, fail closed
// Every remaining freeType (recurring-*, one-time-initial, a keyless entry
// reached via a real connection, and any future type this module doesn't
// special-case) requires a documented hard stop before any live check even
// runs — no point burning a quota lookup on a connection we could never
// trust regardless of its answer.
if (budgetEntry.hardStopGuaranteed !== true) return [];
const candidateConnectionIds = candidate.connectionId
? [candidate.connectionId]
: (candidate.allowedConnectionIds ?? []);
const safe: string[] = [];
for (const connectionId of candidateConnectionIds) {
if (connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID) continue; // never reachable here, defensive
if (isConnectionStateSafe(candidate.provider, connectionId, resolveFreeAccessState, options)) {
safe.push(connectionId);
}
}
return safe;
}
/**
* Pool-level filter, same off-by-default identity contract as
* `filterPaidOnlyCandidates`. For a candidate that survives with a NARROWED
* connection set (the multi-account case), the returned object has
* `allowedConnectionIds` rewritten to exactly the SAFE subset — dispatch can
* then never select a connection this filter didn't verify, because
* `autoStrategy.ts` already enforces `allowedConnectionIds` as a hard
* allowlist downstream (see the module docstring above).
*/
export function filterStrictZeroCostCandidates<T extends StrictZeroCostCandidate>(
pool: T[],
options: StrictZeroCostOptions
): T[] {
if (!options.enabled) return pool;
const kept: T[] = [];
let changed = false;
for (const candidate of pool) {
const budgetEntry = findBudgetEntry(candidate, options.catalog);
const safeConnectionIds = evaluateCandidateConnections(
candidate,
budgetEntry,
options.resolveFreeAccessState,
options
);
if (safeConnectionIds.length === 0) {
changed = true;
continue;
}
const isGenuineNoAuthCandidate = candidate.connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID;
const isSingleConnectionCandidate = candidate.connectionId !== null;
if (isGenuineNoAuthCandidate || isSingleConnectionCandidate) {
// Nothing to narrow — either the no-auth sentinel, or a candidate that
// already pointed at exactly one connection which proved safe.
kept.push(candidate);
continue;
}
// Multi-account candidate: only rewrite if the safe subset is actually
// narrower than what was there before, to preserve the same
// identity-when-nothing-changed contract as `filterPaidOnlyCandidates`.
const original = candidate.allowedConnectionIds ?? [];
const isSameSet =
original.length === safeConnectionIds.length &&
safeConnectionIds.every((id) => original.includes(id));
if (isSameSet) {
kept.push(candidate);
} else {
changed = true;
kept.push({ ...candidate, allowedConnectionIds: safeConnectionIds });
}
}
return changed ? kept : pool;
}
/**
* Separate, optional ToS guard — kept independent from economic safety on
* purpose (Marco's requirement): a model can be economically SAFE and still
* excluded here for ToS reasons, or left in when this guard is off even if
* STRICT_ZERO_COST is on. Reuses the same curated `tos` field, no new data.
*/
export function filterTosAvoidCandidates<T extends StrictZeroCostCandidate>(
pool: T[],
excludeTosAvoid: boolean,
catalog?: readonly FreeModelBudget[]
): T[] {
if (!excludeTosAvoid) return pool;
return pool.filter((candidate) => {
const budgetEntry = findBudgetEntry(candidate, catalog);
return budgetEntry?.tos !== "avoid";
});
}

View File

@@ -26,6 +26,8 @@ import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily";
import { getHiddenModelsByProvider } from "@/models";
import { getSyncedAvailableModelsByConnection, getCustomModels } from "@/lib/db/models";
import { filterPaidOnlyCandidates } from "./paidModelFilter";
import { filterStrictZeroCostCandidates, filterTosAvoidCandidates } from "./strictZeroCostFilter";
import { resolveFreeAccessState } from "./freeAccessQuota";
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
import { resolveProviderAlias } from "../model.ts";
import { filterExcludedCandidates } from "./candidateOverrides";
@@ -590,6 +592,29 @@ export async function prepareVirtualAutoComboInputs(
// exclude paid-only backends from EVERY `auto/*` candidate pool.
const paidFilteredPool = filterPaidOnlyCandidates(pool, settings.hidePaidModels === true);
if (paidFilteredPool !== pool) pool = paidFilteredPool;
// STRICT_ZERO_COST: opt-in, off by default (`settings.freeAccessPolicy !== "strict"`
// leaves `pool` byte-identical, same contract as `hidePaidModels`). See
// `strictZeroCostFilter.ts` for why this is stricter than `hidePaidModels` alone —
// including the connection-safety invariant it enforces per-connection, not just
// per-candidate: `resolveFreeAccessState` here is a raw pass-through of the real
// per-(provider,connectionId) resolver; the filter itself decides which connection(s)
// on each candidate to check and rewrites `allowedConnectionIds` to the SAFE subset.
const strictFilteredPool = filterStrictZeroCostCandidates(pool, {
enabled: settings.freeAccessPolicy === "strict",
resolveFreeAccessState,
// 1 percentage point of headroom, not 0: `freeAccessQuota.ts` reports
// 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.
minRemainingAllowance: 1,
maxStateAgeMs: (Number(settings.autoRefreshProviderQuotaInterval) || 180) * 1000,
});
if (strictFilteredPool !== pool) pool = strictFilteredPool;
// Separate, optional ToS guard — independent of economic safety on purpose.
const tosFilteredPool = filterTosAvoidCandidates(pool, settings.excludeTosAvoid === true);
if (tosFilteredPool !== pool) pool = tosFilteredPool;
return pool;
};

View File

@@ -589,10 +589,7 @@ function resolveSilentCloseOutcome(input: {
if (!input.bytesWereForwarded) return null;
if (!input.clientTerminalSeen) {
if (
input.clientResponseFormat === FORMATS.CLAUDE &&
input.contentWatcher.sawContent()
) {
if (input.clientResponseFormat === FORMATS.CLAUDE && input.contentWatcher.sawContent()) {
// #7699 — upstream dropped after content reached the client on a Claude
// stream. Keep the partial response: emit a clean max_tokens completion
// instead of an error frame so Anthropic SDK / Claude Code don't report
@@ -611,6 +608,16 @@ function resolveSilentCloseOutcome(input: {
if (input.clientResponseFormat === FORMATS.OPENAI && input.contentWatcher.sawContent()) {
return { kind: "error", reason: "Upstream stream ended without a terminal marker" };
}
// Responses-format clients (Codex CLI and other /v1/responses consumers):
// a healthy OpenAI Responses stream ALWAYS terminates with an explicit
// `response.completed` event — it is the format's only terminal marker and
// carries the final status/usage. Content forwarded without it is an
// upstream drop, the same class as #10443 for chat completions; surface a
// synthetic response.failed instead of a silent close so clients report
// the break instead of waiting on a completion event that never comes.
if (isResponsesClientFormat(input.clientResponseFormat) && input.contentWatcher.sawContent()) {
return { kind: "error", reason: "Upstream stream ended without a terminal marker" };
}
}
const watcher = input.contentWatcher;

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 ────────────────────────────────────────────────
// Each reducer mirrors the corresponding build*Summary()'s original for-loop
// body exactly (ingest = one loop iteration, finalize = the post-loop return),
@@ -262,7 +331,27 @@ function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
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;
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) {
finishReason = "tool_calls";
message.tool_calls = finalToolCalls;

View File

@@ -0,0 +1,105 @@
/**
* Ad-hoc, one-shot dry run of STRICT_ZERO_COST against the real candidate
* pools currently served by this OmniRoute instance (fetched via the
* existing read-only `GET /v1/auto-combo/{channel}/candidates` endpoint —
* no changes made, no billable calls). Not wired into any test suite.
*
* Simulates the filter offline: no live usage-quota state is available
* (that adapter only runs inside the deployed container), so
* `resolveFreeAccessState` always returns `undefined` here — meaning any
* quota-based candidate is reported UNKNOWN unless it lacks even a usage
* adapter, in which case it's reported UNKNOWN for that reason instead. This
* intentionally shows the current, honest ceiling of what's usable today.
*
* Uses each candidate's REAL `connectionId` from the live endpoint (rather
* than assuming) to also exercise the post-code-review connection-safety
* check: a `keyless`-catalogued model whose live `connectionId` is NOT the
* no-auth sentinel is correctly reported as excluded here too.
*/
import { readFileSync } from "node:fs";
import {
evaluateCandidateConnections,
findBudgetEntry,
} from "../../open-sse/services/autoCombo/strictZeroCostFilter.ts";
import { SYNTHETIC_NOAUTH_CONNECTION_ID } from "../../open-sse/services/autoCombo/resilienceCandidateFilter.ts";
import { USAGE_FETCHER_PROVIDERS } from "../../open-sse/services/usage.ts";
const usageProviders = new Set<string>(USAGE_FETCHER_PROVIDERS);
const OPTIONS = { minRemainingAllowance: 1, maxStateAgeMs: 180_000 };
interface Candidate {
provider: string;
model: string;
connectionId: string;
}
function loadCandidates(path: string): Candidate[] {
const raw = JSON.parse(readFileSync(path, "utf8"));
const list = Array.isArray(raw) ? raw : raw.candidates;
// The candidates endpoint's `model` field is the FULL "<providerOrAlias>/<modelId>"
// string (`modelStr` — the leading segment is sometimes the provider id,
// e.g. "groq/...", sometimes its short alias, e.g. "oc/..." for opencode);
// FREE_MODEL_BUDGETS.modelId is always bare. Strip exactly the first "/"
// segment (whichever form it is) so e.g. "groq/meta-llama/llama-4-scout..."
// becomes "meta-llama/llama-4-scout..." and "oc/big-pickle" becomes
// "big-pickle", matching the catalog's modelId either way.
return list.map((c: { provider: string; model: string; connectionId?: string }) => {
const slash = c.model.indexOf("/");
return {
provider: c.provider,
model: slash === -1 ? c.model : c.model.slice(slash + 1),
connectionId: c.connectionId ?? SYNTHETIC_NOAUTH_CONNECTION_ID,
};
});
}
function run(label: string, path: string): void {
const candidates = loadCandidates(path);
console.log(`\n=== ${label}${candidates.length} candidati live ===`);
const kept: Candidate[] = [];
const excluded: { candidate: Candidate; reason: string }[] = [];
for (const c of candidates) {
const entry = findBudgetEntry(c);
if (!entry) {
excluded.push({ candidate: c, reason: "non presente nel catalogo free curato" });
continue;
}
const isNoAuthConnection = c.connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID;
if (entry.freeType === "keyless") {
const safe = evaluateCandidateConnections(c, entry, () => undefined, OPTIONS);
if (safe.length > 0) {
kept.push(c);
} else if (!isNoAuthConnection) {
excluded.push({
candidate: c,
reason:
"keyless nel catalogo ma raggiunto tramite una connessione DB reale (non il sentinel noauth) — shortcut non applicato, richiederebbe hardStopGuaranteed",
});
} else {
excluded.push({ candidate: c, reason: "keyless ma valutazione fallita (inatteso)" });
}
continue;
}
const hasAdapter = usageProviders.has(entry.provider);
const reason = !hasAdapter
? `nessun usage adapter per '${entry.provider}' in USAGE_FETCHER_PROVIDERS`
: entry.hardStopGuaranteed !== true
? "hardStopGuaranteed non dichiarato per questo modello"
: "nessuno stato quota live disponibile in questo dry-run offline (richiederebbe il container reale)";
excluded.push({ candidate: c, reason });
}
console.log(`PRIMA (STRICT_ZERO_COST off): ${candidates.length} candidati`);
console.log(`DOPO (STRICT_ZERO_COST on): ${kept.length} candidati sopravvissuti`);
console.log("Sopravvissuti:");
for (const c of kept) console.log(` OK ${c.provider}/${c.model}`);
console.log("Esclusi (motivo):");
for (const { candidate: c, reason } of excluded) {
console.log(` EXCL ${c.provider}/${c.model}${reason}`);
}
}
run("auto/coding:free", process.argv[2] ?? "/tmp/dryrun_coding_free.json");
run("auto/best-free", process.argv[3] ?? "/tmp/dryrun_best-free.json");

View File

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

View File

@@ -15,6 +15,12 @@ if (!support.nodeCompatible) {
process.exit(1);
}
console.log(
`Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).`
);
if (process.versions.bun) {
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";
// 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
// webpack escape hatch.
const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0";
// webpack escape hatch. Under Bun, Turbopack native V8 bindings are unavailable,
// 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();
// 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.

View File

@@ -1,12 +1,12 @@
---
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 -->
## 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

View File

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

View File

@@ -533,9 +533,9 @@ function getStrategyBadgeClass(strategy) {
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
}
function getI18nOrFallback(t, key, fallback) {
function getI18nOrFallback(t, key, fallback, values) {
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 {}
return fallback;
}
@@ -1565,7 +1565,8 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) {
{getI18nOrFallback(
t,
"recommendationsUpdated",
"Recommendations updated for {strategy}."
"Recommendations updated for {strategy}.",
{ strategy: strategyLabel }
).replace("{strategy}", strategyLabel)}
</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 { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
import HarImportButton from "../HarImportButton";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { buildAddProviderSpecificData } from "./connectionProviderSpecificData";
import { getCommandCodeAuthPhaseLabel } from "./commandCodeAuthPhase";
@@ -155,7 +156,7 @@ export default function AddApiKeyModal({
if (!isOpen || wasOpen) return;
// 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
// 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) => ({
...current,
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."
: isWebSessionCredential
? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false)
: isLocalSelfHostedProvider
? t("localProviderApiKeyOptionalHint", {
provider: localProviderMetadata?.name || providerName || provider || "",
})
: apiKeyOptional
? t("apiKeyOptionalHint")
: undefined;
: isLocalSelfHostedProvider
? t("localProviderApiKeyOptionalHint", {
provider: localProviderMetadata?.name || providerName || provider || "",
})
: apiKeyOptional
? t("apiKeyOptionalHint")
: undefined;
const credentialValidationFailedMessage = isWebSessionCredential
? providerText(
t,
@@ -750,6 +751,12 @@ export default function AddApiKeyModal({
t={t}
/>
)}
{provider && (
<HarImportButton
provider={provider}
onImport={(apiKey) => setFormData({ ...formData, apiKey })}
/>
)}
{!isNoAuthWebSessionCredential && (
<div className="flex gap-2">
<Input
@@ -757,6 +764,12 @@ export default function AddApiKeyModal({
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter" && !validating && !saving) {
e.preventDefault();
handleValidate();
}
}}
className="flex-1"
placeholder={apiCredentialPlaceholder}
hint={apiCredentialHint}

View File

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

View File

@@ -4,7 +4,23 @@
// connection. Deriving a unique default from the existing connection count keeps
// the first connection ("main") backward-compatible while giving each subsequent
// one a distinct name ("main-2", "main-3", …).
export function computeConnectionDefaultName(existingConnectionCount?: number): string {
const count = existingConnectionCount ?? 0;
export function computeConnectionDefaultName(
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}`;
}

View File

@@ -17,6 +17,8 @@ import { logRoutingDecision } from "@/lib/a2a/routingLogger";
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
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 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);
}
function authenticate(req: NextRequest): boolean {
// If no API key is configured, allow all requests
const configuredKey = process.env.OMNIROUTE_API_KEY;
if (!configuredKey) return true;
async function authenticate(req: NextRequest): Promise<boolean> {
// /a2a is outside the authz proxy matcher, so the REQUIRE_API_KEY posture the
// pipeline enforces for /v1 never ran here — the route accepted every caller
// 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 token = authHeader.replace(/^Bearer\s+/i, "");
return tokensMatch(token, configuredKey);
const configuredKey = process.env.OMNIROUTE_API_KEY;
if (configuredKey) {
return apiKey ? tokensMatch(apiKey, configuredKey) : false;
}
// No API key required and none configured — allow (keyless local-first).
return true;
}
// ============ JSON-RPC Helpers ============
@@ -179,7 +192,7 @@ async function rejectIfA2ADisabled(id: string | number | null) {
export async function POST(req: NextRequest) {
// Auth check
if (!authenticate(req)) {
if (!(await authenticate(req))) {
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { clearMemoryCache, getMemoryCacheStats } from "@/lib/semanticCache";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";

View File

@@ -38,7 +38,14 @@ export async function GET(request: NextRequest) {
const url = new URL(request.url);
const scope: LeaderboardScope = (url.searchParams.get("scope") || "global") as LeaderboardScope;
const limit = Number(url.searchParams.get("limit") || 100);
const rawLimit = url.searchParams.get("limit");
const limit = rawLimit === null ? 100 : Number(rawLimit);
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
return NextResponse.json(
{ error: "'limit' must be an integer between 1 and 200" },
{ status: 400, headers: CORS_HEADERS }
);
}
const entries = await getTopN(scope, limit);

View File

@@ -18,10 +18,18 @@ export async function GET(request: NextRequest) {
const url = new URL(request.url);
const scope = (url.searchParams.get("scope") || "global") as LeaderboardScope;
const limit = Number(url.searchParams.get("limit") || 50);
const rawLimit = url.searchParams.get("limit");
const limit = rawLimit === null ? 50 : Number(rawLimit);
const apiKeyId = url.searchParams.get("apiKeyId");
const entries = await getTopN(scope, Math.min(limit, 200));
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
return NextResponse.json(
{ error: "'limit' must be an integer between 1 and 200" },
{ status: 400, headers: CORS_HEADERS }
);
}
const entries = await getTopN(scope, limit);
let myRank: number | null = null;
let neighbors = null;

View File

@@ -5,6 +5,7 @@ import { readRunningBuildSha } from "@/lib/monitoring/buildSha";
import { APP_CONFIG } from "@/shared/constants/config";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
/**
* GET /api/monitoring/health — System health overview
@@ -20,10 +21,25 @@ import { isAuthenticated } from "@/shared/utils/apiAuth";
let healthPayloadCache: { payload: unknown; expiresAt: number } | null = null;
const HEALTH_PAYLOAD_TTL_MS = 1000;
export async function GET() {
// GHSA-mvf8-qc78-5mxm: the full health payload fingerprints the host (version,
// node version, pid, memory, provider config). An anonymous caller — the common
// case on a keyless install, and what a liveness/load-balancer probe needs — gets
// only the liveness verdict; the detail is reserved for a management principal.
function publicHealthView(payload: unknown): Record<string, unknown> {
const p = (payload ?? {}) as Record<string, unknown>;
return {
status: p.status ?? "unknown",
...(p.setupComplete !== undefined ? { setupComplete: p.setupComplete } : {}),
};
}
export async function GET(request: Request) {
const fullView = (await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
const cachedNow = Date.now();
if (healthPayloadCache && cachedNow <= healthPayloadCache.expiresAt) {
return NextResponse.json(healthPayloadCache.payload);
return NextResponse.json(
fullView ? healthPayloadCache.payload : publicHealthView(healthPayloadCache.payload)
);
}
const readHealthValue = <T>(label: string, reader: () => T, fallback: T): T => {
@@ -187,7 +203,7 @@ export async function GET() {
});
healthPayloadCache = { payload, expiresAt: Date.now() + HEALTH_PAYLOAD_TTL_MS };
return NextResponse.json(payload);
return NextResponse.json(fullView ? payload : publicHealthView(payload));
} catch (error) {
console.error("[API] GET /api/monitoring/health error:", error);
return NextResponse.json({

View File

@@ -24,6 +24,7 @@ import {
} from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth";
import { syncToCloud } from "@/lib/cloudSync";
import { startLocalServer } from "@/lib/oauth/utils/server";
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
@@ -221,6 +222,16 @@ export async function GET(
(requestDeviceCode as any)(provider, null, providerOverrideConfig)
);
} else if ((provider === "kiro" || provider === "amazon-q") && startUrl) {
// GHSA-7x63: `region` is interpolated into the AWS OIDC endpoint URLs
// below, which requestDeviceCode() then fetches. Validate it against the
// canonical AWS region shape before it can steer the outbound host to an
// attacker-chosen target (userinfo/fragment tricks → SSRF / metadata).
if (!AWS_REGION_PATTERN.test(region)) {
return NextResponse.json(
{ error: "region must be a valid AWS region (e.g. us-east-1)" },
{ status: 400 }
);
}
const providerOverrideConfig = {
...providerData.config,
startUrl,

View File

@@ -3,7 +3,7 @@ import path from "path";
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import {
scanCliProxyAuthDir,
@@ -23,9 +23,9 @@ function cliProxyConfigDir(): string {
}
async function requireImportAuth(request: Request) {
if (!(await isAuthRequired(request))) return null;
if (await isAuthenticated(request)) return null;
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
// require management scope (or a dashboard session), not any valid client key.
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function GET(request: Request) {

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { extractCodexAccountInfo } from "@/lib/oauth/services/codexImport";
import { parseCodexSessionJson } from "@/lib/oauth/utils/codexSessionImport";
import { createProviderConnection } from "@/models";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
/**
@@ -93,10 +93,11 @@ async function parseRequestBody(
return { ok: true, resolved: resolved.resolved };
}
async function requireAuth(request: Request): Promise<NextResponse | null> {
if (!(await isAuthRequired(request))) return null;
if (await isAuthenticated(request)) return null;
return NextResponse.json(buildErrorBody(401, "Unauthorized"), { status: 401 });
async function requireAuth(request: Request): Promise<Response | null> {
// GHSA-mg76: importing a provider connection is a state-mutating admin action.
// Require management scope (or a dashboard session) rather than accepting any
// valid client key, which the PUBLIC /api/oauth/ classification otherwise allows.
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function POST(request: Request) {

View File

@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { normalizeCodexImportRecord, flattenCodexImportPayload } from "@/lib/oauth/services/codexImport";
import { createProviderConnection } from "@/models";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { refreshCodexToken, isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts";
@@ -82,10 +82,10 @@ const bodySchema = z.object({
}),
});
async function requireAuth(request: Request): Promise<NextResponse | null> {
if (!(await isAuthRequired(request))) return null;
if (await isAuthenticated(request)) return null;
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
async function requireAuth(request: Request): Promise<Response | null> {
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
// require management scope (or a dashboard session), not any valid client key.
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function POST(request: Request) {

View File

@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor";
/**
@@ -11,11 +11,9 @@ import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor";
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4).
*/
export async function GET(request: Request) {
if (await isAuthRequired(request)) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
// GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action.
const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
if (authError) return authError;
try {
// Try Cursor IDE first (has both accessToken and machineId)

View File

@@ -6,15 +6,15 @@ import { isCloudEnabled } from "@/models";
import { syncToCloud } from "@/lib/cloudSync";
import { cursorImportSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { resolveProxyForProvider } from "@/models";
async function requireOAuthImportAuth(request: Request) {
if (!(await isAuthRequired(request))) return null;
if (await isAuthenticated(request)) return null;
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
// require management scope (or a dashboard session), not any valid client key.
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
/**

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { homedir } from "os";
import { join } from "path";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import {
createProviderConnection,
getProviderConnections,
@@ -31,11 +31,9 @@ import {
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key.
*/
export async function GET(request: Request) {
if (await isAuthRequired(request)) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
// GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action.
const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
if (authError) return authError;
const { searchParams } = new URL(request.url);
const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro";

View File

@@ -11,7 +11,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { kiroImportSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity";
@@ -38,9 +38,9 @@ export function buildKiroImportError(error: unknown): string {
}
async function requireOAuthImportAuth(request: Request) {
if (!(await isAuthRequired(request))) return null;
if (await isAuthenticated(request)) return null;
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
// require management scope (or a dashboard session), not any valid client key.
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
async function upsertImportedKiroConnection(

View File

@@ -14,14 +14,14 @@ import {
extractLocalRaycastCredentials,
isRaycastLocalExtractAvailable,
} from "@/lib/oauth/services/raycastLocal";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { resolveProxyForProvider } from "@/models";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
async function requireOAuthImportAuth(request: Request) {
if (!(await isAuthRequired(request))) return null;
if (await isAuthenticated(request)) return null;
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
// require management scope (or a dashboard session), not any valid client key.
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function GET(request: Request) {

View File

@@ -11,14 +11,14 @@ import { createProviderConnection } from "@/models";
import { RaycastService } from "@/lib/oauth/services/raycast";
import { raycastImportSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { resolveProxyForProvider } from "@/models";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
async function requireOAuthImportAuth(request: Request) {
if (!(await isAuthRequired(request))) return null;
if (await isAuthenticated(request)) return null;
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
// require management scope (or a dashboard session), not any valid client key.
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function POST(request: Request) {

View File

@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
import { traeImportSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
/**
* POST /api/oauth/trae/import
@@ -22,9 +22,9 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
* region — optional, default "US-East"
*/
async function requireOAuthImportAuth(request: Request) {
if (!(await isAuthRequired(request))) return null;
if (await isAuthenticated(request)) return null;
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
// require management scope (or a dashboard session), not any valid client key.
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function POST(request: Request) {

View File

@@ -28,6 +28,7 @@ import {
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
import { isAuthenticated } from "@/shared/utils/apiAuth";
export const dynamic = "force-dynamic";
import { providerModelMutationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";

View File

@@ -87,6 +87,22 @@ export function parseAlibabaModelStudioModelsForConnection(
export function parseQwenCloudTextModels(data: any): any[] {
return parseCuratedDashscopeModels(data, QWEN_CLOUD_TEXT_MODELS, QWEN_CLOUD_TEXT_MODEL_IDS);
}
// Perplexity's /v1/models lists the Agent API catalog (vendor-prefixed ids like
// "anthropic/claude-fable-5"), but chat requests always go to the classic
// /chat/completions endpoint, which only accepts the Sonar family. Filter
// discovery to Sonar-family ids so agent-style ids never surface as routable
// chat models (#11060). Bounded pattern — no ReDoS-prone quantifiers.
export function parsePerplexitySonarModels(data: any): any[] {
const models = Array.isArray(data?.data)
? data.data
: Array.isArray(data?.models)
? data.models
: [];
return models.filter(
(model: any) => typeof model?.id === "string" && /^sonar(-|$)/.test(model.id)
);
}
type ProviderModelsHeaderContext = {
authType?: string;
providerSpecificData?: unknown;
@@ -659,6 +675,17 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
headers: { Accept: "application/json" },
parseResponse: parseClinepassRecommendedModels,
},
// Perplexity's /v1/models lists the Agent API catalog (vendor-prefixed agent
// ids), but chat only accepts the Sonar family on /chat/completions. Import
// must keep Sonar-family ids only (#11060).
perplexity: {
url: "https://api.perplexity.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: parsePerplexitySonarModels,
},
cohere: {
url: "https://api.cohere.com/v2/models",
method: "GET",

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import {
getProviderAuditTarget,

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import {
getObsidianSyncStatus,
@@ -21,10 +22,19 @@ export async function GET(request: NextRequest) {
try {
const status = await getObsidianSyncStatus();
// GHSA-62vw: the WebDAV password is reusable authentication material. Return
// the plaintext only to a genuine management principal (dashboard session or
// manage-scope key), never to an anonymous caller that reached this handler
// through the requireLogin=false open mode. The dashboard's authenticated
// reveal-password view is unaffected; anonymous callers get a set/unset flag.
const hasManagement =
(await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
return NextResponse.json({
webdavEnabled: status.webdavEnabled,
webdavUsername: status.webdavEnabled ? status.webdavUsername : null,
webdavPassword: status.webdavEnabled ? status.webdavPassword : null,
webdavPassword:
status.webdavEnabled && hasManagement ? status.webdavPassword : null,
webdavPasswordSet: status.webdavEnabled && Boolean(status.webdavPassword),
vaultPath: status.vaultPath,
});
} catch (error) {

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { getProviderById } from "@/shared/constants/providers";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getApiKeys } from "@/lib/db/apiKeys";

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getCallLogs } from "@/lib/usageDb";
import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory";

View File

@@ -5,6 +5,10 @@
* returning combo metadata to API-key callers. Kept in a separate module so
* the projection can be unit-tested without spinning up the Next.js route.
*
* #10968: the projection also reports `accountPinned` per model step — a boolean
* derived from the stripped `connectionId`, so callers can distinguish a combo
* that fails over between two accounts of one provider from a duplicated step.
*
* #3979: client-facing combo catalogs (the `/v1/combos`, VS Code and LobeHub /
* OpenCode import surfaces) can opt into advertising the combo's resolved
* capabilities (multimodal / reasoning / caching) so importing clients enable
@@ -17,6 +21,19 @@ export interface PublicComboStep {
model?: string;
comboName?: string;
providerId?: string;
/**
* #10968: whether this step pins one specific account of its provider.
*
* Two steps that pin different accounts of the same provider project to
* identical `{kind, model, providerId}` objects, so a client cannot tell a
* two-account failover from the same step listed twice. This says which it
* is without exposing the `connectionId` the flag is derived from — not even
* a prefix, per the issue.
*
* Set on every `model` step. Absent on `combo-ref`, which routes through
* another combo and has no account of its own.
*/
accountPinned?: boolean;
}
/**
@@ -66,6 +83,10 @@ export function projectComboStep(step: Record<string, unknown>): PublicComboStep
if (typeof step.providerId === "string" && step.providerId.length > 0) {
out.providerId = step.providerId;
}
// Same shape test as providerId above. `cleanupComboConnectionRefs` drops the
// key when the connection is deleted, so a step whose pinned account is gone
// reports false rather than pointing at nothing.
out.accountPinned = typeof step.connectionId === "string" && step.connectionId.length > 0;
return out;
}
return null;

View File

@@ -68,7 +68,7 @@ async function postHandler(request, context) {
const response = await handleModeration({ body: { ...body, model }, credentials });
if (response?.ok) {
await clearRecoveredProviderState(credentials);
await clearRecoveredProviderState(credentials as Record<string, unknown>);
}
return response;
}

View File

@@ -15,22 +15,15 @@ import { encryptMetadata } from "@/lib/webhookDispatcher";
import { isEncryptionEnabled } from "@/lib/db/encryption";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
import { WEBHOOK_EVENT_VALUES } from "@/lib/webhooks/eventDescriptions";
const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const;
const WEBHOOK_EVENT_VALUES = [
"*",
"request.completed",
"request.failed",
"provider.error",
"provider.recovered",
"quota.exceeded",
"combo.switched",
"test.ping",
] as const;
const WEBHOOK_EVENT_VALUES_WITH_WILDCARD = ["*", ...WEBHOOK_EVENT_VALUES] as const;
const updateWebhookSchema = z
.object({
url: z.string().min(1).max(2000).optional(),
events: z.array(z.enum(WEBHOOK_EVENT_VALUES)).optional(),
events: z.array(z.enum(WEBHOOK_EVENT_VALUES_WITH_WILDCARD)).optional(),
secret: z.string().max(500).optional(),
description: z.string().max(1000).optional(),
enabled: z.boolean().optional(),

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