mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 08:12:20 +03:00
Compare commits
68 Commits
integrate/
...
feat/9544-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea7866ae80 | ||
|
|
7fce2d55a4 | ||
|
|
6f875f8acf | ||
|
|
02534f4e8e | ||
|
|
976d670ff3 | ||
|
|
ff679ab86e | ||
|
|
ebf151e057 | ||
|
|
7d3dc0bc35 | ||
|
|
5e2429ce15 | ||
|
|
6629a9b698 | ||
|
|
c40d4b17ee | ||
|
|
d86ea99713 | ||
|
|
1e15583f29 | ||
|
|
9995bc4893 | ||
|
|
c9a3361e5a | ||
|
|
c9debe92bd | ||
|
|
fad3539a69 | ||
|
|
919f9acd80 | ||
|
|
8a573c56e3 | ||
|
|
f338363cd3 | ||
|
|
28dc5af7ba | ||
|
|
616175a93e | ||
|
|
e4e0c254ea | ||
|
|
2e9abab944 | ||
|
|
4cc9cf8123 | ||
|
|
6c3aea6ba6 | ||
|
|
9edefd4572 | ||
|
|
8fbd331567 | ||
|
|
ef236934c0 | ||
|
|
5f471181fa | ||
|
|
ebdbe3a38f | ||
|
|
535c75b60a | ||
|
|
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 | ||
|
|
3c6f71776e | ||
|
|
0afbe3295b | ||
|
|
84ab6fa7b0 | ||
|
|
14014fda12 | ||
|
|
30cf91e272 | ||
|
|
c30724742d |
@@ -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
|
||||
47
.env.example
47
.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.
|
||||
@@ -2440,10 +2470,10 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Optional add-on (feature flag RADAR_ENABLED, default off — see feature flag
|
||||
# settings, not an env var) that overlays a signed, freshly-curated free-model
|
||||
# catalog on top of the release baseline. Both variables below are optional and
|
||||
# only needed to point the client at a self-hosted/forked feed instead of the
|
||||
# default OmniRoute Radar feed. Used by: src/lib/radar/sync.ts,
|
||||
# src/lib/radar/pinnedKeys.ts.
|
||||
# catalog on top of the release baseline. All four variables below are optional
|
||||
# and only needed to point the client at a self-hosted/forked feed or
|
||||
# supporter-key flow instead of the default OmniRoute Radar service. Used by:
|
||||
# src/lib/radar/sync.ts, src/lib/radar/pinnedKeys.ts, src/lib/radar/links.ts.
|
||||
|
||||
# Base URL of the Radar feed service. Overrides the built-in default so forks
|
||||
# and self-hosters can point at their own signed feed.
|
||||
@@ -2453,3 +2483,12 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# signature, replacing the pinned default key. Required when self-hosting a
|
||||
# feed signed with a different key pair.
|
||||
# RADAR_FEED_PUBKEY=
|
||||
|
||||
# URL the dashboard's "I'm a contributor" button opens (GitHub OAuth
|
||||
# supporter-key claim flow). No pricing/value lives in this repo — only the
|
||||
# link.
|
||||
# RADAR_CONTRIBUTOR_CLAIM_URL=https://radar.omniroute.online/auth/github
|
||||
|
||||
# URL the dashboard's "Support the project" button opens (payment/plans
|
||||
# page). No pricing/value lives in this repo — only the link.
|
||||
# RADAR_SUPPORTER_PLANS_URL=https://radar.omniroute.online/planos
|
||||
|
||||
1
.eslintcache-probe
Normal file
1
.eslintcache-probe
Normal file
File diff suppressed because one or more lines are too long
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -1213,8 +1213,10 @@ jobs:
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
# (tsx/esm = QW-b; o alinhamento de ESCOPO do integration com o npm script fica p/ follow-up)
|
||||
- run: node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
|
||||
- name: Integration tests (shard ${{ matrix.shard }}/2)
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/2
|
||||
run: npm run test:integration:ci
|
||||
|
||||
test-security:
|
||||
name: Security Tests
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -58,7 +58,7 @@ Repository map and Reference Documentation sections below.
|
||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (130 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 104 tools (42 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 scopes |
|
||||
| MCP Server | `open-sse/mcp-server/` | 105 tools (42 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 scopes |
|
||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||
| Skills | `src/lib/skills/` | Extensible skill framework |
|
||||
| Memory | `src/lib/memory/` | Persistent conversational memory |
|
||||
|
||||
@@ -188,7 +188,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 291 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 291 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 40+ free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 104 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 291 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 291 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 40+ free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 105 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
@@ -439,7 +439,7 @@ All **19** strategies — mix & match per combo step:
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 291 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 104 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs."/>
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 291 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 105 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs."/>
|
||||
|
||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||
|
||||
@@ -723,7 +723,7 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo
|
||||
<table>
|
||||
<tr><th align="left">Interface</th><th align="left">Endpoint / command</th><th align="left">Use it for</th></tr>
|
||||
<tr><td align="left" nowrap>🧰 <b>MCP (stdio)</b></td><td align="left" nowrap><code>omniroute --mcp</code></td><td align="left">Plug into Claude Desktop, Cursor, any MCP client</td></tr>
|
||||
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>104 tools</b>, 31 scopes, full audit trail</td></tr>
|
||||
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>105 tools</b>, 31 scopes, full audit trail</td></tr>
|
||||
<tr><td align="left" nowrap>📡 <b>MCP (SSE)</b></td><td align="left" nowrap><code>/api/mcp/sse</code></td><td align="left">Streaming MCP transport</td></tr>
|
||||
<tr><td align="left" nowrap>🤝 <b>A2A</b></td><td align="left" nowrap><code>/.well-known/agent.json</code></td><td align="left">Agent-to-agent, <b>JSON-RPC 2.0</b> + SSE, 6 skills</td></tr>
|
||||
<tr><td align="left" nowrap>🌐 <b>REST API</b></td><td align="left" nowrap><code>/v1/*</code></td><td align="left">OpenAI-compatible — chat, embeddings, images, audio, OCR</td></tr>
|
||||
|
||||
@@ -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 @@
|
||||
- **sse:** New-API / One-API / Sub2API aggregator balance detection for compatible nodes — with the "Aggregator Gateway" toggle on, OmniRoute queries the aggregator's `/api/user/self` to read the account balance, shows it as a dashboard badge and lets quota-preflight routing skip exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default off), with a `quotaPerUnit` override for aggregators that do not use the default 500000 units/$1 rate ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415))
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(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/features/9544-muse-code-cli-provider.md
Normal file
1
changelog.d/features/9544-muse-code-cli-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): add Muse Code CLI provider preset (#9544)
|
||||
1
changelog.d/fixes/8739-yuanbao-sse-parser.md
Normal file
1
changelog.d/fixes/8739-yuanbao-sse-parser.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(yuanbao-web): accept `content` field in SSE text events (upstream format change) (#8739)
|
||||
1
changelog.d/fixes/8813-chatgpt-sentinel.md
Normal file
1
changelog.d/fixes/8813-chatgpt-sentinel.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(errorClassifier): classify ChatGPT Web SENTINEL_BLOCKED 403 as terminal FORBIDDEN, enabling proper combo fallback (#8813)
|
||||
1
changelog.d/fixes/8946-no-tool-output.md
Normal file
1
changelog.d/fixes/8946-no-tool-output.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946)
|
||||
1
changelog.d/fixes/8994-vertex-partner-claude.md
Normal file
1
changelog.d/fixes/8994-vertex-partner-claude.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(vertex): route Claude models to native rawPredict endpoint and respect custom targetFormat overrides (#8994)
|
||||
1
changelog.d/fixes/9029-cursor-narration.md
Normal file
1
changelog.d/fixes/9029-cursor-narration.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cursor): preserve tool context across multi-turn conversations when client lacks conversation_id (#9029)
|
||||
1
changelog.d/fixes/9030-antigravity-system-429s.md
Normal file
1
changelog.d/fixes/9030-antigravity-system-429s.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): move Antigravity client system content to first user message to avoid upstream 429 RESOURCE_EXHAUSTED on oversized systemInstruction (#9030)
|
||||
1
changelog.d/fixes/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/9435-kiro-import-overwrite.md
Normal file
1
changelog.d/fixes/9435-kiro-import-overwrite.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(oauth): Kiro import token endpoint no longer overwrites existing connection when using shared cached OIDC clientId (#9435)
|
||||
1
changelog.d/fixes/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/9531-ci-combo-matrix.md
Normal file
1
changelog.d/fixes/9531-ci-combo-matrix.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(ci): include combo-matrix tests in test-integration job (#9531)
|
||||
1
changelog.d/fixes/9534-modelsdevsync-timers.md
Normal file
1
changelog.d/fixes/9534-modelsdevsync-timers.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(test): prevent flaky modelsDevSync timer assertions by serializing test execution within the file (#9534)
|
||||
1
changelog.d/fixes/9536-usage-misreporting.md
Normal file
1
changelog.d/fixes/9536-usage-misreporting.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)
|
||||
1
changelog.d/fixes/9541-fastpath-db-corruption.md
Normal file
1
changelog.d/fixes/9541-fastpath-db-corruption.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): add transient-error retry to corruption probe to prevent data loss under concurrent load (#9541)
|
||||
1
changelog.d/fixes/9543-searxng-auto-select.md
Normal file
1
changelog.d/fixes/9543-searxng-auto-select.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(search): mark searxng-search as fallbackOnly to prevent auto-select without instance (#9543)
|
||||
1
changelog.d/fixes/9545-gpt56-effort-tools.md
Normal file
1
changelog.d/fixes/9545-gpt56-effort-tools.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): strip provider prefix in getModelTargetFormat to route GPT-5.6 models to /v1/responses (#9545)
|
||||
1
changelog.d/fixes/9550-amazon-q-alias.md
Normal file
1
changelog.d/fixes/9550-amazon-q-alias.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(model): add "aq" alias for amazon-q provider so parseModel resolves it instead of falling back to OpenAI (#9550)
|
||||
1
changelog.d/fixes/9551-proxyfetch-context-bypass.md
Normal file
1
changelog.d/fixes/9551-proxyfetch-context-bypass.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(proxy): NO_PROXY now bypasses context-level proxy in resolveProxyForRequest (#9551)
|
||||
1
changelog.d/fixes/9560-turbopack-nft-guard.md
Normal file
1
changelog.d/fixes/9560-turbopack-nft-guard.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(build): lazy-resolve module-level fs paths to avoid Turbopack NFT whole-source trace (#9560)
|
||||
1
changelog.d/fixes/9567-chatcore-sse-flaky.md
Normal file
1
changelog.d/fixes/9567-chatcore-sse-flaky.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): replace timer-based waits with polling to fix flaky chatCore/SSE tests under CI load (#9567)
|
||||
1
changelog.d/fixes/9568-gemini-tool-casing.md
Normal file
1
changelog.d/fixes/9568-gemini-tool-casing.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** restore original tool name casing in Gemini/Antigravity response translators ([#9568](https://github.com/diegosouzapw/OmniRoute/issues/9568))
|
||||
1
changelog.d/fixes/9575-tool-call-case.md
Normal file
1
changelog.d/fixes/9575-tool-call-case.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)
|
||||
1
changelog.d/fixes/9630-combo-false-503.md
Normal file
1
changelog.d/fixes/9630-combo-false-503.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(combo): distinguish pre-dispatch skips from genuine failures to prevent false 503 ALL_ACCOUNTS_INACTIVE (#9630)
|
||||
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.
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"_comment": "Congelamento da divida ESLint da migracao TypeScript 7 (release/v3.8.50, 2026-08-05; regenerado 2026-08-06 apos prune de entradas orfas). Gerado pelo modo nativo `eslint --suppress-all --suppressions-location config/quality/eslint-suppressions.json` (NODE_OPTIONS=--max-old-space-size=12288). Politica: violacao PRE-EXISTENTE fica suprimida aqui; violacao NOVA (fora deste arquivo) e vermelho imediato e deve ser corrigida, nunca adicionada. Entradas que deixarem de ocorrer sao podadas com `eslint --prune-suppressions` (o job 'No new ESLint warnings' falha com supressoes orfas). A baseline eslintWarnings em config/quality/quality-baseline.json e 0 — o valor real medido com estas supressoes aplicadas.",
|
||||
"open-sse/executors/blackbox-web.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -249,11 +248,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
|
||||
@@ -2085,11 +2079,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"tests/unit/combo-cache-invalidation.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"tests/unit/combo-context-length.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -3333,11 +3322,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"tests/unit/vertex-functioncall-id-3440.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"tests/unit/vertex-media.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
@@ -3368,4 +3352,4 @@
|
||||
"count": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).",
|
||||
@@ -370,7 +371,7 @@
|
||||
"tests/unit/model-sync-route.test.ts": 1016,
|
||||
"tests/unit/models-catalog-route.test.ts": 1636,
|
||||
"tests/unit/perplexity-web.test.ts": 1355,
|
||||
"tests/unit/provider-models-route.test.ts": 1784,
|
||||
"tests/unit/provider-models-route.test.ts": 1787,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2985,
|
||||
"tests/unit/providers-page-utils.test.ts": 1106,
|
||||
"tests/unit/response-sanitizer.test.ts": 1063,
|
||||
@@ -380,7 +381,7 @@
|
||||
"tests/unit/stream-utils.test.ts": 2445,
|
||||
"tests/unit/token-refresh-service.test.ts": 1378,
|
||||
"tests/unit/translator-openai-responses-req.test.ts": 1194,
|
||||
"tests/unit/translator-openai-to-gemini.test.ts": 1616,
|
||||
"tests/unit/translator-openai-to-gemini.test.ts": 1619,
|
||||
"tests/unit/translator-openai-to-kiro.test.ts": 1275,
|
||||
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1234,
|
||||
"tests/unit/usage-service-hardening.test.ts": 1483,
|
||||
@@ -524,10 +525,10 @@
|
||||
"_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,
|
||||
"open-sse/executors/cursor.ts": 1563,
|
||||
"open-sse/executors/deepseek-web.ts": 1148,
|
||||
"open-sse/executors/grok-web.ts": 1044,
|
||||
"open-sse/executors/muse-spark-web.ts": 1405,
|
||||
@@ -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,8 @@
|
||||
"_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.",
|
||||
"_rebaseline_2026_08_06c_v3850_sweepreds_pr2": "Segunda PR do /sweep-reds (fix/release-v3.8.50-basereds-0806b): tests/unit/provider-models-route.test.ts 1784->1787 (medido pelo gate, que conta split(\"\\n\").length) (+2 apos compressao de comentarios) — alinhamento de contrato forcado por dois merges do dia: #9106 tornou gemini-3.1-pro-high user-callable (a entry do alias entra na lista esperada do teste de discovery-retry, +1 linha de dado + 1 de comentario) e ff012ff420 adicionou onboardUser como bootstrap hop (exclusao no mock, ja comprimida a 1 linha). Nao ha o que encolher sem apagar o comentario que explica o porque.",
|
||||
"_rebaseline_2026_08_07_v3850_sweepreds_pr2_toolnamemap": "tests/unit/translator-openai-to-gemini.test.ts 1616->1619 (+3). O frozen estava EXATAMENTE no tamanho da base, entao qualquer linha nova viola. #9568 (c9a3361e5a) fez buildChangedToolNameMap emitir entradas IDENTIDADE (o Gemini minusculiza nomes de tool nas respostas, entao o tradutor de resposta precisa da chave para mapear de volta), o que passou a incluir `_toolNameMap` no envelope Antigravity de qualquer request com tools. As 3 linhas sao: a chave nova na lista esperada de Object.keys, 1 comentario explicando POR QUE ela aparece (sem ele o proximo leitor tenta remove-la de novo) e 1 assert do CONTEUDO do map — presenca de chave sozinha nao provaria a entrada identidade, que e justamente o comportamento novo. Nao ha o que extrair: e alinhamento de contrato dentro de um teste existente."
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -6,7 +6,7 @@ lastUpdated: 2026-06-28
|
||||
|
||||
# OmniRoute MCP Server Documentation
|
||||
|
||||
> Model Context Protocol server with 104 tools across routing, cache, compression, memory, skills, proxy, pool, and context source operations.
|
||||
> Model Context Protocol server with 105 tools across routing, cache, compression, memory, skills, proxy, pool, and context source operations.
|
||||
>
|
||||
> Source of truth: `open-sse/mcp-server/server.ts` computes **104 unique tools** with `countUniqueMcpTools()`: 42 canonical definitions (including the six CCR lifecycle tools and the agent-skills trio), plus memory (3), skills (4), GitHub skills (3), pool (6), gamification (8), plugins (8), Notion (6), Obsidian (22), and two RTK-only compression tools.
|
||||
|
||||
@@ -369,7 +369,7 @@ MCP tool, prompt, and resource registries can compress descriptions at registrat
|
||||
|
||||
Description compression shrinks each tool's metadata; **tool-cardinality reduction** goes one step further by reducing _how many_ tools are announced at all. Advertising fewer tools in the `tools/list` manifest cuts the per-request token cost the client's model pays for the tool catalog ("layer 5" compression). The implementation is a pure, stateless filter in `open-sse/mcp-server/toolCardinality.ts` (`reduceToolManifest`), wired into the registration loop in `createMcpServer()` (`open-sse/mcp-server/server.ts`).
|
||||
|
||||
**Opt-in, off by default.** The filter only runs when at least one of two environment variables is set; with neither set, all 104 tools are announced unchanged.
|
||||
**Opt-in, off by default.** The filter only runs when at least one of two environment variables is set; with neither set, all 105 tools are announced unchanged.
|
||||
|
||||
| Variable | Mode |
|
||||
| :--------------- | :-------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
title: "Radar Free-Model Catalog"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-05
|
||||
lastUpdated: 2026-08-07
|
||||
---
|
||||
|
||||
# Radar Free-Model Catalog
|
||||
|
||||
> **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/`
|
||||
> **Last updated:** 2026-08-05 — v3.8.50
|
||||
> **Last updated:** 2026-08-07 — v3.8.50
|
||||
|
||||
Radar is an **optional add-on** that overlays a signed, freshly-curated free-model
|
||||
catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in
|
||||
`open-sse/config/freeModelCatalog.ts`). It exists because the free-tier landscape moves
|
||||
`open-sse/config/freeModelCatalog.data.ts`). It exists because the free-tier landscape moves
|
||||
faster than release cadence — providers add, shrink, or discontinue free quotas between
|
||||
releases, and the baseline catalog can only be refreshed when a new version ships.
|
||||
|
||||
@@ -82,6 +82,43 @@ that lets the feed service decide which tier to serve (see
|
||||
|
||||
---
|
||||
|
||||
## Getting a supporter key
|
||||
|
||||
The activation screen (`/dashboard/radar`) links out to two flows for **obtaining** a
|
||||
supporter key. The OSS repo itself never issues one, never runs payment code, and
|
||||
**never states a price** — pricing is decided and displayed entirely on the
|
||||
destination pages, not in this repo (spec decision D14).
|
||||
|
||||
- **"I'm a contributor"** — opens `RADAR_CONTRIBUTOR_CLAIM_URL` (default
|
||||
`https://radar.omniroute.online/auth/github`), a GitHub OAuth claim flow hosted on
|
||||
the private radar server. It verifies the visitor's GitHub account and grants a
|
||||
supporter key to anyone with 5+ merged pull requests or a top-100 contributor spot
|
||||
on the repo.
|
||||
- **"Support the project"** — opens `RADAR_SUPPORTER_PLANS_URL` (default
|
||||
`https://radar.omniroute.online/planos`), the payment/plans page.
|
||||
|
||||
Both URLs are resolved server-side (`src/lib/radar/links.ts`, same env-override
|
||||
pattern as `RADAR_FEED_URL`) and relayed to the dashboard through the existing
|
||||
`GET /api/radar/settings` response (`contributorClaimUrl`, `supporterPlansUrl`) — the
|
||||
client component never reads `process.env` itself.
|
||||
|
||||
| Var | Purpose |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). |
|
||||
| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). |
|
||||
|
||||
Once a visitor has a key (`omr_` + 40 hex chars), it is set with `POST
|
||||
/api/radar/settings` (`{ supporterKey }`) — the same endpoint documented under
|
||||
[Data sync](#data-sync-is-a-separate-opt-in--the-privacy-promise) above.
|
||||
|
||||
**Known gap:** the dashboard activation screen does not yet have a dedicated
|
||||
key-paste input — pasting a key today requires calling `POST /api/radar/settings`
|
||||
directly (curl, a script, or a future UI). This release only adds the two claim/plans
|
||||
buttons; the API already accepts and masks the key, but no `<input>` for it exists in
|
||||
`src/app/(dashboard)/dashboard/radar/page.tsx` yet.
|
||||
|
||||
---
|
||||
|
||||
## Security model
|
||||
|
||||
### Ed25519 signature over exact bytes
|
||||
@@ -127,6 +164,24 @@ untouched. The cached payload is defensively re-validated again on every read
|
||||
(`getRadarCatalog()`) — a corrupted or hand-edited cache row falls back to the
|
||||
baseline rather than being served.
|
||||
|
||||
### Response size cap (10 MB)
|
||||
|
||||
`syncRadar()` enforces a **10 MB hard cap** on the feed response body — the signed
|
||||
feed is a KB-scale JSON document, so anything past this points at a misconfigured or
|
||||
hostile `RADAR_FEED_URL` (or an upstream serving garbage), not a legitimate catalog.
|
||||
Enforcement is two-layered:
|
||||
|
||||
1. A `Content-Length` preflight check skips reading the body entirely when the
|
||||
header already declares a value over the cap.
|
||||
2. A running-total check while reading the body enforces the cap even when
|
||||
`Content-Length` is absent or understates the real size — the header is never
|
||||
trusted on its own. Concatenating the accumulated chunks preserves the exact
|
||||
bytes needed for the Ed25519 signature check afterward.
|
||||
|
||||
Exceeding the cap returns `{ status: "too_large" }` and leaves the cache untouched,
|
||||
following the same non-destructive pattern as every other sync failure
|
||||
(`invalid_signature`, `invalid_schema`, `stale`).
|
||||
|
||||
---
|
||||
|
||||
## Tiers: `community` and `live`
|
||||
@@ -146,6 +201,28 @@ recoverable, all non-fatal to the cached state) from a successful `{ status:
|
||||
"updated", version, tier }`. There is no tier-specific error path a client needs to
|
||||
handle.
|
||||
|
||||
### The served tier comes from a response header, not the signed body
|
||||
|
||||
The signed feed **body**'s `tier` field is always `"live"` — the feed service ships
|
||||
**one signed artifact per version**, so the body cannot carry a per-request tier
|
||||
without invalidating the Ed25519 signature (re-signing per request would defeat the
|
||||
point of a pinned, cacheable, verifiable artifact). The tier actually served for a
|
||||
given request is instead carried in the **`x-omniroute-feed-tier` response header**,
|
||||
decided server-side from the request's `Authorization` key.
|
||||
|
||||
`syncRadar()` (`src/lib/radar/sync.ts::parseServedTierHeader()`) is the single place
|
||||
that resolves the tier a client should trust:
|
||||
|
||||
1. Parse `x-omniroute-feed-tier` with `RadarTierSchema` (Zod) — an absent header, or
|
||||
a value that isn't exactly `"community"` or `"live"`, is treated as **not
|
||||
present** (never trusted into the cache/UI as-is; this also covers older feed
|
||||
servers that predate the header).
|
||||
2. Fall back to the signed body's `tier` field (always `"live"`) only when step 1
|
||||
yields nothing.
|
||||
3. The resolved tier is what gets cached and returned as `{ status: "updated",
|
||||
version, tier }` — this is the value the dashboard shows, never the raw body
|
||||
field.
|
||||
|
||||
---
|
||||
|
||||
## Read-time overlay merge rules
|
||||
@@ -183,13 +260,15 @@ Every merged entry carries an `origin` field the UI renders as a badge:
|
||||
|
||||
## Local surfaces — never a feed proxy
|
||||
|
||||
Three local routes back the UI, all under `src/app/api/radar/`:
|
||||
Five local routes back the UI, all under `src/app/api/radar/`:
|
||||
|
||||
| Route | Method | Purpose |
|
||||
| --------------------- | ------ | ---------------------------------------------------------------------- |
|
||||
| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. |
|
||||
| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. |
|
||||
| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. |
|
||||
| Route | Method | Purpose |
|
||||
| ----------------------- | ------ | -------------------------------------------------------------------------------------------------- |
|
||||
| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. |
|
||||
| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. |
|
||||
| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. |
|
||||
| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. |
|
||||
| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. |
|
||||
|
||||
**Hard rule: these routes never proxy the feed service.** The browser only ever talks
|
||||
to the local OmniRoute server; `syncRadar()` is the single module in the whole client
|
||||
@@ -197,11 +276,118 @@ that touches the network for Radar (`src/lib/radar/sync.ts`), and it always runs
|
||||
server-side, never client-side. This keeps the feed URL and any supporter key
|
||||
out of client-facing network traffic entirely.
|
||||
|
||||
All three routes return `404` when `RADAR_ENABLED` is off (see
|
||||
All five routes return `404` when `RADAR_ENABLED` is off (see
|
||||
[Flag](#flag-radar_enabled-default-off) above), and route error responses through
|
||||
`buildErrorBody()`/`sanitizeErrorMessage()` per the repo-wide error-sanitization rule
|
||||
(`docs/security/ERROR_SANITIZATION.md`).
|
||||
|
||||
### Authentication
|
||||
|
||||
All five routes require authentication via `isAuthenticated()`
|
||||
(`src/shared/utils/apiAuth.ts`) — a dashboard session cookie or a management-scoped
|
||||
API key, the same gate that protects the rest of `/api/settings/*`. The flag-off
|
||||
`404` check always runs **before** the auth check, so an install with `RADAR_ENABLED`
|
||||
off stays byte-identical (no auth prompt just to learn the surface doesn't exist);
|
||||
once the flag is on, an unauthenticated request gets `401` before any DB read or
|
||||
write. `GET /api/radar/settings` never returns the raw supporter key regardless of
|
||||
auth state — only the masked form and a `hasSupporterKey` boolean.
|
||||
|
||||
---
|
||||
|
||||
## Referral links (free credits)
|
||||
|
||||
The server-published feed carries a `referrals` section (server-side D28 work, already
|
||||
in production — this section documents the **client** consumption only):
|
||||
|
||||
```ts
|
||||
referrals: {
|
||||
fixed: RadarReferral[], // present in EVERY tier, including community
|
||||
campaigns: RadarReferral[], // only populated on the live (supporter) tier;
|
||||
// the community artifact always publishes []
|
||||
}
|
||||
// RadarReferral = { provider, url, kind: "fixo" | "campanha", validUntil,
|
||||
// requiredAction, isDefault }
|
||||
```
|
||||
|
||||
The client never decides which tier it received or which referrals belong in which
|
||||
tier — the server already publishes two artifacts (`live`/`community`) with
|
||||
`campaigns` gated server-side, same principle as the [tiers](#tiers-community-and-live)
|
||||
section above. `RadarFeedSchema` (`src/lib/radar/feedSchema.ts`) validates `referrals`
|
||||
as a whole-object `.default({fixed:[],campaigns:[]})`, and `campaigns` defaults
|
||||
independently inside it — so a feed cached before this section existed on the server
|
||||
still parses cleanly, and `campaigns` alone can also be absent without failing
|
||||
validation. Every `RadarReferral.url` must be `https://` — a `http://` url fails
|
||||
schema validation.
|
||||
|
||||
### Accessor
|
||||
|
||||
`src/lib/radar/index.ts` exports two read-only accessors, both never throwing (same
|
||||
defensive contract as `getRadarCatalog()` — flag off, no cache, or a corrupt/old cached
|
||||
payload all resolve to the empty shape instead of an error):
|
||||
|
||||
- `getRadarReferrals()` → `{ fixed: RadarReferral[], campaigns: RadarReferral[] }`.
|
||||
- `getDefaultReferralFor(provider)` → the `fixed` referral with `isDefault: true` for
|
||||
that provider, or `null`. Only looks at `fixed` — a campaign is never used as a
|
||||
provider's "default" link.
|
||||
|
||||
The actual "which referral is the default for a provider" rule lives in
|
||||
`findDefaultReferral()` (`src/lib/radar/referrals.ts`), a small pure function with **no
|
||||
DB import** — it is safe to import into a `"use client"` component. `getRadarReferrals`/
|
||||
`getDefaultReferralFor` (in `index.ts`) pull in `@/lib/db/radar` and therefore stay
|
||||
server-only; the providers dashboard imports `referrals.ts` directly instead of
|
||||
`index.ts` (see below) to avoid bundling `better-sqlite3` into the browser.
|
||||
|
||||
### `GET /api/radar/referrals`
|
||||
|
||||
Follows the exact same gate order as every other Radar route: `RADAR_ENABLED` off →
|
||||
`404` (checked first, byte-identical inertia); unauthenticated → `401`; otherwise `200`
|
||||
with `{ fixed, campaigns, tier }` — `tier` comes straight from the cache row and is
|
||||
purely informative (drives the UI's soft upsell copy below), the route does no
|
||||
gating of its own. Never proxies the feed server — same local-cache-only contract as
|
||||
`/api/radar/catalog`.
|
||||
|
||||
### Dashboard UI — "Free credits" tab on `/dashboard/radar`
|
||||
|
||||
Reuses the existing Radar page (`src/app/(dashboard)/dashboard/radar/page.tsx`) as a
|
||||
second tab instead of a new route — less routing/i18n surface for a feature that is a
|
||||
variation on data the page already fetches. Once opted in, the tab bar offers
|
||||
**Catalog** (existing table) and **Free credits**:
|
||||
|
||||
- Fixed links are grouped by provider, each showing `requiredAction` (when present)
|
||||
and a `target="_blank" rel="noopener noreferrer"` button to the referral URL.
|
||||
- Campaigns show the same, plus `validUntil` when present.
|
||||
- When `campaigns` is empty **and** the served tier is `community`, the UI shows a
|
||||
short upsell note ("limited-time campaigns are a supporter extra") — this **never**
|
||||
hides or gates the fixed links list, which stays fully populated for every tier. The
|
||||
upsell is soft messaging only, never a block.
|
||||
|
||||
### Referral link on the provider name (providers dashboard)
|
||||
|
||||
`ProviderPageHeader` (`src/app/(dashboard)/dashboard/providers/[id]/components/`)
|
||||
already linked the provider name to `providerInfo.website` when present, with one
|
||||
precedent for a monetized link: the Kimi (Moonshot AI) partner-link note
|
||||
(`providers.kimiPartnerLinkNote` i18n key). D28 reuses that exact same discreet-note
|
||||
pattern for Radar default referrals instead of introducing a new key.
|
||||
|
||||
Loose coupling, by design:
|
||||
|
||||
- `resolveProviderHeaderLink()` (`src/app/(dashboard)/dashboard/providers/providerPageUtils.ts`)
|
||||
is a **pure** function — `(staticWebsite, referralUrl) => { website, isReferralLink }`
|
||||
— with no dependency on `@/lib/radar` or `@/lib/db/*`. `providerPageUtils.ts` as a
|
||||
whole stays free of those imports (asserted by
|
||||
`tests/unit/provider-header-referral-link.test.ts`).
|
||||
- `ProviderDetailPageClient.tsx` (a `"use client"` component) is the one place allowed
|
||||
to fetch Radar data — via `fetch("/api/radar/referrals")`, the same local-route
|
||||
pattern the Radar dashboard page itself uses — and it computes the default referral
|
||||
client-side with `findDefaultReferral()` from the DB-free `src/lib/radar/referrals.ts`.
|
||||
- With `RADAR_ENABLED` off, the fetch 404s, `referralUrl` stays `null`, and
|
||||
`resolveProviderHeaderLink()` returns the static catalog `website` unchanged — the
|
||||
provider page is byte-identical to before this feature existed. Same outcome when
|
||||
there is no cache yet or no default referral for that specific provider.
|
||||
- When a default referral does apply, `ProviderPageHeader` receives `isReferralLink`
|
||||
and shows the same discreet note/tooltip as the Kimi partner link (reusing the
|
||||
`providers.kimiPartnerLinkNote` key) — never a new, separate visual treatment.
|
||||
|
||||
---
|
||||
|
||||
## How to self-host a feed
|
||||
@@ -231,6 +417,6 @@ feed.
|
||||
## Related docs
|
||||
|
||||
- [`docs/security/ERROR_SANITIZATION.md`](../security/ERROR_SANITIZATION.md) — the
|
||||
error-response pattern the three `/api/radar/*` routes follow.
|
||||
error-response pattern the five `/api/radar/*` routes follow.
|
||||
- [`docs/reference/ENVIRONMENT.md`](../reference/ENVIRONMENT.md#27-radar-feed-self-hosting)
|
||||
— `RADAR_FEED_URL` / `RADAR_FEED_PUBKEY` reference.
|
||||
|
||||
@@ -645,7 +645,7 @@ for (const s of statuses) {
|
||||
}
|
||||
|
||||
// Force re-check a specific proxy
|
||||
invalidateProxyHealth("http://user:pass@1.2.3.4:8080");
|
||||
invalidateProxyHealth("http://user:pass@203.0.113.7:8080");
|
||||
```
|
||||
|
||||
Flaga `stale` jest `true`, gdy wpis cache przekroczył `HEALTH_CACHE_TTL_MS` i następne żądanie wywoła świeży check.
|
||||
@@ -807,7 +807,7 @@ Gdy proxy systematycznie pada, oznacz je ręcznie, by rotator je pomijał:
|
||||
```ts
|
||||
import { failOneproxyProxy } from "omniroute/oneproxyRotator";
|
||||
|
||||
const removed = await failOneproxyProxy("1.2.3.4", 8080);
|
||||
const removed = await failOneproxyProxy("203.0.113.7", 8080);
|
||||
if (removed) {
|
||||
console.log("Proxy marked as failed; rotator will skip it");
|
||||
}
|
||||
|
||||
@@ -645,7 +645,7 @@ for (const s of statuses) {
|
||||
}
|
||||
|
||||
// Force re-check a specific proxy
|
||||
invalidateProxyHealth("http://user:pass@1.2.3.4:8080");
|
||||
invalidateProxyHealth("http://user:pass@203.0.113.7:8080");
|
||||
```
|
||||
|
||||
The `stale` flag is `true` when the cache entry has exceeded `HEALTH_CACHE_TTL_MS` and the next request will trigger a fresh check.
|
||||
@@ -807,7 +807,7 @@ When a proxy consistently fails, mark it manually so the rotator will skip it:
|
||||
```ts
|
||||
import { failOneproxyProxy } from "omniroute/oneproxyRotator";
|
||||
|
||||
const removed = await failOneproxyProxy("1.2.3.4", 8080);
|
||||
const removed = await failOneproxyProxy("203.0.113.7", 8080);
|
||||
if (removed) {
|
||||
console.log("Proxy marked as failed; rotator will skip it");
|
||||
}
|
||||
|
||||
@@ -422,3 +422,14 @@ 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.
|
||||
- **Cap the V8 heap** — set `OMNIROUTE_MEMORY_MB` (e.g. `512`) so the runtime does not calibrate a ceiling larger than the VM. See `docs/reference/ENVIRONMENT.md`.
|
||||
- **Limit concurrent heavy requests** — lower `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` (default `1`); excess requests get a retryable `503` with `Retry-After` instead of competing for memory.
|
||||
- **Avoid `next build` on the VPS** — build locally and deploy the standalone output (`.next/standalone/`).
|
||||
- **Monitor with `top` / `free -m`** — OmniRoute typically uses 200-400 MB RSS at idle on a 1 GB VM.
|
||||
|
||||
@@ -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]
|
||||
>
|
||||
@@ -1261,14 +1275,17 @@ that should be able to run the docs translator.
|
||||
Optional add-on gated by the RADAR_ENABLED feature flag (default off — a feature
|
||||
flag toggled via Settings/DB, not an env var; see
|
||||
[docs/frameworks/RADAR.md](../frameworks/RADAR.md#flag-radar_enabled-default-off)).
|
||||
Both variables below are optional overrides used only to point the client at a
|
||||
self-hosted or forked feed instead of the default OmniRoute Radar feed. See
|
||||
[docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full module doc.
|
||||
The four variables below are optional overrides used only to point the client at a
|
||||
self-hosted or forked feed / supporter-key flow instead of the default OmniRoute
|
||||
Radar service. See [docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full
|
||||
module doc.
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| -------------------- | ------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `RADAR_FEED_URL` | `https://radar.omniroute.dev` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. |
|
||||
| `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. |
|
||||
| Variable | Default | Source File | Description |
|
||||
| -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. |
|
||||
| `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. |
|
||||
| `RADAR_CONTRIBUTOR_CLAIM_URL` | `https://radar.omniroute.online/auth/github` | `src/lib/radar/links.ts` | URL the "I'm a contributor" dashboard button opens (GitHub OAuth supporter-key claim flow). |
|
||||
| `RADAR_SUPPORTER_PLANS_URL` | `https://radar.omniroute.online/planos` | `src/lib/radar/links.ts` | URL the "Support the project" dashboard button opens (payment/plans page). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -12,27 +12,27 @@ export const PROVIDER_MODELS: Record<string, RegistryModel[]> = new Proxy(
|
||||
{} as Record<string, RegistryModel[]>,
|
||||
{
|
||||
get(_, prop) {
|
||||
if (typeof prop === 'symbol') return undefined;
|
||||
if (typeof prop === "symbol") return undefined;
|
||||
return Reflect.get(initModels(), prop, _models);
|
||||
},
|
||||
has(_, prop) {
|
||||
if (typeof prop === 'symbol') return false;
|
||||
if (typeof prop === "symbol") return false;
|
||||
return Reflect.has(initModels(), prop);
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(initModels());
|
||||
},
|
||||
getOwnPropertyDescriptor(_, prop) {
|
||||
if (typeof prop === 'symbol') return undefined;
|
||||
if (typeof prop === "symbol") return undefined;
|
||||
return Object.getOwnPropertyDescriptor(initModels(), prop);
|
||||
},
|
||||
set(_, prop, value) {
|
||||
if (typeof prop === 'symbol') return false;
|
||||
if (typeof prop === "symbol") return false;
|
||||
(initModels() as Record<string, RegistryModel[]>)[prop] = value;
|
||||
return true;
|
||||
},
|
||||
deleteProperty(_, prop) {
|
||||
if (typeof prop === 'symbol') return false;
|
||||
if (typeof prop === "symbol") return false;
|
||||
return Reflect.deleteProperty(initModels(), prop);
|
||||
},
|
||||
}
|
||||
@@ -41,27 +41,27 @@ export const PROVIDER_ID_TO_ALIAS: Record<string, string> = new Proxy(
|
||||
{} as Record<string, string>,
|
||||
{
|
||||
get(_, prop) {
|
||||
if (typeof prop === 'symbol') return undefined;
|
||||
if (typeof prop === "symbol") return undefined;
|
||||
return Reflect.get(initAliases(), prop, _aliases);
|
||||
},
|
||||
has(_, prop) {
|
||||
if (typeof prop === 'symbol') return false;
|
||||
if (typeof prop === "symbol") return false;
|
||||
return Reflect.has(initAliases(), prop);
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(initAliases());
|
||||
},
|
||||
getOwnPropertyDescriptor(_, prop) {
|
||||
if (typeof prop === 'symbol') return undefined;
|
||||
if (typeof prop === "symbol") return undefined;
|
||||
return Object.getOwnPropertyDescriptor(initAliases(), prop);
|
||||
},
|
||||
set(_, prop, value) {
|
||||
if (typeof prop === 'symbol') return false;
|
||||
if (typeof prop === "symbol") return false;
|
||||
(initAliases() as Record<string, string>)[prop] = value;
|
||||
return true;
|
||||
},
|
||||
deleteProperty(_, prop) {
|
||||
if (typeof prop === 'symbol') return false;
|
||||
if (typeof prop === "symbol") return false;
|
||||
return Reflect.deleteProperty(initAliases(), prop);
|
||||
},
|
||||
}
|
||||
@@ -116,7 +116,13 @@ export function findModelName(aliasOrId: string, modelId: string): string {
|
||||
|
||||
export function getModelTargetFormat(aliasOrId: string, modelId: string): string | null {
|
||||
const models = PROVIDER_MODELS[aliasOrId];
|
||||
const found = models?.find((m) => m.id === modelId);
|
||||
// Strip provider prefix if present: "openai/gpt-5.6-luna" → "gpt-5.6-luna"
|
||||
const prefix = aliasOrId + "/";
|
||||
const bareModelId =
|
||||
typeof modelId === "string" && modelId.startsWith(prefix)
|
||||
? modelId.slice(prefix.length)
|
||||
: modelId;
|
||||
const found = models?.find((m) => m.id === bareModelId);
|
||||
if (found?.targetFormat) return found.targetFormat;
|
||||
// #5842: OpenAI "*-pro" reasoning models (o1-pro, gpt-5.x-pro) are only served by
|
||||
// the native /v1/responses endpoint — /v1/chat/completions 404s ("only supported
|
||||
@@ -124,7 +130,7 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string
|
||||
// covers dynamically-synced ids that post-date the catalog (same spirit as the gh
|
||||
// executor's /codex/i routing, 9router#102). Scoped to the openai alias so other
|
||||
// providers shipping *-pro ids keep their own endpoint semantics.
|
||||
if (aliasOrId === "openai" && /-pro$/i.test(modelId)) return "openai-responses";
|
||||
if (aliasOrId === "openai" && /-pro$/i.test(bareModelId)) return "openai-responses";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -222,6 +225,7 @@ import { digitaloceanProvider } from "./registry/digitalocean/index.ts";
|
||||
import { hcnsecProvider } from "./registry/hcnsec/index.ts";
|
||||
import { promptqlProvider } from "./registry/promptql/index.ts";
|
||||
import { hyperagentProvider } from "./registry/hyperagent/index.ts";
|
||||
import { muse_codeProvider } from "./registry/muse-code/index.ts";
|
||||
|
||||
export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
aimlapi: aimlapiProvider,
|
||||
@@ -243,6 +247,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 +373,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 +395,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,
|
||||
@@ -445,5 +452,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
hcnsec: hcnsecProvider,
|
||||
promptql: promptqlProvider,
|
||||
hyperagent: hyperagentProvider,
|
||||
"muse-code": muse_codeProvider,
|
||||
unorouter: unorouterProvider,
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
106
open-sse/config/providers/registry/muse-code/index.ts
Normal file
106
open-sse/config/providers/registry/muse-code/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
/**
|
||||
* Muse Code CLI — Meta's agentic coding tool.
|
||||
*
|
||||
* Wire format: OpenAI Responses API (POST /responses).
|
||||
* Auth: Bearer token from META_API_KEY env var.
|
||||
* Reasoning efforts: xhigh/ultra -> high (handled generically).
|
||||
*
|
||||
* @see https://github.com/joymadhu49/muse-openrouter-shim
|
||||
*/
|
||||
export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "muse-code",
|
||||
alias: "mc",
|
||||
passthroughModels: true,
|
||||
defaultContextLength: 200000,
|
||||
models: [
|
||||
{
|
||||
id: "llama-4-maverick",
|
||||
name: "Llama 4 Maverick",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 131072,
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: true,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs", "logitBias"],
|
||||
},
|
||||
{
|
||||
id: "llama-4-scout",
|
||||
name: "Llama 4 Scout",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 131072,
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: true,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs", "logitBias"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.3-70b",
|
||||
name: "Llama 3.3 70B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.1-405b",
|
||||
name: "Llama 3.1 405B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.1-70b",
|
||||
name: "Llama 3.1 70B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.1-8b",
|
||||
name: "Llama 3.1 8B",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.2-90b-vision",
|
||||
name: "Llama 3.2 90B Vision",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
{
|
||||
id: "llama-3.2-11b-vision",
|
||||
name: "Llama 3.2 11B Vision",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: false,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
targetFormat: "openai-responses",
|
||||
unsupportedParams: ["logprobs", "topLogprobs"],
|
||||
},
|
||||
],
|
||||
});
|
||||
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. */
|
||||
|
||||
@@ -207,6 +207,7 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
|
||||
maxMaxResults: 50,
|
||||
timeoutMs: 10_000,
|
||||
cacheTTLMs: 3 * 60 * 1000,
|
||||
fallbackOnly: true,
|
||||
},
|
||||
|
||||
"ollama-search": {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1219,6 +1219,9 @@ export class CursorExecutor extends BaseExecutor {
|
||||
|
||||
if (isToolFollowUp) {
|
||||
session = cursorSessionManager.acquire(conversationId);
|
||||
// #9029: content-based session match when client lacks conversation_id.
|
||||
if (!session && !body.conversation_id) session = cursorSessionManager.findByToolCallIds(
|
||||
messages.filter(m => m.role === "tool" && m.tool_call_id).map(m => m.tool_call_id!));
|
||||
}
|
||||
|
||||
if (session) {
|
||||
|
||||
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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user