mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 08:12:20 +03:00
Compare commits
33 Commits
green/8728
...
fix/9536-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94f5fe59fa | ||
|
|
5f471181fa | ||
|
|
ebdbe3a38f | ||
|
|
7f36b192f0 | ||
|
|
1e55fbd20b | ||
|
|
7bb4bfc4fb | ||
|
|
0e1f40ed1f | ||
|
|
2a94cbfe14 | ||
|
|
813dbb6e03 | ||
|
|
d69f521491 | ||
|
|
a598fbb090 | ||
|
|
714a315a1a | ||
|
|
274514405f | ||
|
|
91bb6aa619 | ||
|
|
404554caeb | ||
|
|
8e27f5ec8d | ||
|
|
ece486dc38 | ||
|
|
f2e36ad0ce | ||
|
|
ba0a0751c4 | ||
|
|
b0cfc3d31c | ||
|
|
a63940199f | ||
|
|
bd4407cb64 | ||
|
|
9dc0c6881a | ||
|
|
ce6faa44e5 | ||
|
|
2d617325e7 | ||
|
|
607bccb6d6 | ||
|
|
a4fbdbffac | ||
|
|
5ea43c7a9d | ||
|
|
8fdb67f1d3 | ||
|
|
53c8016d53 | ||
|
|
b553ac4d14 | ||
|
|
0720305b38 | ||
|
|
bed6e2b85a |
@@ -0,0 +1 @@
|
||||
- **docs:** add management authentication terminology guide ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786))
|
||||
41
.claude/worktrees/feat-7786/docs/guides/MANAGEMENT-AUTH.md
Normal file
41
.claude/worktrees/feat-7786/docs/guides/MANAGEMENT-AUTH.md
Normal 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.
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(infra):** add systemd autostart unit for Linux ([#8635](https://github.com/diegosouzapw/OmniRoute/issues/8635))
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ coverage
|
||||
# Runtime data and logs
|
||||
data
|
||||
logs
|
||||
.sandbox
|
||||
|
||||
# Local env files (inject at runtime via --env-file or -e)
|
||||
.env
|
||||
|
||||
6
.env.devin-bridge.example
Normal file
6
.env.devin-bridge.example
Normal 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
|
||||
30
.env.example
30
.env.example
@@ -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
5
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
4
@omniroute/opencode-plugin/package-lock.json
generated
4
@omniroute/opencode-plugin/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
317
@omniroute/opencode-plugin/tests/model-allowlist.test.ts
Normal file
317
@omniroute/opencode-plugin/tests/model-allowlist.test.ts
Normal 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");
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
- **docs:** add low-memory/small VPS optimization guide ([#8237](https://github.com/diegosouzapw/OmniRoute/issues/8237))
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
- feat(copilot): add approval gate for runOmniRouteCli commands (#8461)
|
||||
1
changelog.d/features/9031-regolo-ai-provider.md
Normal file
1
changelog.d/features/9031-regolo-ai-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** add Regolo AI OpenAI-compatible provider ([#9031](https://github.com/diegosouzapw/OmniRoute/issues/9031))
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(db):** add provider-scoped model aliases that survive rediscovery ([#9068](https://github.com/diegosouzapw/OmniRoute/issues/9068))
|
||||
1
changelog.d/features/9243-forwarded-header-budget-env.md
Normal file
1
changelog.d/features/9243-forwarded-header-budget-env.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat: make forwarded upstream response-header budget configurable via env var (#9243)
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
1
changelog.d/fixes/9159-fix.plan.md
Normal file
1
changelog.d/fixes/9159-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(management): authorize mcp:connect-only keys on loopback/LAN when requireLogin is enabled (#9159)
|
||||
1
changelog.d/fixes/9259-rolling-rpm-leases.md
Normal file
1
changelog.d/fixes/9259-rolling-rpm-leases.md
Normal 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.
|
||||
1
changelog.d/fixes/9491-port-3005-auth-redirect-login.md
Normal file
1
changelog.d/fixes/9491-port-3005-auth-redirect-login.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(auth):** redirect active sessions from /login by checking the session cookie before showing the login form. (thanks @DaDecky)
|
||||
1
changelog.d/fixes/9536-usage-misreporting.md
Normal file
1
changelog.d/fixes/9536-usage-misreporting.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)
|
||||
2
changelog.d/fixes/ghe-copilot-oauth-lifecycle.md
Normal file
2
changelog.d/fixes/ghe-copilot-oauth-lifecycle.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 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_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."
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
57
docker/devin-bridge/Dockerfile
Normal file
57
docker/devin-bridge/Dockerfile
Normal 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"]
|
||||
218
docker/devin-bridge/compose.yml
Normal file
218
docker/devin-bridge/compose.yml
Normal 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: {}
|
||||
229
docker/devin-bridge/mock-devin.mjs
Executable file
229
docker/devin-bridge/mock-devin.mjs
Executable 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" } });
|
||||
}
|
||||
});
|
||||
130
docker/devin-bridge/network-guard/policy.mjs
Normal file
130
docker/devin-bridge/network-guard/policy.mjs
Normal 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" };
|
||||
}
|
||||
136
docker/devin-bridge/network-guard/proxy.mjs
Normal file
136
docker/devin-bridge/network-guard/proxy.mjs
Normal 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);
|
||||
}
|
||||
27
docker/devin-bridge/run-claude-e2e.sh
Executable file
27
docker/devin-bridge/run-claude-e2e.sh
Executable 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
|
||||
52
docker/devin-bridge/run-claude-live-e2e.sh
Normal file
52
docker/devin-bridge/run-claude-live-e2e.sh
Normal 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'
|
||||
135
docker/devin-bridge/run-contract.mjs
Normal file
135
docker/devin-bridge/run-contract.mjs
Normal 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
181
docs/DEVIN_CLAUDE_BRIDGE.md
Normal 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.
|
||||
115
docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md
Normal file
115
docs/DEVIN_CLAUDE_BRIDGE_PROGRESS.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
>
|
||||
|
||||
252
docs/superpowers/plans/2026-07-27-devin-claude-bridge.md
Normal file
252
docs/superpowers/plans/2026-07-27-devin-claude-bridge.md
Normal 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"
|
||||
```
|
||||
134
docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md
Normal file
134
docs/superpowers/specs/2026-07-27-devin-claude-bridge-design.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
11
open-sse/config/providers/registry/anyapi/index.ts
Normal file
11
open-sse/config/providers/registry/anyapi/index.ts
Normal 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,
|
||||
});
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
11
open-sse/config/providers/registry/electronhub/index.ts
Normal file
11
open-sse/config/providers/registry/electronhub/index.ts
Normal 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,
|
||||
});
|
||||
11
open-sse/config/providers/registry/fastrouter/index.ts
Normal file
11
open-sse/config/providers/registry/fastrouter/index.ts
Normal 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,
|
||||
});
|
||||
14
open-sse/config/providers/registry/llm-kiwi/index.ts
Normal file
14
open-sse/config/providers/registry/llm-kiwi/index.ts
Normal 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,
|
||||
});
|
||||
11
open-sse/config/providers/registry/llmgateway/index.ts
Normal file
11
open-sse/config/providers/registry/llmgateway/index.ts
Normal 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,
|
||||
});
|
||||
33
open-sse/config/providers/registry/raycast/index.ts
Normal file
33
open-sse/config/providers/registry/raycast/index.ts
Normal 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" },
|
||||
],
|
||||
};
|
||||
16
open-sse/config/providers/registry/regolo/index.ts
Normal file
16
open-sse/config/providers/registry/regolo/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 =
|
||||
|
||||
104
open-sse/executors/devin-agentic/anthropicResponse.ts
Normal file
104
open-sse/executors/devin-agentic/anthropicResponse.ts
Normal 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;
|
||||
}
|
||||
217
open-sse/executors/devin-agentic/serializer.ts
Normal file
217
open-sse/executors/devin-agentic/serializer.ts
Normal 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 };
|
||||
}
|
||||
117
open-sse/executors/devin-agentic/toolParser.ts
Normal file
117
open-sse/executors/devin-agentic/toolParser.ts
Normal 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 };
|
||||
}
|
||||
56
open-sse/executors/devin-agentic/types.ts
Normal file
56
open-sse/executors/devin-agentic/types.ts
Normal 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));
|
||||
}
|
||||
571
open-sse/executors/devin-cli-agentic.ts
Normal file
571
open-sse/executors/devin-cli-agentic.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
224
open-sse/executors/raycast.ts
Normal file
224
open-sse/executors/raycast.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -687,6 +687,35 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco
|
||||
if (stopReason === "tool_calls") stopReason = "tool_use";
|
||||
|
||||
const usageSrc = toRecord(openaiResponse.usage);
|
||||
const promptTokens = toNumber(usageSrc.prompt_tokens, 0);
|
||||
const outputTokens = toNumber(usageSrc.completion_tokens, 0);
|
||||
|
||||
// Extract cache tokens from prompt_tokens_details (mirrors the streaming
|
||||
// translator in open-sse/translator/response/openai-to-claude.ts lines 119-148).
|
||||
const promptDetails = toRecord(usageSrc.prompt_tokens_details);
|
||||
const cachedTokens = toNumber(promptDetails.cached_tokens, 0);
|
||||
const cacheCreationTokens = toNumber(promptDetails.cache_creation_tokens, 0);
|
||||
|
||||
// OpenAI's prompt_tokens includes all prompt-side tokens (cached + non-cached).
|
||||
// Claude expects input_tokens to be only non-cached tokens, with cached tokens
|
||||
// exposed separately as cache_read_input_tokens.
|
||||
const inputTokens = promptTokens - cachedTokens - cacheCreationTokens;
|
||||
|
||||
const usage: JsonRecord = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
};
|
||||
|
||||
// Add cache_read_input_tokens if present
|
||||
if (cachedTokens > 0) {
|
||||
usage.cache_read_input_tokens = cachedTokens;
|
||||
}
|
||||
|
||||
// Add cache_creation_input_tokens if present
|
||||
if (cacheCreationTokens > 0) {
|
||||
usage.cache_creation_input_tokens = cacheCreationTokens;
|
||||
}
|
||||
|
||||
const claudeResponse: JsonRecord = {
|
||||
id: toString(openaiResponse.id, `msg_${Date.now()}`),
|
||||
type: "message",
|
||||
@@ -695,10 +724,7 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco
|
||||
content,
|
||||
stop_reason: stopReason,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: toNumber(usageSrc.prompt_tokens, 0),
|
||||
output_tokens: toNumber(usageSrc.completion_tokens, 0),
|
||||
},
|
||||
usage,
|
||||
};
|
||||
|
||||
return claudeResponse;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
193
open-sse/mcp-server/__tests__/createComboTool.test.ts
Normal file
193
open-sse/mcp-server/__tests__/createComboTool.test.ts
Normal 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")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
80
open-sse/services/combo/runtimeUnitCapacity.ts
Normal file
80
open-sse/services/combo/runtimeUnitCapacity.ts
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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.
|
||||
|
||||
225
open-sse/services/newApiAggregatorQuotaFetcher.ts
Normal file
225
open-sse/services/newApiAggregatorQuotaFetcher.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user