Compare commits

...

39 Commits

Author SHA1 Message Date
diegosouzapw
93b5826c8c fix(test): prevent flaky modelsDevSync timer assertions by serializing test execution within the file (#9534) 2026-08-06 20:57:07 -03:00
小妍儿 ✨
5f471181fa refactor(db): add combo repository boundary (#8757)
Validated in local merge-train (tomni-proxmox-113)
2026-08-06 19:11:57 -03:00
Lucas Mellos Carlos
ebdbe3a38f feat(mcp): add omniroute_create_combo tool (#8925)
Validated in local merge-train (tomni-proxmox-113)
2026-08-06 19:11:07 -03:00
Adriano de Oliveira Ferreira
7f36b192f0 fix(compression): bound session-dedup suffix-block scan to prevent OOM (#8438)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:09:05 -03:00
Austin Liu
1e55fbd20b [v3.8.50] fix(open-sse): filter non-numeric values in comboTargetLimits before min calculation (#8774)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:08:59 -03:00
Andrew B.
7bb4bfc4fb fix(combo): fail-fast concurrency gate and execute-mode overflow (#8890)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:08:51 -03:00
Andrew B.
0e1f40ed1f feat(oauth): add Raycast Pro provider with local auto-import (#8895)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:08:44 -03:00
Lucas Israel
2a94cbfe14 feat(executors): add isolated Claude Code bridge over Devin ACP (#8914)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:08:37 -03:00
rinseaid
813dbb6e03 fix(vision): prevent bridge streaming and normalize OMP effort (#8945)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:08:31 -03:00
Aman
d69f521491 fix: reconcile active live model catalogs (#9294)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:08:23 -03:00
Andrew B.
a598fbb090 fix(combo): least-used quota strategy and wildcard UI preservation (#8894)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:08:02 -03:00
Jan Leon
714a315a1a Treat context metadata as a routing hint (#8944)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:07:56 -03:00
hppsc1215
274514405f fix(oauth): GHE Copilot OAuth lifecycle — connect, manual refresh, proactive refresh (#8970)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:07:49 -03:00
Alexey Gusev
91bb6aa619 feat(providers): add comprehensive support for self-hosted Firecrawl via FIRECRAWL_BASE_URL and custom base URLs (#9052)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:07:42 -03:00
Bob.Hou
404554caeb fix(antigravity): alias gemini-3.1-pro-high to gemini-pro-agent (#9106)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:07:35 -03:00
Fajar Hidayat
8e27f5ec8d fix(sse): back the CCR block store with a durable tier (#9061) (#9198)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
2026-08-06 11:07:28 -03:00
Diego Rodrigues de Sa e Souza
ece486dc38 fix(resilience): enforce RPM with rolling leases (#9604)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:41:04 -03:00
Diego Rodrigues de Sa e Souza
f2e36ad0ce fix(ci): clear base-reds on release/v3.8.50 (migration collision + 4 masked gates) (#9600)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:40:56 -03:00
Diego Rodrigues de Sa e Souza
ba0a0751c4 fix(sse): shrink chat.ts model-lockout wiring back under the frozen file-size cap (#9598)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:40:46 -03:00
Diego Rodrigues de Sa e Souza
b0cfc3d31c feat(providers): add LLMGateway and LLM Kiwi registries (#9587)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:40:38 -03:00
Diego Rodrigues de Sa e Souza
a63940199f feat(providers): add FastRouter AnyAPI and ElectronHub registries (#9586)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:40:30 -03:00
Diego Rodrigues de Sa e Souza
bd4407cb64 feat(sse): New-API/One-API/Sub2API aggregator balance detection (#9415) (#9539)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:40:20 -03:00
Diego Rodrigues de Sa e Souza
9dc0c6881a feat(opencode-plugin): add visibleModels/hiddenModels allowlist/blocklist (#9473) (#9538)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:40:09 -03:00
Diego Rodrigues de Sa e Souza
ce6faa44e5 feat(models): treat quota-exhausted errors as non-hideable in Test All (#9511) (#9537)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:40:00 -03:00
Diego Rodrigues de Sa e Souza
2d617325e7 feat(catalog): add hideAutoCombos and hideNoThinkVariants settings toggles (#9418) (#9535)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:39:51 -03:00
Diego Rodrigues de Sa e Souza
607bccb6d6 feat(providers): add connection-level custom upstream headers (#8369) (#9497)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:39:41 -03:00
Diego Rodrigues de Sa e Souza
a4fbdbffac feat(copilot): add approval gate for runOmniRouteCli commands (#8461) (#9495)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:39:27 -03:00
Diego Rodrigues de Sa e Souza
5ea43c7a9d feat: make forwarded upstream response-header budget configurable (#9243) (#9492)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:39:15 -03:00
Diego Rodrigues de Sa e Souza
8fdb67f1d3 fix(auth): redirect active sessions from /login (#9491)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:39:05 -03:00
Diego Rodrigues de Sa e Souza
53c8016d53 docs: add small VPS memory optimization guide (#8237) (#9471)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:38:57 -03:00
Diego Rodrigues de Sa e Souza
b553ac4d14 feat(db): add provider-scoped model aliases (#9068) (#9469)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:38:49 -03:00
Diego Rodrigues de Sa e Souza
0720305b38 feat(providers): add Regolo AI provider (#9031) (#9468)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:38:41 -03:00
Diego Rodrigues de Sa e Souza
bed6e2b85a feat(infra): add systemd autostart unit for Linux (#8635) (#9466)
Validated in local merge-train (diegosouzapw batch)
2026-08-06 10:38:33 -03:00
Diego Rodrigues de Sa e Souza
3c6f71776e [v3.8.50] fix(sse): replay Gemini thought_signature on direct Claude->Gemini path (400 error)
[v3.8.50] fix(sse): replay Gemini thought_signature on direct Claude→Gemini path (400 error)
2026-08-06 07:00:58 -03:00
diegosouzapw
0afbe3295b Merge branch 'release/v3.8.50' into pr-8755-head 2026-08-06 06:42:02 -03:00
diegosouzapw
84ab6fa7b0 test(sse): add thoughtSignature assertion for Gemini direct path (#3440)
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-04 08:33:22 -03:00
diegosouzapw
14014fda12 Merge branch 'claude-to-gemini-400-error-fix' of https://github.com/csoftware-arigpt/OmniRoute into fix/pr-8755-thought-signature 2026-08-04 08:27:04 -03:00
csoftware-arigpt
30cf91e272 Preserve #3440 coverage under signature replay
Seed a connection-scoped Gemini thought signature before exercising the Claude-to-Gemini id assertions. This keeps the regression focused on the public-Gemini versus Vertex id contract while honoring the new signature replay behavior.

Constraint: Signature-less historical Claude tool calls are intentionally converted to context text.

Rejected: Emit unsigned functionCall parts in the fixture | Gemini 3+ rejects that production behavior.

Confidence: high

Scope-risk: narrow

Tested: Prettier, repository ESLint hook, vertex-functioncall-id-3440 Node test, git diff --check

Not-tested: Full repository test suite
2026-07-27 20:18:20 +03:00
csoftware-arigpt
c30724742d fix(sse): replay Gemini thought_signature on direct Claude→Gemini path (#2504)
Direct Claude↔Gemini translator was missing the thought_signature round-trip
that the OpenAI-hub path already had (#2504). Gemini 3+ thinking models strictly
validate thought_signature on every functionCall part in a multi-turn tool-call
batch and return HTTP 400 ("Function call is missing a thought_signature in
functionCall parts") when it is absent — breaking all agentic workflows through
Claude Code.

Three fixes across the direct path:

1. translator/index.ts — thread the per-connection signature namespace
   (connectionId) into the direct-path credentials, mirroring the hub path.
   Without this, claudeToGeminiRequest never receives _signatureNamespace and
   cannot look up stored signatures.

2. translator/response/gemini-to-claude.ts — capture thoughtSignature from
   Gemini response parts (functionCall, thought, or standalone signature parts)
   via state.pendingThoughtSignature, and persist it keyed by
   buildGeminiThoughtSignatureKey(connectionId, toolId). The signature
   frequently lands on a preceding thought part rather than the functionCall
   itself, so pending tracking across stream chunks is required.

3. translator/request/claude-to-gemini.ts — for each tool_use block, resolve the
   stored signature and attach it as thoughtSignature on the functionCall part.
   When no signature is available (historical tool calls predating the store, or
   cold-start), omit the functionCall part entirely and convert the matching
   tool_result to plain text — mirrors openai→gemini context mode, avoiding the
   bare-functionCall 400 while preserving conversation context.
2026-07-27 14:25:16 +03:00
259 changed files with 15471 additions and 1688 deletions

View File

@@ -0,0 +1 @@
- **docs:** add management authentication terminology guide ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786))

View File

@@ -0,0 +1,41 @@
# Management Authentication
OmniRoute uses four distinct credential families for management access. This guide
distinguishes them by purpose, scope, and locality.
| Credential | Scope | Locality | Use Case |
|-------------------------|--------------------|---------------|-----------------------------------|
| Dashboard JWT session | Full management | Localhost | Web dashboard login |
| CLI machine-id token | Full management | Per-machine | `omniroute` CLI commands |
| Scoped `oma_` token | Configurable scope | External | Automation / CI / API access |
| Manage-scope API key | `manage` scope | External | Management API calls |
## Dashboard JWT Session
Generated on dashboard login (`/api/auth/login`). Stored in HTTP-only cookie.
Valid for the session duration. Cannot be used from external hosts.
## CLI Machine-ID Token
Created by `omniroute auth login` on first use. Stored in `~/.omniroute/auth.json`.
Used by the CLI for all management operations. Tied to the machine identity.
## Scoped `oma_` Access Token
Created via dashboard or CLI with configurable scopes (e.g., `manage`, `read`).
Format: `oma_<random-hex>`. Used for programmatic access from external systems.
## Manage-Scope API Key
Standard API key with the `manage` scope enabled. Created in dashboard API Keys page.
Used for management API calls from external hosts.
## Header Examples
```
Authorization: Bearer oma_abc123def456
Authorization: Bearer <standard-api-key-with-manage-scope>
Cookie: omniroute_session=<jwt-token>
```
See `docs/reference/API_REFERENCE.md` for endpoint-specific auth requirements.

View File

@@ -0,0 +1,27 @@
import { describe, it } from "node:test";
import { ok } from "node:assert/strict";
import { readFileSync } from "node:fs";
describe("Management auth documentation (#7786)", () => {
const docPath = "docs/guides/MANAGEMENT-AUTH.md";
const content = readFileSync(docPath, "utf-8");
it("exists and has content", () => {
ok(content.length > 500, "should have substantial content");
ok(content.includes("Dashboard JWT session"));
ok(content.includes("CLI machine-id token"));
ok(content.includes("oma_"));
});
it("documents all four credential families", () => {
const families = ["Dashboard JWT", "CLI machine-id", "oma_", "Manage-scope"];
for (const f of families) {
ok(content.includes(f), `should document ${f}`);
}
});
it("mentions relevant auth header examples", () => {
ok(content.includes("Authorization"));
ok(content.includes("Bearer"));
});
});

View File

@@ -0,0 +1 @@
- **feat(infra):** add systemd autostart unit for Linux ([#8635](https://github.com/diegosouzapw/OmniRoute/issues/8635))

View File

@@ -0,0 +1,19 @@
[Unit]
Description=OmniRoute AI Proxy
After=network.target network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$(which omniroute) start
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
# Security hardening
NoNewPrivileges=true
ProtectSystem=full
PrivateTmp=true
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,23 @@
import { describe, it } from "node:test";
import { ok } from "node:assert/strict";
import { readFileSync, existsSync } from "node:fs";
describe("Systemd autostart (#8635)", () => {
const svcPath = "contrib/systemd/omniroute.service";
const content = readFileSync(svcPath, "utf-8");
it("service file exists", () => {
ok(existsSync(svcPath));
ok(content.length > 200);
});
it("defines required systemd sections", () => {
ok(content.includes("[Unit]"));
ok(content.includes("[Service]"));
ok(content.includes("[Install]"));
});
it("specifies WantedBy=default.target", () => {
ok(content.includes("WantedBy=default.target"));
});
});

View File

@@ -24,6 +24,7 @@ coverage
# Runtime data and logs
data
logs
.sandbox
# Local env files (inject at runtime via --env-file or -e)
.env

View File

@@ -0,0 +1,6 @@
ENABLE_LIVE_DEVIN_TESTS=0
DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7

View File

@@ -353,6 +353,7 @@ ALLOW_API_KEY_REVEAL=false
# instead of growing an unbounded string until the V8 heap is exhausted.
# Used by: open-sse/handlers/chatCore/nonStreamingResponseBody.ts
# Default: 67108864 (64 MB)
# OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES=768
# OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES=67108864
# CORS configuration — controls which cross-origin browser clients can call the API.
@@ -857,6 +858,12 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# (>= 3 retrievals = never compressed). 1 disables the ramp (binary skip at the threshold only).
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: 2.
#COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR=2
# CCR durable block store (#9061). The in-memory store loses blocks to LRU eviction, the TTL, a
# restart, or a retrieve landing on another instance, while the model is told it can retrieve them
# verbatim. Set to false to keep blocks in memory only, at the cost of that promise. Blocks over
# 512KB and cloud runtimes are memory-only regardless.
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: true.
#COMPRESSION_CCR_DURABLE_STORE=true
# T08/H5 — usage-observed prefix freeze (OPT-IN, default off). When enabled, a system prompt seen
# >= THRESHOLD times is treated as a stable cacheable prefix and preserved from compression even
# for providers the static cache-aware heuristic does not recognize (freeze = preserve, never
@@ -1041,6 +1048,17 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# VISION_BRIDGE_BASE_URL=
# VISION_BRIDGE_API_KEY=
# ── Raycast Pro (local auto-import) ──
# Raycast Pro AI is a reverse-engineered, unofficial API — local/personal use
# only (no OAuth client_id/secret; token is captured via macOS Auto-Import
# from the Keychain + local Raycast SQLite DB, or pasted manually). These
# vars are optional manual overrides used by open-sse/services/raycast.ts
# and the direct-probe benchmark script scripts/raycast/usage-benchmark.mjs.
# RAYCAST_BEARER_TOKEN=
# RAYCAST_DEVICE_ID=
# RAYCAST_AID=
# RAYCAST_SIG_SECRET=
# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
# ─────────────────────────────────────────────────────────────────────────────
@@ -1912,6 +1930,18 @@ APP_LOG_TO_FILE=true
# ── Devin CLI binary path ──
# Used by: open-sse/executors/devin-cli.ts. Default: looked up via PATH.
# CLI_DEVIN_BIN=devin
# Agentic bridge-only binary override. The bridge still executes ACP stdio only.
# CLI_DEVIN_AGENTIC_BIN=devin
# Required isolated HOME for the agentic Devin child process.
# DEVIN_AGENTIC_HOME=/home/bridge
# Bounded ACP turn timeout in milliseconds. Default: 120000.
# DEVIN_AGENTIC_ACP_TIMEOUT_MS=120000
# Agentic bridge model aliases. Values must keep the devin-cli-agentic/ prefix.
# DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7
# ── Command Code (custom CLI) callback ──
# Local port used for OAuth-style callbacks from the Command Code CLI helper.

5
.gitignore vendored
View File

@@ -72,6 +72,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
!.env.devin-bridge.example
!.env.homolog.example
# Provider API keys (never commit)
*.api-key
@@ -209,6 +210,8 @@ scripts/i18n/_pending-keys.json
.agents/
.antigravitycli/
.claude/
!tests/fixtures/devin-bridge/e2e-workspace/.claude/
!tests/fixtures/devin-bridge/e2e-workspace/.claude/**
# PR Reviews and local feedback files
pr_reviews*.json
@@ -248,6 +251,8 @@ _artifacts/
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
.artifacts/
# Isolated Devin bridge workspaces, evidence, and test databases
.sandbox/
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
.env.homolog

View File

@@ -196,6 +196,7 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
| Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra | both hooks |
| Provider-tag prefix | Prepend short upstream-provider label to enriched names (e.g. `Claude - Claude Opus 4.7` vs `Kiro - Claude Opus 4.7`, `GHM - GPT 5`) so same-id models routed via different upstream connections group visibly in the picker (default-on, opt-out via `features.providerTag: false`) | both hooks |
| Usable-only filter | Filter to providers with at least one healthy connection in `/api/providers` (opt-in via `features.usableOnly`) | both hooks |
| Model allowlist/blocklist | Curate the model picker to a fixed set of IDs via `features.visibleModels` (allowlist) and/or `features.hiddenModels` (blocklist). Bare suffixes like `claude-opus-4-7` match any `{prefix}/claude-opus-4-7`. Both compose with `usableOnly` (all filters AND together). Blocklist wins over allowlist (deny takes precedence) | both hooks |
| Disk-cache fallback | Last-known-good catalog persisted to disk; hydrates on a cold start when `/v1/models` is unreachable (default-on, opt-out via `features.diskCache: false`) | `config` |
| Bearer injection + suffix-spoof guard | Adds `Authorization` on baseURL-matched requests only | `auth.loader.fetch` |
| Gemini schema sanitization | Strips `$schema`/`$ref`/`additionalProperties` for `gemini-*`/`google-vertex-gemini/*` | `auth.loader.fetch` wrap |
@@ -226,6 +227,8 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models``GHM`, `Gemini``GEMINI`). Idempotent. Combos intentionally skipped (the `Combo:` prefix already conveys multi-upstream). |
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
| `visibleModels` | `string[]` | _unset_ | Allowlist — when set and non-empty, only models whose raw `/v1/models` ID matches are emitted. Bare IDs (no slash, e.g. `claude-opus-4-7`) match any `{prefix}/claude-opus-4-7`; full IDs (e.g. `cc/claude-opus-4-7`) match exactly. Composes with `usableOnly` and `hiddenModels` (all filters AND together). Unset or empty = no filter. |
| `hiddenModels` | `string[]` | _unset_ | Blocklist — models whose raw ID matches are dropped. Same matching rules as `visibleModels`. When a model is in both `visibleModels` and `hiddenModels`, the blocklist wins (deny takes precedence). Composes with `usableOnly` and `visibleModels` (all filters AND together). Unset or empty = no filter. |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
@@ -298,7 +301,45 @@ If you want a narrower-scoped Bearer for MCP (different from the chat/inference
- `compressionMetadata: true` annotates combo display names with their pipeline using traffic-light emoji for intensity (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) so the picker advertises which compression each combo applies and how heavy it is at a glance. Palette: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra. Unknown intensities fall through to raw text (`[rtk:custom-thing]`) so the plugin never hides a value OmniRoute knows but the plugin doesn't.
- `providerTag: true` (default) prepends a short upstream-provider label so the picker shows `Claude - Claude Opus 4.7` for `cc/claude-opus-4-7`, `Kiro - Claude Opus 4.7` for `kr/claude-opus-4-7`, and `GHM - GPT 5` for `ghm/gpt-5` (slot.name `GitHub Models` > 8 chars → abbreviated). Critical when the same model id is sold through multiple upstream connections with different cost/auth/rate-limit profiles. Set to `false` to keep the pre-v3.8.3 unsuffixed format.
## Comparison vs `@omniroute/opencode-provider`
#### Example — curating the model picker (allowlist + blocklist)
A typical OmniRoute instance serves 600+ models. The OpenCode TUI/CLI picker becomes unusable when you need to scroll through hundreds of entries to find the ~30 models you actually use. `visibleModels` and `hiddenModels` let you curate the picker to a fixed set of model IDs that persists in `opencode.json` across config resets.
```jsonc
{
"plugin": [
[
"@omniroute/opencode-plugin",
{
"providerId": "omniroute",
"baseURL": "https://or.example.com",
"features": {
"combos": true,
"enrichment": true,
"usableOnly": true,
"visibleModels": [
"claude-opus-4-7", // bare suffix: matches cc/claude-opus-4-7, kr/claude-opus-4-7, etc.
"cc/claude-sonnet-4-6", // exact: only the cc/ alias
"gemini-2.5-pro",
"gpt-5",
"o3",
"o3-pro",
"o4-mini",
],
"hiddenModels": [
"o3-mini", // hide the mini variant even if visibleModels is unset
],
},
},
],
],
}
```
- `visibleModels` is an allowlist — only models whose raw ID matches are emitted. Bare IDs (no slash) match any provider prefix; full IDs (with slash) match exactly.
- `hiddenModels` is a blocklist — listed models are dropped. When a model is in both lists, the blocklist wins (deny takes precedence).
- Both compose with `usableOnly` (all filters AND together: a model must pass usableOnly AND visibleModels AND not be in hiddenModels).
- Unset or empty = no filter (current behavior).
[`@omniroute/opencode-provider`](https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-provider) is the existing config-generator package — it writes a frozen `provider.<id>` block into `opencode.json` at build time. This plugin is the runtime integration.

View File

@@ -1,12 +1,12 @@
{
"name": "@omniroute/opencode-plugin",
"version": "0.2.0",
"version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@omniroute/opencode-plugin",
"version": "0.2.0",
"version": "0.2.1",
"license": "MIT",
"dependencies": {
"zod": "^4.4.3"

View File

@@ -23,7 +23,7 @@
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [

View File

@@ -177,6 +177,8 @@ const featuresSchema = z
mcpToken: z.string().min(1).optional(),
fetchInterceptor: z.boolean().optional(),
usableOnly: z.boolean().optional(),
visibleModels: z.array(z.string().min(1)).optional(),
hiddenModels: z.array(z.string().min(1)).optional(),
diskCache: z.boolean().optional(),
providerTag: z.boolean().optional(),
debugLog: z.boolean().optional(),
@@ -241,6 +243,11 @@ export const OMNIROUTE_FEATURE_DEFAULTS = {
// default-OFF (read sites use `features.X === true`)
compressionMetadata: false,
usableOnly: false,
// Array flags: unset/empty = no filter. These are not boolean toggles —
// they are operator-curated model-ID lists applied in the dynamic and static
// hooks alongside usableOnly (all filters AND together).
// visibleModels: undefined, // allowlist — only listed IDs pass
// hiddenModels: undefined, // blocklist — listed IDs are dropped
mcpAutoEmit: false,
debugLog: false,
startupDebug: false,
@@ -2826,6 +2833,118 @@ export function isUsableCombo(
return false;
}
// ─────────────────────────────────────────────────────────────────────────
// #9473 — Model allowlist / blocklist filter helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Pre-compiled filter structure for the model allowlist/blocklist.
*
* "exact" holds full raw IDs (e.g. "cc/claude-opus-4-7") for O(1) match.
* "suffixes" holds bare model IDs (e.g. "claude-opus-4-7") that match any
* "{prefix}/claude-opus-4-7" — so operators can curate by model name without
* knowing the provider prefix.
*/
export interface ModelListFilter {
exact: Set<string>;
suffixes: Set<string>;
}
/**
* Compile a string[] of model IDs into a pre-computed filter structure.
* Returns undefined when the list is empty or undefined — the "no filter"
* state that callers use as a passthrough.
*
* IDs containing a "/" are stored in "exact"; bare IDs (no slash) go into
* "suffixes" and match any "{prefix}/<suffix>" at check time.
*/
export function compileModelListFilter(list?: string[]): ModelListFilter | undefined {
if (!list || list.length === 0) return undefined;
const exact = new Set<string>();
const suffixes = new Set<string>();
for (const id of list) {
if (id.includes("/")) {
exact.add(id);
} else {
suffixes.add(id);
}
}
if (exact.size === 0 && suffixes.size === 0) return undefined;
return { exact, suffixes };
}
/**
* Decide whether a raw model ID passes the allowlist/blocklist filter.
*
* Rules (all filters AND together with usableOnly):
* - No visible filter and no hidden filter → keep (passthrough).
* - Visible filter set: id must match either the exact set or the suffix
* set (bare suffix "claude-opus-4-7" matches any "{prefix}/claude-opus-4-7").
* - Hidden filter set: id must NOT match either the exact or suffix set.
* - If id is in BOTH visible and hidden → DROP (deny wins — safer).
* - No-slash ids (e.g. combo names like "claude-primary") are checked
* against the exact set directly, and against the suffix set as a bare
* match.
*
* Pure function — exported so static + dynamic hooks share the same
* verdict logic without divergence.
*/
export function passesModelAllowlist(
id: string,
visible?: ModelListFilter,
hidden?: ModelListFilter
): boolean {
// Hidden filter takes precedence (deny wins over allow).
if (hidden) {
if (hidden.exact.has(id) || matchesSuffix(id, hidden.suffixes)) return false;
}
// Visible filter: if set, id must match.
if (visible) {
if (!visible.exact.has(id) && !matchesSuffix(id, visible.suffixes)) return false;
}
return true;
}
/**
* Decide whether a combo passes the allowlist filter. A combo keeps when
* AT LEAST ONE of its members matches the visible filter. When no visible
* filter is set, all combos pass. Combos with zero resolvable members pass
* (mirrors `isUsableCombo` semantics).
*/
export function passesComboAllowlist(
combo: OmniRouteRawCombo,
visible?: ModelListFilter
): boolean {
if (!visible) return true;
const steps = Array.isArray(combo.models) ? combo.models : [];
if (steps.length === 0) return true;
let sawResolvableMember = false;
for (const step of steps) {
if (step?.kind === "combo-ref") continue;
const modelId = typeof step?.model === "string" ? step.model : "";
if (modelId.length === 0) continue;
sawResolvableMember = true;
if (visible.exact.has(modelId) || matchesSuffix(modelId, visible.suffixes)) return true;
}
// No resolvable member → can't prove it should be hidden; keep.
if (!sawResolvableMember) return true;
// Every resolvable member failed the allowlist → drop.
return false;
}
/**
* Check whether a raw model ID matches any suffix in the set.
* For an id like `cc/claude-opus-4-7`, the suffix after the first `/`
* is checked against the suffixes set. For a bare id like `claude-primary`,
* the id itself is checked against the suffixes set.
*/
function matchesSuffix(id: string, suffixes: Set<string>): boolean {
if (suffixes.size === 0) return false;
const slash = id.indexOf("/");
const suffix = slash > 0 ? id.slice(slash + 1) : id;
return suffixes.has(suffix);
}
/**
* Slugify a combo display name into a copy/paste-friendly URL-safe segment.
* Lowercases, replaces any run of non-alphanumeric chars with a single dash,
@@ -3009,6 +3128,9 @@ export function createOmniRouteProviderHook(
const wantCompressionMeta = features.compressionMetadata === true;
const wantUsableOnly = features.usableOnly === true;
const wantProviderTag = features.providerTag !== false;
// #9473: model allowlist/blocklist — compile once per hook instance.
const visibleFilter = compileModelListFilter(features.visibleModels);
const hiddenFilter = compileModelListFilter(features.hiddenModels);
const now = deps.now ?? Date.now;
// T-07: cache holds RAW fetch results (not pre-derived ModelV2) so that
// the config-shim hook can share the same cache and derive its stripped
@@ -3243,6 +3365,8 @@ export function createOmniRouteProviderHook(
if (!entry.id) continue;
if (canonicalDedup.has(entry.id)) continue;
if (usable && !isUsableRawModelId(entry.id, usable, rawEnrichment)) continue;
// #9473: allowlist/blocklist filter (AND with usableOnly).
if (!passesModelAllowlist(entry.id, visibleFilter, hiddenFilter)) continue;
const model = mapRawModelToModelV2(entry, {
// #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`.
providerId: resolved.omnirouteProviderId,
@@ -3318,6 +3442,8 @@ export function createOmniRouteProviderHook(
if (!combo.id) return false;
if (combo.isHidden === true) return false;
if (usable && !isUsableCombo(combo, usable)) return false;
// #9473: combo allowlist — drop when no member matches visible filter.
if (visibleFilter && !passesComboAllowlist(combo, visibleFilter)) return false;
return true;
});
// Resolved nested combos keyed by their friendly name, so parent
@@ -4135,6 +4261,9 @@ export function buildStaticProviderEntry(
wantUsableOnly && connections && connections.length > 0
? usableProviderAliasSet(connections, enrichment)
: undefined;
// #9473: model allowlist/blocklist — compile once per static-block build.
const visibleFilter = compileModelListFilter(opts.features?.visibleModels);
const hiddenFilter = compileModelListFilter(opts.features?.hiddenModels);
// Provider-tag suffix — default-on, opt-out via `features.providerTag: false`.
// Prepends e.g. `Claude - ` to enriched raw-model names so the picker
// can tell `cc/claude-opus-4-7` (Anthropic) apart from `kr/claude-opus-4-7`
@@ -4172,6 +4301,8 @@ export function buildStaticProviderEntry(
// Skip canonical-named twins when the alias-keyed enriched row exists.
if (canonicalDedup.has(raw.id)) continue;
if (usable && !isUsableRawModelId(raw.id, usable, enrichment)) continue;
// #9473: allowlist/blocklist filter (AND with usableOnly).
if (!passesModelAllowlist(raw.id, visibleFilter, hiddenFilter)) continue;
const caps = raw.capabilities ?? {};
// Enrichment overlay: `/api/pricing/models` carries human display names
// (e.g. "Claude Opus 4.7" for raw id "cc/claude-opus-4-7"). The OC TUI
@@ -4324,6 +4455,8 @@ export function buildStaticProviderEntry(
if (!combo.id) return false;
if (combo.isHidden === true) return false;
if (usable && !isUsableCombo(combo, usable)) return false;
// #9473: combo allowlist — drop when no member matches visible filter.
if (visibleFilter && !passesComboAllowlist(combo, visibleFilter)) return false;
return true;
});

View File

@@ -0,0 +1,317 @@
/**
* #9473 — Model allowlist/blocklist for the opencode-plugin.
*
* Tests for the pure filter helpers (`compileModelListFilter`,
* `passesModelAllowlist`, `passesComboAllowlist`) and the schema + hook-level
* integration. The allowlist/blocklist composes with `usableOnly` (all filters
* AND together), blocklist wins over allowlist (deny takes precedence), and
* bare-suffix entries (e.g. "claude-opus-4-7") match any "{prefix}/claude-opus-4-7".
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
compileModelListFilter,
passesModelAllowlist,
passesComboAllowlist,
parseOmniRoutePluginOptions,
buildStaticProviderEntry,
resolveOmniRoutePluginOptions,
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
} from "../src/index.js";
// ─────────────────────────────────────────────────────────────────────────
// compileModelListFilter
// ─────────────────────────────────────────────────────────────────────────
test("compileModelListFilter: undefined list → undefined", () => {
assert.equal(compileModelListFilter(undefined), undefined);
});
test("compileModelListFilter: empty array → undefined", () => {
assert.equal(compileModelListFilter([]), undefined);
});
test("compileModelListFilter: raw IDs with slash → exact set populated", () => {
const f = compileModelListFilter(["cc/claude-opus-4-7", "glm/gpt-5"]);
assert.ok(f);
assert.equal(f.exact.has("cc/claude-opus-4-7"), true);
assert.equal(f.exact.has("glm/gpt-5"), true);
assert.equal(f.suffixes.size, 0);
});
test("compileModelListFilter: bare IDs (no slash) → suffixes set populated", () => {
const f = compileModelListFilter(["claude-opus-4-7", "gpt-5"]);
assert.ok(f);
assert.equal(f.suffixes.has("claude-opus-4-7"), true);
assert.equal(f.suffixes.has("gpt-5"), true);
assert.equal(f.exact.size, 0);
});
test("compileModelListFilter: mixed raw + bare → both sets populated", () => {
const f = compileModelListFilter(["cc/claude-opus-4-7", "gpt-5"]);
assert.ok(f);
assert.equal(f.exact.has("cc/claude-opus-4-7"), true);
assert.equal(f.suffixes.has("gpt-5"), true);
});
// ─────────────────────────────────────────────────────────────────────────
// passesModelAllowlist
// ─────────────────────────────────────────────────────────────────────────
test("passesModelAllowlist: no visible, no hidden → keep (passthrough)", () => {
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, undefined), true);
});
test("passesModelAllowlist: visible undefined, hidden undefined → keep", () => {
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, undefined), true);
});
test("passesModelAllowlist: visible set, id matches exact → keep", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, undefined), true);
});
test("passesModelAllowlist: visible set, id matches suffix → keep", () => {
const vis = compileModelListFilter(["claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, undefined), true);
});
test("passesModelAllowlist: visible set, id does NOT match → drop", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("glm/gpt-5", vis, undefined), false);
});
test("passesModelAllowlist: visible set, bare suffix matches different prefix → keep", () => {
const vis = compileModelListFilter(["claude-opus-4-7"]);
assert.equal(passesModelAllowlist("kr/claude-opus-4-7", vis, undefined), true);
});
test("passesModelAllowlist: hidden set, id matches exact → drop", () => {
const hid = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false);
});
test("passesModelAllowlist: hidden set, id matches suffix → drop", () => {
const hid = compileModelListFilter(["claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false);
});
test("passesModelAllowlist: hidden set, id does NOT match → keep", () => {
const hid = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("glm/gpt-5", undefined, hid), true);
});
test("passesModelAllowlist: id in BOTH visible and hidden → DROP (deny wins)", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
const hid = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, hid), false);
});
test("passesModelAllowlist: visible allows, hidden blocks different id → keep the visible one", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
const hid = compileModelListFilter(["glm/gpt-5"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, hid), true);
assert.equal(passesModelAllowlist("glm/gpt-5", vis, hid), false);
});
test("passesModelAllowlist: bare-suffix hidden blocks exact match too", () => {
const hid = compileModelListFilter(["claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false);
assert.equal(passesModelAllowlist("kr/claude-opus-4-7", undefined, hid), false);
});
test("passesModelAllowlist: no-slash id, visible set has bare match → keep", () => {
const vis = compileModelListFilter(["claude-primary"]);
assert.equal(passesModelAllowlist("claude-primary", vis, undefined), true);
});
test("passesModelAllowlist: no-slash id, visible set has no match → drop", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("claude-primary", vis, undefined), false);
});
// ─────────────────────────────────────────────────────────────────────────
// passesComboAllowlist
// ─────────────────────────────────────────────────────────────────────────
function combo(models: OmniRouteRawCombo["models"]): OmniRouteRawCombo {
return { id: "c1", name: "Test Combo", models };
}
test("passesComboAllowlist: visible undefined → keep", () => {
const c = combo([{ kind: "model", model: "cc/claude-opus-4-7" }]);
assert.equal(passesComboAllowlist(c, undefined), true);
});
test("passesComboAllowlist: ≥1 member matches visible → keep", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
const c = combo([
{ kind: "model", model: "dead/legacy" },
{ kind: "model", model: "cc/claude-opus-4-7" },
]);
assert.equal(passesComboAllowlist(c, vis), true);
});
test("passesComboAllowlist: zero members match visible → drop", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
const c = combo([
{ kind: "model", model: "glm/gpt-5" },
{ kind: "model", model: "kr/claude-opus-4-7" },
]);
assert.equal(passesComboAllowlist(c, vis), false);
});
test("passesComboAllowlist: bare suffix matches any prefix → keep", () => {
const vis = compileModelListFilter(["claude-opus-4-7"]);
const c = combo([{ kind: "model", model: "kr/claude-opus-4-7" }]);
assert.equal(passesComboAllowlist(c, vis), true);
});
test("passesComboAllowlist: zero members → keep", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesComboAllowlist(combo([]), vis), true);
assert.equal(passesComboAllowlist(combo(undefined), vis), true);
});
test("passesComboAllowlist: only combo-ref steps → keep", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
const c = combo([{ kind: "combo-ref", comboName: "nested" }]);
assert.equal(passesComboAllowlist(c, vis), true);
});
// ─────────────────────────────────────────────────────────────────────────
// Schema — visibleModels / hiddenModels
// ─────────────────────────────────────────────────────────────────────────
test("parseOmniRoutePluginOptions: visibleModels string[] → preserved", () => {
const r = parseOmniRoutePluginOptions({
features: { visibleModels: ["cc/claude-opus-4-7", "gpt-5"] },
});
assert.deepEqual(r.features?.visibleModels, ["cc/claude-opus-4-7", "gpt-5"]);
});
test("parseOmniRoutePluginOptions: hiddenModels string[] → preserved", () => {
const r = parseOmniRoutePluginOptions({
features: { hiddenModels: ["glm/gpt-5"] },
});
assert.deepEqual(r.features?.hiddenModels, ["glm/gpt-5"]);
});
test("parseOmniRoutePluginOptions: both lists together → preserved", () => {
const r = parseOmniRoutePluginOptions({
features: {
visibleModels: ["cc/claude-opus-4-7"],
hiddenModels: ["glm/gpt-5"],
},
});
assert.deepEqual(r.features?.visibleModels, ["cc/claude-opus-4-7"]);
assert.deepEqual(r.features?.hiddenModels, ["glm/gpt-5"]);
});
test("parseOmniRoutePluginOptions: empty string in visibleModels → rejects", () => {
assert.throws(
() =>
parseOmniRoutePluginOptions({
features: { visibleModels: [""] },
}),
/Invalid @omniroute\/opencode-plugin options/
);
});
test("parseOmniRoutePluginOptions: empty string in hiddenModels → rejects", () => {
assert.throws(
() =>
parseOmniRoutePluginOptions({
features: { hiddenModels: [""] },
}),
/Invalid @omniroute\/opencode-plugin options/
);
});
test("parseOmniRoutePluginOptions: unknown features key still rejects (strict invariant)", () => {
assert.throws(
() =>
parseOmniRoutePluginOptions({
features: { visibleModels: ["x"], unknownKey: true },
}),
/Invalid @omniroute\/opencode-plugin options/
);
});
// ─────────────────────────────────────────────────────────────────────────
// buildStaticProviderEntry — allowlist/blocklist integration
// ─────────────────────────────────────────────────────────────────────────
const FAKE_RAW_MODELS: OmniRouteRawModelEntry[] = [
{ id: "cc/claude-opus-4-7", owned_by: "anthropic" },
{ id: "glm/gpt-5", owned_by: "openai" },
{ id: "kr/claude-opus-4-7", owned_by: "anthropic" },
{ id: "claude-primary", owned_by: "combo" },
];
test("buildStaticProviderEntry: no allowlist → all models emitted", () => {
const opts = resolveOmniRoutePluginOptions({ features: {} });
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present");
assert.ok(ids.includes("glm/gpt-5"), "glm/gpt-5 should be present");
assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should be present");
});
test("buildStaticProviderEntry: visibleModels filters to only listed IDs", () => {
const opts = resolveOmniRoutePluginOptions({
features: { visibleModels: ["cc/claude-opus-4-7"] },
});
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present");
assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be filtered out");
assert.equal(ids.includes("kr/claude-opus-4-7"), false, "kr/claude-opus-4-7 should be filtered out");
});
test("buildStaticProviderEntry: hiddenModels drops listed IDs", () => {
const opts = resolveOmniRoutePluginOptions({
features: { hiddenModels: ["glm/gpt-5"] },
});
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present");
assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be hidden");
assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should be present");
});
test("buildStaticProviderEntry: bare-suffix visibleModels matches any prefix", () => {
const opts = resolveOmniRoutePluginOptions({
features: { visibleModels: ["claude-opus-4-7"] },
});
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should match via suffix");
assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should match via suffix");
assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be filtered out");
});
test("buildStaticProviderEntry: id in both visible and hidden → hidden wins", () => {
const opts = resolveOmniRoutePluginOptions({
features: {
visibleModels: ["cc/claude-opus-4-7"],
hiddenModels: ["cc/claude-opus-4-7"],
},
});
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.equal(ids.includes("cc/claude-opus-4-7"), false, "deny takes precedence");
});
test("buildStaticProviderEntry: empty visibleModels → no filter (passthrough)", () => {
const opts = resolveOmniRoutePluginOptions({
features: { visibleModels: [] },
});
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.ok(ids.includes("cc/claude-opus-4-7"), "empty visibleModels should not filter");
assert.ok(ids.includes("glm/gpt-5"), "empty visibleModels should not filter");
});

View File

@@ -0,0 +1 @@
- **docs:** add low-memory/small VPS optimization guide ([#8237](https://github.com/diegosouzapw/OmniRoute/issues/8237))

View File

@@ -0,0 +1 @@
- **feat(providers):** add connection-level custom upstream headers via `provider_specific_data.customHeaders` — applied to every request through that connection, with model-level headers overriding on the same case-insensitive name. (thanks @Benson-mk)

View File

@@ -0,0 +1 @@
- feat(copilot): add approval gate for runOmniRouteCli commands (#8461)

View File

@@ -0,0 +1 @@
- **feat(providers):** add Regolo AI OpenAI-compatible provider ([#9031](https://github.com/diegosouzapw/OmniRoute/issues/9031))

View File

@@ -0,0 +1 @@
- **feat(db):** add provider-scoped model aliases that survive rediscovery ([#9068](https://github.com/diegosouzapw/OmniRoute/issues/9068))

View File

@@ -0,0 +1 @@
- feat: make forwarded upstream response-header budget configurable via env var (#9243)

View File

@@ -0,0 +1,6 @@
---
kind: feature
ref: "#9415"
---
New-API / One-API / Sub2API aggregator balance detection for compatible nodes. When a compatible provider node has the "Aggregator Gateway" toggle enabled, OmniRoute will query the aggregator's `/api/user/self` endpoint to detect the account balance. The dashboard shows the balance badge and quota-preflight routing skips exhausted accounts. The feature is gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default: off). A custom `quotaPerUnit` override is supported for aggregators that use a different rate than the default 500000 units/$1.

View File

@@ -0,0 +1 @@
- **feat(catalog):** added opt-in settings `hideAutoCombos` and `hideNoThinkVariants` (default off) to filter built-in `auto/*` virtual combos and `no-think/*` gateway variants from the `/v1/models` catalog — user-defined combos and original provider models stay listed; routing is unaffected ([#9418](https://github.com/diegosouzapw/OmniRoute/issues/9418))

View File

@@ -0,0 +1 @@
- **feat(opencode-plugin):** added `features.visibleModels` (allowlist) and `features.hiddenModels` (blocklist) to `@omniroute/opencode-plugin` — curate the OpenCode TUI/CLI model picker from 600+ catalog entries down to an operator-defined ID list that persists in `opencode.json` across config resets ([#9473](https://github.com/diegosouzapw/OmniRoute/issues/9473))

View File

@@ -0,0 +1 @@
- **feat(models):** Test All's "Auto-hide failed models" no longer hides quota errors — daily-quota-exhausted and credits-exhausted responses are now classified via the routing path's existing quota detectors, so an evening Test All on a free-tier provider no longer silently wipes the catalog. Quota results stay visible with a distinct amber badge ([#9511](https://github.com/diegosouzapw/OmniRoute/issues/9511))

View File

@@ -0,0 +1 @@
- fix(management): authorize mcp:connect-only keys on loopback/LAN when requireLogin is enabled (#9159)

View File

@@ -0,0 +1 @@
- **fix(resilience):** Enforce RPM limits with rolling leases and atomic global/provider/account admission ([#9259](https://github.com/diegosouzapw/OmniRoute/pull/9259)). The configured global RPM budget is shared across all enabled provider connections within one process; provider/account overrides add narrower scopes.

View File

@@ -0,0 +1 @@
- **fix(auth):** redirect active sessions from /login by checking the session cookie before showing the login form. (thanks @DaDecky)

View File

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

View File

@@ -0,0 +1,2 @@
- **fix(oauth):** GHE Copilot OAuth lifecycle — connecting an account and refreshing its token both failed. Adding a connection died with `gheUrl is required for GHE Copilot OAuth` because the poll handler's `ghe-copilot` branch was unreachable dead code: the provider is listed in `NO_PKCE_DEVICE_CODE_PROVIDERS`, and that set-based check ran first, calling `pollForToken()` without the `extraData` carrying `gheUrl`. Separately, every manual `Refresh` click surfaced `Token refresh failed — provider returned no new token`, and the proactive pre-request refresh never fired for GHE connections — the manual route, the health-check sweep and `checkAndRefreshToken()` all still special-cased plain `github`, while GHE Copilot's device-code flow never yields a `refresh_token` (only a GitHub access token plus a short-lived Copilot sub-token). `refreshCopilotToken()` now takes an optional `baseUrl` so it can target a GHE host's `<gheUrl>/api/v3` Copilot token endpoint, and `ghe-copilot` is wired in alongside `github` at all four sites.
- **fix(health-check):** the access-token-only branch of the token health-check sweep no longer logs an unconditional `has no refresh token but has a GitHub access token` line on every tick. That path runs once per 60 s sweep for every `github` / `ghe-copilot` connection, so it emitted ~1440 identical entries per day per connection reporting that nothing had changed. It now logs only when the sweep actually attempted a Copilot sub-token refresh, and says whether that refresh succeeded or failed — so a genuine failure still surfaces instead of being buried in steady-state noise.

View File

@@ -249,11 +249,6 @@
"count": 1
}
},
"open-sse/services/rateLimitManager.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"open-sse/services/taskAwareRouter.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 4

View File

@@ -1,5 +1,6 @@
{
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent's conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR's own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgents conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PRs own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
"_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.",
"_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).",
"_rebaseline_2026_07_22_8131_windowshide_cloudflared_spawn": "PR #8167 (Dingding-leo, fix/windows-hide-child-process, #8131) own growth: src/lib/cloudflaredTunnel.ts 934->935 (+1, irreducible call-site wiring — the single `windowsHide: true` option added to the existing cloudflared spawn() options object so no transient conhost.exe/cmd console window flashes open on Windows). Covered by the pre-merge-fix regression test tests/unit/windows-hide-child-process-spawns-8131.test.ts (added for the two additional spawn() sites the PR missed: ServiceSupervisor.ts, versionManager/processManager.ts) plus the windowsHide assertion added to tests/unit/services/installers/runNpm-shell-5379.test.ts (installers/utils.ts buildNpmExecOptions).",
@@ -524,7 +525,7 @@
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
"_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.",
"open-sse/executors/antigravity.ts": 1528,
"open-sse/executors/base.ts": 1635,
"open-sse/executors/base.ts": 1640,
"open-sse/executors/chatgpt-web.ts": 3241,
"open-sse/executors/codex.ts": 1562,
"open-sse/executors/cursor.ts": 1560,
@@ -539,12 +540,12 @@
"open-sse/mcp-server/schemas/tools.ts": 1505,
"open-sse/mcp-server/server.ts": 1411,
"open-sse/mcp-server/tools/advancedTools.ts": 1120,
"open-sse/services/accountFallback.ts": 1966,
"open-sse/services/accountFallback.ts": 1972,
"open-sse/services/adobeFireflyClient.ts": 2385,
"open-sse/services/claudeCodeCompatible.ts": 1202,
"open-sse/services/combo.ts": 3648,
"open-sse/services/compression/strategySelector.ts": 1060,
"open-sse/services/rateLimitManager.ts": 1105,
"open-sse/services/rateLimitManager.ts": 1167,
"open-sse/translator/response/openai-responses.ts": 1204,
"open-sse/utils/cursorAgentProtobuf.ts": 1505,
"open-sse/utils/stream.ts": 2889,
@@ -558,7 +559,7 @@
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2615,
"src/app/(dashboard)/dashboard/health/page.tsx": 1165,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1316,
"src/app/(dashboard)/dashboard/providers/page.tsx": 1928,
"src/app/(dashboard)/dashboard/providers/page.tsx": 1944,
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201,
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1019,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1464,
@@ -573,7 +574,7 @@
"src/lib/tokenHealthCheck.ts": 1021,
"src/lib/db/apiKeys.ts": 1529,
"src/lib/db/core.ts": 1637,
"src/lib/db/migrationRunner.ts": 1077,
"src/lib/db/migrationRunner.ts": 1084,
"src/lib/db/models.ts": 1097,
"src/lib/db/providers.ts": 1034,
"src/lib/memory/retrieval.ts": 1073,
@@ -605,5 +606,6 @@
"_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests.",
"_rebaseline_2026_08_05_9323_agentrouter_waf_retry": "PR #9323 (fix(agentrouter): retry on 400 content-blocked + burst guard) own growth: open-sse/executors/base.ts 1578->1623 (check-file-size.mjs conta via split(\"\\n\").length; wc -l ve 1622). As +45 linhas sao o WAF_RETRY_CONFIG + o burst guard via gateOutboundRequest() para o WAF do agentrouter.org, com comentarios explicando o porque de cada mitigacao e cobertos por tests/unit/base-executor-waf-retry.test.ts e tests/unit/wafRateLimit.test.ts. Crescimento funcional legitimo, nao inchaco.",
"_rebaseline_2026_08_05_9529_own_growth": "PR #9529 own growth (base release/v3.8.50 medida EXATAMENTE nos frozen antigos, entao o modo base-relative #8522 nao cobre): open-sse/services/rateLimitManager.ts 1060->1105 (+45: helper applyLimiterSettings() que re-arma o heartbeat do reservoir apos updateSettings — fix do bug Bottleneck 2.19.5 que congelava a fila weighted; TDD em tests/unit/ratelimit-reservoir-refresh.test.ts); tests/integration/chat-pipeline.test.ts 1592->1598 (+6: User-Agent do codex derivado de getCodexClientVersion() em vez de literal pinado — teste-irmao alinhado ao contrato); tests/unit/provider-validation-specialty.test.ts 2980->2985 (+5: cobertura NOVA claude-web 429 -> valid:false, alinhamento #9406); open-sse/translator/response/openai-responses.ts 1174->1204 (+30: buildResponsesReasoningSummaryDelta MOVIDA do leaf pureHelpers.ts para o host — a funcao do #9500 muta stream state e violava o contrato do leaf puro; o LOC total do par host+leaf nao cresceu, o pureHelpers encolheu o mesmo tanto). Crescimento por fix de producao + cobertura adicional + realocacao arquitetural, nao inchaco.",
"_rebaseline_2026_08_06_v3850_inherited_drift_reconcile": "Reconciliacao 2026-08-06 do drift ACUMULADO da release/v3.8.50 apos o lote de merges de 08-05/06: 13 arquivos acima do frozen no tip puro 8180b49ce1 (medidos pelo proprio gate). O modo PR base-relative (#8522) deixa PRs inocentes passarem, e os rebaselines individuais dos PRs se perderam nas resolucoes sucessivas de conflito deste hot-file — o drift so aparece no modo absoluto (nightly/local). Crescimentos funcionais dos PRs mergeados: #9024 topology click-nav src/app/(dashboard)/dashboard/HomePageClient.tsx; #9324 OpenRouter enrich src/app/(dashboard)/dashboard/providers/page.tsx; #9329 quota card ordering src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx; #9193 context-window suffixes src/sse/handlers/chat.ts; #9332 nested Claude server tool ids open-sse/executors/base.ts; #9228 strip orphaned tool outputs open-sse/executors/codex.ts; #9236 nvidia tool-name normalize open-sse/executors/default.ts; #9314 nested tool_call validation open-sse/executors/kiro.ts; #9260 caller identity REST hops open-sse/mcp-server/server.ts; #8934 cache breakpoints tests tests/unit/chatcore-translation-paths.test.ts; #9193 suffix tests tests/unit/combo-routing-engine.test.ts; #9196 reasoning-on-tool-finish tests tests/unit/sse-auth.test.ts; #9163 GPT-5.6 Max reasoning tests tests/unit/translator-openai-to-kiro.test.ts. default.ts e kiro.ts entram no frozen (estavam sem entrada, acima do cap 1000). Atualizacao pos-medicao (a base avancou durante o ciclo do PR): src/sse/handlers/chat.ts 1857->1877 (#9184 affinity EOF evict) e open-sse/executors/default.ts 1027->1042 (#9005 Kimi K3 tool-name backfill)."
"_rebaseline_2026_08_06_v3850_inherited_drift_reconcile": "Reconciliacao 2026-08-06 do drift ACUMULADO da release/v3.8.50 apos o lote de merges de 08-05/06: 13 arquivos acima do frozen no tip puro 8180b49ce1 (medidos pelo proprio gate). O modo PR base-relative (#8522) deixa PRs inocentes passarem, e os rebaselines individuais dos PRs se perderam nas resolucoes sucessivas de conflito deste hot-file — o drift so aparece no modo absoluto (nightly/local). Crescimentos funcionais dos PRs mergeados: #9024 topology click-nav src/app/(dashboard)/dashboard/HomePageClient.tsx; #9324 OpenRouter enrich src/app/(dashboard)/dashboard/providers/page.tsx; #9329 quota card ordering src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx; #9193 context-window suffixes src/sse/handlers/chat.ts; #9332 nested Claude server tool ids open-sse/executors/base.ts; #9228 strip orphaned tool outputs open-sse/executors/codex.ts; #9236 nvidia tool-name normalize open-sse/executors/default.ts; #9314 nested tool_call validation open-sse/executors/kiro.ts; #9260 caller identity REST hops open-sse/mcp-server/server.ts; #8934 cache breakpoints tests tests/unit/chatcore-translation-paths.test.ts; #9193 suffix tests tests/unit/combo-routing-engine.test.ts; #9196 reasoning-on-tool-finish tests tests/unit/sse-auth.test.ts; #9163 GPT-5.6 Max reasoning tests tests/unit/translator-openai-to-kiro.test.ts. default.ts e kiro.ts entram no frozen (estavam sem entrada, acima do cap 1000). Atualizacao pos-medicao (a base avancou durante o ciclo do PR): src/sse/handlers/chat.ts 1857->1877 (#9184 affinity EOF evict) e open-sse/executors/default.ts 1027->1042 (#9005 Kimi K3 tool-name backfill).",
"_rebaseline_2026_08_06b_v3850_sweepreds_drift": "Segunda reconciliacao de 2026-08-06 (/sweep-reds sobre o tip puro 2ddbbc61a6): 3 arquivos voltaram a passar do frozen apos os merges do mesmo dia, com atribuicao 1:1 por commit. (1) src/app/(dashboard)/dashboard/providers/page.tsx 1928->1944 e (2) open-sse/executors/base.ts 1635->1640, ambos do #9515 (feat(radar): flag-gated signed free-model catalog overlay, commit e7f6b1d130) — o overlay do Radar entra por wiring nos chokepoints ja existentes (a resolucao/verificacao do catalogo assinado mora fora destes dois arquivos); +16 e +5 linhas liquidas nao sao extraiveis sem inventar um leaf por callsite. (3) open-sse/services/accountFallback.ts 1966->1972 do #8704 (commit c4527f97bd), +6 linhas de dados em CREDITS_EXHAUSTED_SIGNALS ('has been exhausted', fixes #8631). src/sse/handlers/chat.ts 1880>1877 tambem estava violando e NAO entra aqui de proposito: e drenado por encolhimento na PR #9598, sem rebaseline. Crescimento proprio DESTA PR: src/lib/db/migrationRunner.ts 1077->1084 (+7) — o guard retroativo em isSchemaAlreadyApplied para os arquivos renumerados 137/138, exigido pela propria mensagem de erro de colisao do runner (ambas as migracoes sao ALTER TABLE ADD COLUMN puro, nao idempotente). Dois `case` + dois `return hasColumn(...)` + 3 linhas de comentario dentro do switch existente; nao extraivel."
}

View File

@@ -82,9 +82,10 @@
"tightenSlack": 10
},
"openapiCoverage.pct": {
"value": 38,
"value": 39.2,
"direction": "up",
"eps": 0.5,
"_tighten_2026_08_06_v3850_sweepreds": "38.0 -> 39.2 (aperto EXIGIDO pelo step 'Require-tighten (blocking)', que estava vermelho em ~60 PRs abertas de release/v3.8.50 — base-red herdado, nao defeito das PRs). A cobertura melhorou no ciclo porque as rotas novas entraram documentadas. 39.2 = valor medido pelo CI Quality Ratchet no run 31088889488; o tip puro 2ddbbc61a6 mede 39.3 localmente (npm run check:openapi-coverage: 247/628 rotas), entao 39.2 e o valor conservador dos dois. Aperto = gate mais ESTRITO, nunca mascaramento.",
"_tighten_2026_07_04_v3844_release": "36.9 -> 39.3 (aperto exigido pelo --require-tighten no PR de release #5925). A cobertura OpenAPI melhorou no ciclo (9 rotas documentadas em 8fb020676 + as rotas novas de #5939/#5817/#6034/#5998 documentadas junto das features). 39.3 = valor medido pelo CI Quality Ratchet no run 28708141003 (tip 00c55afcb).",
"_rebaseline_2026_06_28_v3839_release": "37.8 -> 36.9 (-0.9, beyond the 0.5 eps). v3.8.39 cycle drift surfaced ONLY on the release PR (the openapi-coverage ratchet does NOT run on PR->release fast-gates). The cycle added API/internal routes (antigravity paste-credentials onboarding, CCR ranged/grep/stats retrieve params, mcp 404 session handling) faster than docs/openapi.yaml coverage; documenting LOCAL_ONLY/internal onboarding routes in the PUBLIC spec would be gaming (same precedent as _rebaseline_2026_06_18_v3828_cycle_close). Measured by CI collect-metrics (run 28317145160) = 36.9. My release-finalize tree touches no routes (only the openapi.yaml version bump). Raising coverage by documenting public routes is tracked as follow-up doc debt.",
"_rebaseline_2026_06_23_v3834_release": "38.4 -> 37.8 (-0.6, beyond the 0.5 eps so it failed the ratchet). v3.8.34 cycle drift: contributor PRs added API routes (e.g. quota/usage/opencode-go endpoints) faster than openapi.yaml coverage; the openapi-coverage ratchet does NOT run on PR->release fast-gates so it surfaced only on the release PR. Verified my release-finalize working tree touches no routes / openapi paths (only version bump in openapi.yaml). Measured by CI quality:collect (run 28000387577) = 37.8. Raising coverage by documenting the new routes is tracked as follow-up doc debt.",

View File

@@ -42,6 +42,7 @@ x-common: &common
- LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0}
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- NODE_OPTIONS=--max-old-space-size=2048
volumes:
- ./data:/app/data
healthcheck:

View File

@@ -0,0 +1,57 @@
FROM node:26.0.0-bookworm-slim
ARG CLAUDE_CODE_VERSION=2.1.220
ARG DEVIN_CLI_VERSION=3000.2.17
ARG TARGETARCH
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git bash python3 make g++ tini \
&& rm -rf /var/lib/apt/lists/* \
&& npm install --global "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
RUN set -eu; \
case "${TARGETARCH}" in \
amd64) devin_arch=x86_64-unknown-linux; devin_sha=f0e1e9363afc6ee68c4ef87bab4aeb7ff5cc08a5fa838350ef3ceefdbb2a2be2 ;; \
arm64) devin_arch=aarch64-unknown-linux; devin_sha=116dc71ef085a922bc3ff0ea0377d4b26c529a431d58246e36572913e2d25624 ;; \
*) echo "Unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://static.devin.ai/cli/${DEVIN_CLI_VERSION}/devin-${DEVIN_CLI_VERSION}-${devin_arch}.tar.gz" -o /tmp/devin.tar.gz; \
echo "${devin_sha} /tmp/devin.tar.gz" | sha256sum -c -; \
tar -xzf /tmp/devin.tar.gz -C /tmp; \
install -m 0755 "$(find /tmp -type f -name devin | head -1)" /usr/local/bin/devin; \
rm -rf /tmp/devin.tar.gz /tmp/devin-*
RUN groupadd --gid 10001 bridge \
&& useradd --uid 10001 --gid bridge --create-home --home-dir /home/bridge --shell /bin/bash bridge \
&& mkdir -p /opt/omniroute /workspace \
&& chown -R bridge:bridge /opt/omniroute /workspace
WORKDIR /opt/omniroute
USER bridge
COPY --chown=bridge:bridge package.json package-lock.json .npmrc ./
RUN npm ci --ignore-scripts --no-audit --fund=false
COPY --chown=bridge:bridge . .
RUN npm rebuild better-sqlite3 || true
ENV HOME=/home/bridge \
CLAUDE_CONFIG_DIR=/home/bridge/.claude-devin-isolated \
DEVIN_AGENTIC_HOME=/home/bridge \
DATA_DIR=/home/bridge/.omniroute-isolated \
SQLITE_FILE=/home/bridge/.omniroute-isolated/storage.sqlite \
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
DISABLE_TELEMETRY=1 \
DISABLE_ERROR_REPORTING=1 \
DISABLE_AUTOUPDATER=1 \
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 \
NEXT_TELEMETRY_DISABLED=1
RUN mkdir -p /home/bridge/.claude-devin-isolated /home/bridge/.local/share/devin \
/home/bridge/.omniroute-isolated
RUN DATA_DIR=/tmp/omniroute-build-data \
SQLITE_FILE=/tmp/omniroute-build-data/storage.sqlite \
npm run build \
&& rm -rf /tmp/omniroute-build-data
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["bash"]

View File

@@ -0,0 +1,218 @@
name: omniroute-devin-bridge
x-isolated-environment: &isolated-environment
HOME: /home/bridge
CLAUDE_CONFIG_DIR: /home/bridge/.claude-devin-isolated
DEVIN_AGENTIC_HOME: /home/bridge
DATA_DIR: /home/bridge/.omniroute-isolated
SQLITE_FILE: /home/bridge/.omniroute-isolated/storage.sqlite
ANTHROPIC_BASE_URL: http://omniroute:20128
ANTHROPIC_AUTH_TOKEN: sk-local-devin-gateway
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
DISABLE_TELEMETRY: "1"
DISABLE_ERROR_REPORTING: "1"
DISABLE_AUTOUPDATER: "1"
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1"
DEVIN_BRIDGE_MODEL: ${DEVIN_BRIDGE_MODEL:-devin-cli-agentic/swe-1-7}
ANTHROPIC_MODEL: ${DEVIN_BRIDGE_MODEL:-devin-cli-agentic/swe-1-7}
ANTHROPIC_DEFAULT_SONNET_MODEL: ${DEVIN_BRIDGE_SONNET_MODEL:-devin-cli-agentic/swe-1-7}
ANTHROPIC_DEFAULT_OPUS_MODEL: ${DEVIN_BRIDGE_OPUS_MODEL:-devin-cli-agentic/swe-1-7}
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${DEVIN_BRIDGE_HAIKU_MODEL:-devin-cli-agentic/swe-1-7}
CLAUDE_CODE_SUBAGENT_MODEL: ${DEVIN_BRIDGE_SUBAGENT_MODEL:-devin-cli-agentic/swe-1-7}
REQUIRE_API_KEY: "true"
OMNIROUTE_API_KEY: sk-local-devin-gateway
x-runtime: &runtime
image: omniroute-devin-bridge:local
build:
context: ../..
dockerfile: docker/devin-bridge/Dockerfile
args:
CLAUDE_CODE_VERSION: 2.1.220
DEVIN_CLI_VERSION: 3000.2.17
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=256m
- /opt/omniroute/.source:rw,nosuid,nodev,size=16m,uid=10001,gid=10001
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
environment: *isolated-environment
networks: [bridge-internal]
services:
omniroute:
<<: *runtime
profiles: [offline]
hostname: omniroute
environment:
<<: *isolated-environment
CLI_DEVIN_AGENTIC_BIN: /opt/omniroute/docker/devin-bridge/mock-devin.mjs
DEVIN_BRIDGE_MOCK_LOG: /evidence/mock-acp.jsonl
command: ["npm", "run", "start"]
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:20128/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 2s
timeout: 2s
retries: 60
volumes:
- omniroute-offline-data:/home/bridge/.omniroute-isolated
- ../../.sandbox/evidence:/evidence
- ./mock-devin.mjs:/opt/omniroute/docker/devin-bridge/mock-devin.mjs:ro
claude:
<<: *runtime
profiles: [offline]
depends_on:
omniroute:
condition: service_healthy
claude-egress-guard:
condition: service_healthy
working_dir: /workspace
command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-e2e.sh"]
environment:
<<: *isolated-environment
NODE_USE_ENV_PROXY: "1"
HTTP_PROXY: http://claude-egress-guard:8080
HTTPS_PROXY: http://claude-egress-guard:8080
NO_PROXY: omniroute
volumes:
- claude-isolated-config:/home/bridge/.claude-devin-isolated
- ../../.sandbox/e2e-workspace:/workspace
- ../../.sandbox/evidence:/evidence
- ./run-claude-e2e.sh:/opt/omniroute/docker/devin-bridge/run-claude-e2e.sh:ro
contract:
<<: *runtime
profiles: [offline]
depends_on:
omniroute:
condition: service_healthy
command: ["node", "/opt/omniroute/docker/devin-bridge/run-contract.mjs"]
volumes:
- ./run-contract.mjs:/opt/omniroute/docker/devin-bridge/run-contract.mjs:ro
claude-egress-guard:
image: node:26.0.0-bookworm-slim
profiles: [offline, live-devin]
user: "10001:10001"
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
command: ["node", "/guard/proxy.mjs"]
environment:
GUARD_LISTEN: 0.0.0.0:8080
GUARD_POLICY: deny-all
GUARD_LOG: /guard-audit/egress.jsonl
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('net').connect(8080,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))",
]
interval: 1s
timeout: 1s
retries: 15
volumes:
- ./network-guard:/guard:ro
- ../../.sandbox/guard-audit/claude:/guard-audit
networks: [bridge-internal]
network-guard:
image: node:26.0.0-bookworm-slim
profiles: [live-devin]
user: "10001:10001"
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
command: ["node", "/guard/proxy.mjs"]
environment:
GUARD_LISTEN: 0.0.0.0:8080
GUARD_POLICY: devin
GUARD_LOG: /guard-audit/egress.jsonl
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('net').connect(8080,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))",
]
interval: 1s
timeout: 1s
retries: 15
volumes:
- ./network-guard:/guard:ro
- ../../.sandbox/guard-audit/devin:/guard-audit
networks: [devin-guard-internal, guard-egress]
omniroute-live:
<<: *runtime
profiles: [live-devin]
hostname: omniroute
depends_on:
network-guard:
condition: service_healthy
environment:
<<: *isolated-environment
CLI_DEVIN_AGENTIC_BIN: /usr/local/bin/devin
DEVIN_BRIDGE_PROXY_URL: http://network-guard:8080
networks: [bridge-internal, devin-guard-internal]
command: ["npm", "run", "start"]
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:20128/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 2s
timeout: 2s
retries: 60
volumes:
- devin-auth:/home/bridge/.local/share/devin
- omniroute-live-data:/home/bridge/.omniroute-isolated
claude-live:
<<: *runtime
profiles: [live-devin]
depends_on:
omniroute-live:
condition: service_healthy
claude-egress-guard:
condition: service_healthy
working_dir: /workspace
command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-live-e2e.sh"]
environment:
<<: *isolated-environment
NODE_USE_ENV_PROXY: "1"
HTTP_PROXY: http://claude-egress-guard:8080
HTTPS_PROXY: http://claude-egress-guard:8080
NO_PROXY: omniroute
volumes:
- claude-isolated-config:/home/bridge/.claude-devin-isolated
- ../../.sandbox/live-workspace:/workspace
- ../../.sandbox/evidence:/evidence
- ./run-claude-live-e2e.sh:/opt/omniroute/docker/devin-bridge/run-claude-live-e2e.sh:ro
networks:
bridge-internal:
internal: true
devin-guard-internal:
internal: true
guard-egress: {}
volumes:
claude-isolated-config: {}
devin-auth: {}
omniroute-offline-data: {}
omniroute-live-data: {}

View File

@@ -0,0 +1,229 @@
#!/usr/bin/env node
import fs from "node:fs";
import readline from "node:readline";
if (
process.argv[2] !== "acp" ||
process.argv[3] !== "--agent-type" ||
process.argv[4] !== "summarizer" ||
process.argv.length !== 5
) {
process.exit(64);
}
const logFile = process.env.DEVIN_BRIDGE_MOCK_LOG || "/evidence/mock-acp.jsonl";
const rl = readline.createInterface({ input: process.stdin });
const send = (value) => process.stdout.write(`${JSON.stringify(value)}\n`);
const log = (value) => fs.appendFileSync(logFile, `${JSON.stringify(value)}\n`);
const actions = [
{
name: "Skill",
arguments: { skill: "bridge-proof" },
},
{
name: "Bash",
arguments: {
command: "find . -maxdepth 2 -type f -print",
description: "Locate the fixture files",
},
},
{
name: "Read",
arguments: { file_path: "/workspace/math.js" },
},
{
name: "Edit",
arguments: {
file_path: "/workspace/math.js",
old_string: "return a - b;",
new_string: "return a * b;",
},
},
{
name: "Bash",
arguments: { command: "npm test", description: "Run the fixture tests" },
},
{
name: "Edit",
arguments: {
file_path: "/workspace/math.js",
old_string: "return a * b;",
new_string: "return a + b;",
},
},
{
name: "Bash",
arguments: { command: "npm test", description: "Confirm the corrected fixture" },
},
];
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") {
if (message.params?.protocolVersion !== 1) {
send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "ACP v1 required" } });
return;
}
send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: 1 } });
} else if (message.method === "session/new") {
if (message.params?.cwd !== "/home/bridge" || !Array.isArray(message.params?.mcpServers)) {
send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "unsafe session" } });
return;
}
send({
jsonrpc: "2.0",
id: message.id,
result: { sessionId: "offline" },
});
} else if (message.method === "session/set_config_option") {
send({
jsonrpc: "2.0",
id: message.id,
error: { code: -32602, message: "summarizer mode must not be mutated" },
});
} else if (message.method === "session/prompt") {
const prompt = String(message.params?.prompt?.[0]?.text || "");
if (!prompt.includes("[Devin Summarizer Bridge]") || !prompt.includes("[Execution Trace]")) {
send({
jsonrpc: "2.0",
id: message.id,
error: { code: -32602, message: "summarizer bridge framing required" },
});
return;
}
if (prompt.includes("CONTRACT_AFTER_TOOL")) {
log({ provider: "devin-cli-agentic", scenario: "after-tool" });
send({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: "offline",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "contract continued" },
},
},
});
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
return;
}
if (prompt.includes("CONTRACT_EXIT")) {
log({ provider: "devin-cli-agentic", scenario: "exit" });
process.exit(7);
}
if (prompt.includes("CONTRACT_ERROR")) {
log({ provider: "devin-cli-agentic", scenario: "error" });
send({
jsonrpc: "2.0",
id: message.id,
error: { code: -32000, message: "deterministic upstream failure" },
});
return;
}
if (prompt.includes("CONTRACT_TEXT")) {
log({ provider: "devin-cli-agentic", scenario: "text" });
send({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: "offline",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "contract text" },
},
},
});
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
return;
}
if (prompt.includes("CONTRACT_NARRATIVE_REPAIR")) {
const isRepair = prompt.includes("[Single Repair Attempt]");
log({
provider: "devin-cli-agentic",
scenario: "narrative-repair",
stage: isRepair ? "repair" : "initial",
});
send({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: "offline",
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: isRepair
? '<tool>{"name":"Read","arguments":{"file_path":"/workspace/math.js"}}</tool>'
: "I'll start by reading the math.js file, then run the tests.",
},
},
},
});
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
return;
}
if (prompt.includes("CONTRACT_TOOL")) {
log({ provider: "devin-cli-agentic", scenario: "tool" });
send({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: "offline",
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: '<tool>{"name":"Read","arguments":{"file_path":"/workspace/math.js"}}</tool>',
},
},
},
});
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
return;
}
const resultCount = (prompt.match(/\[Tool Result\]/g) || []).length;
if (!prompt.includes("CLAUDE_MD_BRIDGE_ACTIVE") || !prompt.includes("COMMAND_BRIDGE_ACTIVE")) {
send({
jsonrpc: "2.0",
id: message.id,
error: { code: -32602, message: "Claude project context missing" },
});
return;
}
const action = actions[resultCount];
const text = action
? `<tool>${JSON.stringify(action)}</tool>`
: "BRIDGE_E2E_COMPLETE CLAUDE_MD_BRIDGE_ACTIVE SKILL_BRIDGE_ACTIVE COMMAND_BRIDGE_ACTIVE";
if (!action && !prompt.includes("SKILL_BRIDGE_ACTIVE")) {
send({
jsonrpc: "2.0",
id: message.id,
error: { code: -32602, message: "Skill result missing" },
});
return;
}
log({
provider: "devin-cli-agentic",
model: message.params?.model || "swe-1-7",
resultCount,
action: action?.name || "final",
});
const midpoint = Math.max(1, Math.floor(text.length / 2));
for (const chunk of [text.slice(0, midpoint), text.slice(midpoint)]) {
send({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: "offline",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: chunk },
},
},
});
}
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
}
});

View File

@@ -0,0 +1,130 @@
export const DEVIN_ALLOWED_SUFFIXES = Object.freeze([".devin.ai", ".cognition.ai"]);
export const DEVIN_ALLOWED_EXACT_HOSTS = Object.freeze([
"server.codeium.com",
"unleash.codeium.com",
]);
function normalizeHostname(hostname) {
return String(hostname || "")
.trim()
.toLowerCase()
.replace(/\.$/, "");
}
export function isAllowedGuardHostname(hostname, policy = "deny-all") {
if (policy !== "devin") return false;
const value = normalizeHostname(hostname);
if (!value) return false;
if (DEVIN_ALLOWED_EXACT_HOSTS.includes(value)) return true;
return DEVIN_ALLOWED_SUFFIXES.some(
(suffix) => value === suffix.slice(1) || value.endsWith(suffix)
);
}
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
export function sanitizeForwardHeaders(headers, target) {
const connectionTokens = String(headers.connection || "")
.split(",")
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
const blocked = new Set([...HOP_BY_HOP_HEADERS, ...connectionTokens]);
const sanitized = {};
for (const [name, value] of Object.entries(headers)) {
if (value === undefined || blocked.has(name.toLowerCase()) || name.toLowerCase() === "host") {
continue;
}
sanitized[name] = value;
}
sanitized.host = target.host;
return sanitized;
}
export function parseConnectAuthority(authority) {
const value = String(authority || "");
const match = value.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/);
if (!match) return null;
const hostname = normalizeHostname(match[1] || match[2]);
const port = Number(match[3]);
if (!hostname || port !== 443) return null;
return { hostname, port };
}
function readUint24(buffer, offset) {
return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2];
}
export function parseTlsClientHelloSni(buffer) {
if (!Buffer.isBuffer(buffer)) return { status: "invalid", reason: "not_buffer" };
let offset = 0;
const handshakeParts = [];
while (offset < buffer.length) {
if (buffer.length - offset < 5) return { status: "need-more" };
if (buffer[offset] !== 22) return { status: "invalid", reason: "not_handshake_record" };
const recordLength = buffer.readUInt16BE(offset + 3);
if (recordLength <= 0 || recordLength > 18432) {
return { status: "invalid", reason: "invalid_record_length" };
}
if (buffer.length - offset - 5 < recordLength) return { status: "need-more" };
handshakeParts.push(buffer.subarray(offset + 5, offset + 5 + recordLength));
offset += 5 + recordLength;
}
const handshake = Buffer.concat(handshakeParts);
if (handshake.length < 4) return { status: "need-more" };
if (handshake[0] !== 1) return { status: "invalid", reason: "not_client_hello" };
const helloLength = readUint24(handshake, 1);
if (helloLength > 65531) return { status: "invalid", reason: "client_hello_too_large" };
if (handshake.length - 4 < helloLength) return { status: "need-more" };
const hello = handshake.subarray(4, 4 + helloLength);
let cursor = 34;
if (hello.length < cursor + 1) return { status: "invalid", reason: "truncated_hello" };
const sessionLength = hello[cursor++];
cursor += sessionLength;
if (hello.length < cursor + 2) return { status: "invalid", reason: "truncated_ciphers" };
const cipherLength = hello.readUInt16BE(cursor);
cursor += 2 + cipherLength;
if (hello.length < cursor + 1) return { status: "invalid", reason: "truncated_compression" };
const compressionLength = hello[cursor++];
cursor += compressionLength;
if (hello.length < cursor + 2) return { status: "invalid", reason: "missing_extensions" };
const extensionsLength = hello.readUInt16BE(cursor);
cursor += 2;
const extensionsEnd = cursor + extensionsLength;
if (extensionsEnd > hello.length) return { status: "invalid", reason: "truncated_extensions" };
while (cursor < extensionsEnd) {
if (extensionsEnd - cursor < 4) return { status: "invalid", reason: "truncated_extension" };
const type = hello.readUInt16BE(cursor);
const length = hello.readUInt16BE(cursor + 2);
cursor += 4;
if (cursor + length > extensionsEnd) {
return { status: "invalid", reason: "invalid_extension_length" };
}
if (type === 0) {
const data = hello.subarray(cursor, cursor + length);
if (data.length < 5 || data.readUInt16BE(0) !== data.length - 2 || data[2] !== 0) {
return { status: "invalid", reason: "invalid_server_name" };
}
const nameLength = data.readUInt16BE(3);
if (nameLength !== data.length - 5) {
return { status: "invalid", reason: "invalid_server_name_length" };
}
const serverName = normalizeHostname(data.subarray(5).toString("ascii"));
if (!/^[a-z0-9.-]+$/.test(serverName)) {
return { status: "invalid", reason: "invalid_server_name_value" };
}
return { status: "ok", serverName };
}
cursor += length;
}
return { status: "invalid", reason: "missing_sni" };
}

View File

@@ -0,0 +1,136 @@
import fs from "node:fs";
import http from "node:http";
import net from "node:net";
import { pathToFileURL } from "node:url";
import {
isAllowedGuardHostname,
parseConnectAuthority,
parseTlsClientHelloSni,
sanitizeForwardHeaders,
} from "./policy.mjs";
const MAX_CLIENT_HELLO_BYTES = 64 * 1024;
const CLIENT_HELLO_TIMEOUT_MS = 3000;
export function createGuardProxy({
policy = "deny-all",
logPath = "/tmp/egress.jsonl",
allowHostname = (hostname) => isAllowedGuardHostname(hostname, policy),
connectSocket = (port, hostname, onConnect) => net.connect(port, hostname, onConnect),
} = {}) {
if (!new Set(["deny-all", "devin"]).has(policy)) {
throw new Error(`Unknown network guard policy: ${policy}`);
}
function audit(hostname, decision, reason) {
fs.appendFileSync(
logPath,
`${JSON.stringify({ at: new Date().toISOString(), hostname, decision, reason })}\n`
);
}
const server = http.createServer((req, res) => {
let target;
try {
target = new URL(req.url);
} catch {
res.writeHead(400).end("invalid proxy target\n");
return;
}
if (target.protocol !== "http:" || target.username || target.password) {
audit(target.hostname, "deny", "invalid_http_target");
res.writeHead(403).end("egress denied\n");
return;
}
if (!allowHostname(target.hostname)) {
audit(target.hostname, "deny", "host_policy");
res.writeHead(403).end("egress denied\n");
return;
}
audit(target.hostname, "allow", "host_policy");
const upstream = http.request(
target,
{
method: req.method,
headers: sanitizeForwardHeaders(req.headers, target),
},
(reply) => {
res.writeHead(reply.statusCode || 502, reply.headers);
reply.pipe(res);
}
);
req.pipe(upstream);
upstream.on("error", () => res.writeHead(502).end("upstream error\n"));
});
server.on("connect", (req, client, head) => {
const authority = parseConnectAuthority(req.url);
if (!authority) {
audit(req.url, "deny", "invalid_connect_authority");
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
return;
}
const { hostname, port } = authority;
if (!allowHostname(hostname)) {
audit(hostname, "deny", "host_policy");
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
return;
}
let buffer = Buffer.from(head);
let settled = false;
const timer = setTimeout(() => fail("client_hello_timeout"), CLIENT_HELLO_TIMEOUT_MS);
timer.unref?.();
const cleanup = () => {
clearTimeout(timer);
client.removeListener("data", onData);
};
const fail = (reason) => {
if (settled) return;
settled = true;
cleanup();
audit(hostname, "deny", reason);
client.destroy();
};
const inspect = () => {
if (buffer.length > MAX_CLIENT_HELLO_BYTES) return fail("client_hello_too_large");
const parsed = parseTlsClientHelloSni(buffer);
if (parsed.status === "need-more") return;
if (parsed.status !== "ok") return fail(parsed.reason || "invalid_client_hello");
if (parsed.serverName !== hostname) return fail("sni_mismatch");
settled = true;
cleanup();
client.pause();
const upstream = connectSocket(port, hostname, () => {
audit(hostname, "allow", "sni_match");
if (buffer.length) upstream.write(buffer);
upstream.pipe(client);
client.pipe(upstream);
client.resume();
});
upstream.on("error", () => client.destroy());
};
const onData = (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
inspect();
};
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
client.on("data", onData);
if (buffer.length) inspect();
client.resume();
});
return server;
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
const [host, portText] = (process.env.GUARD_LISTEN || "0.0.0.0:8080").split(":");
const server = createGuardProxy({
policy: process.env.GUARD_POLICY || "deny-all",
logPath: process.env.GUARD_LOG || "/tmp/egress.jsonl",
});
server.listen(Number(portText), host);
}

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
unset ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_BEDROCK_BASE_URL ANTHROPIC_VERTEX_BASE_URL
unset CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY
set -o pipefail
check() {
"$@"
printf 'E2E check passed: %s\n' "$*"
}
claude -p --output-format stream-json --verbose --max-turns 12 \
--permission-mode bypassPermissions \
"/bridge-check" | tee /evidence/claude-stream.jsonl
if grep -Eqi 'log[ -]?in|authenticate.*anthropic|claude\.ai' /evidence/claude-stream.jsonl; then
echo "Claude Code requested forbidden authentication" >&2
exit 1
fi
check grep -q 'return a + b;' /workspace/math.js
npm test
check grep -q 'Skill' /workspace/.e2e-hook.log
check grep -q 'Read' /workspace/.e2e-hook.log
check grep -q 'Edit' /workspace/.e2e-hook.log
check grep -q 'Bash' /workspace/.e2e-hook.log
check grep -q 'BRIDGE_E2E_COMPLETE' /evidence/claude-stream.jsonl

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
unset ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_BEDROCK_BASE_URL ANTHROPIC_VERTEX_BASE_URL
unset CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY
bridge_system_prompt="You are a coding agent inside Claude Code. Use only the client-owned tools supplied in the request. Never execute or request a Devin-owned tool. When work requires a tool, select the appropriate client tool and wait for its result before continuing."
scenario_cooldown_seconds="${DEVIN_BRIDGE_LIVE_SCENARIO_COOLDOWN_SECONDS:-15}"
run_scenario() {
local evidence_file="$1"
local prompt="$2"
claude -p --output-format stream-json --verbose --max-turns 12 \
--tools Read,Edit,Bash \
--system-prompt "$bridge_system_prompt" \
--permission-mode bypassPermissions "$prompt" | tee "$evidence_file"
if grep -Eqi 'log[ -]?in|authenticate.*anthropic|claude\.ai' "$evidence_file"; then
echo "Claude Code requested forbidden authentication" >&2
exit 1
fi
}
validate_scenario() {
local evidence_file="$1"
local marker="$2"
local required_tools="$3"
local require_npm_test="$4"
local required_slash_command="${5:-}"
local required_skill="${6:-}"
local accept_explicit_completion="${7:-false}"
node /opt/omniroute/scripts/devin-bridge/validate-claude-evidence.mjs \
"$evidence_file" "$marker" "$required_tools" "$require_npm_test" \
"$required_slash_command" "$required_skill" "$accept_explicit_completion"
}
run_scenario /evidence/live-analysis.jsonl \
"Read /workspace/CLAUDE.md, /workspace/math.js, and /workspace/math.test.js directly without searching or editing. Explain the defect, then end with LIVE_ANALYSIS_COMPLETE."
validate_scenario /evidence/live-analysis.jsonl LIVE_ANALYSIS_COMPLETE Read false
sleep "$scenario_cooldown_seconds"
run_scenario /evidence/live-fix.jsonl \
"Use Edit now to replace 'return a - b;' with 'return a + b;' in /workspace/math.js. Then use Bash to run npm test. Do not summarize before npm test succeeds. End with LIVE_FIX_COMPLETE only after the test passes."
grep -q 'return a + b;' /workspace/math.js
npm test
validate_scenario /evidence/live-fix.jsonl LIVE_FIX_COMPLETE Edit,Bash true
sleep "$scenario_cooldown_seconds"
run_scenario /evidence/live-command.jsonl "/bridge-check"
validate_scenario /evidence/live-command.jsonl BRIDGE_E2E_COMPLETE Bash true \
bridge-check bridge-proof true
printf 'PASS: three live Devin-backed Claude Code scenarios completed\n'

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
const endpoint = "http://omniroute:20128/v1/messages";
const headers = {
"anthropic-version": "2023-06-01",
"content-type": "application/json",
"x-api-key": "sk-local-devin-gateway",
};
const model = process.env.DEVIN_BRIDGE_MODEL || "devin-cli-agentic/swe-1-7";
async function request(prompt, extra = {}) {
return fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
model,
max_tokens: 256,
messages: [{ role: "user", content: prompt }],
...extra,
}),
});
}
const textReply = await request("CONTRACT_TEXT");
assert.equal(textReply.status, 200);
assert.match(textReply.headers.get("content-type") || "", /application\/json/);
const textBody = await textReply.json();
assert.equal(textBody.type, "message");
assert.equal(textBody.role, "assistant");
assert.equal(textBody.stop_reason, "end_turn");
assert.deepEqual(textBody.content, [{ type: "text", text: "contract text" }]);
const toolReply = await request("CONTRACT_TOOL", {
stream: true,
tools: [
{
name: "Read",
description: "Read a file",
input_schema: {
type: "object",
properties: { file_path: { type: "string" } },
required: ["file_path"],
additionalProperties: false,
},
},
],
});
assert.equal(toolReply.status, 200);
assert.match(toolReply.headers.get("content-type") || "", /text\/event-stream/);
const toolStream = await toolReply.text();
const eventNames = toolStream
.split("\n")
.filter((line) => line.startsWith("event: "))
.map((line) => line.slice(7));
assert.deepEqual(eventNames, [
"message_start",
"content_block_start",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
]);
const toolEvents = toolStream
.split("\n")
.filter((line) => line.startsWith("data: "))
.map((line) => JSON.parse(line.slice(6)));
const toolUse = toolEvents.find((event) => event.type === "content_block_start")?.content_block;
assert.equal(toolUse?.type, "tool_use");
assert.equal(toolUse?.name, "Read");
assert.match(toolUse?.id || "", /^tool_devin_/);
const repairedNarrativeReply = await request("CONTRACT_NARRATIVE_REPAIR", {
tools: [
{
name: "Read",
description: "Read a file",
input_schema: {
type: "object",
properties: { file_path: { type: "string" } },
required: ["file_path"],
additionalProperties: false,
},
},
],
});
assert.equal(repairedNarrativeReply.status, 200);
const repairedNarrativeBody = await repairedNarrativeReply.json();
assert.equal(repairedNarrativeBody.stop_reason, "tool_use");
assert.equal(repairedNarrativeBody.content?.[0]?.type, "tool_use");
assert.equal(repairedNarrativeBody.content?.[0]?.name, "Read");
const continuationReply = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
model,
max_tokens: 256,
tools: [
{
name: "Read",
description: "Read a file",
input_schema: { type: "object", properties: {}, additionalProperties: true },
},
],
messages: [
{ role: "user", content: "CONTRACT_TOOL" },
{ role: "assistant", content: [toolUse] },
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: toolUse.id,
content: "CONTRACT_AFTER_TOOL",
},
],
},
],
}),
});
assert.equal(continuationReply.status, 200);
const continuationBody = await continuationReply.json();
assert.equal(continuationBody.stop_reason, "end_turn");
assert.deepEqual(continuationBody.content, [{ type: "text", text: "contract continued" }]);
for (const marker of ["CONTRACT_ERROR", "CONTRACT_EXIT"]) {
const failedReply = await request(marker);
assert.equal(failedReply.status, 502);
const failedBody = await failedReply.json();
assert.equal(failedBody.error?.type, "server_error");
assert.doesNotMatch(JSON.stringify(failedBody), /stack|anthropic|openai/i);
}
console.log("PASS: Anthropic Messages wire contracts and fail-closed errors passed");

181
docs/DEVIN_CLAUDE_BRIDGE.md Normal file
View File

@@ -0,0 +1,181 @@
# Devin Claude Bridge
`devin-cli-agentic` lets the real Claude Code runtime use OmniRoute's local Anthropic
Messages endpoint while the official Devin CLI supplies model responses over ACP stdio. It
does not modify the existing Anthropic, Claude OAuth, Claude Web, or `devin-cli` providers.
> **Current status: offline and live validated.** The pinned Claude Code `2.1.220` completed
> three isolated scenarios through Devin CLI `3000.2.17` and model
> `swe-1-7-lightning`. The final live run proved client-owned `Read`, `Edit`, and `Bash`
> turns, successful `npm test` results, project command and skill discovery, Devin-only
> routing, and zero Claude egress.
## Architecture
```text
Claude Code 2.1.220 (isolated non-root Linux container)
-> http://omniroute:20128/v1/messages
-> devin-cli-agentic (Claude-format, no-auth provider)
-> devin acp --agent-type summarizer (official ACP stdio, no Devin tools)
-> Devin account in the dedicated devin-auth volume
```
The official CLI's default ACP agent can execute its own tools, so this bridge does not use
it. It starts the fixed `summarizer` ACP agent, whose official CLI mode has no tools, and
frames the serialized Anthropic request as an execution trace. When another Claude-owned
action is needed, the response must contain exactly one client tool envelope. Any ACP
`tool_call` or `tool_call_update` is rejected before a response can be reported as
successful.
The serializer in `open-sse/executors/devin-agentic/serializer.ts` preserves `system`,
`text`, `tool_use`, `tool_result`, `thinking`, `redacted_thinking`, `tool_choice`, and the
tools supplied by Claude Code. Images and unknown blocks fail explicitly. Large tool results
use a visible truncation marker.
The parser accepts one standalone `<tool>{...}</tool>` envelope per model turn. It checks
the name against the request's tool list, validates arguments against that tool's JSON
Schema, rejects mixed narrative/actions, and permits one bounded repair. Claude Code then
executes the resulting Anthropic `tool_use` locally and sends the `tool_result` back through
OmniRoute.
## Isolation and threat model
The host's Claude installation, account, and configuration are out of scope and treated as
forbidden. The Compose services:
- run as UID/GID `10001:10001`, with a read-only root filesystem, dropped capabilities, and
`no-new-privileges`;
- use a private `/home/bridge`, a dedicated Claude config volume, isolated OmniRoute data,
and a separate `devin-auth` volume;
- mount only disposable `.sandbox` workspaces/evidence;
- do not mount the host home, Keychain, SSH, cloud credentials, or Docker socket;
- construct explicit environments and remove Anthropic API/OAuth/routing variables;
- direct Claude Code inference only to `http://omniroute:20128` with a local-only key.
The offline profile uses an internal network. In the live profile, OmniRoute reaches the
official Devin endpoints only through `network-guard`; unrelated destinations are denied.
Claude Code has a separate deny-all egress guard and can reach only the local OmniRoute
service through `NO_PROXY`. Guard audit files are mounted only by their guard process. The
scripts verify file ownership, mode, link count, and every decision before exporting
token-free evidence.
Run the isolation proof independently:
```bash
./scripts/devin-bridge/verify-anthropic-isolation
```
It validates topology, named mounts, non-root/read-only settings, explicit local routing,
absence of sensitive environment variables, absence of the Docker socket, blocked access to
`api.anthropic.com` and `claude.ai`, Devin-only provider selection, and explicit failure when
the ACP backend is unavailable.
## First-time setup and normal use
Build the pinned image:
```bash
./scripts/devin-bridge/build
```
Authenticate only the isolated Devin volume:
```bash
ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/login-devin
```
The login command uses the official manual-token flow intended for remote/container
environments. The value is entered directly into the CLI prompt; it is not passed as a
process argument, written to Git, or copied from the host.
Launch the isolated Claude Code runtime:
```bash
./scripts/devin-bridge/launch
```
`launch` rechecks isolation, Devin authentication, and model discovery before starting the
containerized Claude Code. It never runs the host's Claude executable. Model aliases can be
set in `.env.devin-bridge`; every configured value must keep the
`devin-cli-agentic/` prefix.
## Validation commands
The reproducible offline path requires no Devin account and has no runtime Internet:
```bash
./scripts/devin-bridge/test-unit
./scripts/devin-bridge/test-contract
./scripts/devin-bridge/test-e2e-mock
./scripts/devin-bridge/verify-anthropic-isolation
```
The authenticated opt-in live path is:
```bash
ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/test-live-devin
```
The live runner waits between scenarios to avoid opening ACP sessions in a burst and
validates structured Claude stream events instead of trusting textual claims. Its three
scenarios prove:
1. direct project reads and defect analysis;
2. a real `Edit`, a client-owned `Bash` `npm test`, and a terminal result;
3. `/bridge-check` plus `bridge-proof` discovery, project reads, another successful
client-owned `npm test`, and completion without pending work.
The final gate also checks the Devin network audit and requires the Claude egress audit to
remain empty.
## Updating pinned tools
The image pins Node, Claude Code, and Devin CLI in
`docker/devin-bridge/Dockerfile`. To update:
1. change the explicit versions;
2. replace both architecture-specific Devin archive checksums with values for the official
artifact;
3. rebuild and run every offline validation command;
4. confirm the versions inside the image;
5. rerun the authenticated three-scenario live suite.
Do not install either CLI globally on the host or replace checksum verification with an
unverified download.
## Diagnosis and cleanup
- `docker compose -f docker/devin-bridge/compose.yml --profile offline logs omniroute`
shows local routing and sanitized executor errors.
- `.sandbox/evidence/mock-acp.jsonl` records deterministic mock ACP actions.
- `.sandbox/evidence/claude-stream.jsonl` records the real Claude Code offline run.
- `.sandbox/evidence/live-*.jsonl` records the three validated live streams.
- `.sandbox/evidence/egress.jsonl` and `.sandbox/evidence/claude-egress.jsonl` are validated,
token-free copies of the guard audits.
Stop owned containers and networks while preserving login/config volumes:
```bash
./scripts/devin-bridge/clean
```
Remove the complete bridge-owned environment, including named volumes:
```bash
./scripts/devin-bridge/clean --all
```
## Limits
- The bridge relies on the fixed no-tools `summarizer` role because Devin CLI `3000.2.17`
does not expose a neutral no-tools ACP agent. The adapter compensates for summary-shaped
intermediate responses, but one bounded repair can still fail explicitly.
- Live ACP calls can return transient `502`/`504` responses. The harness spaces scenarios;
persistent failure remains fail-closed and never selects another provider.
- ACP context is reconstructed from each Anthropic request; there is no process/session
affinity.
- One tool call is supported per model response; parallel calls are rejected.
- Images are explicitly unsupported. Vision, thinking output, effort controls, and a 1M
context window are not advertised.
- SSE uses valid Anthropic lifecycle events but is emitted after the bounded ACP turn is
collected; ACP chunks are not forwarded incrementally.

View File

@@ -0,0 +1,115 @@
# Devin Claude Bridge Progress
Updated: 2026-07-28
## Baseline
- Fork version: `3.8.49`.
- Starting branch: `release/v3.8.49`.
- Starting commit: `ed7db3ee5f89a144b2d931d8605534522f83de30`.
- Fixed runtime artifacts: Node `26.0.0`, Claude Code `2.1.220`, Devin CLI `3000.2.17`.
- Existing `devin-cli` remains unchanged; the new path is the separate
`devin-cli-agentic` provider.
## Implemented architecture
- Claude Code runs only inside the non-root bridge container with its own empty config
volume and local OmniRoute base URL.
- `devin-cli-agentic` preserves Anthropic messages, tool schemas, `tool_use`, and
`tool_result`, then calls the official Devin CLI over ACP stdio.
- The executor starts `devin acp --agent-type summarizer`. This is the only fixed official
ACP role in the pinned CLI that has no Devin-owned tools.
- The request is framed as an execution trace. Devin can return one strict client tool
envelope; Claude Code executes that tool locally.
- Internal ACP `tool_call` events, unsupported blocks, invalid schemas, narrative actions,
timeouts, cancellation, and process failure all fail closed.
- Provider and network policy prevent combo/auto/Anthropic fallback.
## Offline proof
- Focused serializer, parser, executor, ACP lifecycle, wire-format, environment, and audit
tests pass (39/39).
- The contract suite covers Anthropic JSON/SSE, `tool_use`, `tool_result` continuation,
fragmented ACP frames, stderr, early exit, timeout, cancellation, and fail-closed provider
loss.
- The production bridge image builds with the pinned CLIs.
- Real Claude Code offline E2E loads `CLAUDE.md`, the project skill and slash command, fires
hooks, executes local tools over multiple turns, observes a failed test, repairs the file,
reruns the test, and completes.
- The isolation verifier proves non-root/read-only execution, isolated mounts and config,
blocked Anthropic/Claude access, no host credential mounts, local-only inference, and no
fallback.
Evidence is generated under `.sandbox/evidence` and ignored by Git.
## Regression status
- `typecheck:core`, focused ESLint, Prettier, shell/Node syntax, and the complete documentation
accuracy suite pass.
- The broad `npm run check` is not reported as passed: after its lint phase, the repository
test runner remained alive while an existing `ioredis` client repeatedly retried an
unavailable local Redis endpoint after `quota-redis-store.test.ts`. The bridge-focused
suites, production image build, offline E2E, isolation proof, and live gate do not use that
Redis service and all pass.
## Live Devin proof
Passed with the official in-container login and discovered model
`swe-1-7-lightning`. The terminal live run completed all three scenarios:
1. Claude Code loaded the fixture instructions, issued client-owned `Read` calls, and
returned a correct defect analysis.
2. Claude Code issued a real `Edit` changing subtraction to addition, then a client-owned
`Bash` call running `npm test`; the test reported one pass and zero failures.
3. Claude Code initialization listed `bridge-check` and `bridge-proof`, read the corrected
source and test, executed another client-owned `npm test`, and completed successfully.
The live evidence validator parses stream JSON and requires successful tool results. It does
not accept a textual claim that a tool ran. It also rejects terminal summaries that report a
blocker, incomplete work, or required next steps.
The final live gate reported:
```text
PASS: validated Claude evidence for LIVE_ANALYSIS_COMPLETE
PASS: validated Claude evidence for LIVE_FIX_COMPLETE
PASS: validated Claude evidence for BRIDGE_E2E_COMPLETE
PASS: three live Devin-backed Claude Code scenarios completed
PASS: live model swe-1-7-lightning was discovered and validated by three scenarios
```
The same gate validated the network audit: only the Devin guard path was used, no internal
Devin tool event was accepted, and the Claude egress audit remained empty.
## Investigation conclusion
The initial default-agent hypothesis failed because ACP permission modes do not turn the
default Devin agent into a raw inference backend. Even `ask` mode can emit Devin-owned
`tool_call` events. A discovered `allowed-tools: []` agent configuration was not consumed by
`devin acp` in CLI `3000.2.17`.
The working adaptation uses the official `summarizer` agent because it is structurally
no-tools. Its fixed summarization behavior can produce intermediate prose, so the bridge
frames requests as execution traces, detects future-action narration, performs at most one
strict repair, and otherwise fails. Live validation also exposed transient ACP timeouts;
the harness now spaces independent scenarios rather than weakening routing or retrying into
another provider.
## Safety record
No host Claude executable, configuration, login, OAuth token, Keychain, or Anthropic API was
used. The dedicated Docker volumes remain role-separated. No credential value is written to
the repository or evidence output.
During the early baseline, a focused test without isolated `DATA_DIR` initialized the
repository's normal OmniRoute database at `/Users/lucasisrael/.omniroute/storage.sqlite`.
It was not rolled back or touched again. Every bridge command now pins database and temporary
paths under the worktree's `.sandbox` directory.
## Remaining limits
- The no-tools backend has a summarizer system role rather than a neutral generation role.
- One client tool call per response is supported; parallel tool calls are rejected.
- ACP processes are per-turn and stateless.
- Live Devin availability can still produce explicit `502`/`504` failures.
- Images and unadvertised vision/effort/large-context capabilities remain unsupported.

View File

@@ -422,3 +422,13 @@ See also [TUNNELS_GUIDE.md](./TUNNELS_GUIDE.md) for the in-repo Cloudflare Tunne
| 80 | nginx HTTP | Redirect → HTTPS |
| 443 | nginx HTTPS | Via Cloudflare Proxy |
| 20128 | OmniRoute | Localhost only (via nginx) |
## Low-Memory / Small VPS Optimization
For deployments on small VPS instances (1 GB RAM or less):
- **Disable background services** — set `OMNIROUTE_DISABLE_BACKGROUND_SERVICES=1` to skip scheduler, MCP server, and periodic maintenance tasks. See `docs/reference/ENVIRONMENT.md`.
- **Use SQLite WAL mode** — enabled by default, reduces peak memory during concurrent reads.
- **Limit connection concurrency** — reduce `OMNIROUTE_MAX_POOL_SIZE` and `OMNIROUTE_DB_POOL_SIZE` in your environment.
- **Avoid `next build` on the VPS** — build locally and deploy the standalone output (`.next/standalone/`).
- **Monitor with `top` / `free -m`** — OmniRoute typically uses 200-400 MB RSS at idle on a 1 GB VM.

View File

@@ -196,6 +196,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. |
| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `800` | `src/shared/middleware/chatBodyAdmission.ts` | Hard chat history cap. Requests above it receive structured compact-required `413` before compression, translation, or provider dispatch. |
| `OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES` | `67108864` (64 MB) | `open-sse/handlers/chatCore/nonStreamingResponseBody.ts` | Hard cap for a non-streaming upstream response buffered fully into memory. Past this the upstream reader is cancelled and the request fails fast instead of growing an unbounded string until the heap is exhausted. |
| `OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES` | `768` | `open-sse/handlers/chatCore/responseHeaders.ts` | Max wire bytes forwarded from upstream response headers. When the budget is exceeded, lower-priority headers (e.g., custom `x-codex-*`, `x-oai-request-id`) are dropped to stay within common reverse-proxy header limits. Set higher to forward more upstream metadata at the cost of larger response header size. |
| `CORS_ORIGIN` | _(unset)_ | `src/server/cors/origins.ts` | Legacy single-origin CORS allowlist. Prefer `CORS_ALLOWED_ORIGINS` for new deployments. CORS is only for cross-origin browser API clients; authenticated dashboard writes use same-origin requests plus session-bound CSRF protection instead. |
| `CORS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/cors/origins.ts` | Comma-separated CORS allowlist. No wildcard is sent unless `CORS_ALLOW_ALL=true` is explicitly configured. |
| `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | Development-only escape hatch to echo any browser `Origin`. Do not enable on shared or production deployments. |
@@ -380,6 +381,14 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. |
| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. |
| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. |
| `CLI_DEVIN_AGENTIC_BIN` | `devin` | `open-sse/executors/devin-cli-agentic.ts` | Agentic bridge-only Devin CLI override. The executor accepts only the local ACP stdio upstream. |
| `DEVIN_AGENTIC_HOME` | _(required)_ | `open-sse/executors/devin-cli-agentic.ts` | Absolute isolated home for the agentic Devin subprocess; accepted bridge paths are `/home/bridge` and task-local `.sandbox` paths. |
| `DEVIN_AGENTIC_ACP_TIMEOUT_MS` | `120000` | `open-sse/executors/devin-cli-agentic.ts` | Maximum duration of one Devin ACP turn before the bridge terminates the child and returns an explicit timeout. |
| `DEVIN_BRIDGE_MODEL` | `devin-cli-agentic/swe-1-7` | `docker/devin-bridge/compose.yml` | Main Claude Code model alias for the isolated bridge. The live harness replaces the example with a model returned by the current Devin account. |
| `DEVIN_BRIDGE_SONNET_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Sonnet default. |
| `DEVIN_BRIDGE_OPUS_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Opus default. |
| `DEVIN_BRIDGE_HAIKU_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Haiku default. |
| `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. |
| `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. |
| `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). |
| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). |
@@ -453,6 +462,7 @@ detection above).
| `COMPRESSION_PIPELINE_BREAKER_THRESHOLD` | `3` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Consecutive cross-request failures before an engine's breaker opens. |
| `COMPRESSION_PIPELINE_BREAKER_COOLDOWN_MS` | `30000` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Milliseconds an opened engine stays skipped before a half-open probe. |
| `COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR` | `2` | `open-sse/services/compression/engines/ccr/index.ts` | T08/H8 CCR retrieval-feedback ramp: each prior retrieval of a stored block raises its effective `minChars` linearly (frequently-retrieved content compresses less; `>=3` retrievals = never compressed). `1` disables the ramp (binary skip at the threshold only). |
| `COMPRESSION_CCR_DURABLE_STORE` | `true` | `open-sse/services/compression/engines/ccr/index.ts` | CCR durable block store (#9061). Backs the in-memory store with SQLite so a block survives LRU eviction, the TTL, a restart, or a retrieve landing on another instance. Set `false` to keep blocks in memory only. Blocks over 512KB and cloud runtimes stay memory-only regardless. |
| `COMPRESSION_PREFIX_FREEZE_ENABLED` | `false` | `open-sse/services/compression/prefixFreeze.ts` | T08/H5 usage-observed prefix freeze master switch. **Opt-in (default off)** — when on, a system prompt observed `>=` the threshold is treated as a stable cacheable prefix and preserved from compression even for providers the static cache heuristic misses (freeze only *preserves*, never mutates). |
| `COMPRESSION_PREFIX_FREEZE_THRESHOLD` | `3` | `open-sse/services/compression/prefixFreeze.ts` | Observations of a system prompt before it is treated as a frozen stable prefix. |
| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. |
@@ -510,6 +520,10 @@ Built-in credentials for **localhost development**. For remote deployments, regi
| `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | Frontend `tk` token to send as `validated` on `/api/chat`. Required when Blackbox enforces token matching; otherwise OmniRoute falls back to a random UUID. See issue #2252. |
| `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. When the URL is OmniRoute's own `/v1`, the describe sub-request sends `x-omniroute-admission-bypass: internal` and authenticates with the resolved self-loop credential (`sk_omniroute` sentinel in local mode, or `OMNIROUTE_API_KEY` / `ROUTER_API_KEY`#1350) so `REQUIRE_API_KEY=true` deployments work. |
| `VISION_BRIDGE_API_KEY` | Vision Bridge guardrail | API key for the URL above. Overrides per-provider OpenAI / Google env vars for non-Anthropic vision-bridge calls. Anthropic models keep their dedicated Anthropic key path. Issue #2232. |
| `RAYCAST_BEARER_TOKEN` | Raycast Pro | Optional manual override for the Raycast access token (normally captured via macOS Auto-Import). No OAuth client_id/secret — reverse-engineered, local/personal use only. |
| `RAYCAST_DEVICE_ID` | Raycast Pro | Optional manual override for the Raycast device ID used to sign requests. |
| `RAYCAST_AID` | Raycast Pro | Optional manual override for the Raycast account/app ID; falls back to the device ID when unset. |
| `RAYCAST_SIG_SECRET` | Raycast Pro | Optional override for the request-signing HMAC secret. Defaults to a community-extracted value in `open-sse/services/raycast.ts`. |
> [!WARNING]
>

View File

@@ -0,0 +1,252 @@
# Devin Claude Bridge Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a fail-closed `devin-cli-agentic` provider that serves local Anthropic Messages requests through Devin CLI ACP stdio while preserving Claude Code tool-use semantics.
**Architecture:** Add a separate Claude-format provider and executor instead of changing the existing OpenAI-format `devin-cli` summarizer. Keep parsing, prompt serialization, Anthropic response rendering, and ACP process handling in focused files under `open-sse/executors/devin-agentic/`, then wire them into the existing provider and executor registries.
**Tech Stack:** TypeScript ES modules, Node child process stdio, Anthropic Messages JSON/SSE, JSON-RPC 2.0 ACP, Node test runner.
---
### Task 1: Agentic Bridge Core
**Files:**
- Create: `open-sse/executors/devin-agentic/types.ts`
- Create: `open-sse/executors/devin-agentic/serializer.ts`
- Create: `open-sse/executors/devin-agentic/toolParser.ts`
- Create: `open-sse/executors/devin-agentic/anthropicResponse.ts`
- Test: `tests/unit/executor-devin-cli-agentic-core.test.ts`
- [ ] **Implement and prove serialization, parsing, validation, and Anthropic rendering**
Interfaces:
```ts
export function serializeAnthropicForDevin(body: unknown): DevinPrompt;
export function parseDevinToolRequest(text: string, tools: AnthropicTool[]): ParsedToolRequest | null;
export function buildClaudeTextResponse(args: ClaudeResponseArgs): Record<string, unknown>;
export function buildClaudeToolUseResponse(args: ClaudeToolUseArgs): Record<string, unknown>;
export function buildClaudeSseFrames(message: Record<string, unknown>): string;
```
Invariants:
- Preserve `text`, `tool_use`, `tool_result`, `thinking`, and `redacted_thinking`.
- Reject `image` with a clear error.
- Reject unknown content block types.
- Allow only one tool request per model turn.
- Validate tool arguments against object JSON Schema with `required`, `type`, `properties`, `additionalProperties`, `enum`, `items`, and scalar types.
- Generate deterministic ids from tool name and canonicalized arguments.
Run: `node --import tsx/esm --test tests/unit/executor-devin-cli-agentic-core.test.ts`
Expected: core tests pass after dependencies are installed.
### Task 2: ACP Executor And Provider Wiring
**Files:**
- Create: `open-sse/executors/devin-cli-agentic.ts`
- Modify: `open-sse/executors/index.ts`
- Create: `open-sse/config/providers/registry/devin-cli-agentic/index.ts`
- Modify: `open-sse/config/providers/index.ts`
- Test: `tests/unit/executor-devin-cli-agentic-acp.test.ts`
- [ ] **Implement and prove fail-closed ACP execution**
Behavior:
- `buildUrl()` returns `devin://acp/stdio`.
- `buildHeaders()` returns `{}`.
- `execute()` spawns only `devin acp` by default or the explicit `CLI_DEVIN_AGENTIC_BIN`/`CLI_DEVIN_BIN` override.
- The child environment removes Anthropic and Claude routing credentials before spawn.
- The executor sends `initialize`, `session/new`, and `session/prompt`.
- The executor collects `agent_message_chunk` text and `session/prompt` final result.
- Non-streaming Claude clients receive native Anthropic JSON.
- Streaming Claude clients receive native Anthropic SSE lifecycle frames.
- Spawn failure, ACP error, timeout, and early exit produce non-2xx responses with sanitized messages.
Run: `node --import tsx/esm --test tests/unit/executor-devin-cli-agentic-acp.test.ts`
Expected: ACP mock tests pass after dependencies are installed.
### Task 3: Isolation Scripts And Documentation
**Files:**
- Create: `scripts/devin-bridge/verify-anthropic-isolation`
- Create: `scripts/devin-bridge/test-unit`
- Create: `scripts/devin-bridge/launch`
- Create: `docs/DEVIN_CLAUDE_BRIDGE.md`
- Modify: `.gitignore`
- [ ] **Implement offline guardrails and operator docs**
Behavior:
- `verify-anthropic-isolation` fails if `CLAUDE_CONFIG_DIR` is missing, points outside an isolated path, or if Anthropic routing env vars are present.
- `test-unit` runs the focused unit tests.
- `launch` refuses to start unless `ENABLE_LIVE_DEVIN_TESTS=1` for live Devin or `DEVIN_BRIDGE_OFFLINE=1` for offline mock mode.
- Documentation distinguishes tested offline behavior from live Devin opt-in behavior.
Run: `./scripts/devin-bridge/verify-anthropic-isolation` with explicit isolated env.
Expected: exits 0 with isolated env and non-zero without it.
### Task 4: Verification
**Files:**
- No additional source files.
- [ ] **Run proportional checks and capture real output**
Commands:
```bash
./scripts/devin-bridge/test-unit
npm test
```
Expected in this workspace before installing dependencies: both commands fail with `ERR_MODULE_NOT_FOUND` for `tsx`. Expected after `npm install`: focused tests pass; `npm test` outcome must be reported from real output.
### Task 5: Close Core Security And Protocol Gaps
**Files:**
- Modify: `open-sse/executors/devin-cli-agentic.ts`
- Modify: `open-sse/executors/devin-agentic/*.ts`
- Modify: `tests/unit/executor-devin-cli-agentic-*.test.ts`
- [ ] **Prove environment allowlisting, response-id correlation, strict standalone tool envelopes, unique ids, bounded repair, size limits, cancellation cleanup, sanitized errors, and explicit `devin://acp/stdio` validation**
Run with `HOME`, `DATA_DIR`, and `SQLITE_FILE` under `.sandbox`; expected: all focused tests pass and an outside-path test fails closed.
### Task 6: Build Reproducible Containers And Network Guard
**Files:**
- Create: `docker/devin-bridge/Dockerfile`
- Create: `docker/devin-bridge/compose.yml`
- Create: `docker/devin-bridge/network-guard/*`
- Create: `docker/devin-bridge/mock-devin/*`
- Create: `.env.devin-bridge.example`
- [ ] **Pin Claude Code 2.1.220 and Devin CLI 3000.2.17, create non-root offline/live profiles, separate auth/config volumes, explicit env allowlist, no host credential mounts, and denied-domain telemetry**
Run: `docker compose -f docker/devin-bridge/compose.yml --profile offline config`; expected: no forbidden mounts/env inheritance and only internal runtime networks.
### Task 7: Deliver Isolation And Operator Scripts
**Files:**
- Create/modify: `scripts/devin-bridge/{build,test-unit,test-contract,test-e2e-mock,verify-anthropic-isolation,login-devin,test-live-devin,launch,clean}`
- [ ] **Make every command idempotent, sandbox-scoped, fail-closed, and secret-safe**
Run: `./scripts/devin-bridge/verify-anthropic-isolation`; expected: positive offline proof passes and each deliberately removed guard returns non-zero.
### Task 8: Real Claude Code Offline E2E
**Files:**
- Create: `tests/fixtures/devin-bridge/e2e-workspace/*`
- Create: `tests/e2e/devin-claude-bridge.e2e.*`
- [ ] **Run pinned Claude Code in the offline container through local `/v1/messages` and mock ACP, proving CLAUDE.md, skill, command, hook, Read/Edit/Bash, tests, multi-turn continuation, and no Anthropic traffic**
Run: `./scripts/devin-bridge/test-e2e-mock`; expected: workspace diff and tests prove Claude Code executed tools while mock Devin only requested them.
### Task 9: Regression, Documentation, Live Gate, And Delivery
**Files:**
- Modify: `docs/DEVIN_CLAUDE_BRIDGE.md`
- Create: `docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md`
- [ ] **Run focused suites, typecheck, lint, build, docs checks, offline E2E, and isolation proof with fresh output; then run live only after official in-container Devin login**
If login is unavailable, record live as not tested and expose exactly `./scripts/devin-bridge/login-devin` followed by `./scripts/devin-bridge/test-live-devin`. Commit each reversible unit; do not merge or publish until all offline critical checks are green.
### Task 10: Close The Authenticated Live Runtime
**Files:**
- Modify: `open-sse/executors/devin-cli-agentic.ts`
- Modify: `docker/devin-bridge/compose.yml`
- Create: `docker/devin-bridge/network-guard/policy.mjs`
- Modify: `docker/devin-bridge/network-guard/proxy.mjs`
- Modify: `scripts/devin-bridge/select-live-model.mjs`
- Modify: `scripts/devin-bridge/common`
- Modify: `scripts/devin-bridge/login-devin`
- Modify: `scripts/devin-bridge/test-live-devin`
- Modify: `scripts/devin-bridge/verify-anthropic-isolation`
- Modify: `tests/unit/executor-devin-cli-agentic-acp.test.ts`
- Create: `tests/unit/devin-bridge-live-runtime.test.ts`
- [ ] **Implement and prove the authenticated network, auth, and catalog boundaries with block-level TDD**
Invariants:
- The ACP child receives proxy variables only when `DEVIN_BRIDGE_PROXY_URL` is exactly
`http://network-guard:8080`; arbitrary inherited proxy and credential variables stay absent.
- The guard permits suffixes `.devin.ai` and `.cognition.ai`, exact hosts
`server.codeium.com` and `unleash.codeium.com`, and nothing else.
- Claude services cannot mount `devin-auth`; non-Claude services cannot mount the Claude config.
- A zero exit from `devin auth status` is insufficient when output contains a server-fetch failure.
- `family_uid: swe-1.7-lightning` resolves to catalog id `swe-1-7-lightning`; unknown normalized
values fail instead of becoming model ids.
- Login uses the official manual-token flow so no container loopback callback is required.
Run:
```bash
./scripts/devin-bridge/test-unit
node --import tsx/esm --test tests/unit/devin-bridge-live-runtime.test.ts
./scripts/devin-bridge/verify-anthropic-isolation --static
```
Expected: focused tests and static isolation pass; deliberate untrusted proxy, host, mount, auth
status, and model fixtures fail closed.
- [ ] **Commit the reversible live-runtime repair**
```bash
git add open-sse/executors/devin-cli-agentic.ts docker/devin-bridge \
scripts/devin-bridge tests/unit/devin-bridge-live-runtime.test.ts \
tests/unit/executor-devin-cli-agentic-acp.test.ts
git commit -m "fix: close Devin bridge live runtime gaps"
```
### Task 11: Prove Offline And Live Completion
**Files:**
- Modify: `docker/devin-bridge/run-claude-live-e2e.sh`
- Modify: `docs/DEVIN_CLAUDE_BRIDGE.md`
- Modify: `docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md`
- [ ] **Run the complete deterministic bridge proof before any paid request**
```bash
./scripts/devin-bridge/test-unit
./scripts/devin-bridge/test-contract
./scripts/devin-bridge/test-e2e-mock
./scripts/devin-bridge/verify-anthropic-isolation
npm run typecheck:core
npm run lint
npm run build
npm run check:docs-all
```
Expected: all bridge-specific checks, typecheck, lint, build, and documentation checks pass with
isolated data paths. Any unrelated full-suite infrastructure hang is recorded separately and is
not converted into a pass.
- [ ] **Run exactly the three authorized live scenarios and the no-fallback failure probe**
```bash
ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/test-live-devin
```
Expected: dynamic discovery selects a returned Devin catalog model; Claude Code reads without
editing, then edits and runs the fixture test, then executes the fixture command. Evidence shows
native tool use by Claude Code, only `devin-cli-agentic` routing, no allowed non-Devin egress,
and an Anthropic-shaped error after the Devin backend is deliberately made unavailable.
- [ ] **Update verified documentation and commit the evidence-backed delivery state**
```bash
git add docker/devin-bridge/run-claude-live-e2e.sh docs/DEVIN_CLAUDE_BRIDGE.md \
docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md
git commit -m "docs: record verified Devin bridge live delivery"
```

View File

@@ -0,0 +1,134 @@
# Devin Claude Bridge Design
## Baseline
- Branch: `release/v3.8.49`
- HEAD: `ed7db3ee5f89a144b2d931d8605534522f83de30`
- Package version: `3.8.49`
- Node: `v26.0.0`
- npm: `11.12.1`
- Pre-existing worktree state: `.tug/` untracked
- Dependency state: `node_modules` is absent; the first focused test run failed before loading tests because `tsx` was not installed.
- Tugline state: `tug` exists, but `tug search` failed with MCP connection closed and `tug doctor` hung; it was interrupted.
- Upstream check: `git ls-remote` failed because GitHub DNS was unavailable. Web search of the public repository showed the existing `devin-cli` summarizer provider, but no evidence of `devin-cli-agentic`.
## Source Anchors
- `/v1/messages`: `src/app/api/v1/messages/route.ts`
- Existing Devin provider: `open-sse/config/providers/registry/devin-cli/index.ts`
- Existing Devin executor: `open-sse/executors/devin-cli.ts`
- Executor registry: `open-sse/executors/index.ts`
- Provider registry: `open-sse/config/providers/index.ts`
- Format detection: `open-sse/services/provider.ts`
- Claude non-streaming response conversion: `open-sse/handlers/responseTranslator.ts`
- Existing Devin ACP unit test: `tests/unit/executor-devin-cli-acp-protocol-8406.test.ts`
## Findings
The existing `devin-cli` provider is intentionally OpenAI-format and summarizer-oriented. Its executor spawns `devin acp --agent-type summarizer`, flattens the message history into a single text prompt, and emits OpenAI SSE text chunks. It does not preserve Anthropic `tool_use` and `tool_result` blocks.
The safest implementation is a new provider id, `devin-cli-agentic`, with a separate executor. This leaves `devin-cli`, Anthropic OAuth, Claude OAuth, Claude Web, and all host Claude configuration code untouched. The new provider is fail-closed: it only resolves to `devin://acp/stdio`, uses the official Devin CLI ACP stdio path, and has no fallback provider.
## Architecture
Claude Code sends Anthropic Messages requests to local OmniRoute. OmniRoute resolves model ids prefixed with `devin-cli-agentic/` to a new Claude-format provider. The new executor translates the complete Anthropic request into an explicit text prompt for Devin ACP, including system text, structured message history, tool schemas, and prior tool results.
Devin remains a model backend. The executor starts the official fixed no-tools summarizer
role with `devin acp --agent-type summarizer` and frames the serialized request as an
execution trace. Devin must request client-owned tool execution by emitting a strict
XML-wrapped JSON block:
```xml
<tool>
{"name":"Read","arguments":{"file_path":"src/index.ts"}}
</tool>
```
The bridge parses exactly one tool request per model turn, validates that the tool name was supplied in the incoming request, validates arguments against a minimal JSON Schema validator, generates a stable `tool_devin_...` id, and returns a native Anthropic `tool_use` block. If no valid tool request is present, the bridge returns text with `stop_reason: "end_turn"`.
## Error And Safety Rules
- Unsupported Anthropic content blocks fail explicitly; images are rejected.
- Unknown tools fail explicitly.
- Invalid tool arguments fail explicitly.
- Invalid tool XML/JSON fails explicitly.
- Narrative claims that a tool was executed are returned as text, not actions.
- ACP spawn, timeout, early exit, and stderr-only failures return explicit Devin errors.
- The executor never reads `~/.claude`, `~/.claude.json`, macOS Keychain paths, or host Claude config.
- Live Devin is outside normal tests and remains opt-in via `ENABLE_LIVE_DEVIN_TESTS=1`.
## Test Strategy
Focused unit tests cover serialization, tool parsing, validation, Anthropic JSON, Anthropic SSE, malformed tool output, unknown tools, invalid arguments, image rejection, timeout, and spawn failure. Environment scripts provide an offline isolation verifier without reading host Claude credentials.
## Mandatory Runtime Isolation
The bridge runs only through `docker/devin-bridge/compose.yml`. The runtime image is non-root, uses a private `/home/bridge`, and mounts only disposable workspaces, evidence, and bridge harness files. Application source is copied into the image. It never mounts the host home, Docker socket, SSH, cloud credentials, or global Claude configuration. The container receives an explicit environment allowlist; the executor also constructs an allowlisted child environment instead of copying `process.env`.
Build-time network access installs Claude Code `2.1.220` and Devin CLI `3000.2.17` with pinned integrity/checksum. Runtime profiles are separate: `offline` uses only an internal Compose network; `live-devin` exposes egress only through a proxy guard whose allowlist contains Devin/Cognition suffixes and whose default is denial. Devin authentication lives only in the named `devin-auth` volume. Claude configuration lives in a different named volume and is initialized empty.
## Fail-Closed Routing
`devin-cli-agentic` accepts only the synthetic `devin://acp/stdio` target and an explicit Devin binary path inside the container. It cannot use provider combos, auto routing, account fallback, fallback URLs, or an HTTP upstream. Model aliases resolve only to models returned by the Devin catalog or explicitly configured Devin model ids. An ACP failure, timeout, cancellation, invalid frame, unavailable model, or stopped sidecar becomes an Anthropic-shaped error response; no secondary provider is attempted.
## Agentic Contract
The serializer preserves request order, `system`, `tool_choice`, exact tool schemas, `text`, `tool_use`, `tool_result`, `thinking`, and `redacted_thinking`. It rejects unsupported blocks and caps large tool results with an explicit truncation marker and original size. The parser accepts exactly one standalone `<tool>` envelope, validates with Zod/JSON Schema infrastructure already present in OmniRoute, rejects unknown tools and mixed narrative/action output, and performs at most one bounded repair prompt. Tool ids combine a per-request nonce with canonical arguments so repeated identical calls remain unique while their association is stable within the turn.
## Required Proof
The offline profile must prove the ACP lifecycle, fragmented frames, stderr, early exit, hang/cancel, Anthropic JSON/SSE order, no fallback, and a real pinned Claude Code run that reads, edits, runs tests, observes `CLAUDE.md`, loads a skill and command, fires a hook, and completes at least one `tool_use -> tool_result -> continuation` loop. The isolation verifier checks env, mounts, UID, config paths, DNS/connection logs, local inference destination, selected provider, and fail-closed behavior. Live Devin is proved only by official in-container login and three isolated agentic scenarios.
## Safety Incident During Baseline
The first focused test was run without `DATA_DIR` isolation and initialized `/Users/lucasisrael/.omniroute/storage.sqlite`; logs reported schema-column additions. No Anthropic data was accessed. The external database will not be touched again or destructively rolled back. Every bridge command and test now must set `HOME`, `DATA_DIR`, `SQLITE_FILE`, and temporary directories inside `.sandbox`, and an automated guard must reject paths outside the task workspace.
## Live Completion Repair
The first authenticated live attempt disproved four assumptions in the initial container
design. The official CLI reports a valid login even when its server-status request fails;
that request uses the exact hosts `server.codeium.com` and `unleash.codeium.com`, which the
guard denied. The OmniRoute executor also built a fresh allowlisted child environment that
omitted the proxy, so `devin acp` could not leave the internal network. Model discovery emits
family identifiers such as `swe-1.7`, while the OmniRoute catalog uses canonical ids such as
`swe-1-7`. Finally, browser login redirects to a loopback listener inside the one-off
container, which is not reachable from the host browser.
The repair keeps the fully containerized architecture and does not weaken the deny-by-default
network. The guard gains an exact-host allowlist for the two Codeium control-plane hosts while
retaining suffix-based access only for Devin and Cognition; telemetry destinations such as
Sentry remain denied. Compose supplies `DEVIN_BRIDGE_PROXY_URL` with the single accepted value
`http://network-guard:8080`, and the executor derives `HTTP_PROXY` and `HTTPS_PROXY` from that
explicit bridge setting instead of inheriting arbitrary host proxy variables. Claude services
mount only the Claude config volume, and only the OmniRoute live service mounts the Devin auth
volume.
Fresh login uses the official `devin auth login --force-manual-token-flow`, which is intended
for remote environments where localhost redirects cannot work. The credential is pasted only
into the interactive CLI terminal and never appears in arguments, logs, evidence, or Git.
Authentication validation requires both the logged-in marker and the absence of a server-fetch
failure. Model discovery accepts the real `family_uid`/`model_uid` fields, maps punctuation to a
catalog id only after an exact normalized match, and prefers the already-proved lightning model
when available.
Tests first prove the trusted proxy boundary, exact host policy, volume separation, strict auth
status gate, and catalog normalization. The live gate then runs three real Claude Code scenarios
through the authenticated in-container Devin CLI and requires local Read/Edit/Bash activity,
passing fixture tests, Devin-only routing, no allowed non-Devin egress, and an explicit error
when the Devin backend is stopped.
## Final Live Result
The default-agent design was rejected after live evidence showed that `ask` mode can still
emit Devin-owned ACP tool calls. The pinned CLI does not apply its top-level agent
configuration to `devin acp`, so an `allowed-tools: []` configuration could not create a
neutral backend. The fixed summarizer role is the only official ACP role in this version that
is structurally no-tools.
The execution-trace adaptation passed the authenticated live gate with
`swe-1-7-lightning`. Three Claude Code processes completed analysis, edit/test, and local
command/skill scenarios. Structured evidence proved that Claude Code issued `Read`, `Edit`,
and `Bash` tool calls; two client-owned `npm test` calls succeeded. The guard audit proved
Devin-only outbound access and zero Claude egress. Intermediate summary-shaped responses and
transient ACP timeouts remain explicit failure modes; the adapter performs one bounded repair
and the harness spaces scenarios to avoid bursty session creation.

View File

@@ -144,6 +144,9 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([
export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({
// gemini-3.1-pro-low is not aliased: the upstream accepts it verbatim.
// gemini-3.1-pro-high: the discovery slot returns HTTP 400 on v1internal;
// the live upstream id is gemini-pro-agent (see ANTIGRAVITY_PUBLIC_MODELS).
"gemini-3.1-pro-high": "gemini-pro-agent",
"gemini-3-pro-image-preview": "gemini-3-pro-image",
// Legacy Claude display ids → current upstream ids. NOTE: an earlier comment here
// assumed Claude was removed from Antigravity 2.0 and would 404; discussion #3184

View File

@@ -180,6 +180,24 @@ export function getRegistryEntry(provider: string): RegistryEntry | null {
return REGISTRY[provider] || _byAlias.get(provider) || null;
}
/**
* Decide whether a non-empty live catalog may exclude omitted static models
* during request routing and wildcard expansion.
*
* Live discovery is authoritative by default, including for dynamic providers.
* Providers with intentionally partial discovery must explicitly opt out in
* their registry entry.
*/
export function providerUsesAuthoritativeLiveCatalog(provider: string): boolean {
const entry = getRegistryEntry(provider);
if (entry && typeof entry.liveCatalogAuthoritative === "boolean") {
return entry.liveCatalogAuthoritative;
}
return true;
}
/** Get all registered provider IDs */
export function getRegisteredProviders(): string[] {
return Object.keys(REGISTRY);

View File

@@ -22,6 +22,7 @@ import { glmProvider } from "./registry/glm/index.ts";
import { glmtProvider } from "./registry/glm/t/index.ts";
import { glm_cnProvider } from "./registry/glm/cn/index.ts";
import { traeProvider } from "./registry/trae/index.ts";
import { raycastProvider } from "./registry/raycast/index.ts";
import { muse_spark_webProvider } from "./registry/muse-spark-web/index.ts";
import { lmarenaProvider } from "./registry/lmarena/index.ts";
import { kilocodeProvider } from "./registry/kilocode/index.ts";
@@ -147,6 +148,7 @@ import { siliconflowProvider } from "./registry/siliconflow/index.ts";
import { gitlab_duoProvider } from "./registry/gitlab-duo/index.ts";
import { command_codeProvider } from "./registry/command-code/index.ts";
import { novitaProvider } from "./registry/novita/index.ts";
import { regoloProvider } from "./registry/regolo/index.ts";
import { windsurfProvider } from "./registry/windsurf/index.ts";
import { zed_hostedProvider } from "./registry/zed-hosted/index.ts";
import { nanogptProvider } from "./registry/nanogpt/index.ts";
@@ -169,6 +171,7 @@ import { kilo_gatewayProvider } from "./registry/kilo-gateway/index.ts";
import { bailian_coding_planProvider } from "./registry/bailian-coding-plan/index.ts";
import { gigachatProvider } from "./registry/gigachat/index.ts";
import { devin_cliProvider } from "./registry/devin-cli/index.ts";
import { devin_cli_agenticProvider } from "./registry/devin-cli-agentic/index.ts";
import { auggieProvider } from "./registry/auggie/index.ts";
import { chutesProvider } from "./registry/chutes/index.ts";
import { chenzkProvider } from "./registry/chenzk/index.ts";
@@ -243,6 +246,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
glmt: glmtProvider,
"glm-cn": glm_cnProvider,
trae: traeProvider,
raycast: raycastProvider,
"muse-spark-web": muse_spark_webProvider,
lmarena: lmarenaProvider,
kilocode: kilocodeProvider,
@@ -368,6 +372,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"gitlab-duo": gitlab_duoProvider,
"command-code": command_codeProvider,
novita: novitaProvider,
regolo: regoloProvider,
windsurf: windsurfProvider,
"zed-hosted": zed_hostedProvider,
nanogpt: nanogptProvider,
@@ -389,6 +394,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"bailian-coding-plan": bailian_coding_planProvider,
gigachat: gigachatProvider,
"devin-cli": devin_cliProvider,
"devin-cli-agentic": devin_cli_agenticProvider,
auggie: auggieProvider,
chutes: chutesProvider,
chenzk: chenzkProvider,

View File

@@ -0,0 +1,11 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
export const anyapiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "anyapi",
alias: "anyapi",
baseUrl: "https://api.anyapi.ai/v1/chat/completions",
modelsUrl: "https://api.anyapi.ai/v1/models",
models: [],
passthroughModels: true,
});

View File

@@ -8,6 +8,9 @@ export const command_codeProvider: RegistryEntry = {
baseUrl: "https://api.commandcode.ai",
chatPath: "/alpha/generate",
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.
liveCatalogAuthoritative: false,
authType: "apikey",
authHeader: "Authorization",
authPrefix: "Bearer ",

View File

@@ -0,0 +1,21 @@
import type { RegistryEntry } from "../../shared.ts";
import { DEVIN_MODEL_CATALOG } from "../devin/catalog.ts";
export const devin_cli_agenticProvider: RegistryEntry = {
id: "devin-cli-agentic",
alias: "dva",
format: "claude",
executor: "devin-cli-agentic",
baseUrl: "devin://acp/stdio",
// Authentication is owned exclusively by the official Devin CLI inside its
// isolated volume. OmniRoute must not import or persist a host credential.
authType: "none",
authHeader: "none",
defaultContextLength: 200000,
models: DEVIN_MODEL_CATALOG.map((model) => ({
...model,
toolCalling: true,
supportsReasoning: false,
supportsVision: false,
})),
};

View File

@@ -0,0 +1,11 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
export const electronhubProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "electronhub",
alias: "electronhub",
baseUrl: "https://api.electronhub.ai/v1/chat/completions",
modelsUrl: "https://api.electronhub.ai/v1/models",
models: [],
passthroughModels: true,
});

View File

@@ -0,0 +1,11 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
export const fastrouterProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "fastrouter",
alias: "fastrouter",
baseUrl: "https://api.fastrouter.ai/api/v1/chat/completions",
modelsUrl: "https://api.fastrouter.ai/api/v1/models",
models: [],
passthroughModels: true,
});

View File

@@ -0,0 +1,14 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
export const llmKiwiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "llm-kiwi",
alias: "llmkiwi",
baseUrl: "https://api.llm.kiwi/v1/chat/completions",
modelsUrl: "https://api.llm.kiwi/v1/models",
models: [
{ id: "auto", name: "Auto" },
{ id: "hrLLM", name: "hrLLM" },
],
passthroughModels: true,
});

View File

@@ -0,0 +1,11 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
export const llmgatewayProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "llmgateway",
alias: "llmgateway",
baseUrl: "https://api.llmgateway.io/v1/chat/completions",
modelsUrl: "https://api.llmgateway.io/v1/models",
models: [],
passthroughModels: true,
});

View File

@@ -0,0 +1,33 @@
/**
* @file index.ts
* @description Raycast Pro AI provider registry entry (reverse-engineered, unofficial API).
*
* @changes
* - [2026-07-28] [Composer] - Initial Raycast provider registry module
*/
import type { RegistryEntry } from "../../shared.ts";
/** Seed catalog — full list synced from Raycast /api/v1/ai/models on connect/import. */
export const raycastProvider: RegistryEntry = {
id: "raycast",
alias: "rc",
format: "openai",
executor: "raycast",
baseUrl: "https://backend.raycast.com/api/v1/ai",
authType: "oauth",
authHeader: "bearer",
defaultContextLength: 128000,
models: [
{ id: "openai-gpt-5-mini", name: "GPT-5 Mini" },
{ id: "openai-gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "anthropic-claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "google-gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "raycast-ray1", name: "Ray1" },
{ id: "raycast-ray1-mini", name: "Ray1 Mini" },
{ id: "perplexity-sonar", name: "Sonar" },
{ id: "perplexity-sonar-pro", name: "Sonar Pro" },
{ id: "mistral-open-mistral-nemo", name: "Mistral Nemo" },
{ id: "xai-grok-3-mini", name: "Grok 3 Mini" },
],
};

View File

@@ -0,0 +1,16 @@
import type { RegistryEntry } from "../../shared.ts";
export const regoloProvider: RegistryEntry = {
id: "regolo",
alias: "regolo",
format: "openai",
executor: "default",
baseUrl: "https://api.regolo.ai",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "regolo-chat", name: "Regolo Chat" },
{ id: "regolo-fast", name: "Regolo Fast" },
],
passthroughModels: true,
};

View File

@@ -139,6 +139,15 @@ export interface RegistryEntry {
clientVersion?: string;
timeoutMs?: number;
passthroughModels?: boolean;
/**
* Whether a non-empty synchronized live model list is exhaustive enough
* to reject static registry IDs that it omits.
*
* Defaults to true. Set this explicitly to false for providers whose
* discovery endpoint is known to return only a partial subset of the models
* that the provider can route.
*/
liveCatalogAuthoritative?: boolean;
/** Default context window for all models in this provider (can be overridden per-model) */
defaultContextLength?: number;
/** Maximum OpenAI-compatible function name length accepted by this provider. */

View File

@@ -252,6 +252,16 @@ export function sanitizeReasoningEffortForProvider(
const effortStr = typeof c.effort === "string" ? c.effort.toLowerCase() : "";
const modelStr = model || "";
// Oh My Pi exposes `minimal`, while Codex's Responses API starts at `low`.
// Normalize every carrier before the Codex executor sends the upstream request.
if (provider === "codex" && effortStr === "minimal") {
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: normalized reasoning_effort minimal → low`
);
return writeEffortValue(b, "low", c);
}
const githubOptIn =
provider === "github" && GITHUB_REASONING_EFFORT_OPT_IN_PATTERN.test(modelStr);
const rejecting =

View File

@@ -0,0 +1,104 @@
import {
estimateTokens,
type ClaudeResponseArgs,
type ClaudeToolUseArgs,
type JsonRecord,
} from "./types.ts";
function usage(inputTokens: number, outputTokens: number) {
return {
input_tokens: inputTokens,
output_tokens: outputTokens,
};
}
export function buildClaudeTextResponse(args: ClaudeResponseArgs): JsonRecord {
return {
id: args.id,
type: "message",
role: "assistant",
model: args.model,
content: [{ type: "text", text: args.text }],
stop_reason: "end_turn",
stop_sequence: null,
usage: usage(args.inputTokens, args.outputTokens || estimateTokens(args.text)),
};
}
export function buildClaudeToolUseResponse(args: ClaudeToolUseArgs): JsonRecord {
return {
id: args.id,
type: "message",
role: "assistant",
model: args.model,
content: [
{
type: "tool_use",
id: args.tool.id,
name: args.tool.name,
input: args.tool.input,
},
],
stop_reason: "tool_use",
stop_sequence: null,
usage: usage(args.inputTokens, args.outputTokens),
};
}
function frame(event: string, data: JsonRecord): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
export function buildClaudeSseFrames(message: JsonRecord): string {
const content = Array.isArray(message.content) ? message.content : [];
const startMessage = { ...message, content: [], stop_reason: null, stop_sequence: null };
let out = frame("message_start", { type: "message_start", message: startMessage });
content.forEach((block, index) => {
const blockRecord = block as JsonRecord;
if (blockRecord.type === "text") {
out += frame("content_block_start", {
type: "content_block_start",
index,
content_block: { type: "text", text: "" },
});
out += frame("content_block_delta", {
type: "content_block_delta",
index,
delta: { type: "text_delta", text: String(blockRecord.text || "") },
});
out += frame("content_block_stop", { type: "content_block_stop", index });
return;
}
if (blockRecord.type === "tool_use") {
out += frame("content_block_start", {
type: "content_block_start",
index,
content_block: {
type: "tool_use",
id: blockRecord.id,
name: blockRecord.name,
input: {},
},
});
out += frame("content_block_delta", {
type: "content_block_delta",
index,
delta: {
type: "input_json_delta",
partial_json: JSON.stringify(blockRecord.input || {}),
},
});
out += frame("content_block_stop", { type: "content_block_stop", index });
}
});
out += frame("message_delta", {
type: "message_delta",
delta: { stop_reason: message.stop_reason, stop_sequence: null },
usage: { output_tokens: (message.usage as JsonRecord | undefined)?.output_tokens || 0 },
});
out += frame("message_stop", { type: "message_stop" });
return out;
}

View File

@@ -0,0 +1,217 @@
import {
asRecord,
DevinAgenticBridgeError,
estimateTokens,
type AnthropicTool,
type DevinPrompt,
} from "./types.ts";
import { createHash } from "node:crypto";
export const MAX_TOOL_RESULT_CHARS = 65536;
function stringifyContentValue(value: unknown): string {
if (typeof value === "string") return value;
if (value == null) return "";
return JSON.stringify(value);
}
function boundedToolResult(value: unknown): string {
const text = stringifyContentValue(value);
if (text.length <= MAX_TOOL_RESULT_CHARS) return text;
const removed = text.length - MAX_TOOL_RESULT_CHARS;
return `${text.slice(0, MAX_TOOL_RESULT_CHARS)}\n[TRUNCATED ${removed} CHARACTERS BY OMNIROUTE]`;
}
function serializeSystem(system: unknown): string[] {
if (typeof system === "string" && system.trim()) return [`[System]\n${system}`];
if (!Array.isArray(system)) return [];
const parts: string[] = [];
for (const block of system) {
const record = asRecord(block);
if (record.type === "text") {
parts.push(String(record.text || ""));
} else if (Object.keys(record).length > 0) {
throw new DevinAgenticBridgeError(
`Unsupported Anthropic system block type: ${String(record.type || "unknown")}`,
"unsupported_system_block"
);
}
}
return parts.length > 0 ? [`[System]\n${parts.join("\n")}`] : [];
}
function serializeBlock(
block: unknown,
knownToolUses: Set<string>,
tools: AnthropicTool[]
): string {
const record = asRecord(block);
const type = String(record.type || "");
if (type === "text") return String(record.text || "");
if (type === "thinking") return `[Thinking]\n${String(record.thinking || "")}`;
if (type === "redacted_thinking") return "[Redacted Thinking]";
if (type === "tool_use") {
const id = String(record.id || "").trim();
const name = String(record.name || "").trim();
if (!id || knownToolUses.has(id)) {
throw new DevinAgenticBridgeError(
id ? `Duplicate Anthropic tool_use id: ${id}` : "Anthropic tool_use is missing id",
id ? "duplicate_tool_use_id" : "missing_tool_use_id"
);
}
const declared = tools.find((tool) => tool.name === name);
if (!declared) {
throw new DevinAgenticBridgeError(
`Historical tool_use references undeclared tool: ${name || "unknown"}`,
"undeclared_historical_tool"
);
}
knownToolUses.add(id);
return [
"[Assistant Tool Use]",
`id: ${id}`,
`name: ${name}`,
"arguments:",
JSON.stringify(record.input || {}, null, 2),
].join("\n");
}
if (type === "tool_result") {
const toolUseId = String(record.tool_use_id || "").trim();
if (!toolUseId || !knownToolUses.has(toolUseId)) {
throw new DevinAgenticBridgeError(
`Anthropic tool_result references unknown tool_use id: ${toolUseId || "missing"}`,
"orphan_tool_result"
);
}
return [
"[Tool Result]",
`tool_use_id: ${toolUseId}`,
`is_error: ${record.is_error === true ? "true" : "false"}`,
"content:",
boundedToolResult(record.content),
].join("\n");
}
if (type === "image") {
throw new DevinAgenticBridgeError(
"Anthropic image blocks are not supported by devin-cli-agentic",
"unsupported_image_block"
);
}
throw new DevinAgenticBridgeError(
`Unsupported Anthropic content block type: ${type || "unknown"}`,
"unsupported_content_block"
);
}
function serializeMessage(
message: unknown,
knownToolUses: Set<string>,
tools: AnthropicTool[]
): string {
const record = asRecord(message);
const role = String(record.role || "user");
if (role !== "user" && role !== "assistant") {
throw new DevinAgenticBridgeError(
`Unsupported Anthropic message role: ${role}`,
"unsupported_role"
);
}
const label = role === "assistant" ? "Assistant" : role === "system" ? "System" : "User";
const content = record.content;
if (typeof content === "string") return `[${label}]\n${content}`;
if (!Array.isArray(content)) return `[${label}]\n${stringifyContentValue(content)}`;
return `[${label}]\n${content
.map((block) => serializeBlock(block, knownToolUses, tools))
.join("\n\n")}`;
}
function normalizeTools(tools: unknown): AnthropicTool[] {
if (tools == null) return [];
if (!Array.isArray(tools)) {
throw new DevinAgenticBridgeError("Anthropic tools must be an array", "invalid_tools");
}
return tools.map((tool) => {
const record = asRecord(tool);
const name = typeof record.name === "string" ? record.name.trim() : "";
if (!name) {
throw new DevinAgenticBridgeError("Anthropic tool is missing name", "invalid_tool_name");
}
return {
name,
description: typeof record.description === "string" ? record.description : undefined,
input_schema: asRecord(record.input_schema),
};
});
}
function serializeToolCatalog(tools: AnthropicTool[]): string[] {
if (tools.length === 0) return [];
return [
[
"[Available Tools]",
"When a tool is required, respond with exactly one XML-wrapped JSON object:",
'<tool>{"name":"ToolName","arguments":{}}</tool>',
"Use only the tools listed below. Do not claim that a tool was executed.",
"Do not execute tools inside Devin or emit ACP tool-call events; request them only with the XML envelope.",
"Never describe a future tool action in plain text; emit the tool envelope instead.",
].join("\n"),
...tools.map((tool) =>
[
`[Tool] ${tool.name}`,
tool.description ? `description: ${tool.description}` : "description:",
"input_schema:",
JSON.stringify(tool.input_schema || { type: "object", properties: {} }, null, 2),
].join("\n")
),
];
}
function serializeToolChoice(value: unknown, tools: AnthropicTool[]): string[] {
if (value == null) return [];
const choice = asRecord(value);
const type = String(choice.type || "");
if (type === "auto") return ["[Tool Choice]\nauto"];
if (type === "any") return ["[Tool Choice]\nA tool call is required."];
if (type === "none") return ["[Tool Choice]\nDo not call a tool."];
if (type === "tool") {
const name = String(choice.name || "").trim();
if (!tools.some((tool) => tool.name === name)) {
throw new DevinAgenticBridgeError(
`tool_choice references unknown tool: ${name}`,
"invalid_tool_choice"
);
}
return [`[Tool Choice]\nCall exactly this tool: ${name}`];
}
throw new DevinAgenticBridgeError(
`Unsupported Anthropic tool_choice type: ${type || "missing"}`,
"invalid_tool_choice"
);
}
export function serializeAnthropicForDevin(body: unknown): DevinPrompt {
const record = asRecord(body);
const messages = Array.isArray(record.messages) ? record.messages : [];
const tools = normalizeTools(record.tools);
const knownToolUses = new Set<string>();
const sections: string[] = [
...serializeSystem(record.system),
...serializeToolCatalog(tools),
...serializeToolChoice(record.tool_choice, tools),
...messages.map((message) => serializeMessage(message, knownToolUses, tools)),
].filter((section) => section.trim().length > 0);
if (sections.length === 0) {
throw new DevinAgenticBridgeError("Anthropic request contains no messages", "empty_messages");
}
const text = sections.join("\n\n---\n\n");
const idSeed = createHash("sha256").update(text).digest("hex").slice(0, 24);
return { text, tools, inputTokensEstimate: estimateTokens(text), idSeed };
}

View File

@@ -0,0 +1,117 @@
import { createHash } from "node:crypto";
import { asRecord, DevinAgenticBridgeError, type AnthropicTool, type JsonRecord } from "./types.ts";
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.entries(value as JsonRecord)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, val]) => `${JSON.stringify(key)}:${stableJson(val)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function typeOf(value: unknown): string {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}
function validateSchema(value: unknown, schema: JsonRecord, path: string): string[] {
const errors: string[] = [];
const expectedType = schema.type;
if (typeof expectedType === "string") {
const actual = typeOf(value);
if (expectedType === "integer") {
if (!Number.isInteger(value)) errors.push(`${path} must be integer`);
} else if (actual !== expectedType) {
errors.push(`${path} must be ${expectedType}, got ${actual}`);
}
}
if (Array.isArray(schema.enum) && !schema.enum.some((item) => item === value)) {
errors.push(
`${path} must be one of ${schema.enum.map((item) => JSON.stringify(item)).join(", ")}`
);
}
if (schema.type === "object" || (value && typeof value === "object" && !Array.isArray(value))) {
const record = asRecord(value);
const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
for (const key of required) {
if (!(key in record)) errors.push(`${path}.${key} is required`);
}
const properties = asRecord(schema.properties);
for (const [key, propSchema] of Object.entries(properties)) {
if (key in record)
errors.push(...validateSchema(record[key], asRecord(propSchema), `${path}.${key}`));
}
if (schema.additionalProperties === false) {
for (const key of Object.keys(record)) {
if (!(key in properties)) errors.push(`${path}.${key} is not allowed`);
}
}
}
if (Array.isArray(value) && schema.items) {
const itemSchema = asRecord(schema.items);
value.forEach((item, index) =>
errors.push(...validateSchema(item, itemSchema, `${path}[${index}]`))
);
}
return errors;
}
export function parseDevinToolRequest(text: string, tools: AnthropicTool[], idSeed = "") {
const matches = [...text.matchAll(/<tool>\s*([\s\S]*?)\s*<\/tool>/g)];
if (matches.length === 0) return null;
if (matches.length > 1) {
throw new DevinAgenticBridgeError(
"Devin response contained more than one tool request; parallel tool use is not supported",
"multiple_tool_requests"
);
}
if (text.trim() !== matches[0][0].trim()) {
throw new DevinAgenticBridgeError(
"Devin tool request must be a standalone tool envelope without narrative text",
"mixed_tool_narrative"
);
}
let payload: JsonRecord;
try {
payload = asRecord(JSON.parse(matches[0][1] || "{}"));
} catch {
throw new DevinAgenticBridgeError("Devin tool request was not valid JSON", "invalid_tool_json");
}
const name = typeof payload.name === "string" ? payload.name.trim() : "";
if (!name)
throw new DevinAgenticBridgeError("Devin tool request is missing name", "missing_tool_name");
const tool = tools.find((candidate) => candidate.name === name);
if (!tool) {
throw new DevinAgenticBridgeError(`Devin requested unknown tool: ${name}`, "unknown_tool");
}
const input = asRecord(payload.arguments);
const schema = tool.input_schema || { type: "object", properties: {} };
const errors = validateSchema(input, schema, "arguments");
if (errors.length > 0) {
throw new DevinAgenticBridgeError(
`Devin tool arguments failed schema validation: ${errors.join("; ")}`,
"invalid_tool_arguments"
);
}
const digest = createHash("sha256")
.update(`${idSeed}:${name}:${stableJson(input)}`)
.digest("hex")
.slice(0, 16);
return { id: `tool_devin_${digest}`, name, input };
}

View File

@@ -0,0 +1,56 @@
export type JsonRecord = Record<string, unknown>;
export type AnthropicTool = {
name: string;
description?: string;
input_schema?: JsonRecord;
};
export type DevinPrompt = {
text: string;
tools: AnthropicTool[];
inputTokensEstimate: number;
idSeed: string;
};
export type ParsedToolRequest = {
id: string;
name: string;
input: JsonRecord;
};
export type ClaudeResponseArgs = {
id: string;
model: string;
text: string;
inputTokens: number;
outputTokens: number;
};
export type ClaudeToolUseArgs = {
id: string;
model: string;
tool: ParsedToolRequest;
inputTokens: number;
outputTokens: number;
};
export class DevinAgenticBridgeError extends Error {
status: number;
code: string;
constructor(message: string, code = "devin_agentic_error", status = 400) {
super(message);
this.name = "DevinAgenticBridgeError";
this.code = code;
this.status = status;
}
}
export function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
export function estimateTokens(text: string): number {
return Math.max(1, Math.ceil(text.length / 4));
}

View File

@@ -0,0 +1,571 @@
import { spawn } from "node:child_process";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { randomUUID } from "node:crypto";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { DEVIN_MODEL_CATALOG } from "../config/providers/registry/devin/catalog.ts";
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
import {
buildClaudeSseFrames,
buildClaudeTextResponse,
buildClaudeToolUseResponse,
} from "./devin-agentic/anthropicResponse.ts";
import { serializeAnthropicForDevin } from "./devin-agentic/serializer.ts";
import { parseDevinToolRequest } from "./devin-agentic/toolParser.ts";
import { asRecord, DevinAgenticBridgeError, estimateTokens } from "./devin-agentic/types.ts";
type AcpMessage = {
jsonrpc: "2.0";
id?: number | null;
method?: string;
params?: unknown;
result?: unknown;
error?: { code: number; message: string };
};
const ACP_PROTOCOL_VERSION = 1;
const MAX_ACP_OUTPUT_CHARS = 1024 * 1024;
const TRUSTED_DEVIN_BRIDGE_PROXY_URL = "http://network-guard:8080";
const REPAIRABLE_TOOL_ERRORS = new Set([
"invalid_tool_json",
"missing_tool_name",
"unknown_tool",
"invalid_tool_arguments",
"multiple_tool_requests",
"mixed_tool_narrative",
"unexecuted_tool_intent",
]);
function describesUnexecutedToolIntent(text: string): boolean {
const action = "(?:read|inspect|examine|edit|fix|run|check|test|start)";
const futureAction = new RegExp(
`\\b(?:(?:next(?: immediate)?|immediate next)\\s+(?:task|step)|planned actions?)\\b[\\s\\S]{0,320}\\b${action}\\b`,
"i"
);
return (
futureAction.test(text) ||
new RegExp(`\\b(?:i(?:'ll| will)|let me)\\b[^\\n.!?]{0,160}\\b${action}\\b`, "i").test(text) ||
new RegExp(`\\bnext steps?\\s*:\\s*${action}\\b`, "i").test(text) ||
new RegExp(`\\bnext immediate (?:task|step)\\s*:\\s*${action}\\b`, "i").test(text) ||
new RegExp(`\\bplanned actions?\\s*:\\s*${action}\\b`, "i").test(text) ||
new RegExp(`\\b(?:still|now)\\s+(?:need|needs|required)\\s+to\\s+${action}\\b`, "i").test(
text
) ||
/\btests?\s+(?:have|has|were|was)?\s*not\s+(?:yet\s+)?(?:been\s+)?run\b/i.test(text)
);
}
function framePromptForNoToolsSummarizer(promptText: string): string {
return [
"[Devin Summarizer Bridge]",
"Treat the content below as an execution trace whose next assistant output must be determined.",
"If another client-owned action is required, return exactly one <tool> JSON envelope using the catalog in the trace and no prose.",
"The client will execute that tool; never execute or claim to execute a tool inside Devin.",
"The client workspace is /workspace; /home/bridge is only the isolated Devin process home.",
"If the task is complete, return only a concise final answer.",
"Do not wrap the response in Markdown fences or a <summary> element.",
"",
"[Execution Trace]",
promptText,
].join("\n");
}
const CLAUDE_ENV_BLOCKLIST = [
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_BEDROCK_BASE_URL",
"ANTHROPIC_VERTEX_BASE_URL",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
];
function resolveDevinBin(): string {
const envBin = process.env.CLI_DEVIN_AGENTIC_BIN?.trim() || process.env.CLI_DEVIN_BIN?.trim();
if (envBin) return envBin;
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
const winPath = path.join(localAppData, "devin", "cli", "bin", "devin.exe");
if (fs.existsSync(winPath)) return winPath;
return "devin.exe";
}
for (const candidate of [
path.join(os.homedir(), ".local", "share", "devin", "bin", "devin"),
path.join(os.homedir(), ".devin", "bin", "devin"),
]) {
if (fs.existsSync(candidate)) return candidate;
}
return "devin";
}
function rpc(method: string, params: unknown, id: number): string {
return JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
}
export function assertLocalAcpUrl(url: string): void {
if (url !== "devin://acp/stdio") {
throw new DevinAgenticBridgeError(
"devin-cli-agentic accepts only the local Devin ACP stdio upstream",
"invalid_acp_upstream",
500
);
}
}
function isIsolatedHome(value: string): boolean {
return value === "/home/bridge" || value.includes("/.sandbox/");
}
export function buildDevinChildEnv(
_credentials: ExecuteInput["credentials"],
source: NodeJS.ProcessEnv = process.env
): NodeJS.ProcessEnv {
const home = source.DEVIN_AGENTIC_HOME?.trim() || "";
if (!home || !path.isAbsolute(home) || !isIsolatedHome(home)) {
throw new DevinAgenticBridgeError(
"DEVIN_AGENTIC_HOME must be an absolute path inside the bridge sandbox",
"unsafe_devin_home",
500
);
}
const env: NodeJS.ProcessEnv = {
HOME: home,
XDG_CONFIG_HOME: path.join(home, ".config"),
XDG_DATA_HOME: path.join(home, ".local", "share"),
XDG_CACHE_HOME: path.join(home, ".cache"),
PATH: source.PATH || "/usr/local/bin:/usr/bin:/bin",
LANG: source.LANG || "C.UTF-8",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
DISABLE_TELEMETRY: "1",
DISABLE_ERROR_REPORTING: "1",
DISABLE_AUTOUPDATER: "1",
};
if (source.LC_ALL) env.LC_ALL = source.LC_ALL;
if (source.TERM) env.TERM = source.TERM;
if (source.DEVIN_BRIDGE_MOCK_LOG === "/evidence/mock-acp.jsonl") {
env.DEVIN_BRIDGE_MOCK_LOG = source.DEVIN_BRIDGE_MOCK_LOG;
}
if (source.DEVIN_BRIDGE_PROXY_URL === TRUSTED_DEVIN_BRIDGE_PROXY_URL) {
env.HTTP_PROXY = TRUSTED_DEVIN_BRIDGE_PROXY_URL;
env.HTTPS_PROXY = TRUSTED_DEVIN_BRIDGE_PROXY_URL;
}
for (const key of CLAUDE_ENV_BLOCKLIST) delete env[key];
return env;
}
function errorBody(error: unknown) {
const bridge = error instanceof DevinAgenticBridgeError ? error : null;
const status = bridge?.status || 500;
const message = bridge?.message || (error instanceof Error ? error.message : String(error));
return buildErrorBody(status, sanitizeErrorMessage(message), undefined, {
type: "devin_agentic_error",
code: bridge?.code || "devin_agentic_error",
});
}
export async function runAcpTurn(args: {
devinBin: string;
env: NodeJS.ProcessEnv;
model: string;
promptText: string;
signal?: AbortSignal | null;
log?: ExecuteInput["log"];
}) {
const timeoutMs = Number(process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS || 120000);
const child = spawn(args.devinBin, ["acp", "--agent-type", "summarizer"], {
env: args.env,
cwd: args.env.HOME,
stdio: ["pipe", "pipe", "pipe"],
shell: false,
});
let nextId = 1;
let buffer = "";
let text = "";
let phase: "initialize" | "session" | "prompt" = "initialize";
let sessionId = "";
let initializeRequestId = 0;
let sessionRequestId = 0;
let promptRequestId = 0;
let settled = false;
return await new Promise<string>((resolve, reject) => {
const abortHandler = () => {
finish(new DevinAgenticBridgeError("Devin ACP request was cancelled", "acp_cancelled", 499));
};
const finish = (err: Error | null, value = "") => {
if (settled) return;
settled = true;
clearTimeout(timer);
args.signal?.removeEventListener("abort", abortHandler);
try {
child.stdin.end();
} catch {}
if (!child.killed) child.kill("SIGTERM");
if (err) reject(err);
else resolve(value);
};
const timer = setTimeout(() => {
finish(
new DevinAgenticBridgeError(`Devin ACP timed out after ${timeoutMs}ms`, "acp_timeout", 504)
);
}, timeoutMs);
timer.unref?.();
const send = (method: string, params: unknown) => {
const id = nextId++;
child.stdin.write(rpc(method, params, id));
return id;
};
if (args.signal?.aborted) return abortHandler();
args.signal?.addEventListener("abort", abortHandler, { once: true });
child.on("error", (err) => {
const message =
err.message.includes("ENOENT") || err.message.includes("not found")
? `Devin CLI not found: ${args.devinBin}. Install the official Devin CLI or set CLI_DEVIN_AGENTIC_BIN.`
: `Devin CLI spawn error: ${err.message}`;
finish(new DevinAgenticBridgeError(message, "spawn_failed", 502));
});
child.stderr.on("data", (chunk: Buffer) => {
args.log?.debug?.("DEVIN_AGENTIC", `stderr: ${chunk.toString("utf8").slice(0, 200)}`);
});
child.stdout.on("data", (chunk: Buffer) => {
buffer += chunk.toString("utf8");
if (buffer.length + text.length > MAX_ACP_OUTPUT_CHARS) {
finish(
new DevinAgenticBridgeError(
"Devin ACP output exceeded the bridge limit",
"acp_output_too_large",
502
)
);
return;
}
let nl: number;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
let msg: AcpMessage;
try {
msg = JSON.parse(line);
} catch {
finish(
new DevinAgenticBridgeError(
"Devin ACP emitted invalid JSON on stdout",
"invalid_acp_frame",
502
)
);
return;
}
if (msg.error) {
finish(
new DevinAgenticBridgeError(
`Devin ACP error ${msg.error.code}: ${msg.error.message}`,
"acp_error",
502
)
);
return;
}
if (phase === "initialize" && msg.id === initializeRequestId && msg.result !== undefined) {
const protocolVersion = Number(asRecord(msg.result).protocolVersion);
if (protocolVersion !== ACP_PROTOCOL_VERSION) {
finish(
new DevinAgenticBridgeError(
`Devin ACP negotiated unsupported protocol version: ${String(protocolVersion)}`,
"unsupported_acp_version",
502
)
);
return;
}
phase = "session";
sessionRequestId = send("session/new", {
cwd: args.env.HOME,
mcpServers: [],
model: args.model || undefined,
});
continue;
}
if (phase === "session" && msg.id === sessionRequestId && msg.result !== undefined) {
const sessionResult = asRecord(msg.result);
sessionId = String(sessionResult.sessionId || "");
if (!sessionId) {
finish(
new DevinAgenticBridgeError(
"Devin ACP session/new returned no sessionId",
"missing_session_id",
502
)
);
return;
}
phase = "prompt";
promptRequestId = send("session/prompt", {
sessionId,
prompt: [{ type: "text", text: framePromptForNoToolsSummarizer(args.promptText) }],
});
continue;
}
if (msg.method === "session/update" || msg.method === "$/update") {
const params = asRecord(msg.params);
const updateSessionId = String(params.sessionId || "");
if (updateSessionId && sessionId && updateSessionId !== sessionId) {
finish(
new DevinAgenticBridgeError(
"Devin ACP update referenced a different session",
"acp_session_mismatch",
502
)
);
return;
}
const update = asRecord(params.update);
const kind = String(update.sessionUpdate || params.type || "");
if (kind === "tool_call" || kind === "tool_call_update") {
finish(
new DevinAgenticBridgeError(
"Devin attempted to execute a tool internally; Claude Code must own all tool execution",
"devin_internal_tool_execution",
502
)
);
return;
}
if (kind === "agent_message_chunk") {
text += extractText(update.content);
} else if (
kind === "message_delta" ||
kind === "text_delta" ||
kind === "content_delta"
) {
text += String(params.content || params.delta || params.text || "");
}
continue;
}
if (phase === "prompt" && msg.id === promptRequestId && msg.result !== undefined) {
const stopReason = String(asRecord(msg.result).stopReason || "");
if (stopReason === "cancelled") {
finish(
new DevinAgenticBridgeError("Devin ACP cancelled the turn", "acp_cancelled", 502)
);
return;
}
const resultText =
extractText(asRecord(msg.result).content) || extractText(asRecord(msg.result).message);
const finalText = text || resultText;
if (!finalText) {
finish(
new DevinAgenticBridgeError(
`Devin ACP completed without model output (stopReason=${stopReason || "missing"})`,
"empty_acp_output",
502
)
);
return;
}
finish(null, finalText);
continue;
}
if (msg.id !== undefined && msg.id !== null && !msg.method) {
finish(
new DevinAgenticBridgeError(
`Devin ACP returned an unexpected response id: ${String(msg.id)}`,
"unexpected_acp_response",
502
)
);
return;
}
}
});
child.on("close", (code) => {
if (settled) return;
if (code === 0 && text) finish(null, text);
else
finish(
new DevinAgenticBridgeError(
`Devin CLI exited before completing the turn with code ${code}`,
"acp_early_exit",
502
)
);
});
initializeRequestId = send("initialize", {
protocolVersion: ACP_PROTOCOL_VERSION,
clientInfo: { name: "omniroute-devin-cli-agentic", version: "1.0" },
clientCapabilities: {},
});
});
}
function assertKnownDevinModel(model: string): void {
if (!DEVIN_MODEL_CATALOG.some((entry) => entry.id === model)) {
throw new DevinAgenticBridgeError(
`Model is not present in the current Devin catalog: ${model}`,
"unknown_devin_model",
400
);
}
}
async function generateAgenticOutput(
args: Omit<Parameters<typeof runAcpTurn>[0], "promptText">,
promptText: string
) {
const first = await runAcpTurn({ ...args, promptText });
return first;
}
function extractText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map((item) => extractText(item)).join("");
const record = asRecord(value);
if (typeof record.text === "string") return record.text;
if (typeof record.content === "string") return record.content;
return "";
}
export class DevinCliAgenticExecutor extends BaseExecutor {
constructor() {
super("devin-cli-agentic", { id: "devin-cli-agentic", baseUrl: "devin://acp/stdio" });
}
buildUrl(): string {
const url = "devin://acp/stdio";
assertLocalAcpUrl(url);
return url;
}
buildHeaders(): Record<string, string> {
return {};
}
transformRequest(): unknown {
return null;
}
async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) {
try {
assertKnownDevinModel(model);
const prompt = serializeAnthropicForDevin(body);
const devinBin = resolveDevinBin();
log?.info?.("DEVIN_AGENTIC", `devin acp → model=${model}, bin=${devinBin}`);
const turnArgs = {
devinBin,
env: buildDevinChildEnv(credentials),
model,
signal,
log,
};
let text = await generateAgenticOutput(turnArgs, prompt.text);
let tool;
try {
if (prompt.tools.length > 0 && describesUnexecutedToolIntent(text)) {
throw new DevinAgenticBridgeError(
"The response described a future action without performing it; call exactly one tool now",
"unexecuted_tool_intent"
);
}
tool = parseDevinToolRequest(text, prompt.tools, prompt.idSeed);
} catch (error) {
if (
!(error instanceof DevinAgenticBridgeError) ||
!REPAIRABLE_TOOL_ERRORS.has(error.code)
) {
throw error;
}
const requiresToolOnRepair = error.code === "unexecuted_tool_intent";
const repairPrompt = [
prompt.text,
"",
"---",
"",
"[Single Repair Attempt]",
`The previous output was rejected: ${sanitizeErrorMessage(error.message)}`,
requiresToolOnRepair
? "Plain text is not accepted for this repair. Return exactly one standalone <tool> JSON envelope now."
: "Return either plain final text or exactly one standalone <tool> JSON envelope.",
"Do not narrate a tool action.",
].join("\n");
text = await generateAgenticOutput(turnArgs, repairPrompt);
tool = parseDevinToolRequest(text, prompt.tools, prompt.idSeed);
if (requiresToolOnRepair && !tool) {
throw new DevinAgenticBridgeError(
"Devin repeated a narrated tool action instead of requesting a tool",
"unexecuted_tool_intent",
502
);
}
}
const id = `msg_devin_${randomUUID().replaceAll("-", "")}`;
const outputTokens = estimateTokens(text);
const message = tool
? buildClaudeToolUseResponse({
id,
model,
tool,
inputTokens: prompt.inputTokensEstimate,
outputTokens,
})
: buildClaudeTextResponse({
id,
model,
text,
inputTokens: prompt.inputTokensEstimate,
outputTokens,
});
const responseBody = stream ? buildClaudeSseFrames(message) : JSON.stringify(message);
return {
response: new Response(responseBody, {
status: 200,
headers: {
"Content-Type": stream ? "text/event-stream" : "application/json",
"Cache-Control": "no-cache",
},
}),
url: "devin://acp/stdio",
headers: {},
transformedBody: { model, promptLength: prompt.text.length },
};
} catch (error) {
const bridge = error instanceof DevinAgenticBridgeError ? error : null;
return {
response: new Response(JSON.stringify(errorBody(error)), {
status: bridge?.status || 500,
headers: { "Content-Type": "application/json" },
}),
url: "devin://acp/stdio",
headers: {},
transformedBody: { model },
};
}
}
}

View File

@@ -20,9 +20,15 @@ const FIRECRAWL_DEFAULT_BASE_URL = "https://api.firecrawl.dev";
const FIRECRAWL_DEFAULT_TIMEOUT_MS = 30_000;
/** Resolve the configured Firecrawl base URL, falling back to the public cloud API. */
function getFirecrawlBaseUrl(): string {
function getFirecrawlBaseUrl(credentials?: WebFetchCredentials): string {
const envBase = process.env.FIRECRAWL_BASE_URL?.trim();
return envBase ? envBase.replace(/\/+$/, "") : FIRECRAWL_DEFAULT_BASE_URL;
if (envBase) return envBase.replace(/\/+$/, "");
const providerData = credentials?.providerSpecificData;
const credBase = typeof credentials?.baseUrl === "string" ? credentials.baseUrl : providerData?.baseUrl;
if (typeof credBase === "string" && credBase.trim()) {
return credBase.trim().replace(/\/+$/, "");
}
return FIRECRAWL_DEFAULT_BASE_URL;
}
/** Whether the given base URL is the default Firecrawl cloud endpoint. */
@@ -67,7 +73,7 @@ interface FirecrawlScrapeOptions {
export async function firecrawlFetch(opts: FirecrawlScrapeOptions): Promise<WebFetchResult> {
const { url, format, depth, waitForSelector, includeMetadata, credentials } = opts;
const baseUrl = getFirecrawlBaseUrl();
const baseUrl = getFirecrawlBaseUrl(credentials);
const isDefaultBaseUrl = isDefaultFirecrawlBaseUrl(baseUrl);
// The API key is mandatory for the public Firecrawl cloud API, but optional

View File

@@ -31,6 +31,7 @@ import { NlpCloudExecutor } from "./nlpcloud.ts";
import { WindsurfExecutor } from "./windsurf.ts";
import { ZedHostedExecutor } from "./zed-hosted.ts";
import { DevinCliExecutor } from "./devin-cli.ts";
import { DevinCliAgenticExecutor } from "./devin-cli-agentic.ts";
import { AuggieExecutor } from "./auggie.ts";
import { DeepSeekWebExecutor } from "./deepseek-web.ts";
import { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts";
@@ -55,6 +56,7 @@ import { CheaperInferenceExecutor } from "./cheaperinference.ts";
import { KimiWebExecutor } from "./kimi-web.ts";
import { DoubaoWebExecutor } from "./doubao-web.ts";
import { QwenWebExecutor } from "./qwen-web.ts";
import { RaycastExecutor } from "./raycast.ts";
import { HailuoWebExecutor } from "./hailuo-web.ts";
import { ZaiWebExecutor } from "./zai-web.ts";
import { KimiExecutor } from "./kimi.ts";
@@ -128,6 +130,7 @@ const executors = {
ws: new WindsurfExecutor(), // Alias
"zed-hosted": new ZedHostedExecutor(),
"devin-cli": new DevinCliExecutor(),
"devin-cli-agentic": new DevinCliAgenticExecutor(),
devin: new DevinCliExecutor(), // Alias
"deepseek-web": new DeepSeekWebWithAutoRefreshExecutor(),
"ds-web": new DeepSeekWebWithAutoRefreshExecutor(), // Alias
@@ -176,6 +179,8 @@ const executors = {
"doubao-web": new DoubaoWebExecutor(),
db: new DoubaoWebExecutor(), // Alias
"qwen-web": new QwenWebExecutor(),
raycast: new RaycastExecutor(),
rc: new RaycastExecutor(), // Alias
"hailuo-web": new HailuoWebExecutor(),
"zai-web": new ZaiWebExecutor(),
zw: new ZaiWebExecutor(), // Alias
@@ -264,6 +269,7 @@ export { NlpCloudExecutor } from "./nlpcloud.ts";
export { WindsurfExecutor } from "./windsurf.ts";
export { ZedHostedExecutor } from "./zed-hosted.ts";
export { DevinCliExecutor } from "./devin-cli.ts";
export { DevinCliAgenticExecutor } from "./devin-cli-agentic.ts";
export { AuggieExecutor } from "./auggie.ts";
export { CopilotWebExecutor } from "./copilot-web.ts";
export { CopilotM365WebExecutor } from "./copilot-m365-web.ts";

View File

@@ -0,0 +1,224 @@
/**
* @file raycast.ts
* @description Executor for Raycast Pro AI (reverse-engineered backend.raycast.com API).
*
* @changes
* - [2026-07-27] [Composer] - Initial Raycast Pro local-dev executor
*/
import { BaseExecutor, mergeUpstreamExtraHeaders, type ProviderCredentials } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import {
RAYCAST_CHAT_URL,
buildRaycastChatBody,
buildRaycastHeaders,
parseRaycastSseText,
} from "../services/raycast.ts";
type JsonRecord = Record<string, unknown>;
type ChatMessage = { role?: string; content?: unknown };
export class RaycastExecutor extends BaseExecutor {
constructor() {
super("raycast", PROVIDERS.raycast);
}
buildUrl(): string {
return RAYCAST_CHAT_URL;
}
buildHeaders(credentials: ProviderCredentials, payload?: string): Record<string, string> {
const body = payload || "{}";
return buildRaycastHeaders(body, credentials as JsonRecord);
}
async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }) {
const reqBody = body as { messages?: ChatMessage[]; temperature?: number };
let payload: string;
try {
payload = buildRaycastChatBody(model as string, reqBody.messages || [], reqBody.temperature);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
response: new Response(
JSON.stringify({
error: { message: sanitizeErrorMessage(message), type: "invalid_request_error", code: "" },
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
),
url: RAYCAST_CHAT_URL,
headers: {},
transformedBody: body,
};
}
const headers = this.buildHeaders(credentials as ProviderCredentials, payload);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders as Record<string, string> | null);
let raycastResponse: Response;
try {
raycastResponse = await fetch(RAYCAST_CHAT_URL, {
method: "POST",
headers,
body: payload,
signal: signal || undefined,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
response: new Response(
JSON.stringify({
error: { message: sanitizeErrorMessage(message), type: "api_error", code: "" },
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
),
url: RAYCAST_CHAT_URL,
headers,
transformedBody: payload,
};
}
if (!raycastResponse.ok) {
const errorText = await raycastResponse.text();
return {
response: new Response(
JSON.stringify({
error: {
message: sanitizeErrorMessage(`Raycast API error (${raycastResponse.status})`),
type: "api_error",
code: String(raycastResponse.status),
},
}),
{ status: raycastResponse.status, headers: { "Content-Type": "application/json" } }
),
url: RAYCAST_CHAT_URL,
headers,
transformedBody: payload,
};
}
const responseId = `chatcmpl-raycast-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const modelId = model as string;
if (stream !== false) {
const raycastBody = raycastResponse.body;
if (!raycastBody) {
return {
response: new Response(
JSON.stringify({
error: { message: "Raycast returned empty stream body", type: "api_error", code: "" },
}),
{ status: 502, headers: { "Content-Type": "application/json" } }
),
url: RAYCAST_CHAT_URL,
headers,
transformedBody: payload,
};
}
const sseStream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const reader = raycastBody.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line.startsWith("data:")) continue;
try {
const data = JSON.parse(line.slice(5).trim()) as {
text?: string;
finish_reason?: string | null;
complete?: boolean;
};
const hasContent = typeof data.text === "string" && data.text.length > 0;
const hasFinishReason =
data.finish_reason !== undefined && data.finish_reason !== null;
if (data.complete || (!hasContent && !hasFinishReason)) continue;
const chunk = {
id: responseId,
object: "chat.completion.chunk",
created,
model: modelId,
choices: [
{
index: 0,
delta: { content: data.text || "" },
finish_reason: hasFinishReason ? data.finish_reason : null,
},
],
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
} catch {
// Ignore malformed SSE data.
}
}
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
} catch (err) {
controller.error(err);
}
},
});
return {
response: new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url: RAYCAST_CHAT_URL,
headers,
transformedBody: payload,
};
}
const responseText = await raycastResponse.text();
const content = parseRaycastSseText(responseText);
return {
response: new Response(
JSON.stringify({
id: responseId,
object: "chat.completion",
created,
model: modelId,
choices: [
{
index: 0,
message: { role: "assistant", content, refusal: null },
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
),
url: RAYCAST_CHAT_URL,
headers,
transformedBody: payload,
};
}
}

View File

@@ -223,6 +223,7 @@ import { recordCost } from "@/domain/costRules";
import { calculateCost } from "@/lib/usage/costCalculator";
import {
buildClaudePassthroughToolNameMap,
mergeResponseToolNameMap,
normalizeOpenAIToolFinishReasons,
restoreNonStreamingToolNames,
} from "./chatCore/passthroughToolNames.ts";
@@ -921,6 +922,15 @@ export async function handleChatCore({
? credentials.providerSpecificData.customUserAgent.trim()
: "";
// #8369: connection-level custom upstream headers from provider_specific_data.
const connectionCustomHeaders =
credentials?.providerSpecificData &&
typeof credentials.providerSpecificData === "object" &&
typeof credentials.providerSpecificData.customHeaders === "object" &&
!Array.isArray(credentials.providerSpecificData.customHeaders)
? (credentials.providerSpecificData.customHeaders as Record<string, string>)
: undefined;
// Upstream extra-header building extracted to chatCore/upstreamExecuteHeaders.ts (#3501); bind the
// per-request inputs once and delegate so the existing call sites stay byte-identical.
const buildUpstreamHeadersForExecute = (modelToCall: string): Record<string, string> =>
@@ -932,6 +942,7 @@ export async function handleChatCore({
resolvedModel,
sourceFormat,
connectionCustomUserAgent,
connectionCustomHeaders,
settings,
});
@@ -1713,14 +1724,16 @@ export async function handleChatCore({
comboConfig as unknown as { name: string; models: unknown[] },
allCombosData as unknown as { name: string; models: unknown[] }[]
);
comboTargetLimits = targets.map((t: { modelStr?: string; provider?: string }) =>
// Fall back to ResolvedComboTarget.provider when modelStr lacks a
// provider/ prefix — parseModel alone returns provider:null (#8716).
getComboTargetTokenLimit({
modelStr: t.modelStr,
provider: t.provider,
})
);
// Fall back to ResolvedComboTarget.provider when modelStr lacks a
// provider/ prefix — parseModel alone returns provider:null (#8716).
comboTargetLimits = targets
.map((t: { modelStr?: string; provider?: string }) =>
getComboTargetTokenLimit({ modelStr: t.modelStr, provider: t.provider })
)
.filter(
(limit): limit is number =>
typeof limit === "number" && Number.isFinite(limit) && limit > 0
);
}
// chatCore executes per concrete target (handleSingleModel resolves
// provider/effectiveModel before delegating). Compress against THIS

View File

@@ -30,12 +30,27 @@ const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([
"x-accel-buffering",
]);
const DEFAULT_FORWARDED_HEADER_BUDGET_BYTES = 768;
/**
* Resolve the forwarded upstream response-header budget from an optional string value
* (typically `process.env.OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES`). Returns the
* default of 768 when the input is unset, empty, or non-positive.
* Extracted as a pure function so unit tests can pass values directly without
* module-cache manipulation.
*/
export function resolveForwardedHeaderBudget(env?: string): number {
const parsed = Number.parseInt(String(env ?? process.env.OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_FORWARDED_HEADER_BUDGET_BYTES;
}
/**
* Keep upstream-derived headers comfortably below common reverse-proxy response-header limits.
* This budget includes each header name, separator, value, and trailing CRLF. OmniRoute's own
* response metadata and framework/security headers are added separately.
* Override with `OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES`.
*/
export const MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES = 768;
export const MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES = resolveForwardedHeaderBudget();
const MAX_LOGGED_DROPPED_RESPONSE_HEADERS = 20;
const responseHeaderEncoder = new TextEncoder();

View File

@@ -12,6 +12,7 @@
import { getModelUpstreamExtraHeaders } from "@/lib/db/models";
import { resolveModelAlias } from "../../services/modelDeprecation.ts";
import { CPA_FORCE_FAST_MODE_HEADER, shouldRequestClaudeFastMode } from "@/lib/providers/claudeFastMode";
import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders";
export function buildUpstreamHeadersForExecute(opts: {
modelToCall: string;
@@ -21,6 +22,7 @@ export function buildUpstreamHeadersForExecute(opts: {
resolvedModel: string;
sourceFormat: string;
connectionCustomUserAgent: string;
connectionCustomHeaders?: Record<string, string>;
settings: unknown;
}): Record<string, string> {
const {
@@ -31,6 +33,7 @@ export function buildUpstreamHeadersForExecute(opts: {
resolvedModel,
sourceFormat,
connectionCustomUserAgent,
connectionCustomHeaders,
settings,
} = opts;
@@ -55,6 +58,23 @@ export function buildUpstreamHeadersForExecute(opts: {
}
}
// #8369: merge connection-level custom headers UNDER model-level so model-level wins on the
// same case-insensitive header name. Forbidden header names (hop-by-hop, auth) are silently
// skipped via isForbiddenCustomHeaderName().
if (connectionCustomHeaders) {
for (const [key, value] of Object.entries(connectionCustomHeaders)) {
const keyLower = key.trim().toLowerCase();
if (!keyLower) continue;
if (isForbiddenCustomHeaderName(key)) continue;
const existingKey = Object.keys(upstreamHeaders).find(
(k) => k.toLowerCase() === keyLower
);
if (!existingKey) {
upstreamHeaders[key] = value;
}
}
}
// Claude Fast Mode opt-in. When enabled in Settings > AI AND the target provider is the canonical
// Anthropic `claude` provider (Claude Code-compatible CPA bridges are excluded since they select
// their own entrypoint) AND the model id matches the configured list, signal to a paired

View File

@@ -5,6 +5,8 @@ export interface FirecrawlSearchParams {
searchType: string;
maxResults: number;
token?: string;
baseUrl?: string;
providerSpecificData?: Record<string, unknown>;
country?: string;
language?: string;
timeRange?: string;
@@ -68,7 +70,11 @@ export function buildFirecrawlSearchRequest(
params: FirecrawlSearchParams
): { url: string; init: RequestInit } {
const envBase = process.env.FIRECRAWL_BASE_URL?.trim().replace(/\/+$/, "");
const url = envBase ? `${envBase}/v2/search` : config.baseUrl;
const providerData = params.providerSpecificData as Record<string, unknown> | undefined;
const paramBase = typeof params.baseUrl === "string" ? params.baseUrl : providerData?.baseUrl;
const customBase = typeof paramBase === "string" && paramBase.trim() ? paramBase.trim().replace(/\/+$/, "") : undefined;
const rawBase = envBase || customBase;
const url = rawBase ? `${rawBase}/v2/search` : config.baseUrl;
const { includes, excludes } = parseDomainFilter(params.domainFilter);
const source = params.searchType === "news" ? "news" : "web";

View File

@@ -50,6 +50,8 @@ export interface WebFetchResult {
export interface WebFetchCredentials {
apiKey?: string;
baseUrl?: string;
providerSpecificData?: Record<string, unknown>;
}
const WEB_FETCH_PROVIDERS = ["firecrawl", "jina-reader", "tavily-search", "tinyfish"] as const;

View File

@@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { MCP_TOOLS, MCP_TOOL_MAP, createComboInput, createComboTool } from "../schemas/tools.ts";
import { createMcpServer } from "../server.ts";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
const mockLogToolCall = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("../audit.ts", () => ({
logToolCall: mockLogToolCall,
}));
describe("omniroute_create_combo MCP tool schema", () => {
it("should be registered in MCP_TOOLS and MCP_TOOL_MAP", () => {
const tool = MCP_TOOLS.find((t) => t.name === "omniroute_create_combo");
expect(tool).toBeDefined();
expect(MCP_TOOL_MAP["omniroute_create_combo"]).toBeDefined();
});
it("should require write:combos scope", () => {
expect(createComboTool.scopes).toContain("write:combos");
});
it("should validate a minimal payload (name + models)", () => {
const result = createComboInput.safeParse({
name: "My Combo",
models: [{ provider: "anthropic", model: "claude-sonnet" }],
});
expect(result.success).toBe(true);
});
it("should validate a full payload with description and strategy", () => {
const result = createComboInput.safeParse({
name: "My Combo",
description: "A test combo",
strategy: "priority",
models: [
{ provider: "anthropic", model: "claude-sonnet" },
{ provider: "google", model: "gemini-pro" },
],
});
expect(result.success).toBe(true);
});
it("should reject a payload missing name", () => {
const result = createComboInput.safeParse({
models: [{ provider: "anthropic", model: "claude-sonnet" }],
});
expect(result.success).toBe(false);
});
it("should reject a payload with an empty models array", () => {
const result = createComboInput.safeParse({ name: "My Combo", models: [] });
expect(result.success).toBe(false);
});
it("should reject an unknown strategy value", () => {
const result = createComboInput.safeParse({
name: "My Combo",
strategy: "not-a-real-strategy",
models: [{ provider: "anthropic", model: "claude-sonnet" }],
});
expect(result.success).toBe(false);
});
});
describe("omniroute_create_combo handler (via MCP dispatch)", () => {
let client: Client;
beforeEach(async () => {
mockFetch.mockReset();
mockLogToolCall.mockClear();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = createMcpServer();
await server.connect(serverTransport);
client = new Client({ name: "create-combo-test", version: "1.0.0" });
await client.connect(clientTransport);
});
afterEach(async () => {
await client.close();
});
it("should appear in tools/list after registration", async () => {
const { tools } = await client.listTools();
const tool = tools.find((t) => t.name === "omniroute_create_combo");
expect(tool).toBeDefined();
expect(tool?.description).toContain("Registers new combo");
});
it("should POST to /api/combos and return the created combo on success", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
combo: { id: "combo-123", name: "My Combo", strategy: "priority", enabled: true },
}),
});
const args = {
name: "My Combo",
models: [{ provider: "anthropic", model: "claude-sonnet" }],
};
const result = await client.callTool({ name: "omniroute_create_combo", arguments: args });
expect(result.isError).toBeFalsy();
const content = result.content[0] as { type: string; text: string };
const data = JSON.parse(content.text);
expect(data.success).toBe(true);
expect(data.combo.id).toBe("combo-123");
expect(data.combo.name).toBe("My Combo");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/combos"),
expect.objectContaining({ method: "POST" })
);
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body as string);
expect(body.name).toBe("My Combo");
expect(body.models).toHaveLength(1);
// Audit: the invocation must be logged to mcp_audit (via logToolCall).
expect(mockLogToolCall).toHaveBeenCalledWith(
"omniroute_create_combo",
expect.objectContaining({ name: "My Combo" }),
expect.objectContaining({ success: true }),
expect.any(Number),
true
);
});
it("should pass through optional description and strategy fields", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
combo: { id: "combo-456", name: "Cost Saver", strategy: "cost-optimized", enabled: true },
}),
});
await client.callTool({
name: "omniroute_create_combo",
arguments: {
name: "Cost Saver",
description: "Prefers cheaper models",
strategy: "cost-optimized",
models: [
{ provider: "anthropic", model: "claude-haiku" },
{ provider: "google", model: "gemini-flash" },
],
},
});
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body as string);
expect(body.description).toBe("Prefers cheaper models");
expect(body.strategy).toBe("cost-optimized");
expect(body.models).toHaveLength(2);
});
it("should return isError and log the failure when the backend rejects the combo (e.g. name collision)", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 409,
text: async () => "Combo name already exists",
});
const result = await client.callTool({
name: "omniroute_create_combo",
arguments: {
name: "Duplicate Combo",
models: [{ provider: "anthropic", model: "claude-sonnet" }],
},
});
expect(result.isError).toBe(true);
const content = result.content[0] as { type: string; text: string };
expect(content.text).toContain("Error");
expect(mockLogToolCall).toHaveBeenCalledWith(
"omniroute_create_combo",
expect.objectContaining({ name: "Duplicate Combo" }),
null,
expect.any(Number),
false,
expect.stringContaining("Combo name already exists")
);
});
});

View File

@@ -192,6 +192,53 @@ export const switchComboTool: McpToolDefinition<typeof switchComboInput, typeof
sourceEndpoints: ["/api/combos"],
};
// --- Tool 4b: omniroute_create_combo ---
export const createComboInput = z.object({
name: z
.string()
.trim()
.min(1)
.max(100)
.describe("Unique combo name (letters, numbers, spaces, -, _, /, ., [ and ])"),
description: z.string().max(2000).optional().describe("Optional human-readable description"),
strategy: z
.enum(ROUTING_STRATEGY_VALUES)
.optional()
.describe("Routing strategy (default: priority)"),
models: z
.array(
z.object({
provider: z.string().describe("Provider name (e.g., 'claude', 'gemini')"),
model: z.string().describe("Model ID for that provider"),
})
)
.min(1)
.describe("Ordered model chain; order defines priority"),
});
export const createComboOutput = z.object({
success: z.boolean(),
combo: z.object({
id: z.string(),
name: z.string(),
strategy: z.string(),
enabled: z.boolean(),
}),
});
export const createComboTool: McpToolDefinition<typeof createComboInput, typeof createComboOutput> =
{
name: "omniroute_create_combo",
description:
"Registers a new combo (model chain) with a name, ordered model list, and optional routing strategy. Full validation (name collisions, nested-combo DAG, composite tiers) is enforced by the combos API.",
inputSchema: createComboInput,
outputSchema: createComboOutput,
scopes: ["write:combos"],
auditLevel: "full",
phase: 1,
sourceEndpoints: ["/api/combos"],
};
// --- Tool 5: omniroute_check_quota ---
export const checkQuotaInput = z.object({
provider: z
@@ -1460,6 +1507,7 @@ export const MCP_TOOLS = [
listCombosTool,
getComboMetricsTool,
switchComboTool,
createComboTool,
checkQuotaTool,
routeRequestTool,
costReportTool,

View File

@@ -12,6 +12,7 @@ import {
listCombosInput,
getComboMetricsInput,
switchComboInput,
createComboInput,
checkQuotaInput,
routeRequestInput,
costReportInput,
@@ -393,6 +394,27 @@ async function handleSwitchCombo(args: { comboId: string; active: boolean }) {
}
}
async function handleCreateCombo(args: {
name: string;
description?: string;
strategy?: string;
models: { provider: string; model: string }[];
}) {
const start = Date.now();
try {
const result = await omniRouteFetch("/api/combos", {
method: "POST",
body: JSON.stringify(args),
});
await logToolCall("omniroute_create_combo", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await logToolCall("omniroute_create_combo", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
}
async function handleCheckQuota(args: { provider?: string; connectionId?: string }) {
const start = Date.now();
try {
@@ -738,6 +760,17 @@ export function createMcpServer(): McpServer {
)
);
server.registerTool(
"omniroute_create_combo",
{
description: "Registers a new combo (model chain) with name, models, and strategy",
inputSchema: createComboInput,
},
withScopeEnforcement("omniroute_create_combo", (args) =>
handleCreateCombo(createComboInput.parse(args))
)
);
server.registerTool(
"omniroute_check_quota",
{

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from "vitest";
import { acquire, isAccountSemaphoreFull, resetAll } from "../accountSemaphore.ts";
describe("isAccountSemaphoreFull fail-fast concurrency gate", () => {
beforeEach(() => {
resetAll();
});
it("returns false when no semaphore gate exists", () => {
expect(isAccountSemaphoreFull("featherless-ai", "conn-1", 1)).toBe(false);
});
it("returns false when maxConcurrency is null, <= 0, or bypassed", () => {
expect(isAccountSemaphoreFull("featherless-ai", "conn-1", null)).toBe(false);
expect(isAccountSemaphoreFull("featherless-ai", "conn-1", 0)).toBe(false);
});
it("returns false when running < maxConcurrency", async () => {
const release = await acquire("featherless-ai:conn-1", { maxConcurrency: 2 });
expect(isAccountSemaphoreFull("featherless-ai", "conn-1", 2)).toBe(false);
release();
});
it("returns true immediately when running >= maxConcurrency", async () => {
const release = await acquire("featherless-ai:conn-1", { maxConcurrency: 1 });
expect(isAccountSemaphoreFull("featherless-ai", "conn-1", 1)).toBe(true);
release();
expect(isAccountSemaphoreFull("featherless-ai", "conn-1", 1)).toBe(false);
});
});

View File

@@ -192,12 +192,17 @@ describe("TierResolver", () => {
});
it("uses cache for repeated models", () => {
classifyTiers([
clearTierCache();
const results = classifyTiers([
{ provider: "openai", model: "gpt-4o" },
{ provider: "openai", model: "gpt-4o" },
]);
// If cache works, second call should be instant; test passes if no error
expect(true).toBe(true);
// Observable effect of the cache: the duplicate resolves to the same tier and only
// ONE entry is memoized (getTierStats counts cache entries, not classify calls).
assert.equal(results.length, 2);
assert.equal(results[0].tier, results[1].tier);
const stats = getTierStats();
assert.equal(stats.free + stats.cheap + stats.premium, 1);
});
});

View File

@@ -1631,7 +1631,11 @@ export function checkFallbackError(
!errorStr.toLowerCase().includes("hour quota") &&
!errorStr.toLowerCase().includes("quota has been exceeded")
) {
return resolveApiKeyForbiddenFallback(errorStr, buildRetryableFallback, RateLimitReason.AUTH_ERROR);
return resolveApiKeyForbiddenFallback(
errorStr,
buildRetryableFallback,
RateLimitReason.AUTH_ERROR
);
}
}
@@ -1953,6 +1957,8 @@ export function applyErrorState<T extends AccountState | null | undefined>(
return nextState;
}
export { isAccountSemaphoreFull } from "./accountSemaphore.ts";
/**
* Get account health score (0-100) for P2C selection (Phase 9)
* @param {object} account

View File

@@ -342,6 +342,24 @@ export function getStats(): Record<string, AccountSemaphoreStatsEntry> {
return stats;
}
/**
* Check if an account semaphore key is currently at or over its max concurrency limit.
* Returns true if running >= maxConcurrency or blocked.
*/
export function isAccountSemaphoreFull(
provider: string,
accountKey: string,
maxConcurrency?: number | null
): boolean {
if (isBypassed(maxConcurrency)) return false;
const key = buildAccountSemaphoreKey({ provider, accountKey });
const gate = gates.get(key);
if (!gate) return false;
const effectiveCap = maxConcurrency ?? gate.maxConcurrency;
if (isBypassed(effectiveCap)) return false;
return gate.running >= effectiveCap || isBlocked(gate);
}
/**
* Reset a single key and reject queued waiters.
*/

View File

@@ -201,16 +201,43 @@ function calculateSpecificityMatch(
}
}
/**
* Pool-wide maxima used to normalize cost/latency/stability factors. These are
* identical for every candidate in a given pool, so callers scoring many
* candidates against the same pool should compute this ONCE via
* computePoolMaxima() and pass it to calculateFactors — recomputing it inside
* a per-candidate loop turns an O(n) scoring pass into O(n^2) (#OOM incident:
* a zero-config "auto" combo with no explicit candidatePool can expand the
* pool to 1000s of provider/model targets, at which point the repeated
* `pool.map()` + spread here dominates heap churn and can OOM the process).
*/
export interface PoolMaxima {
maxCost: number;
maxLatency: number;
maxStdDev: number;
}
export function computePoolMaxima(pool: ProviderCandidate[]): PoolMaxima {
let maxCost = 0.001;
let maxLatency = 1;
let maxStdDev = 0.001;
for (const p of pool) {
if (p.costPer1MTokens > maxCost) maxCost = p.costPer1MTokens;
if (p.p95LatencyMs > maxLatency) maxLatency = p.p95LatencyMs;
if (p.latencyStdDev > maxStdDev) maxStdDev = p.latencyStdDev;
}
return { maxCost, maxLatency, maxStdDev };
}
export function calculateFactors(
candidate: ProviderCandidate,
pool: ProviderCandidate[],
taskType: string,
getTaskFitness: (model: string, taskType: string) => number,
manifestHint?: RoutingHint | null
manifestHint?: RoutingHint | null,
precomputedMaxima?: PoolMaxima
): ScoringFactors {
const maxCost = Math.max(...pool.map((p) => p.costPer1MTokens), 0.001);
const maxLatency = Math.max(...pool.map((p) => p.p95LatencyMs), 1);
const maxStdDev = Math.max(...pool.map((p) => p.latencyStdDev), 0.001);
const { maxCost, maxLatency, maxStdDev } = precomputedMaxima ?? computePoolMaxima(pool);
// Every factor is contractually [0,1]. clamp01 guards against bad telemetry
// (negative quota / cost / latency, NaN, out-of-range candidate-supplied
@@ -245,9 +272,17 @@ export function scorePool(
getTaskFitness: (model: string, taskType: string) => number = () => 0.5,
manifestHint?: RoutingHint | null
): ScoredProvider[] {
const poolMaxima = computePoolMaxima(pool);
return pool
.map((candidate) => {
const factors = calculateFactors(candidate, pool, taskType, getTaskFitness, manifestHint);
const factors = calculateFactors(
candidate,
pool,
taskType,
getTaskFitness,
manifestHint,
poolMaxima
);
return {
provider: candidate.provider,
model: candidate.model,

View File

@@ -14,6 +14,7 @@ import {
getModelLockoutInfo,
getRuntimeProviderProfile,
hasPerModelQuota,
isAccountSemaphoreFull,
isModelLocked,
MODEL_ACCESS_DENIED_PATTERNS,
recordModelLockoutFailure,
@@ -1012,6 +1013,20 @@ export async function handleComboChat({
if (i > 0) fallbackCount++;
return null;
}
// Concurrency gate: fail-fast skip when connection is at max_concurrent capacity (e.g. Featherless 1/1)
const maxConcurrentCap = await lookupPositiveCap(connectionId);
if (
maxConcurrentCap &&
isAccountSemaphoreFull(provider, connectionId, maxConcurrentCap)
) {
log.info(
"COMBO",
`Skipping ${modelStr} — connection ${connectionId} is at max concurrency cap (${maxConcurrentCap})`
);
if (i > 0) fallbackCount++;
return null;
}
}
// Retry loop for transient errors

View File

@@ -33,6 +33,7 @@ import { getTaskFitness } from "../autoCombo/taskFitness.ts";
import {
calculateFactors,
calculateScore,
computePoolMaxima,
type ProviderCandidate,
type ScoringWeights,
} from "../autoCombo/scoring.ts";
@@ -351,6 +352,11 @@ export function scoreAutoTargets(
) {
const targetByExecutionKey = new Map(targets.map((target) => [target.executionKey, target]));
const activeCandidates = candidates.filter((candidate) => candidate.quotaCutoffBlocked !== true);
// Computed once per scoring pass, not per candidate — see computePoolMaxima's
// doc comment (scoring.ts) for the O(n^2) OOM this avoids on large auto-combo
// candidate pools (#OOM incident, zero-config auto combo expanding to 1000s
// of provider/model targets).
const poolMaxima = computePoolMaxima(activeCandidates as unknown as ProviderCandidate[]);
return activeCandidates
.map((candidate) => {
@@ -373,10 +379,11 @@ export function scoreAutoTargets(
};
const factors = calculateFactors(
candidate as ProviderCandidate,
activeCandidates,
activeCandidates as unknown as ProviderCandidate[],
taskType ?? "general",
getTaskFitness,
manifestHint ?? undefined
manifestHint ?? undefined,
poolMaxima
);
let score = calculateScore(factors, weights);
// B17: Quota Share soft-policy deprioritization
@@ -447,11 +454,16 @@ export async function expandAutoComboCandidatePool(
.filter((p): p is string => typeof p === "string" && p.length > 0)
),
];
// Pre-build a Set of already-present modelStr values so candidate-pool
// expansion doesn't turn into O(n^2) per provider. See #OOM incident
// (zero-config auto combo expanding to 1000s of provider/model targets).
const seenModelStrs = new Set(eligibleTargets.map((t) => t.modelStr));
for (const providerId of providerIds) {
const providerModels = getProviderModels(providerId);
for (const model of providerModels) {
const modelStr = `${providerId}/${model.id}`;
if (!eligibleTargets.some((t) => t.modelStr === modelStr)) {
if (!seenModelStrs.has(modelStr)) {
seenModelStrs.add(modelStr);
eligibleTargets.push({
kind: "model",
stepId: modelStr,

View File

@@ -8,6 +8,8 @@
* getComboModelsFromData, validateComboDAG, resolveNestedComboModels,
* filterTargetsByRequestCompatibility) are re-exported from combo.ts for the
* ~20 external consumers (chatCore.ts, the /api/combos routes, embeddings, etc.).
* Context-window metadata is advisory: known-fitting targets are ordered first,
* while catalog-too-small targets remain available for runtime fallback.
* No barrel import — depends only on sibling leaves.
*/
@@ -531,9 +533,7 @@ function hasKnownCompatibleContextLimit(
return evaluateContextLimit(capabilities, requirements, target.modelStr) === true;
}
function hasOnlyContextWindowFailures(reasons: string[]): boolean {
return reasons.length > 0 && reasons.every((reason) => reason === "context_window");
}
const HARD_COMPAT_REASONS = new Set(["tools", "vision", "structured_output", "output_tokens"]);
/**
* #8332: vision is a hard requirement, not a soft preference — a target whose vision
@@ -694,67 +694,34 @@ export function filterTargetsByRequestCompatibility(
if (!needsFiltering) return targets;
const rejected: Array<{ target: ResolvedComboTarget; reasons: string[] }> = [];
const compatible = targets.filter((target) => {
const targetReasons = new Map<ResolvedComboTarget, string[]>();
for (const target of targets) {
const reasons = getTargetCompatibilityFailures(target, requirements);
if (reasons.length === 0) return true;
rejected.push({ target, reasons });
return false;
});
targetReasons.set(target, reasons);
if (reasons.length > 0) rejected.push({ target, reasons });
}
// Unknown context limits are safe only as a fallback. If this request already
// filtered at least one known-too-small target and known-good targets remain,
// prefer the known-good set over unknown metadata gaps. If no known-good
// context target remains, fall back to the strategy order for context-only
// candidates instead of letting unknown metadata be the only survivors.
const rejectedForContextWindow = rejected.some((entry) =>
// Context metadata is advisory. Keep every target that has no hard capability
// mismatch, but prefer targets whose known limit fits. A stale catalog entry must
// never remove the only target that could accept the request at runtime.
const compatible = targets.filter((target) => {
const reasons = targetReasons.get(target) || [];
return !reasons.some((reason) => HARD_COMPAT_REASONS.has(reason));
});
const hadKnownTooSmallContextTarget = rejected.some((entry) =>
entry.reasons.includes("context_window")
);
if (requirements.requiredContextTokens > 0 && rejectedForContextWindow) {
if (
requirements.requiredContextTokens > 0 &&
hadKnownTooSmallContextTarget &&
compatible.length > 1
) {
const knownContextCompatible = compatible.filter((target) =>
hasKnownCompatibleContextLimit(target, requirements)
);
if (knownContextCompatible.length > 0 && knownContextCompatible.length < compatible.length) {
const knownContextCompatibleTargets = new Set(knownContextCompatible);
for (const target of compatible) {
if (!knownContextCompatibleTargets.has(target)) {
rejected.push({ target, reasons: ["context_window_unknown"] });
}
}
log.info(
"COMBO",
`${label}: kept ${knownContextCompatible.length}/${targets.length} targets for request requirements`
);
log.debug?.(
"COMBO",
`${label}: rejected targets ${rejected
.map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`)
.join(", ")}`
);
return knownContextCompatible;
}
if (knownContextCompatible.length === 0 && compatible.length > 0) {
const rejectedByTarget = new Map(rejected.map((entry) => [entry.target, entry.reasons]));
const contextOnlyFallback = targets.filter((target) => {
const reasons = rejectedByTarget.get(target);
return !reasons || hasOnlyContextWindowFailures(reasons);
});
if (contextOnlyFallback.length > compatible.length) {
log.warn(
"COMBO",
`${label}: no known-compatible context target remains; preserving strategy order for context-only candidates`
);
log.debug?.(
"COMBO",
`${label}: rejected targets ${rejected
.map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`)
.join(", ")}`
);
return contextOnlyFallback;
}
const knownSet = new Set(knownContextCompatible);
return [...knownContextCompatible, ...compatible.filter((target) => !knownSet.has(target))];
}
}

View File

@@ -21,6 +21,7 @@ import { handlePipelineChat, type PipelineStep } from "../pipeline.ts";
import type { resolveComboSetupConfig } from "../comboConfig.ts";
import { clampComboDepth, MAX_GLOBAL_ATTEMPTS, resolveDelayMs } from "./comboPredicates.ts";
import { resolveComboRuntimeUnits, resolveComboTargets } from "./comboStructure.ts";
import { isComboModelVisible } from "./comboVisibility.ts";
import { buildFusionHandleSingleModel, extractFusionPanelSpec } from "./fusionPanel.ts";
import {
clampStickyWeightedTargetLimit,
@@ -340,12 +341,20 @@ export async function tryFusionDispatch(args: {
runCombo: RunCombo;
}): Promise<Response | null> {
const { cfg, combo, config, strategy, log } = args;
const judgeModel = typeof cfg.judgeModel === "string" ? cfg.judgeModel : undefined;
const configuredJudge = typeof cfg.judgeModel === "string" ? cfg.judgeModel : undefined;
// The panel is filtered for hidden models by resolveComboTargets, but the
// explicit judge is a bare string that never passes through it (#8878). Drop a
// hidden judge so fusion falls back to a surviving panel member instead of
// dispatching a model the operator hid.
const judgeModel =
configuredJudge && !isComboModelVisible(configuredJudge, null, args.hiddenModelsByProvider)
? undefined
: configuredJudge;
const fusionTuning =
cfg.fusionTuning && typeof cfg.fusionTuning === "object"
? (cfg.fusionTuning as FusionTuning)
: undefined;
if (strategy !== "fusion" && (judgeModel || fusionTuning)) {
if (strategy !== "fusion" && (configuredJudge || fusionTuning)) {
log.warn(
"COMBO",
`Combo "${combo.name}" sets config.judgeModel/fusionTuning but strategy is "${strategy}" — these fields are only consumed by the fusion strategy and will be ignored (#6455)`
@@ -353,16 +362,30 @@ export async function tryFusionDispatch(args: {
}
if (strategy !== "fusion") return null;
const { panel: fusionModels, comboRefUnits } = extractFusionPanelSpec(
resolveComboTargets(
combo,
args.allCombos,
clampComboDepth(config.maxComboDepth),
args.hiddenModelsByProvider
).map((target) => target.modelStr),
const resolvedFusionTargets = resolveComboTargets(
combo,
args.allCombos,
clampComboDepth(config.maxComboDepth),
args.hiddenModelsByProvider
);
// extractFusionPanelSpec only understands model strings / combo refs, so the
// resolved targets have to be flattened before it runs. Keep them indexed so
// the panel can be rehydrated below — dispatching the bare strings strips
// `providerId` and every panel member loses its provider identity (#8878).
const resolvedByModelStr = new Map<string, (typeof resolvedFusionTargets)[number]>();
for (const target of resolvedFusionTargets) {
if (!resolvedByModelStr.has(target.modelStr)) resolvedByModelStr.set(target.modelStr, target);
}
const { panel: fusionPanel, comboRefUnits } = extractFusionPanelSpec(
resolvedFusionTargets.map((target) => target.modelStr),
combo.name,
null
);
// A panel entry naming a combo ref stays a string (it is a combo name, not a
// model); everything else regains its resolved target.
const fusionModels = fusionPanel.map((entry) =>
comboRefUnits.has(entry) ? entry : (resolvedByModelStr.get(entry) ?? entry)
);
// Untyped like the existing `nestingContext` further down — `nesting` is
// already `ComboNestingContext | null` per HandleComboChatOptions, no new
// import needed.

View File

@@ -31,7 +31,7 @@
import { wildcardMatch } from "../wildcardRouter.ts";
import { getProviderModels } from "../../config/providerModels.ts";
import { getSyncedAvailableModels } from "../../../src/lib/db/models.ts";
import { getActiveSyncedCatalog } from "../../../src/lib/db/models/activeSyncedCatalog.ts";
import type { ComboLike } from "./types.ts";
/** Sentinel pattern used for "all models of a provider". */
@@ -116,39 +116,18 @@ function parseWildcardEntry(entry: unknown): ProviderWildcardSpec | null {
}
/**
* Collect candidate model IDs for a provider from two sources:
* 1. Synced available models in the DB (runtime-dynamic; custom/OAuth providers)
* 2. Static provider registry (built-in providers bundled with the release)
*
* The union is deduped by model id.
* Collect candidate model IDs using the active synced catalog as the
* authoritative source when it is non-empty. Static registry models remain a
* fail-open fallback when no active usable catalog exists.
*/
async function collectProviderModelIds(providerId: string): Promise<string[]> {
const seen = new Set<string>();
const ids: string[] = [];
const liveCatalog = await getActiveSyncedCatalog(providerId);
// 1. Synced DB models (highest priority — reflects the live catalog)
try {
const synced = await getSyncedAvailableModels(providerId);
for (const m of synced) {
if (m.id && !seen.has(m.id)) {
seen.add(m.id);
ids.push(m.id);
}
}
} catch {
// Non-fatal — DB may be offline in tests or at early init.
if (liveCatalog.authoritative) {
return liveCatalog.models.map((model) => model.id);
}
// 2. Static registry models (fallback / built-in providers)
const registryModels = getProviderModels(providerId);
for (const m of registryModels) {
if (m.id && !seen.has(m.id)) {
seen.add(m.id);
ids.push(m.id);
}
}
return ids;
return getProviderModels(providerId).map((model) => model.id);
}
/**

View File

@@ -18,10 +18,18 @@
* and orderTargetsByResetWindow shares the same rrCounters Map from ./rrState.ts
* (D7a) so reset-aware tie rotation stays consistent with round-robin routing.
*
* @changes
* - [2026-07-24] [Composer] - Exclude Antigravity accounts without stored projectId from reset-aware pool
* - [2026-07-24] [Composer] - Skip quota-exhausted and rate-limited connections in reset-aware expansion
*
* Pure leaf: this module never imports from the combo barrel.
*/
import { getRuntimeProviderProfile, type ProviderProfile } from "../accountFallback.ts";
import {
getRuntimeProviderProfile,
isAccountUnavailable,
type ProviderProfile,
} from "../accountFallback.ts";
import { PRE_SCREEN_CONCURRENCY } from "../comboConfig.ts";
import { getQuotaFetcher } from "../quotaPreflight.ts";
import { getCircuitBreaker } from "../../../src/shared/utils/circuitBreaker";
@@ -37,6 +45,8 @@ import {
type QuotaFetchCacheConfig,
} from "./quotaScoring.ts";
import { rankByHeadroom, type HeadroomSaturation } from "./headroomRanking.ts";
import { preferAntigravityConnectionsWithStoredProject } from "../antigravityProjectPersistence.ts";
import { isQuotaExhaustedForRequest } from "../../../src/domain/quotaCache.ts";
const RESET_AWARE_CONNECTION_CACHE_TTL_MS = 30_000;
const RESET_AWARE_QUOTA_FETCH_CONCURRENCY = 5;
@@ -75,9 +85,14 @@ async function getQuotaAwareConnectionsForTarget(
(async () => {
try {
const connections = await getCachedProviderConnections({ provider, isActive: true });
const activeConnections = Array.isArray(connections)
let activeConnections = Array.isArray(connections)
? (connections as Array<Record<string, unknown>>)
: [];
if (provider === "antigravity" || provider === "agy") {
activeConnections = preferAntigravityConnectionsWithStoredProject(
activeConnections
) as Array<Record<string, unknown>>;
}
if (
!resetAwareConnectionCache.has(provider) &&
resetAwareConnectionCache.size >= MAX_RESET_AWARE_CACHE
@@ -199,6 +214,18 @@ async function expandTargetsByQuotaAwareConnections(
}
for (const connectionId of connectionIds) {
const provider = getResetAwareProvider(target);
const connection = connectionById.get(connectionId);
if (
connection &&
typeof connection.rateLimitedUntil === "string" &&
isAccountUnavailable(connection.rateLimitedUntil)
) {
continue;
}
if (provider && isQuotaExhaustedForRequest(connectionId, provider, target.modelStr || null)) {
continue;
}
expandedTargets.push({
...target,
connectionId,

View File

@@ -0,0 +1,80 @@
/**
* @file runtimeUnitCapacity.ts
* @description Concurrency-capacity checks for nested combo execute-mode units so
* ordered strategies overflow to the next slot instead of queueing on a full connection.
*
* @changes
* - [2026-07-24] [Composer] - Initial capacity pre-check for execute-mode runtime units
*/
import { isAccountSemaphoreFull } from "../accountSemaphore.ts";
import { resolveComboTargets } from "./comboStructure.ts";
import { lookupPositiveCap } from "./concurrencyCaps.ts";
import type { ComboCollectionLike, ComboLike, ResolvedComboUnit } from "./types.ts";
type CapLookup = (connectionId: string) => Promise<number | null>;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function getCombosList(allCombos: ComboCollectionLike): ComboLike[] {
const combos = Array.isArray(allCombos) ? allCombos : allCombos?.combos || [];
return combos.filter(
(combo): combo is ComboLike => isRecord(combo) && typeof combo.name === "string"
);
}
function findComboByName(allCombos: ComboCollectionLike, name: string): ComboLike | null {
return getCombosList(allCombos).find((combo) => combo.name === name) || null;
}
async function isConnectionAtConcurrencyCap(
provider: string,
connectionId: string,
lookupCap: CapLookup
): Promise<boolean> {
const cap = await lookupCap(connectionId);
if (!cap) return false;
return isAccountSemaphoreFull(provider, connectionId, cap);
}
/**
* Returns true when the runtime unit should be skipped because every limited
* connection it would use is already at max_concurrent.
*/
export async function isRuntimeUnitAtConcurrencyCap(
unit: ResolvedComboUnit,
allCombos: ComboCollectionLike,
lookupCap: CapLookup = lookupPositiveCap
): Promise<boolean> {
if (unit.kind === "model") {
if (!unit.connectionId || !unit.provider) return false;
return isConnectionAtConcurrencyCap(unit.provider, unit.connectionId, lookupCap);
}
const childCombo = findComboByName(allCombos, unit.comboName);
if (!childCombo) return false;
const targets = resolveComboTargets(childCombo, allCombos, 1);
const byConnection = new Map<string, { provider: string; connectionId: string }>();
for (const target of targets) {
if (!target.connectionId || !target.provider) continue;
byConnection.set(target.connectionId, {
provider: target.provider,
connectionId: target.connectionId,
});
}
if (byConnection.size === 0) return false;
let sawLimitedConnection = false;
for (const { provider, connectionId } of byConnection.values()) {
const cap = await lookupCap(connectionId);
if (!cap) continue;
sawLimitedConnection = true;
if (!isAccountSemaphoreFull(provider, connectionId, cap)) {
return false;
}
}
return sawLimitedConnection;
}

View File

@@ -1,7 +1,14 @@
// Nested combo runtime unit execution — see combo.ts for integration.
/**
* @file runtimeUnits.ts
* @description Nested combo runtime unit execution — see combo.ts for integration.
*
* @changes
* - [2026-07-24] [Composer] - Skip execute-mode units at concurrency cap before dispatch
*/
import { errorResponse } from "../../utils/error.ts";
import { recordComboRequest } from "../comboMetrics.ts";
import { resolveDelayMs } from "./comboPredicates.ts";
import { isRuntimeUnitAtConcurrencyCap } from "./runtimeUnitCapacity.ts";
import { validateResponseQuality, releaseQualityClone } from "./validateQuality.ts";
import type { ResponseValidationConfig } from "./responseValidation.ts";
import type {
@@ -190,6 +197,15 @@ export async function executeRuntimeUnitCombo(args: {
let fallbackCount = 0;
for (const unit of orderedUnits) {
if (await isRuntimeUnitAtConcurrencyCap(unit, args.allCombos)) {
args.log.info(
"COMBO",
`Skipping ${unit.kind} ${unitDisplayName(unit)} — concurrency cap reached`
);
fallbackCount += 1;
continue;
}
for (let retry = 0; retry <= maxRetries; retry += 1) {
if (args.signal?.aborted)
return { response: errorResponse(499, "Client disconnected"), unit };

View File

@@ -37,6 +37,13 @@
*/
import crypto from "node:crypto";
import {
deleteAllCcrBlocks,
deleteCcrBlockRow,
loadCcrBlock,
persistCcrBlock,
touchCcrBlock,
} from "../../../../../src/lib/db/ccrBlocks.ts";
import { createCompressionStats } from "../../stats.ts";
import { queryBlock, type CcrQuery } from "./ccrQuery.ts";
import { injectCcrProtocolInstruction } from "./protocolInstruction.ts";
@@ -156,6 +163,150 @@ function buildStoreKey(hash: string, principalId?: string): string {
return `${principalId ?? ANON} ${hash}`;
}
// ─── durable second tier (#9061) ──────────────────────────────────────────────
/**
* The map above is the hot cache. It loses entries to cross-principal LRU eviction, to
* the TTL, to restarts, and to a retrieve landing on another instance, while
* `fidelityGateStep` waives fidelity checks for sampling engines on the grounds that
* their drop is "CCR-recoverable", and the protocol instruction promises the model a
* verbatim block. These helpers put the block on disk so that promise survives.
*
* Every one of them is best-effort: a store without a usable database (compression
* preview, unit tests, a read-only volume) degrades to today's in-memory behaviour
* rather than failing the request.
*
* Three guards keep this from changing what the deployment stores at rest more than it
* has to. They follow the call-log artifact path, which faced the same question:
*
* 1. Blocks over `MAX_DURABLE_BLOCK_BYTES` stay memory-only. `MAX_CCR_BLOCK_BYTES` is
* 2 MB, and 5,000 of those would be 10 GB of prompt text in SQLite. 512 KB is the
* ceiling #1647 already set on call artifacts for this exact reason.
* 2. No durable tier on a cloud runtime, which has no local disk to write to.
* 3. `COMPRESSION_CCR_DURABLE_STORE=false` turns it off. The content is prompt text, and an
* operator who does not want that on disk needs a switch that is not a rebuild.
*
* The switch defaults to on because the model is already told, by the CCR protocol
* instruction, that it can retrieve the block verbatim. Leaving it off by default would
* keep that promise hollow for everyone who never reads this file.
*/
const MAX_DURABLE_BLOCK_BYTES = 512 * 1024;
/** Matches the detection call-log artifacts use (`callLogArtifacts.ts`). */
const isCloudRuntime = typeof globalThis.caches === "object" && globalThis.caches !== null;
function durableTierEnabled(): boolean {
return !isCloudRuntime && process.env.COMPRESSION_CCR_DURABLE_STORE !== "false";
}
const loggedDurableErrors = new Set<string>();
function warnDurableError(operation: string, error: unknown): void {
if (process.env.NODE_ENV === "test") return;
if (loggedDurableErrors.has(operation)) return;
if (loggedDurableErrors.size >= 20) {
const first = loggedDurableErrors.values().next().value;
if (first !== undefined) loggedDurableErrors.delete(first);
}
loggedDurableErrors.add(operation);
const message = error instanceof Error ? error.message : String(error);
console.warn(`[ccr] durable ${operation} failed: ${message}`);
}
/**
* Writes are deferred off the request path. Measured on this repo, a synchronous
* `persistCcrBlock` costs 0.032 ms at the 600-char minimum block but 1.77 ms at 500 KB,
* past the 1 ms this engine declares in its metadata, and roughly 7x the sha256 it
* already pays over the same bytes. The block is in the map before the defer runs, so an
* in-process retrieve never waits for the disk; only a crash inside that tick loses the
* durable copy, and the client's next request re-stores it under the same hash.
*
* Persist and delete share this queue so they cannot reorder: `setImmediate` is FIFO, and
* a delete that overtook its own persist would resurrect the block it just removed.
*/
const MAX_PENDING_DURABLE_WRITES = 1_000;
let pendingDurableWrites = 0;
let droppedDurableWrites = 0;
function deferDurable(operation: string, work: () => void, droppable = false): void {
// Backpressure. A burst faster than SQLite drains would otherwise queue without bound
// and hold every block's content live in the closure. Dropping a persist is safe: the
// block is still in the map, and the client re-stores it under the same hash on its
// next request. Deletes are never dropped, or a deleted block would come back.
if (droppable && pendingDurableWrites >= MAX_PENDING_DURABLE_WRITES) {
droppedDurableWrites++;
return;
}
pendingDurableWrites++;
setImmediate(() => {
pendingDurableWrites--;
try {
work();
} catch (error) {
warnDurableError(operation, error);
}
});
}
function persistEntry(entry: CcrEntry): void {
if (!durableTierEnabled()) return;
if (entry.bytes > MAX_DURABLE_BLOCK_BYTES) return;
const snapshot = { ...entry };
deferDurable("persist", () => persistCcrBlock(snapshot), true);
}
function forgetEntry(hash: string, principalId: string): void {
if (!durableTierEnabled()) return;
deferDurable("delete", () => deleteCcrBlockRow(principalId, hash));
}
/**
* Read a block the map no longer holds and put it back in the map, so the LRU/byte
* accounting keeps working from there. Returns null when there is no durable row, which
* is also what a missing database looks like.
*/
function rehydrateEntry(hash: string, principalId: string, now: number): CcrEntry | null {
if (!durableTierEnabled()) return null;
let row: ReturnType<typeof loadCcrBlock>;
try {
row = loadCcrBlock(principalId, hash, now);
} catch (error) {
warnDurableError("load", error);
return null;
}
if (!row) return null;
const entry: CcrEntry = {
hash: row.hash,
principalId: row.principalId,
content: row.content,
bytes: row.bytes,
chars: row.chars,
lines: row.lines,
contentType: row.contentType,
source: row.source as CcrEntrySource,
createdAt: row.createdAt,
lastAccessedAt: now,
expiresAt: row.expiresAt,
};
// Re-admit through the same budgets a fresh store would face. If the block no longer
// fits, it stays on disk and is served straight from the row instead of being cached.
if (enforcePrincipalBudget(entry.principalId, entry.bytes) && enforceGlobalBudget(entry.bytes)) {
const key = buildStoreKey(hash, principalId === ANON ? undefined : principalId);
ccrStore.set(key, entry);
ccrTotalBytes += entry.bytes;
principalBytesMap.set(entry.principalId, principalBytes(entry.principalId) + entry.bytes);
}
try {
touchCcrBlock(entry.principalId, hash, now);
} catch (error) {
warnDurableError("touch", error);
}
return entry;
}
function readLifecycleCounters(principalId: string): CcrLifecycleCounters {
return (
lifecycleByPrincipal.get(principalId) ?? {
@@ -203,7 +354,12 @@ function removeEntry(key: string, reason?: "expired" | "capacity"): boolean {
if (remainingPrincipalBytes === 0) principalBytesMap.delete(entry.principalId);
else principalBytesMap.set(entry.principalId, remainingPrincipalBytes);
const counters = mutableLifecycleCounters(entry.principalId);
if (reason === "expired") counters.expiredEvictions++;
if (reason === "expired") {
counters.expiredEvictions++;
// Expiry is the one eviction that means the block is finished. Capacity eviction is
// not: that block stays on disk, which is the point of the durable tier (#9061).
forgetEntry(entry.hash, entry.principalId);
}
if (reason === "capacity") counters.capacityEvictions++;
return true;
}
@@ -350,6 +506,7 @@ export function tryStoreBlock(
ccrStore.set(key, entry);
ccrTotalBytes += bytes;
principalBytesMap.set(owner, principalBytes(owner) + bytes);
persistEntry(entry);
return { stored: true, hash, metadata: publicMetadata(entry) };
}
@@ -372,7 +529,12 @@ export function storeBlock(
export function retrieveBlock(hash: string, principalId?: string, now = Date.now()): string | null {
const key = buildStoreKey(hash, principalId);
const entry = getActiveEntry(key, now);
if (!entry) return null;
if (!entry) {
// Miss in the hot cache is not proof the block is gone (#9061): LRU eviction, the TTL
// sweep, a restart, or another instance all land here while the row is still on disk.
const restored = rehydrateEntry(hash, principalId ?? ANON, now);
return restored ? restored.content : null;
}
entry.lastAccessedAt = now;
ccrStore.delete(key);
ccrStore.set(key, entry);
@@ -435,6 +597,14 @@ export function resetCcrStore(): void {
principalBytesMap.clear();
ccrTotalBytes = 0;
lifecycleByPrincipal.clear();
// Through the same queue as persist/delete, so a reset cannot overtake a write it was
// meant to clear.
deferDurable("reset", deleteAllCcrBlocks);
}
/** Resolves once the deferred durable writes queued so far have run. */
export function flushCcrDurableWrites(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
export function inspectCcrBlock(
@@ -469,7 +639,11 @@ export function listCcrBlocks(
}
export function deleteCcrBlock(hash: string, principalId?: string, _now = Date.now()): boolean {
return removeEntry(buildStoreKey(hash, principalId));
const removedFromCache = removeEntry(buildStoreKey(hash, principalId));
// An explicit delete must reach the durable tier too, otherwise the next retrieve
// rehydrates the block the caller just deleted.
forgetEntry(hash, principalId ?? ANON);
return removedFromCache;
}
export function getCcrStoreStats(principalId?: string, now = Date.now()): CcrStoreStats {

View File

@@ -47,14 +47,19 @@ const DEFAULT_MIN_BLOCK_CHARS = 80;
/** Minimum number of lines a block must span to be a dedup candidate. */
const MIN_BLOCK_LINES = 3;
/**
* Request-wide ceiling for the suffix strings materialized by the exact pass.
* 32 MiB keeps ordinary sessions byte-identical while preventing line-rich inputs
* from retaining a quadratic graph of suffix copies.
* O(n²) guard for {@link findSuffixBlocks} (OOM incident): a single message with
* thousands of lines otherwise generates one full-length suffix string PER line,
* all retained at once. A real agent conversation embedding a large
* line-numbered file view (e.g. a tool result pasting a multi-thousand-line
* file back into the chat) drove ~1.7GB of live suffix strings and OOM-killed
* the 2GB heap (heap snapshot confirmed 6801 `{ block }` objects). These bound
* both the number of suffix starts scanned
* and the total bytes of retained blocks, so memory is O(budget) instead of O(n²).
* Dedup is best-effort — skipping the tail only forgoes some compression, never
* changes output correctness.
*/
const MAX_SUFFIX_WORK_CHARS = 32 * 1024 * 1024;
const SUFFIX_WORK_BUDGET_WARNING = "session-dedup: skipped (suffix work budget exceeded)";
type SuffixWorkBudget = { remaining: number };
const MAX_SUFFIX_STARTS = 2000;
const MAX_TOTAL_BLOCK_BYTES = 8 * 1024 * 1024;
// ─── hash helper (SHA-256 prefix, collision-resistant) ───────────────────────
@@ -67,24 +72,6 @@ function hashBlock(text: string): string {
// ─── suffix-block extraction ──────────────────────────────────────────────────
/**
* Reserves the characters that findSuffixBlocks() would materialize for one text.
* The scan observes line starts without splitting or constructing any suffix strings.
*/
function reserveSuffixWork(text: string, passCount: number, budget: SuffixWorkBudget): boolean {
let start = 0;
while (start <= text.length) {
const suffixChars = (text.length - start) * passCount;
if (suffixChars > budget.remaining) return false;
budget.remaining -= suffixChars;
const nextNewline = text.indexOf("\n", start);
if (nextNewline === -1) break;
start = nextNewline + 1;
}
return true;
}
/**
* For each starting line position, emit the suffix block `lines[start..end]`
* (i.e. from `start` to the end of the line array). This ensures that any
@@ -102,12 +89,19 @@ function findSuffixBlocks(
const seen = new Set<string>();
const results: Array<{ block: string; startLine: number }> = [];
for (let start = 0; start < n; start++) {
// O(n²) guard (#OOM): cap the number of suffix starts and the total retained
// block bytes so a huge message can't materialize thousands of full-length
// suffix strings at once. See MAX_SUFFIX_STARTS / MAX_TOTAL_BLOCK_BYTES.
const maxStarts = Math.min(n, MAX_SUFFIX_STARTS);
let totalBlockBytes = 0;
for (let start = 0; start < maxStarts; start++) {
const block = lines.slice(start).join("\n");
const blockLines = n - start;
if (blockLines >= MIN_BLOCK_LINES && block.length >= minBlockChars && !seen.has(block)) {
seen.add(block);
results.push({ block, startLine: start });
totalBlockBytes += block.length;
if (totalBlockBytes >= MAX_TOTAL_BLOCK_BYTES) break;
}
}
return results;
@@ -145,9 +139,7 @@ function dedupeWithinMessage(
for (const { block } of sortedBlocks) {
// Only dedup blocks that appear 2+ times in the text.
const occurrences = (
result.match(new RegExp(block.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []
).length;
const occurrences = (result.match(new RegExp(block.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length;
if (occurrences < 2) continue;
const sha = hashBlock(block);
@@ -269,7 +261,7 @@ type MessageLike = {
function processMessages(
messages: MessageLike[],
minBlockChars: number
): { messages: MessageLike[]; dedupCount: number; suffixWorkBudgetExceeded: boolean } {
): { messages: MessageLike[]; dedupCount: number } {
// Collect (msgIdx, text) for non-system string-content messages.
// For multipart, index each text part separately.
const msgTexts: Array<{ msgIdx: number; text: string }> = [];
@@ -291,24 +283,13 @@ function processMessages(
}
if (msgTexts.length === 0) {
return { messages, dedupCount: 0, suffixWorkBudgetExceeded: false };
}
// Single-message exact dedup enumerates suffixes once; cross-message dedup does so
// in both passes. Reserve the request-wide work up front so no quadratic suffix graph
// is partially materialized before the engine decides to fail open.
const suffixWorkBudget: SuffixWorkBudget = { remaining: MAX_SUFFIX_WORK_CHARS };
const passCount = msgTexts.length === 1 ? 1 : 2;
for (const { text } of msgTexts) {
if (!reserveSuffixWork(text, passCount, suffixWorkBudget)) {
return { messages, dedupCount: 0, suffixWorkBudgetExceeded: true };
}
return { messages, dedupCount: 0 };
}
const { deduped, dedupCount } = dedupMessageTexts(msgTexts, minBlockChars);
if (dedupCount === 0) {
return { messages, dedupCount: 0, suffixWorkBudgetExceeded: false };
return { messages, dedupCount: 0 };
}
const result = messages.map((msg, i) => {
@@ -337,7 +318,7 @@ function processMessages(
return { ...msg };
});
return { messages: result, dedupCount, suffixWorkBudgetExceeded: false };
return { messages: result, dedupCount };
}
// ─── schema & validation ──────────────────────────────────────────────────────
@@ -383,8 +364,7 @@ function validateSessionDedupConfig(config: Record<string, unknown>): EngineVali
const f = config["fuzzy"];
if (typeof f === "object" && f !== null) {
const fe = (f as Record<string, unknown>)["enabled"];
if (fe !== undefined && typeof fe !== "boolean")
errors.push("fuzzy.enabled must be a boolean");
if (fe !== undefined && typeof fe !== "boolean") errors.push("fuzzy.enabled must be a boolean");
} else if (typeof f !== "boolean") {
errors.push("fuzzy must be an object { enabled } or a boolean");
}
@@ -435,18 +415,10 @@ export const sessionDedupEngine: CompressionEngine = {
}
const start = performance.now();
const {
messages: exactMessages,
dedupCount,
suffixWorkBudgetExceeded,
} = processMessages(messages as MessageLike[], minBlockChars);
if (suffixWorkBudgetExceeded) {
const durationMs = Math.round(performance.now() - start);
const stats = createCompressionStats(body, body, "stacked", [], undefined, durationMs);
stats.validationWarnings = [SUFFIX_WORK_BUDGET_WARNING];
return { body, compressed: false, stats };
}
const { messages: exactMessages, dedupCount } = processMessages(
messages as MessageLike[],
minBlockChars
);
const { messages: finalMessages, fuzzyCount } = runFuzzyPass(
exactMessages,

View File

@@ -104,6 +104,19 @@ export function parseFirecrawlCreditUsage(data: unknown): FirecrawlQuota | null
};
}
export function getFirecrawlBaseUrl(connection?: Record<string, unknown>): string | null {
const envBase = process.env.FIRECRAWL_BASE_URL?.trim();
if (envBase && !envBase.includes("api.firecrawl.dev")) {
return envBase.replace(/\/+$/, "");
}
const providerData = toRecord(connection?.providerSpecificData);
const connBase = typeof connection?.baseUrl === "string" ? connection.baseUrl : providerData?.baseUrl;
if (typeof connBase === "string" && connBase.trim() && !connBase.includes("api.firecrawl.dev")) {
return connBase.trim().replace(/\/+$/, "");
}
return null;
}
export async function fetchFirecrawlQuota(
connectionId: string,
connection?: Record<string, unknown>
@@ -113,6 +126,21 @@ export async function fetchFirecrawlQuota(
return cached.quota;
}
const customBase = getFirecrawlBaseUrl(connection);
if (customBase) {
return {
used: 0,
total: 0,
percentUsed: 0,
resetAt: null,
remainingCredits: 0,
planCredits: 0,
extraCreditsInferred: 0,
overPlan: false,
limitReached: false,
};
}
const apiKey = extractFirecrawlApiKey(connection);
if (!apiKey) {
quotaCache.set(connectionId, { quota: null, fetchedAt: Date.now() });

View File

@@ -1,5 +1,6 @@
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
import { resolveWildcardAlias } from "./wildcardRouter.ts";
import { getRegisteredProviderEffortBaseModelId } from "../utils/registeredEffortVariants.ts";
type ProviderModelAliasMap = Record<string, Record<string, string>>;
type ModelAliasValue = string | { provider?: string; model?: string };
@@ -341,6 +342,48 @@ async function getActiveSyncedProvidersForModel(modelId: string) {
}
}
async function reconcileInferredProvidersWithActiveCatalog(providerIds: string[], modelId: string) {
const uniqueProviders = Array.from(new Set(providerIds));
try {
const { reconcileProvidersWithActiveSyncedCatalog } =
await import("@/lib/db/models/activeSyncedCatalog");
const reconciliations = await Promise.all(
uniqueProviders.map(async (provider) => {
const effortBaseModelId = getRegisteredProviderEffortBaseModelId(provider, modelId);
const catalogModelId = effortBaseModelId ?? modelId;
const reconciliation = await reconcileProvidersWithActiveSyncedCatalog(
[provider],
catalogModelId
);
return {
provider,
allowed: reconciliation.providers.includes(provider),
excluded: reconciliation.excludedProviders.includes(provider),
};
})
);
return {
providers: reconciliations
.filter((result) => result.allowed)
.map((result) => result.provider),
excludedProviders: reconciliations
.filter((result) => result.excluded)
.map((result) => result.provider),
};
} catch {
return {
providers: uniqueProviders,
excludedProviders: [],
};
}
}
function isTruthyEnv(value: string | undefined) {
return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim());
}
@@ -597,19 +640,22 @@ async function resolveModelByProviderInference(modelId: string, extendedContext:
};
}
}
// #FIX: synced catalogs (populated from `/v1/models` per connection) can
// claim ownership of models the provider does not actually serve (e.g. a
// `kiro` upstream briefly advertising `claude-opus-5` before it was
// vendored into the registry). Without this filter the bare-routing path
// would forward traffic to providers that 404 on the upstream call.
// Auto-discovery still wins when no static registry entry exists for the
// model id — only entries that conflict with the static catalog are dropped.
const staticCatalogProviders = MODEL_TO_PROVIDERS.get(modelId) || [];
const validatedSyncedProviders =
staticCatalogProviders.length > 0
? activeSyncedProviders.filter((p) => staticCatalogProviders.includes(p))
: activeSyncedProviders;
const providers = getInferredProvidersForModel(modelId, validatedSyncedProviders);
const candidateProviders = getInferredProvidersForModel(modelId, activeSyncedProviders);
const { providers, excludedProviders } = await reconcileInferredProvidersWithActiveCatalog(
candidateProviders,
modelId
);
if (providers.length === 0 && excludedProviders.length > 0) {
return {
provider: null,
model: modelId,
extendedContext,
errorType: "model_not_found",
errorMessage: `Model '${modelId}' is not available in the active live catalog for provider(s): ${excludedProviders.join(", ")}.`,
};
}
const nonOpenAIProviders = providers.filter((p) => p !== "openai");
// Bare model IDs from Codex CLI do not preserve OmniRoute's `cx/` prefix.
@@ -686,6 +732,25 @@ async function resolveModelByProviderInference(modelId: string, extendedContext:
activeCandidates = canonicalCandidates.filter((p) => activeProviders.has(p));
}
// An authoritative active live catalog excluded at least one static
// candidate, and none of the remaining static candidates has an active
// connection. Do not escape the live-catalog decision by selecting an
// unrelated inactive provider that happens to share the same static model id.
if (
activeProviders &&
activeProviders.size > 0 &&
activeCandidates.length === 0 &&
excludedProviders.length > 0
) {
return {
provider: null,
model: modelId,
extendedContext,
errorType: "model_not_found",
errorMessage: `Model '${modelId}' is not available in the active live catalog for provider(s): ${excludedProviders.join(", ")}.`,
};
}
// Auto-pick:
// 1. If active providers match, pick from active candidates (first active provider).
// 2. If no active providers filter applied, but canonical candidates deduplicate to 1 provider, pick it.

View File

@@ -0,0 +1,225 @@
/**
* newApiAggregatorQuotaFetcher.ts — Generalized New-API / One-API / Sub2API
* Aggregator Balance Quota Fetcher
*
* Generalizes the AgentRouter (agentrouterQuotaFetcher.ts) balance detection
* so any OpenAI/Anthropic-compatible custom node pointing at a self-hosted
* New-API / One-API / Sub2API gateway can report its balance.
*
* New-API (QuantumNous/new-api, a fork of One API) exposes:
*
* GET {base}/api/user/self
* Authorization: Bearer {systemAccessToken}
* New-Api-User: {userId}
* -> { "data": { "quota": <int> } } (raw New-API credit units)
*
* `quota_per_unit` (units per $1) defaults to 500000, overridable via
* `providerSpecificData.quotaPerUnit`.
*
* Credentials: the System Access Token + New-Api-User id are read from
* `connection.providerSpecificData.consoleApiKey` (reusing the existing generic
* field, same precedent as AgentRouter/Bailian) and
* `connection.providerSpecificData.newApiUserId` respectively.
*
* The `newApiAggregatorBalance` boolean flag in providerSpecificData must be
* `true` for the fetcher to activate — this is the opt-in toggle.
*
* Cache: in-memory TTL (60s), same pattern as sibling fetchers.
*
* Registration: this module exports fetchNewApiAggregatorQuota for dynamic
* dispatch; it does NOT self-register against a static provider key.
* Dynamic dispatch is handled by quotaPreflight.ts + quotaMonitor.ts.
*/
import type { QuotaInfo } from "./quotaPreflight.ts";
import { throttleQuotaFetch } from "./quotaFetchThrottle.ts";
import { toNumber } from "@/shared/utils/numeric";
const SELF_PATH = "/api/user/self";
// New-API-wide default: units per $1. See #6850 — can be hardcoded rather
// than fetched from /api/status on every call.
const DEFAULT_QUOTA_PER_UNIT = 500_000;
const CACHE_TTL_MS = 60_000; // 60 seconds
export interface NewApiAggregatorQuota extends QuotaInfo {
rawQuota: number;
dollarBalance: number;
limitReached: boolean;
}
interface CacheEntry {
quota: NewApiAggregatorQuota;
fetchedAt: number;
}
const quotaCache = new Map<string, CacheEntry>();
const _cacheCleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of quotaCache) {
if (now - entry.fetchedAt > CACHE_TTL_MS * 5) {
quotaCache.delete(key);
}
}
}, 5 * 60_000);
if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) {
(_cacheCleanup as { unref?: () => void }).unref?.();
}
function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
/**
* Strip trailing `/v1` (or `/v1/`) from a baseUrl so that node URLs like
* `https://host/v1` still hit `{host}/api/user/self` rather than
* `{host}/v1/api/user/self`.
*/
function stripV1Suffix(baseUrl: string): string {
return baseUrl.replace(/\/v1\/?$/, "");
}
function extractCredentials(connection?: Record<string, unknown>): {
systemAccessToken: string | null;
userId: string | null;
baseUrl: string | null;
quotaPerUnit: number;
aggregatorFlag: boolean;
} {
const providerSpecificData = toRecord(connection?.providerSpecificData);
const systemAccessToken =
typeof providerSpecificData.consoleApiKey === "string" &&
providerSpecificData.consoleApiKey.trim().length > 0
? providerSpecificData.consoleApiKey
: null;
const userId =
typeof providerSpecificData.newApiUserId === "string" &&
providerSpecificData.newApiUserId.trim().length > 0
? providerSpecificData.newApiUserId
: null;
const rawBaseUrl =
typeof providerSpecificData.baseUrl === "string" &&
providerSpecificData.baseUrl.trim().length > 0
? providerSpecificData.baseUrl.trim()
: null;
const baseUrl = rawBaseUrl ? stripV1Suffix(rawBaseUrl) : null;
const rawQuotaPerUnit = toNumber(providerSpecificData.quotaPerUnit, 0);
const quotaPerUnit = rawQuotaPerUnit > 0 ? rawQuotaPerUnit : DEFAULT_QUOTA_PER_UNIT;
const aggregatorFlag = providerSpecificData.newApiAggregatorBalance === true;
return { systemAccessToken, userId, baseUrl, quotaPerUnit, aggregatorFlag };
}
function parseNewApiAggregatorQuotaResponse(
data: unknown,
quotaPerUnit: number
): NewApiAggregatorQuota | null {
const obj = toRecord(data);
const dataObj = toRecord(obj.data);
const rawQuotaValue = "quota" in dataObj ? dataObj.quota : obj.quota;
if (rawQuotaValue === undefined) return null;
const rawQuota = toNumber(rawQuotaValue, -1);
if (rawQuota < 0) return null;
const dollarBalance = rawQuota / quotaPerUnit;
const limitReached = rawQuota <= 0;
// No known upstream "total" grant to compute a real percentage against — follow
// DeepSeek's boolean-availability precedent (0% used = has balance, 100% = exhausted).
const percentUsed = limitReached ? 1 : 0;
return {
used: percentUsed * 100,
total: 100,
percentUsed,
resetAt: null,
rawQuota,
dollarBalance,
limitReached,
};
}
/**
* Fetch current quota for a New-API / One-API / Sub2API aggregator connection.
*
* @param connectionId - Connection ID from the DB (used to key the cache)
* @param connection - Optional connection object with providerSpecificData credentials
* @returns NewApiAggregatorQuota or null if fetch fails / no credentials / not opted in
*/
export async function fetchNewApiAggregatorQuota(
connectionId: string,
connection?: Record<string, unknown>
): Promise<QuotaInfo | null> {
const cached = quotaCache.get(connectionId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.quota;
}
const { systemAccessToken, userId, baseUrl, quotaPerUnit, aggregatorFlag } =
extractCredentials(connection);
if (!aggregatorFlag) return null;
if (!systemAccessToken || !userId || !baseUrl) return null;
const url = `${baseUrl}${SELF_PATH}`;
try {
await throttleQuotaFetch();
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${systemAccessToken}`,
"New-Api-User": userId,
"Content-Type": "application/json",
Accept: "application/json",
},
signal: AbortSignal.timeout(8_000),
});
if (response.status === 401 || response.status === 403) {
quotaCache.delete(connectionId);
return null;
}
if (!response.ok) {
return null;
}
const data = await response.json();
const quota = parseNewApiAggregatorQuotaResponse(data, quotaPerUnit);
if (!quota) return null;
quotaCache.set(connectionId, { quota, fetchedAt: Date.now() });
return quota;
} catch {
return null;
}
}
/**
* Force-invalidate the cache for a connection.
*/
export function invalidateNewApiAggregatorQuotaCache(connectionId: string): void {
quotaCache.delete(connectionId);
}
/**
* Check whether a connection has opted in to New-API aggregator balance
* detection. Used by the dynamic dispatch in quotaPreflight / quotaMonitor.
*/
export function isNewApiAggregatorBalanceConnection(
connection?: Record<string, unknown>
): boolean {
const providerSpecificData = toRecord(connection?.providerSpecificData);
return providerSpecificData.newApiAggregatorBalance === true;
}

View File

@@ -11,8 +11,10 @@
* API (#6846 Phase 1). Every other provider still gets zero behavior change; the
* whole path is a no-op unless an entry (or a resolved override, see below) exists.
*
* Wired as a pre-schedule gate in `withRateLimit` (rateLimitManager.ts). Bottleneck
* still applies on top — this only adds a floor for header-less providers.
* Composed into the rolling lease gate in `withRateLimit` (rateLimitManager.ts).
* The exported acquire helpers remain available for provider-specific callers
* and tests, while the main request path acquires global and provider scopes
* atomically.
*/
import { SlidingWindowLimiter, type RateLimitWindow } from "./slidingWindowLimiter.ts";

View File

@@ -8,7 +8,11 @@
* Alertas deduplicados por sessão (janela de 5min).
*/
import { registerQuotaFetcher, type QuotaFetcher } from "./quotaPreflight.ts";
import {
registerQuotaFetcher,
resolveDynamicQuotaFetcher,
type QuotaFetcher,
} from "./quotaPreflight.ts";
import { getSessionInfo } from "./sessionManager.ts";
export { registerQuotaFetcher };
@@ -199,7 +203,12 @@ function scheduleNextPoll(sessionId: string, intervalMs: number): void {
}
try {
const fetcher = quotaFetcherRegistry.get(provider);
let fetcher = quotaFetcherRegistry.get(provider);
// Dynamic fallback: for compatible-provider connections with the
// aggregator flag + feature flag, use the generalized New-API fetcher.
if (!fetcher && current.connectionSnapshot) {
fetcher = resolveDynamicQuotaFetcher(provider, current.connectionSnapshot);
}
if (!fetcher) {
current.status = current.lastQuotaPercent === null ? "idle" : current.status;
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);

View File

@@ -18,6 +18,10 @@
* it — once you invoke preflight, it runs the fetcher and evaluates.
*/
import { isCompatibleProviderConnectionId } from "@/shared/utils/compatibleProviderId";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { fetchNewApiAggregatorQuota } from "./newApiAggregatorQuotaFetcher.ts";
export interface PreflightQuotaResult {
proceed: boolean;
reason?: string;
@@ -231,6 +235,29 @@ export function evaluateQuotaCutoff(
return quotaPercentCutoffResult(quota, thresholds);
}
/**
* Resolve a dynamic quota fetcher for compatible-provider connections that
* opt in to New-API / One-API / Sub2API aggregator balance detection.
* Returns the fetcher when both the feature flag and the connection's
* aggregator flag are true; otherwise returns undefined.
*/
export function resolveDynamicQuotaFetcher(
provider: string,
connection: Record<string, unknown>
): QuotaFetcher | undefined {
// Dynamic dispatch only for compatible-provider connection IDs
if (!isCompatibleProviderConnectionId(provider)) return undefined;
// Connection must opt in via providerSpecificData.newApiAggregatorBalance
const psd = connection?.providerSpecificData as Record<string, unknown> | undefined;
if (!psd || psd.newApiAggregatorBalance !== true) return undefined;
// Feature flag must be enabled
if (!isFeatureFlagEnabled("NEWAPI_AGGREGATOR_BALANCE")) return undefined;
return fetchNewApiAggregatorQuota;
}
export async function preflightQuota(
provider: string,
connectionId: string,
@@ -239,9 +266,14 @@ export async function preflightQuota(
): Promise<PreflightQuotaResult> {
// No legacy enable-flag gate here — the caller decides when to invoke us
// (see file-level docstring). When there's no fetcher we proceed silently.
const fetcher = getQuotaFetcher(provider);
let fetcher = getQuotaFetcher(provider);
if (!fetcher) {
return { proceed: true };
// Dynamic fallback: for compatible-provider connections with the
// aggregator flag + feature flag, use the generalized New-API fetcher.
fetcher = resolveDynamicQuotaFetcher(provider, connection);
if (!fetcher) {
return { proceed: true };
}
}
let quota: QuotaInfo | null = null;

View File

@@ -13,7 +13,12 @@ import { parseRetryAfterFromBody } from "./accountFallback.ts";
import { getAntigravityQuotaFamily } from "./antigravityQuotaFamily.ts";
import { getProviderCategory } from "../config/providerRegistry.ts";
import { getCodexRateLimitKey } from "../executors/codex.ts";
import { awaitProviderDefaultSlot, setProviderQuotaOverrides } from "./providerDefaultRateLimit.ts";
import {
getProviderDefaultRateLimit,
setProviderQuotaOverrides,
} from "./providerDefaultRateLimit.ts";
import { keyContainsConnection, RollingRpmGate } from "./rollingRpmGate.ts";
import { toNumber } from "@/shared/utils/numeric";
import {
DEFAULT_RESILIENCE_SETTINGS,
resolveResilienceSettings,
@@ -39,25 +44,36 @@ interface LearnedLimitEntry {
interface LimiterUpdateSettings {
maxConcurrent?: number | null;
minTime: number;
reservoir?: number | null;
reservoirRefreshAmount?: number | null;
reservoirRefreshInterval?: number | null;
}
type JsonRecord = Record<string, unknown>;
type QueueTimeoutReason = "local-queue" | "upstream-cooldown";
function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toNumber(value: unknown, fallback = 0): number {
const parsed =
typeof value === "number"
? value
: typeof value === "string" && value.trim().length > 0
? Number(value)
: Number.NaN;
return Number.isFinite(parsed) ? parsed : fallback;
function createQueueTimeoutError(
provider: string,
model: string | null,
maxWaitMs: number,
reason: QueueTimeoutReason = "local-queue",
cause?: unknown
) {
const target = model ? `${provider}/${model}` : provider;
const message =
reason === "upstream-cooldown"
? `Request dropped after waiting ${maxWaitMs}ms for an upstream rate-limit cooldown for ${target}. ` +
`The provider cooldown outlasted OmniRoute's local wait budget; this is not local queue saturation.`
: `Request dropped after exceeding the local rate-limit queue budget maxWaitMs (${maxWaitMs}ms) for ` +
`${target} — this is OmniRoute's request queue ` +
`(resilienceSettings.requestQueue.maxWaitMs), not an upstream timeout. Raise it in ` +
`Settings → Resilience if this is queue saturation rather than a slow provider.`;
const queueErr = new Error(message, cause === undefined ? undefined : { cause }) as Error & {
code?: string;
};
queueErr.code = "RATE_LIMIT_QUEUE_TIMEOUT";
return queueErr;
}
function isNodeTestRunnerChild(): boolean {
@@ -101,16 +117,15 @@ let initialized = false;
let currentRequestQueueSettings: RequestQueueSettings = DEFAULT_RESILIENCE_SETTINGS.requestQueue;
// Watchdog: detect Bottleneck limiters that are wedged (queue has work, but no
// jobs are dispatched). When the reservoir/refresh state desyncs from reality,
// this catches it and force-resets so traffic isn't stuck forever.
// jobs are dispatched). RPM admission happens before Bottleneck, so a queued
// Bottleneck job with no active work is a concurrency scheduler failure.
const lastDispatchAt = new Map<string, number>();
let nextJobTraceId = 1;
let watchdogInterval: ReturnType<typeof setInterval> | null = null;
const WATCHDOG_INTERVAL_MS = 30_000;
// Threshold has to exceed any *legitimate* gap between dispatches:
// - default reservoirRefreshInterval is 60s
// - adaptive minTime can climb to ~60s for 1-RPM providers (see updateFromHeaders)
// 120s gives a 2× margin against both, while still catching the actual wedge
// case we observed (queue stalled for 3+ minutes with no progress).
// Threshold has to exceed any legitimate gap caused by adaptive minTime while
// still catching the actual wedge case we observed (queue stalled for 3+
// minutes with no progress).
const WEDGE_THRESHOLD_MS = 120_000;
/**
@@ -128,19 +143,10 @@ function isAutoEnableActive(settings: RequestQueueSettings): boolean {
return settings.autoEnableApiKeyProviders;
}
// Sentinels for "no rate limit" / effectively infinite capacity. The reservoir
// value uses Number.MAX_SAFE_INTEGER so the bucket can never realistically be
// exhausted; maxConcurrent uses a smaller-but-still-vast ceiling since
// Bottleneck tracks concurrent jobs in memory and an unbounded number would
// risk internal counter overflow under sustained pressure.
const EFFECTIVELY_INFINITE = Number.MAX_SAFE_INTEGER;
// Bottleneck handles concurrency and pacing. RPM is enforced by the rolling
// lease limiter above rather than by a fixed-window reservoir.
const EFFECTIVELY_INFINITE_CONCURRENCY = 1000;
// Resolve an RPM override. 0 or missing means "infinite" (no rate cap).
function resolveRpm(override: number | undefined | null): number {
return typeof override === "number" && override > 0 ? override : EFFECTIVELY_INFINITE;
}
// Resolve a minTime override. 0 or missing means "no minimum gap".
function resolveMinTime(override: number | undefined | null): number {
return typeof override === "number" && override > 0 ? override : 0;
@@ -152,15 +158,9 @@ function resolveMaxConcurrent(override: number | undefined | null): number {
}
function buildLimiterDefaults() {
// 0 or missing values mean "infinite" / no rate limit applies. This treats
// the global request-queue settings the same way per-connection overrides
// are interpreted (see resolveRpm / resolveMinTime / resolveMaxConcurrent).
return {
maxConcurrent: resolveMaxConcurrent(currentRequestQueueSettings.concurrentRequests),
minTime: resolveMinTime(currentRequestQueueSettings.minTimeBetweenRequestsMs),
reservoir: resolveRpm(currentRequestQueueSettings.requestsPerMinute),
reservoirRefreshAmount: resolveRpm(currentRequestQueueSettings.requestsPerMinute),
reservoirRefreshInterval: 60 * 1000,
};
}
@@ -271,12 +271,18 @@ function reconcileEnabledConnections(
function watchdogTick() {
const now = Date.now();
rpmGate.cleanupExpired(now);
// Clean up idle limiters that haven't been used recently
for (const [key, limiter] of Array.from(limiters)) {
const lastUsed = limiterLastUsed.get(key) ?? 0;
if (now - lastUsed > INACTIVE_LIMITER_MS) {
const counts = limiter.counts();
if (counts.QUEUED === 0 && counts.RUNNING === 0 && counts.EXECUTING === 0) {
if (
counts.RECEIVED === 0 &&
counts.QUEUED === 0 &&
counts.RUNNING === 0 &&
counts.EXECUTING === 0
) {
limiters.delete(key);
lastDispatchAt.delete(key);
limiterLastUsed.delete(key);
@@ -289,7 +295,10 @@ function watchdogTick() {
}
for (const [key, limiter] of Array.from(limiters)) {
const counts = limiter.counts();
if (counts.QUEUED === 0) continue;
// RECEIVED-only work is still active and must not be evicted. Once a job
// is stably queued, Bottleneck reports it in QUEUED with RECEIVED=0; that
// is the state the wedge detector is designed to recover.
if (counts.RECEIVED > 0 || counts.QUEUED === 0) continue;
if (counts.RUNNING > 0 || counts.EXECUTING > 0) continue;
const lastDispatch = lastDispatchAt.get(key);
// No heartbeat yet → seed it and skip this tick. Prevents false wedge
@@ -302,7 +311,7 @@ function watchdogTick() {
if (stalledMs < WEDGE_THRESHOLD_MS) continue;
warnRateLimit(
`🚨 [RATE-LIMIT] WEDGED: ${key} queued=${counts.QUEUED} running=0 executing=0 stalled=${stalledMs}ms — force-resetting`
`🚨 [RATE-LIMIT] WEDGED: ${key} received=${counts.RECEIVED} queued=${counts.QUEUED} running=0 executing=0 stalled=${stalledMs}ms — force-resetting`
);
// Live incident (log id 1784465227489-a2cbc0): disconnect() releases the
// heartbeat timer but does NOT reject the QUEUED jobs already sitting on
@@ -353,15 +362,38 @@ export function stopRateLimitWatchdog(): void {
watchdogInterval = null;
}
export function __installLimiterForTests(
provider: string,
connectionId: string,
limiter: Bottleneck,
model = null
): void {
const key = getLimiterKey(provider, connectionId, model);
limiters.set(key, limiter);
lastDispatchAt.set(key, Date.now());
limiterLastUsed.set(key, Date.now());
}
export function __runRateLimitWatchdogForTests(): void {
watchdogTick();
}
export function __getLimiterForTests(provider: string, connectionId: string, model = null) {
return getLimiter(provider, connectionId, model);
}
export function __setLastDispatchAtForTests(
provider: string,
connectionId: string,
model: string | null,
timestamp: number
): void {
lastDispatchAt.set(getLimiterKey(provider, connectionId, model), timestamp);
}
function evictWedgeLimiter(key: string, limiter: Bottleneck): void {
if (limiters.get(key) !== limiter) return;
limiters.delete(key);
lastDispatchAt.delete(key);
limiterLastUsed.delete(key);
trackAsyncOperation(limiter.disconnect());
trackAsyncOperation(
limiter.stop({ dropWaitingJobs: true, dropErrorMessage: "rate-limit-watchdog-wedge-reset" })
);
evictLimiterAndDropQueued(key, limiter, "rate-limit-watchdog-wedge-reset");
}
/**
@@ -422,13 +454,10 @@ export async function initializeRateLimits() {
// budget + concurrency cap (nvidia today). No-op for every provider without
// an entry in either providerQuotaOverrides or PROVIDER_DEFAULT_RATE_LIMITS.
setProviderQuotaOverrides(resilience.providerQuotaOverrides);
const { explicitCount, autoCount } = reconcileEnabledConnections(
connections as unknown[],
currentRequestQueueSettings
);
await updateAllLimiterSettings();
// Load per-connection rate limit overrides
// Load per-connection rate limit overrides before reconciliation can create
// any limiter. The RPM gate reads these overrides at admission time, and
// Bottleneck still needs the non-RPM connection settings immediately.
connectionRateLimitOverrides.clear();
for (const conn of connections as Array<Record<string, unknown>>) {
const overrides = conn.rateLimitOverrides;
@@ -437,6 +466,12 @@ export async function initializeRateLimits() {
}
}
const { explicitCount, autoCount } = reconcileEnabledConnections(
connections as unknown[],
currentRequestQueueSettings
);
updateAllLimiterSettings();
if (explicitCount > 0 || autoCount > 0) {
logRateLimit(
`🛡️ [RATE-LIMIT] Loaded ${explicitCount} explicit + ${autoCount} auto-enabled protection(s)`
@@ -474,22 +509,14 @@ export function enableRateLimitProtection(connectionId) {
*/
export function disableRateLimitProtection(connectionId) {
enabledConnections.delete(connectionId);
// Evict limiters for this connection from the cache. Do NOT call limiter.stop() —
// it permanently rejects future .schedule() calls with "This limiter has been stopped",
// and in-flight requests holding a reference to the old instance would fail with 502.
// Call disconnect() (not stop()) to release Bottleneck's internal heartbeat timer
// without permanently poisoning the instance for any remaining in-flight jobs.
// Eviction-only would leak the heartbeat timer until GC; disconnect() releases it
// synchronously so the runtime memory footprint stays flat under heavy connection churn.
// .stop() is reserved exclusively for SIGTERM/SIGINT shutdown (see shutdownLimiters).
// Drop queued jobs before evicting the limiter. Otherwise disconnect() leaves
// callers waiting on an instance that is no longer reachable from the cache.
for (const [key, limiter] of Array.from(limiters)) {
if (key.includes(connectionId)) {
limiters.delete(key);
lastDispatchAt.delete(key);
limiterLastUsed.delete(key);
trackAsyncOperation(limiter.disconnect());
if (keyContainsConnection(key, connectionId)) {
evictLimiterAndDropQueued(key, limiter, "rate-limit-connection-disabled");
}
}
rpmGate.clearConnection(connectionId);
}
/**
@@ -517,13 +544,11 @@ export function refreshConnectionRateLimits(connectionId, overrides) {
}
// Evict limiters referencing this connection so they get recreated on next use
for (const [key, limiter] of Array.from(limiters)) {
if (key.includes(connectionId)) {
limiters.delete(key);
lastDispatchAt.delete(key);
limiterLastUsed.delete(key);
trackAsyncOperation(limiter.disconnect());
if (keyContainsConnection(key, connectionId)) {
evictLimiterAndDropQueued(key, limiter, "rate-limit-settings-refresh");
}
}
rpmGate.clearConnection(connectionId);
}
/**
@@ -546,6 +571,15 @@ function getLimiterKey(provider, connectionId, model = null) {
return `${provider}:${connectionId}`;
}
const rpmGate = new RollingRpmGate({
getGlobalRpm: () => currentRequestQueueSettings.requestsPerMinute,
getProviderWindow: getProviderDefaultRateLimit,
getConnectionRpm: (connectionId) => connectionRateLimitOverrides.get(connectionId)?.rpm,
getLimiterKey,
createQueueTimeoutError: (provider, model, maxWaitMs, reason) =>
createQueueTimeoutError(provider, model, maxWaitMs, reason),
});
function getLimiter(provider, connectionId, model = null) {
const key = getLimiterKey(provider, connectionId, model);
@@ -554,23 +588,14 @@ function getLimiter(provider, connectionId, model = null) {
const overrides = connectionRateLimitOverrides.get(connectionId);
if (overrides) {
// 0 (or missing) means "no override — fall through to buildLimiterDefaults()".
// Without this guard, an rpm of 0 sets reservoir=0, which Bottleneck treats
// as "depleted" and blocks ALL requests indefinitely. Treating 0 as "use
// default" lets users effectively disable per-connection limits without
// globally raising the system default.
if (typeof overrides.maxConcurrent === "number" && overrides.maxConcurrent > 0) {
defaults.maxConcurrent = overrides.maxConcurrent;
}
if (typeof overrides.minTime === "number" && overrides.minTime > 0) {
defaults.minTime = overrides.minTime;
}
if (typeof overrides.rpm === "number" && overrides.rpm > 0) {
defaults.reservoir = overrides.rpm;
defaults.reservoirRefreshAmount = overrides.rpm;
defaults.reservoirRefreshInterval = 60 * 1000;
}
// TODO: TPM/TPD integration — requires a token-bucket vs request-bucket
// separation (Bottleneck's reservoir is request-count, not token-count).
// separation. RPM is handled by the rolling lease gate below.
// When added, treat 0/missing the same way: fall through to system default.
}
const limiter = new Bottleneck({
@@ -593,6 +618,15 @@ function getLimiter(provider, connectionId, model = null) {
return limiters.get(key);
}
function evictLimiterAndDropQueued(key: string, limiter: Bottleneck, reason: string): void {
if (limiters.get(key) === limiter) {
limiters.delete(key);
lastDispatchAt.delete(key);
limiterLastUsed.delete(key);
}
trackAsyncOperation(limiter.stop({ dropWaitingJobs: true, dropErrorMessage: reason }));
}
/**
* Acquire a rate limit slot before making a request.
* If rate limiting is disabled for this connection, returns immediately.
@@ -604,32 +638,7 @@ function getLimiter(provider, connectionId, model = null) {
* @param {AbortSignal} signal - Optional abort signal to cancel waiting
* @returns {Promise<unknown>} Result of fn()
*/
async function getQueueHealthSnapshot(key: string, limiter: Bottleneck) {
const counts = limiter.counts();
let reservoirRemaining: number | null = null;
try {
reservoirRemaining = await limiter.currentReservoir();
} catch {
// Snapshot logging must never affect request handling.
}
const lastDispatch = lastDispatchAt.get(key);
return {
queued: counts.QUEUED,
running: counts.RUNNING,
executing: counts.EXECUTING,
reservoirRemaining,
lastDispatchAgeMs: lastDispatch ? Date.now() - lastDispatch : null,
};
}
export async function withRateLimit(
provider,
connectionId,
model,
fn,
signal = null,
retryAfterWedge = true
) {
export async function withRateLimit(provider, connectionId, model, fn, signal = null) {
if (!enabledConnections.has(connectionId)) {
return fn();
}
@@ -642,18 +651,20 @@ export async function withRateLimit(
throw err;
}
// Proactive sliding-window fallback for header-less providers with a declared cap
// (Fase 8.2). No-op unless PROVIDER_DEFAULT_RATE_LIMITS has an entry for `provider`.
await awaitProviderDefaultSlot(
const maxWaitMs = currentRequestQueueSettings.maxWaitMs;
const queueStartedAt = Date.now();
const rpmLease = await rpmGate.acquire(
provider,
connectionId,
model,
signal,
currentRequestQueueSettings.maxWaitMs
maxWaitMs,
queueStartedAt
);
const limiter = getLimiter(provider, connectionId, model);
const maxWaitMs = currentRequestQueueSettings.maxWaitMs;
const scheduleOpts = maxWaitMs && maxWaitMs > 0 ? { expiration: maxWaitMs } : {};
const key = getLimiterKey(provider, connectionId, model);
const jobId = `${key}:job-${nextJobTraceId++}`;
const scheduleOpts = { id: jobId };
// Issue #6593: opt-in admission cap — fast-reject before Bottleneck's
// schedule() (and before any downstream compression/prompt work runs) when
@@ -664,30 +675,77 @@ export async function withRateLimit(
model ? `${provider}/${model}` : provider
);
if (admissionErr) {
rpmLease?.release();
logRateLimit(
`🚧 [RATE-LIMIT] ${getLimiterKey(provider, connectionId, model)} — queue full, rejecting fast (maxQueueDepth=${currentRequestQueueSettings.maxQueueDepth})`
);
throw admissionErr;
}
let dispatched = false;
let queueExpired = false;
let dispatchCancelled = false;
let queueTimer: ReturnType<typeof setTimeout> | undefined;
const remainingWaitMs =
maxWaitMs > 0 ? Math.max(1, maxWaitMs - (Date.now() - queueStartedAt)) : 0;
const queueTimeoutPromise =
remainingWaitMs > 0
? new Promise<never>((_, reject) => {
queueTimer = setTimeout(() => {
if (dispatched) return;
queueExpired = true;
logRateLimit(
`⏰ [RATE-LIMIT] ${key} — job exceeded ${Math.ceil(maxWaitMs / 1000)}s queue wait budget, dropping`
);
reject(new Error("rate-limit-queue-timeout"));
}, remainingWaitMs);
})
: null;
const scheduled = limiter.schedule(scheduleOpts, async () => {
if (queueExpired) {
throw createQueueTimeoutError(provider, model, maxWaitMs);
}
if (dispatchCancelled) {
const error = new Error("The operation was aborted before limiter dispatch");
error.name = "AbortError";
throw error;
}
if (signal?.aborted) {
const error = new Error("The operation was aborted before limiter dispatch");
error.name = "AbortError";
throw error;
}
dispatched = true;
if (queueTimer) clearTimeout(queueTimer);
return fn();
});
try {
if (signal) {
let abortListener: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
const onAbort = () => {
const reason = signal.reason;
// Preserve native Error reasons (including AbortController's
// read-only DOMException) instead of mutating or wrapping them.
// Reject before evicting the queued job so the caller observes its
// abort reason instead of Bottleneck's internal drop error.
if (reason instanceof Error) {
reject(reason);
return;
} else {
const err = new Error(
typeof reason === "string" ? reason : "The operation was aborted"
);
err.name = "AbortError";
if (reason !== undefined) {
(err as Error & { cause?: unknown }).cause = reason;
}
reject(err);
}
const err = new Error(typeof reason === "string" ? reason : "The operation was aborted");
err.name = "AbortError";
if (reason !== undefined) {
(err as Error & { cause?: unknown }).cause = reason;
if (!dispatched) {
dispatchCancelled = true;
if (queueTimer) clearTimeout(queueTimer);
// Leave the cancelled job in Bottleneck so queued peers are not dropped.
// Its scheduled callback will consume one queue turn and exit before fn().
}
reject(err);
};
if (signal.aborted) {
onAbort();
@@ -698,51 +756,30 @@ export async function withRateLimit(
});
try {
return await Promise.race([limiter.schedule(scheduleOpts, fn), abortPromise]);
const races: Promise<unknown>[] = [scheduled, abortPromise];
if (queueTimeoutPromise) races.push(queueTimeoutPromise);
return await Promise.race(races);
} finally {
if (abortListener) {
signal.removeEventListener("abort", abortListener);
}
}
} else {
return await limiter.schedule(scheduleOpts, fn);
return await (queueTimeoutPromise
? Promise.race([scheduled, queueTimeoutPromise])
: scheduled);
}
} catch (err) {
// Bottleneck's raw `This job timed out after <maxWaitMs> ms.` is
// indistinguishable from an upstream gateway timeout, so it leaks into 502
// bodies / call-log `last_error` and gets misdiagnosed as a provider outage
// (#4165). Rewrite it into a clear, OmniRoute-owned error (knob named,
// upstream disclaimed, original kept as `cause`, `code` for classification).
// If the limiter is idle with capacity after the expiry, the scheduler is wedged.
// Reset it and retry this never-dispatched function once on a fresh limiter.
if (err?.message?.includes("This job timed out")) {
const key = getLimiterKey(provider, connectionId, model);
const queueState = await getQueueHealthSnapshot(key, limiter);
logRateLimit(
`⏰ [RATE-LIMIT] ${key} — job expired after ${Math.ceil((maxWaitMs || 0) / 1000)}s in queue, dropping`
);
const limiterIsWedged =
retryAfterWedge &&
queueState.running === 0 &&
queueState.executing === 0 &&
typeof queueState.reservoirRemaining === "number" &&
queueState.reservoirRemaining > 0 &&
typeof queueState.lastDispatchAgeMs === "number" &&
queueState.lastDispatchAgeMs >= Math.max(1, maxWaitMs || 0);
if (limiterIsWedged) {
logRateLimit(`🔄 [RATE-LIMIT] ${key} — recovering idle limiter after queue expiry`);
evictWedgeLimiter(key, limiter);
return withRateLimit(provider, connectionId, model, fn, signal, false);
}
const queueErr = new Error(
`Request dropped after exceeding the local rate-limit queue budget maxWaitMs (${maxWaitMs}ms) for ` +
`${model ? `${provider}/${model}` : provider} — this is OmniRoute's request queue ` +
`(resilienceSettings.requestQueue.maxWaitMs), not an upstream timeout. Raise it in ` +
`Settings → Resilience if this is queue saturation rather than a slow provider.`,
if (queueTimer) clearTimeout(queueTimer);
if (!dispatched) rpmLease?.release();
if (err?.message === "rate-limit-upstream-429") {
const rateLimitErr = new Error(
`Request dropped while the ${provider} connection was under an upstream rate-limit cooldown`,
{ cause: err }
) as Error & { code?: string };
queueErr.code = "RATE_LIMIT_QUEUE_TIMEOUT";
throw queueErr;
) as Error & { code?: string; status?: number };
rateLimitErr.code = "RATE_LIMIT_UPSTREAM_429";
rateLimitErr.status = 429;
throw rateLimitErr;
}
// The watchdog's stop({ dropWaitingJobs: true }) wedge-recovery (above) rejects
// queued jobs with this exact message. Rewrite it the same way as the timeout
@@ -759,6 +796,9 @@ export async function withRateLimit(
wedgeErr.code = "RATE_LIMIT_QUEUE_WEDGED";
throw wedgeErr;
}
if (err?.message === "rate-limit-queue-timeout") {
throw createQueueTimeoutError(provider, model, maxWaitMs);
}
throw err;
}
}
@@ -802,20 +842,12 @@ export function updateFromHeaders(provider, connectionId, headers, status, model
`🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s, dropping ${counts.QUEUED} queued request(s)`
);
// Evict from the cache so follow-up learning from the same error body
// can materialize a fresh limiter immediately. Do NOT call limiter.stop() —
// it permanently rejects future .schedule() calls with "This limiter has been stopped".
// In-flight requests holding a reference to the evicted instance will fail (they
// were already going to fail — the 429 means the API rejected them), but future
// requests will get a fresh Bottleneck instance via getLimiter().
// Call disconnect() (not stop()) to release Bottleneck's internal heartbeat timer
// without permanently poisoning the instance for any remaining in-flight jobs.
// Without disconnect() here, every 429 leaks a heartbeat timer until GC reclaims
// the abandoned Bottleneck; under sustained quota pressure that is a real leak.
limiters.delete(limiterKey);
lastDispatchAt.delete(limiterKey);
limiterLastUsed.delete(limiterKey);
trackAsyncOperation(limiter.disconnect());
rpmGate.block(provider, connectionId, model, retryAfterMs);
// Evict from the cache before stopping so follow-up requests get a fresh
// instance. Stopping the unreachable instance rejects its queued jobs and
// releases its heartbeat without poisoning the replacement limiter.
evictLimiterAndDropQueued(limiterKey, limiter, "rate-limit-upstream-429");
return;
}
@@ -830,28 +862,31 @@ export function updateFromHeaders(provider, connectionId, headers, status, model
// Normal response — update limiter from headers
if (!isNaN(limit) && limit > 0) {
const resetMs = parseResetTime(resetStr) || 60000;
// Calculate optimal minTime from RPM limit
const minTime = Math.max(0, Math.floor(60000 / limit) - 10); // Small buffer
const updates: LimiterUpdateSettings = { minTime };
const resetMs = parseResetTime(resetStr) || 60000;
// If remaining is low (< 10% of limit), set reservoir to throttle immediately
// Keep adaptive pacing from response headers, but do not mutate an RPM
// reservoir. RPM admission is enforced by the rolling lease gate.
if (!isNaN(remaining)) {
if (remaining < limit * 0.1) {
updates.reservoir = remaining;
updates.reservoirRefreshAmount = limit;
updates.reservoirRefreshInterval = resetMs;
rpmGate.learnHeaderWindow(
provider,
connectionId,
model,
remaining,
resetMs,
Date.now() + resetMs
);
logRateLimit(
`⚠️ [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)}${remaining}/${limit} remaining, throttling`
);
} else if (remaining > limit * 0.5) {
// Plenty of headroom — relax the limiter
updates.minTime = 0;
updates.reservoir = null;
updates.reservoirRefreshAmount = null;
updates.reservoirRefreshInterval = null;
rpmGate.clearLearnedHeaderWindow(provider, connectionId, model);
}
}
@@ -984,6 +1019,8 @@ export async function __resetRateLimitManagerForTests() {
}
limiters.clear();
enabledConnections.clear();
connectionRateLimitOverrides.clear();
rpmGate.reset();
initialized = false;
lastDispatchAt.clear();
limiterLastUsed.clear();
@@ -1088,17 +1125,10 @@ export function updateFromResponseBody(provider, connectionId, responseBody, sta
const { retryAfterMs, reason } = parseRetryAfterFromBody(responseBody);
if (retryAfterMs && retryAfterMs > 0) {
const limiter = getLimiter(provider, connectionId, model);
getLimiter(provider, connectionId, model);
logRateLimit(
`🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — body-parsed retry: ${Math.ceil(retryAfterMs / 1000)}s (${reason})`
);
trackAsyncOperation(
applyLimiterSettings(limiter, {
reservoir: 0,
reservoirRefreshAmount: 60,
reservoirRefreshInterval: retryAfterMs,
})
);
rpmGate.block(provider, connectionId, model, retryAfterMs);
}
}

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