Compare commits

...

68 Commits

Author SHA1 Message Date
diegosouzapw
ea7866ae80 Merge remote-tracking branch 'origin/release/v3.8.50' into feat/9544-muse-code-cli-provider 2026-08-07 17:01:16 -03:00
Diego Rodrigues de Sa e Souza
7fce2d55a4 fix(combo): replace tab-indented blocks with spaces
Fix TS1128 parsing error. Tabs introduced by #9630 in combo.ts replaced with spaces.

Refs #9679
2026-08-07 16:58:01 -03:00
diegosouzapw
6f875f8acf fix(combo): replace tab-indented blocks with spaces in #9630 changes
The commit for #9630 introduced tab characters instead of 2-space
indentation in two blocks (handleComboChat and handleRoundRobinCombo).
Tabs in TypeScript cause TS1128 parsing errors because the parser
expects consistent space-based indentation.

Fix: replace all leading tabs with the proper 2-space indentation
level matching the surrounding codebase convention.

This restores typecheck:core to a clean state on the release branch.
2026-08-07 16:42:03 -03:00
Diego Rodrigues de Sa e Souza
02534f4e8e feat(radar): contributor + supporter claim buttons on the activation screen (#9710)
* feat(radar): add F4/T7 contributor-claim / supporter-plans link config

Pure, DB-free src/lib/radar/links.ts resolves the two outbound "get a
supporter key" URLs (contributor GitHub-OAuth claim + supporter plans
page), same env-override pattern as RADAR_FEED_URL. No pricing/value is
ever resolved here (D14) — only the link.

* feat(radar): relay F4/T7 claim/plans links via GET /api/radar/settings

Smallest-surface option per spec: no dedicated route. The existing
settings snapshot now also returns contributorClaimUrl/supporterPlansUrl
so the dashboard client never reads process.env itself. Both are plain
public URLs, gated by the same flag/auth checks as the rest of the
response.

* feat(radar): add contributor/supporter claim buttons to activation screen

F4/T7 — "I'm a contributor" opens the GitHub OAuth claim flow;
"Support the project" opens the plans/payment page. Both links come
from the settings fetch (never a hardcoded URL in this client
component) and open in a new tab. No price/value anywhere in the
copy — the destination page is the only place pricing lives (D14).

i18n: 5 new radarPage keys (claimSectionTitle, contributorButton,
contributorHint, supporterButton, supporterHint) added to all 43
locale files with the English copy as fallback value.

* docs(radar): document F4/T7 supporter-key acquisition paths

RADAR.md: new "Getting a supporter key" section covering both claim
flows, the two env-var overrides, and the current gap (no dedicated
key-paste input in the dashboard yet — POST /api/radar/settings is the
only way to set one today). ENVIRONMENT.md + .env.example: register
RADAR_CONTRIBUTOR_CLAIM_URL / RADAR_SUPPORTER_PLANS_URL for
check:env-doc-sync.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-07 16:41:00 -03:00
Diego Rodrigues de Sa e Souza
976d670ff3 fix(combo): distinguish pre-dispatch skips from genuine failures to prevent false 503 ALL_ACCOUNTS_INACTIVE (#9630)
Closes #9630
2026-08-07 13:45:58 -03:00
Diego Rodrigues de Sa e Souza
ff679ab86e fix(sse): move Antigravity client system content to first user message to avoid upstream 429 (#9030)
Closes #9030
2026-08-07 11:23:33 -03:00
Diego Rodrigues de Sa e Souza
ebf151e057 fix(cursor): preserve tool context across multi-turn conversations when client lacks conversation_id (#9029)
Closes #9029
2026-08-07 11:23:27 -03:00
Diego Rodrigues de Sa e Souza
7d3dc0bc35 fix(vertex): route Claude models to native rawPredict and respect targetFormat overrides (#8994)
Closes #8994
2026-08-07 11:23:19 -03:00
Diego Rodrigues de Sa e Souza
5e2429ce15 fix(errorClassifier): classify ChatGPT Web SENTINEL_BLOCKED 403 as FORBIDDEN, enabling combo fallback (#8813)
Closes #8813
2026-08-07 11:23:12 -03:00
Diego Rodrigues de Sa e Souza
6629a9b698 fix(yuanbao-web): accept content field in SSE text events (upstream format change) (#8739)
Closes #8739
2026-08-07 11:23:07 -03:00
Diego Rodrigues de Sa e Souza
c40d4b17ee fix(ci): clear the NEW base-reds from the 08-06 merge batch (migration collision #2 + broken import) (#9688)
* test(base): realign six suites with contracts that #9100/#8990/#9009 deliberately changed

Continuing the base-red drain — every one of these reproduces on the pure tip.

- tests/snapshots/provider/translate-path.json: regenerated via UPDATE_GOLDEN=1.
  The diff is ADDITION-ONLY — the unorouter block from #9009; no existing
  provider entry changed. 3/3.
- tests/unit/provider-models-route.test.ts: ff012ff420 added onboardUser as a
  bootstrap fallback next to loadCodeAssist; the mock now excludes it from the
  discovery-URL ledger like it already excluded loadCodeAssist, otherwise it
  consumed the injected 503 and the retry assertion misfired. 59/59.
- tests/unit/responses-commentary-passthrough-6199.test.ts: #8990 (c996dc93c2)
  deliberately preserves `tools` on the TERMINAL response.completed snapshot
  (Codex CLI rebuilds its tool list from it); the assertion now pins the echoed
  tools instead of their absence. Still stripped on created/in_progress. 7/7.
- tests/unit/vision-compression-authoritative-capability-7237.test.ts:
  68cb678780 added the 'gpt-5' fragment, so the heuristic-vs-spec DRIFT this
  suite documented no longer exists; the cases now guard the agreement, keep a
  conservative-for-unknown-ids probe, and reproduce the strip-bug shape with an
  explicit false instead of deriving it. 4/4.
- tests/unit/provider-limits-proxy-fail-closed.test.ts +
  tests/unit/image-generation-route.test.ts: #9100 made the proxy reachability
  probe NON-BLOCKING (optimistic dispatch; the probe aborts only in-flight
  requests — its own t14 sibling was updated to this exact pattern). Instant
  mocks therefore won the race and the PROXY_UNREACHABLE 503 became unobservable
  (a success or a generic 502). The mocks now stay in flight (never-resolving,
  so the aborted continuation cannot reach the restored real fetch), and the
  fail-closed proof is the settled rejection itself plus zero egress AFTER the
  fast-fail. Production fail-closed semantics are unchanged — the proxy dispatch
  path still throws; only the mock timing was stale. 3/3 and 20/20.

Refs #9298

* fix(guardrails): forward the router deps seam through callVisionModel

tests/unit/guardrails/vision-bridge-sse-and-reasoning.test.ts was 7/7 red on any
clean box (CI shard 3/4): callVisionModel() called getBestVisionModel()/
getFallbackModels() WITHOUT the routers' existing VisionBridgeRouterDeps seam,
so the credential check always hit the live connections DB — no vision-capable
connection meant 'No vision-capable provider connected' before the mocked fetch
was ever reached, and on a dev box auto-selection could swap the fixed model
under the assertions.

The routers already accepted deps; only the forwarding was missing. Added the
optional 5th param (backward compatible — the sole production caller,
visionBridge.ts, injects its own callVisionModel and is unaffected) and the
suite now pins selection with hasUsableCredentials: async () => null
(indeterminate → the fixed model is honored, DB untouched). 7/7.

Sibling suites re-run green: vision-bridge-callmodel 2/2, visionBridge 25/25,
visionBridgeHelpers.callVisionModel 8/8, visionBridgeRouter 10/10,
vision-bridge-cc-no-reroute 8/8.

Refs #9298

* fix(db,combo): clear the NEW base-reds the 08-06 merge batch introduced

The tip moved while the first sweep PR (#9600) was in review, and three fresh
base-reds landed with it — same classes as before, all reproduced on the pure
tip 9995bc4893:

1. ANOTHER migration collision: #9061 shipped 134_ccr_blocks.sql onto the slot
   134_proxy_logs_egress_ip.sql (#9291) has held since 08-04. getMigrationFiles()
   throws on collision, so every DB-touching test died at bootstrap again.
   Renumbered to 139 (next free slot). No retroactive guard needed this time:
   both statements are IF NOT EXISTS, and no DB can have applied it as 134 —
   the runner refused to run at all while the collision existed.
2. BROKEN IMPORT killing the combo module graph: #8894 imported
   preferAntigravityConnectionsWithStoredProject from
   ../antigravityProjectPersistence.ts — a module that exists NOWHERE in the
   repo (it came from an unmerged sibling branch). Anything importing
   quotaStrategies.ts died with ERR_MODULE_NOT_FOUND. Implemented the helper in
   the real persistence module (antigravityProjectPersist.ts, #8491) with the
   semantics the call site needs — prefer connections that already carry a
   stored projectId, never emptying the pool — and pointed the import there.
   New regression suite tests/unit/antigravity-prefer-stored-project.test.ts
   (5/5), including an import-graph probe that reproduces the break shape.
3. Sibling-test drift from #9106 (gemini-3.1-pro-high now user-callable): its
   own suites were updated but provider-models-route.test.ts was not. Expected
   discovery list realigned; testFrozen 1784->1787 justified in the baseline
   (irreducible +2 after comment compression; gate counts split-newlines).

Also regenerated tests/snapshots/provider/translate-path.json — addition-only:
devin-cli-agentic, raycast, regolo (today's provider merges), zero removals.

image-generation-route 20/20 (was import-dead), provider-models-route 59/59,
antigravity-prefer-stored-project 5/5, provider-translate-path-golden 3/3.

Refs #9298

* fix(changelog): convert the #9415 fragment to the required bullet shape

Another base-red from the 08-06 batch: bd4407cb64 landed
changelog.d/features/9415-newapi-sub2api-aggregator-balance.md as YAML
frontmatter + a prose paragraph. Every other fragment in changelog.d/ is a
single markdown bullet, and both consumers enforce that —
scripts/check/check-changelog-integrity.mjs:97 and the release aggregator
(scripts/release/aggregate-changelog.mjs:57) reject anything that does not
start with '- ', so 'Merge integrity (changelog + generated skills)' was red
for every PR targeting the release branch.

Rewritten as a bullet with the standard issue link, preserving the feature
description (aggregator gateway toggle, /api/user/self balance read, dashboard
badge, quota-preflight skip, NEWAPI_AGGREGATOR_BALANCE flag default off,
quotaPerUnit override). Swept the rest of changelog.d/ — this was the only
malformed fragment.

check:changelog-integrity OK.

Refs #9298

* fix(types,docs): clear the 5 typecheck errors and the fabricated env vars on the base

Third pass over the base-reds, from the 2026-08-06T22:51Z verdict on #9298 —
it reported "Typecheck (core)" with only the FIRST error; there are five, all on
the pure tip 9995bc4893. Two are real production defects.

**Real bugs**

- open-sse/services/compression/engines/ccr/index.ts:295 called
  enforceGlobalBudget(entry.bytes) against an (owner, bytes) signature. The
  `bytes` argument arrived undefined, so `ccrTotalBytes + undefined` is NaN,
  `NaN > MAX` is false (the eviction loop exits immediately) and `NaN <= MAX` is
  false (the re-admit is refused). The #9061 durable tier therefore NEVER
  repopulated its in-memory map: every retrieve after a restart or an eviction
  re-read from SQLite forever, and evictions could not prefer the owning
  principal. Fixed and pinned by a new case in
  tests/unit/ccr-durable-store-9061.test.ts (11/11) — verified failing against
  the buggy call and passing against the fix.
- open-sse/services/combo/fusionPanel.ts:54 read `step.model` after #8894
  widened ComboStep with ComboProviderWildcardStep (which carries modelPattern,
  not model), so a wildcard step in a fusion panel pushed `undefined` onto the
  panel. Now resolved through getComboModelString(), which already handles every
  step shape and returns null for the ones without a concrete model id.

**Type-only**

- accountSemaphore.ts:203 — isBypassed() returns a plain boolean and cannot
  narrow `number | null` (an `x is null | undefined` predicate would be unsound:
  0 bypasses too). Added resolveActiveCap(), the narrowing companion isBypassed
  is now defined in terms of; the acquire path uses the narrowed value.
- comboStructure.ts:140 — same #8894 widening: `prompt` only exists on a model
  step, so it is now read under a kind check.
- firecrawlQuotaFetcher.ts:136 — the function returns full FirecrawlQuota
  objects but was annotated Promise<QuotaInfo | null>, which made the
  custom-base literal an excess-property error. Widened to the accurate type
  (FirecrawlQuota extends QuotaInfo, so callers are unaffected).

**Fabricated docs (the "Docs sync + fabricated-docs (strict)" HARD failure)**

docs/ops/VM_DEPLOYMENT_GUIDE.md recommended OMNIROUTE_MAX_POOL_SIZE and
OMNIROUTE_DB_POOL_SIZE (#9471). Neither is read anywhere in the codebase.
Replaced with the two knobs that do exist and are already documented in
ENVIRONMENT.md: OMNIROUTE_MEMORY_MB and OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT.

typecheck:core 5 errors -> 0. check:fabricated-docs + check:env-doc-sync OK.
accountSemaphore 6/6, ccr-durable-store 11/11, ccr-protocol 9/9,
combo-fusion-strategy 10/10, combo-fusion-comboref 5/5, combo-fusion-warn 4/4,
firecrawl-executor 7/7, executor-firecrawl-fetch 4/4.

Refs #9298

* fix(tests): type the #3440 vertex helpers instead of `any` (the 3 base ESLint errors)

The "ESLint errors: 3 error(s)" HARD failure in the #9298 verdict is
tests/unit/vertex-functioncall-id-3440.test.ts lines 32/41/50: the three
find*(result: any) walkers. `@typescript-eslint/no-explicit-any` is an ERROR in
tests/ (and open-sse/) since #6218, and this file landed on 2026-08-04 without a
suppressions entry, so every run of `lint:json --max-warnings 0` failed. That
step prints nothing on failure, which is why the gate looked like a silent
crash across the open PRs.

Replaced with a GeminiRequestLike interface describing exactly what the three
walkers traverse (contents[].parts[]), so the assertions keep their meaning and
nothing is cast away.

eslint on the file: clean. Suite: 6/6.

Refs #9298

* docs(proxy): use an RFC 5737 documentation IP in the proxy examples

The #9298 verdict headlines its docs failure with
`L810 [stale-version] 1.2.3: const removed = await failOneproxyProxy("1.2.3.4", 8080)`.
That is a false positive: check-deprecated-versions.mjs matches
`/\bv?[12]\.\d+\.\d+\b/`, and the example IP literal 1.2.3.4 contains "1.2.3".

Swapped both occurrences in PROXY_GUIDE.md (and its pl mirror) for 203.0.113.7,
from the RFC 5737 documentation range that exists precisely for examples — it
cannot collide with a version pattern and is the correct thing to print in docs
regardless. Drift count 64 -> 62; no gate threshold was touched.

The gate that actually FAILED under "Docs sync + fabricated-docs (strict)" was
check:fabricated-docs (the invented pool env vars), fixed in the previous
commit; this one removes the misleading line the verdict quotes.

* test(base): allowlist probeUtils and realign the #7849 suite to the replacement bound

Two more base-reds, both visible only after the migration collision stopped
killing the shards.

**check-db-rules — src/lib/db/probeUtils.ts not classified**

#9541 added probeUtils.ts (transient-error retry for the SQLite corruption
probe). It is imported ONLY by src/lib/db/core.ts, exactly like its siblings
schemaColumns / optimizationSettings / providerNodeSelect, so re-exporting it
through localDb.ts would push callers toward the barrel-import anti-pattern the
gate exists to prevent. Added to INTENTIONALLY_INTERNAL with that rationale.
check-db-rules 22/22, check:db-rules exit 0.

**session-dedup-memory-7849 — pinned a mechanism that was replaced**

7f36b192f0 (#7855 follow-up) swapped the shared "suffix work budget" for the
MAX_SUFFIX_STARTS / MAX_TOTAL_BLOCK_BYTES guards and deleted both the budget and
its SUFFIX_WORK_BUDGET_WARNING string. It updated session-dedup.test.ts but not
this sibling, so 3 of its 4 cases asserted a warning that can no longer be
emitted.

Realigned to the contract that actually survives — which is the invariant #7849
was opened for, not the mechanism:
- the pathological pair must stay BOUNDED (completes in <4s, body intact) —
  measured at ~280ms on the current guards;
- it must FAIL OPEN — original body returned by identity, compressed false,
  stats null (the explanatory zero-savings stats belonged to the removed
  budget path, which skipped before producing any);
- the 512 MiB child fixture must still exit 0 with the full engine chain
  (session-dedup, lite, rtk, headroom, caveman) — that IS the OOM guard — and
  session-dedup must still report its skip, now pinned by prefix since the
  reason string moved with the mechanism.

No threshold was loosened and no case was deleted: 4/4 here, 8/8 on the sibling
session-dedup.test.ts.

Refs #9298

* docs(mcp): bump the tool count to 105 and realign two vitest count pins

Three more base-reds from the same 08-06 batch, all count/contract drift that
the merged PRs left in sibling files.

**Docs Gates (fast-path) — 3 STRICT drifts**

check:docs-counts measures the MCP tool set from live code: it is 105 now
(#8925 added omniroute_create_combo), while README.md, AGENTS.md and
docs/frameworks/MCP-SERVER.md still claimed 104. Updated all five occurrences
(two of them inside SVG alt text). check:docs-all exits 0.

**Vitest (fast-path) — 2 failures**

- open-sse/mcp-server/__tests__/essentialTools.test.ts pinned 11 phase-1 tools;
  #8925 shipped omniroute_create_combo as phase 1, making it 12. Verified by
  enumerating MCP_ESSENTIAL_TOOLS directly.
- tests/unit/autoCombo/provider-family-combos.test.ts pinned the auto/glm
  provider set to [auggie, glm, zai]. #8914 (Devin ACP bridge) added
  devin-cli-agentic, whose catalog (registry/devin/catalog.ts:90-93) advertises
  the glm-5-2* line — so it belongs in the family pool for exactly the reason
  the test's own comment gives for auggie: a no-auth backend that genuinely
  serves a family model is a legitimate member. Expected set updated, invariant
  unchanged.

npm run test:vitest 36/36 files, 340/340 tests.

Refs #9298

* fix(combo,usage,oauth): drain the base-reds the shard fix exposed

With the migration collision and the broken import out of the way the four unit
shards actually run, and a further layer of base-reds became visible on the pure
tip 9995bc4893. Three are production defects.

**Production defects**

- open-sse/services/combo/runtimeUnitCapacity.ts:58 called resolveComboTargets()
  WITHOUT the hidden-model snapshot, so it fell back to the default
  getHiddenModelsByProvider() — a fresh full key_value read PER nested combo-ref
  unit, on every request. #8878 threaded the snapshot through the other call
  sites and missed this one. Threaded it from executeRuntimeUnitCombo (and from
  the dispatchPrelude call site), restoring the one-snapshot-per-request
  invariant combo-hidden-leaf-routing.test.ts pins. 9/9.
- open-sse/services/usage/firecrawl.ts silently ignored its own `apiKey`
  parameter: 91bb6aa619 moved the fetch to
  fetchFirecrawlQuota(connectionId, connection), which reads the key off the
  connection record, so any caller passing the key directly got "Firecrawl API
  key not available". The explicit key is now merged into the connection passed
  down. firecrawl-usage 8/8.
- src/lib/oauth/constants/oauth.ts was missing a RAYCAST entry in PROVIDERS
  while src/lib/oauth/providers/index.ts registers `raycast` (#8895), so every
  consumer reading PROVIDERS did not know Raycast Pro exists. Also added its
  OAUTH_TEST_CONFIG entry (checkExpiry only — it is an `import_token` provider
  with refreshToken always null), which #8408's guard explicitly requires rather
  than grandfathering. oauth-providers-config 25/25, oauth-test-config-8408 2/2.

**Count / contract drift from the same batch**

- feature flags 45 -> 46, APIKEY_PROVIDERS 197 -> 198 (Raycast Pro #8895),
  unique MCP tools 107 -> 108. Each re-derived from the source of truth.
- vi + pt-BR locales: translated the 8 keys #9415 added
  (providers.newApiAggregator* and providers.modelTestQuotaTooltip) instead of
  relaxing the parity guard. i18n-vi 5/5, i18n-pt-br 3/3.
- login-bootstrap-route: #9491 added `authenticated` to the require-login
  payload so /login can redirect an active session; the three deepEqual bodies
  now carry it. 10/10.

**Flaky-by-construction, made deterministic**

tests/unit/chat-combo-live-test.test.ts asserted the early-keepalive frame with
a 100ms mocked upstream while resolveKeepaliveThreshold() is 2000ms for
openai/*. It only ever passed while unrelated handler latency happened to push
the total past the threshold — incidental, not deterministic, and it stopped
holding once the handler got faster. The mock now sleeps 2400ms so the slow path
is guaranteed and the assertion means what it says. 5/5.

typecheck:core exit 0. check:file-size (base-relative) OK.

Refs #9298

* test(base): run the orphaned #8890 suite and realign three mechanism pins

**check:test-discovery — a suite that had NEVER executed**

#8890 landed open-sse/services/__tests__/fail-fast-concurrency-gate.test.ts into
a directory no runner collects (only one explicit file from that folder is in
vitest.mcp.config.ts), so it ran zero times since it merged. Wired it into the
runner AND into check-test-discovery.mjs's mirrored collector list, which the
gate keeps in sync deliberately. It passes 4/4 now that it actually runs —
test:vitest goes 36 -> 37 files, 340 -> 344 tests.

**check-db-rules-classification** — 37 -> 38 audited modules, adding probeUtils
alongside the INTENTIONALLY_INTERNAL entry from the previous commit.

**ratelimit-reservoir-refresh** — #9604 (rolling RPM leases) DELETED Bottleneck's
fixed-window reservoir, so currentReservoir() is null and the poll for
`reservoir === 2` could never settle. It updated several sibling suites but not
this one. The pin on the removed mechanism is gone; what remains is the
invariant the original Bottleneck heartbeat bug actually broke and that #9529
opened this test for — after a header-learned updateSettings() the limiter must
keep admitting work, proven by racing a post-exhaustion request against a 5s
timer. 1/1.

**translator-openai-to-gemini** — #9568 (c9a3361e5a) made
buildChangedToolNameMap emit IDENTITY entries too, because Gemini lowercases
tool names in functionCall responses and the response translator needs a key to
map them back. Any request carrying tools therefore carries `_toolNameMap` in
the Antigravity envelope now. Expected key list updated and the map's contents
asserted explicitly rather than left implicit. 45/45.

Refs #9298

* fix(db): restore node-backed synced catalogs and realign the #8944 context hints

**Production regression from #9294 (d69f521491)**

lookupModelMeta moved from getSyncedAvailableModels(providerId) to
getActiveSyncedCatalog(providerId). The new reader unions models only from rows
in `provider_connections` with isActive = 1 — but a provider NODE lives in
`provider_nodes` and NEVER has a connections row, so filtering by active
connection ids silently dropped every node's synced catalog.

The consequence was not just a missing list: lookupModelMeta reads that catalog
for RUNTIME METADATA, so for openai-compatible nodes it took out
- `supportedThinkingEfforts`, which is what splitSyncedEffortSuffix needs — so
  `<prefix>/<model>-high` stopped resolving to the base id and the effort was
  never derived (#7694), and
- `contextWindow` / `maxInputTokens`, used by the combo context-window filter.

getActiveSyncedCatalog now falls back to the provider-wide key_value set — the
exact pre-#9294 source — when no active connection carries a catalog, and marks
that fallback explicitly NON-authoritative. #9294's live-catalog gating is about
what an active connection actually serves, so a node-backed catalog informs
metadata while never being able to reject a model as unavailable. `available`
therefore stays fail-open for nodes, as it was before.

sync-reasoning-supported-efforts-7694 23/23 (was 21/2).
live-model-catalog-reconciliation-8926 11/11 and combo-provider-wildcard 23/23
confirm #9294's own coverage is untouched.

**#8944 sibling-test drift**

714a315a1a ("Treat context metadata as a routing hint") deliberately turned the
context-window check from a HARD filter into an ordering hint: a catalog-too-small
target is demoted, not removed, because a stale catalog entry must never delete
the only target that could accept the request at runtime. The PR updated one case
in this suite and left three asserting the old drop behaviour. Realigned to the
new contract — the too-small target must lose the ordering to the fitting one
while remaining present — and renamed them from "still rejects"/"still dropped"
to "is demoted"/"ordered last" so the names stop describing the removed
behaviour. 14/14.

**file-size**

tests/unit/translator-openai-to-gemini.test.ts testFrozen 1616 -> 1619: the
frozen value sat exactly at the base size, so the 3 lines the previous commit's
_toolNameMap alignment needs could not fit. Justified in the baseline.

typecheck:core exit 0.

Refs #9298

* chore(stryker): register the two covering suites missing from tap.testFiles

check:mutation-test-coverage flags any unit test that covers a mutated module but
is absent from stryker.conf.json tap.testFiles — without the entry its mutant
kills do not count toward the module's score.

- tests/unit/antigravity-prefer-stored-project.test.ts covers
  open-sse/services/combo/quotaStrategies.ts (added earlier in this PR).
- tests/unit/executor-devin-cli-agentic-acp.test.ts covers
  src/sse/services/auth.ts — pre-existing drift, same gate, same fix.

Inserted in alphabetical position only; the rest of the file is byte-identical
(it is not prettier-formatted upstream and reformatting it is out of scope here).

Refs #9298

* fix(db): drop the never-wired getSessionModelUsageCounts (knip regression)

The dead-code ratchet only ran once the earlier Fast Quality Gates steps stopped
failing, and it lands at 228 vs baseline 227.

The extra symbol is src/lib/db/contextHandoffs.ts::getSessionModelUsageCounts,
added by #8894 "for least-used strategy" and never wired: the least-used branch
in applyStrategyOrdering.ts uses the pre-existing sortTargetsByUsage(), and the
helper has no caller in src/, open-sse/ or tests/. It is the same incomplete-PR
shape as that PR's import of a module which does not exist in the repo (fixed
earlier in this branch).

Removed rather than baselined — bumping the ratchet would loosen the gate, and
removal is exactly the remedy the gate prescribes. Same treatment the Dario
installer's never-wired uninstall() got in #9600. The implementation is
recoverable from a598fbb090 whenever someone actually wires a session-aware
least-used strategy.

check:dead-code 228 -> 227 (baseline untouched). check:db-rules exit 0.
context-handoff 13/13, db-context-handoffs 7/7, service-context-handoff 11/11.

Refs #9298

* fix(security): embed the Raycast signature secret via resolvePublicCred (HR#11)

The secret-scan ratchet only ran once the earlier Fast Quality Gates steps
stopped failing, and it lands at 1 finding vs baseline 0.

The finding is open-sse/services/raycast.ts:19 —
RAYCAST_DEFAULT_SIG_SECRET, a 64-hex request-signature secret that #8895
committed as a bare string literal. It is genuinely public (community-extracted
from the Raycast macOS client; the SAME value ships to every install, it is not
a per-user credential), which is exactly the category Hard Rule #11 governs:
public upstream credentials MUST go through resolvePublicCred()
(open-sse/utils/publicCreds.ts), never a literal — see
docs/security/PUBLIC_CREDS.md.

So the fix is the mandated pattern, not a .gitleaks.toml allowlist entry: added
`raycast_sig_secret` to EMBEDDED_DEFAULTS as the XOR-masked byte sequence and
resolved it with the existing RAYCAST_SIG_SECRET env override. The
providerSpecificData.sigSecret override is untouched. Verified the decoded value
is byte-identical to the literal it replaces.

check:secrets secretFindings 1 -> 0. check:public-creds exit 0.
publicCreds 12/12, raycast-auth 6/6, raycast-local-extract 1/1,
trae-publiccred 3/3. typecheck:core exit 0.

Refs #9298

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-07 11:13:20 -03:00
Diego Rodrigues de Sa e Souza
d86ea99713 feat(radar): referral links — free-credits tab + default provider link (#9697)
* feat(radar): client-side schema + accessor for referral links (D28)

Server already publishes a signed `referrals` section on the Radar feed
({fixed, campaigns}); this adds the client mirror: RadarFeedSchema gains a
`.default()`-backed `referrals` field (old cached feeds without it stay
valid) with https-only url validation, and src/lib/radar/index.ts exposes
getRadarReferrals()/getDefaultReferralFor() (never throw: flag off, no
cache, or a corrupt/old payload all resolve to the empty shape). The
provider-default lookup itself lives in a new DB-free src/lib/radar/
referrals.ts so it stays safe to import from a "use client" component.

* feat(radar): add GET /api/radar/referrals route (D28)

Local-only route mirroring the /api/radar/catalog gate order: RADAR_ENABLED
off => 404 before any auth check (byte-identical flag-off inertia),
unauthenticated => 401, otherwise 200 with {fixed, campaigns, tier} read
straight from the local cache. Never proxies the private feed server.

* feat(dashboard): add "free credits" tab to the Radar page (D28)

Reuses the existing /dashboard/radar page instead of a new route (less
routing/i18n surface): a second tab lists fixed referral links (grouped by
provider, with requiredAction + an external-link button) and temporary
campaigns (with validUntil). When campaigns is empty and the served tier is
community, shows a soft upsell note — never gates the fixed links list,
which stays fully populated on every tier. Adds 10 new radarPage i18n keys
(English fallback) to all 43 locale files to avoid dropping i18n-ui-coverage
below threshold.

* feat(providers): use Radar default referral link on the provider name (D28)

ProviderPageHeader already linked the provider name to providerInfo.website
with a precedent for a monetized link (the Kimi partner-link note); this
lets a Radar default referral override that URL, reusing the exact same
discreet note instead of a new visual treatment.

Loose coupling: resolveProviderHeaderLink() in providerPageUtils.ts is a
pure function with no @/lib/radar or @/lib/db/* import (asserted by the new
test), so the providers dashboard never depends on the DB-touching Radar
module to render. ProviderDetailPageClient (a "use client" component) is
the only place that fetches Radar data, via the local /api/radar/referrals
route (same pattern the Radar page itself uses) and the DB-free
findDefaultReferral() helper. With RADAR_ENABLED off, no cache, or no
default referral for the provider, the header renders byte-identical to
before this feature existed.

* docs(radar): document referral links / free credits (D28)

Adds a "Referral links (free credits)" section covering the referrals feed
shape, the getRadarReferrals()/getDefaultReferralFor() accessors, the new
GET /api/radar/referrals route, the Radar page's "Free credits" tab, and
the loosely-coupled referral link on the provider-name header. Also
corrects the local-routes count (four -> five) now that /api/radar/
referrals exists.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-07 10:17:46 -03:00
Diego Rodrigues de Sa e Souza
1e15583f29 fix(radar): close audit gaps (auth, feed fields, opt-in state, sidebar gate, size cap) + daily sync scheduler (#9686)
* fix(radar): preserve extended feed fields and honor local enable override

applyFeed()'s MergedEntry shape omitted contextWindow/capabilities/limits/
setup even though FeedModel always carries them, so the dashboard's setup
link, Context column, and capability badges never rendered and the setup
page's provider lookup always failed. Both merge paths (mergeOne and
feedModelToMerged) now copy the four fields through, respecting rule 1
(local override wins) same as every other field.

feedModelToMerged() also unconditionally forced enabled:false when the feed
disabled a feed-only entry, even when the operator had locally overridden
enabled:true — mergeOne() already applies overrides after the disable rule
and got this right. feedModelToMerged() now only force-disables when there
is no local `enabled` override, matching mergeOne()'s semantics.

* fix(radar): cap feed sync response body at 10MB

syncRadar() buffered the entire feed response via
Buffer.from(await res.arrayBuffer()) with no size limit, so a
misconfigured or hostile RADAR_FEED_URL (or an upstream serving garbage)
could force an unbounded in-memory buffer. Enforcement is two-layered: a
Content-Length preflight skips reading an already-oversized body entirely,
and a running-total check while reading the stream enforces the cap even
when Content-Length is absent or understates the real size — concatenating
the accumulated chunks preserves the exact bytes the signature check needs.

Exceeding the cap returns a new { status: "too_large" } SyncStatus and
leaves the cache untouched, following the same non-destructive pattern as
every other sync failure (invalid_signature/invalid_schema/stale).

* fix(radar): gate the sidebar radar item behind RADAR_ENABLED

The "radar" sidebar item was registered unconditionally in
sidebarVisibility/sections.ts, but Sidebar.tsx has no feature-flag
awareness (it's a client component), so the link stayed visible and
clickable with RADAR_ENABLED off, landing on a 404 dashboard page.

Sidebar items gain an opt-in `featureFlagKey` field plus a pure
isSidebarItemVisibleForFlags() filter (fails open when a flag isn't in the
map, so a missing/not-yet-loaded key never hides an unrelated item). The
resolved flag value piggy-backs on the /api/settings response the sidebar
already fetches on mount (new `radarEnabled` field) rather than adding a
dedicated round trip.

* fix(radar): require auth on management routes, add GET settings

GET /api/radar/catalog, POST /api/radar/sync, and POST /api/radar/settings
had zero authentication — any client that could reach the local server
could read the merged catalog, trigger a sync, or flip the opt-in/set the
supporter key. All three (plus the new GET below) now call
isAuthenticated() from the shared apiAuth guard, same gate as the rest of
/api/settings/*. The RADAR_ENABLED flag-off 404 check keeps running FIRST
so flag-off inertia stays byte-identical (no auth prompt just to learn the
surface doesn't exist); auth runs after it, before any DB access.

Adds GET /api/radar/settings, returning { optIn, hasSupporterKey,
supporterKeyMasked } — the raw key never leaves the server on either verb.
The dashboard page's fetchSettings() now calls this endpoint instead of
inferring opt-in state from the catalog response (which always defaulted
to unknown/null), so an already-activated operator no longer sees the
activation screen on every reload. handleSync() also handles the new
too_large sync status introduced by the response-cap fix, reusing the
existing generic sync-failed copy (no new UI strings).

* docs(radar): fix stale feed URL, document tier header/auth/size cap

- RADAR_FEED_URL default was documented as radar.omniroute.dev in
  ENVIRONMENT.md; the actual default (src/lib/radar/sync.ts) and every
  other reference use radar.omniroute.online — fix the one stale spot.
- Correct the FREE_MODEL_BUDGETS source path: it's declared in
  freeModelCatalog.data.ts, not freeModelCatalog.ts (which only
  re-exports it).
- Document that the signed feed body's `tier` is always "live" (one
  signed artifact per version) and the actually-served tier comes from
  the `x-omniroute-feed-tier` response header, resolved with a Zod parse
  + fallback to the body field.
- Document that all four /api/radar/* routes now require auth
  (isAuthenticated(), same gate as /api/settings/*), the new
  GET /api/radar/settings route, and the new too_large sync status from
  the 10MB response cap.

* feat(radar): daily sync scheduler + auto-sync on page open

Spec asks for a 1x/day sync while opted in and fresh data on every page
open. The scheduler only arms itself when RADAR_ENABLED AND the opt-in are
already on (boot) or right after the user opts in (settings route) — a
flag-off install never creates the timer, preserving the inertia contract.
The page auto-syncs once per mount when the cached feed is older than 6h.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-07 08:26:18 -03:00
Diego Rodrigues de Sa e Souza
9995bc4893 fix(security): anchor hostname comparison in Adobe Firefly login (#778)
Parse and compare hostname with dot-anchored endsWith instead of substring includes. Closes code-scanning #778.
2026-08-06 23:12:54 -03:00
Diego Rodrigues de Sa e Souza
c9a3361e5a fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)
Closes #9575
2026-08-06 22:58:51 -03:00
Diego Rodrigues de Sa e Souza
c9debe92bd fix(translator): restore original tool name casing in Gemini response translators (#9568)
Closes #9568
2026-08-06 22:55:50 -03:00
Diego Rodrigues de Sa e Souza
fad3539a69 fix(sse): replace timer-based waits with polling to fix flaky chatCore/SSE tests under CI load (#9567)
Closes #9567
2026-08-06 22:55:44 -03:00
Diego Rodrigues de Sa e Souza
919f9acd80 fix(build): lazy-resolve module-level fs paths to avoid Turbopack NFT whole-source trace (#9560)
Closes #9560
2026-08-06 22:55:38 -03:00
Diego Rodrigues de Sa e Souza
8a573c56e3 fix(proxy): NO_PROXY now bypasses context-level proxy in resolveProxyForRequest (#9551)
Closes #9551
2026-08-06 22:55:33 -03:00
Diego Rodrigues de Sa e Souza
f338363cd3 fix(model): add aq alias for amazon-q provider so parseModel resolves it instead of falling back to OpenAI (#9550)
Closes #9550
2026-08-06 22:55:27 -03:00
Diego Rodrigues de Sa e Souza
28dc5af7ba fix(providers): strip provider prefix in getModelTargetFormat to route GPT-5.6 models to /v1/responses (#9545)
Closes #9545
2026-08-06 22:55:21 -03:00
Diego Rodrigues de Sa e Souza
616175a93e fix(search): mark searxng-search as fallbackOnly to prevent auto-select without instance (#9543)
Closes #9543
2026-08-06 22:55:16 -03:00
Diego Rodrigues de Sa e Souza
e4e0c254ea fix(db): add transient-error retry to corruption probe to prevent data loss under concurrent load (#9541)
Closes #9541
2026-08-06 22:55:10 -03:00
Diego Rodrigues de Sa e Souza
2e9abab944 fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)
Closes #9536
2026-08-06 22:55:05 -03:00
Diego Rodrigues de Sa e Souza
4cc9cf8123 fix(test): prevent flaky modelsDevSync timer assertions by serializing test execution within the file (#9534)
Closes #9534
2026-08-06 22:54:59 -03:00
Diego Rodrigues de Sa e Souza
6c3aea6ba6 fix(ci): include combo-matrix tests in test-integration job (#9531)
Closes #9531
2026-08-06 22:54:53 -03:00
Diego Rodrigues de Sa e Souza
9edefd4572 fix(oauth): Kiro import token endpoint no longer overwrites existing connection when using shared cached OIDC clientId (#9435)
Closes #9435
2026-08-06 22:54:48 -03:00
Diego Rodrigues de Sa e Souza
8fbd331567 fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946)
Closes #8946
2026-08-06 22:54:42 -03:00
diegosouzapw
ef236934c0 feat(providers): add Muse Code CLI provider preset (#9544) 2026-08-06 21:29:32 -03:00
小妍儿 ✨
5f471181fa refactor(db): add combo repository boundary (#8757)
Validated in local merge-train (tomni-proxmox-113)
2026-08-06 19:11:57 -03:00
Lucas Mellos Carlos
ebdbe3a38f feat(mcp): add omniroute_create_combo tool (#8925)
Validated in local merge-train (tomni-proxmox-113)
2026-08-06 19:11:07 -03:00
diegosouzapw
535c75b60a fix(security): parse and compare hostname instead of substring match in Adobe Firefly login
Replace request.url.includes(FIREFLY_3P_HOST_SUFFIX) with parsed-hostname
comparison (anchored endsWith), closing CodeQL alert #778.

The old substring check could be bypassed by an attacker-controlled page
visited during the browser login window — a URL like
'https://evil.com/firefly-3p.ff.adobe.io' would pass the gate and its
Bearer token would be captured as the Adobe credential.

Practical severity is low (only during operator-initiated, time-boxed
login on a temp-profile browser), but the fix is one line and matches
the dot-anchored idiom used in docker/devin-bridge/network-guard/.

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

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

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

Confidence: high

Scope-risk: narrow

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

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

Three fixes across the direct path:

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 |

0
MAX Normal file
View File

View File

@@ -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 1595%, ~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 1595%, ~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&apos;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&apos;s docs."/>
<sub>📊 Full methodology &amp; per-feature detail vs 9router, OpenRouter, CLIProxyAPI &amp; 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
- **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))

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
- feat(providers): add Muse Code CLI provider preset (#9544)

View File

@@ -0,0 +1 @@
- fix(yuanbao-web): accept `content` field in SSE text events (upstream format change) (#8739)

View File

@@ -0,0 +1 @@
- fix(errorClassifier): classify ChatGPT Web SENTINEL_BLOCKED 403 as terminal FORBIDDEN, enabling proper combo fallback (#8813)

View File

@@ -0,0 +1 @@
- fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946)

View File

@@ -0,0 +1 @@
- fix(vertex): route Claude models to native rawPredict endpoint and respect custom targetFormat overrides (#8994)

View File

@@ -0,0 +1 @@
- fix(cursor): preserve tool context across multi-turn conversations when client lacks conversation_id (#9029)

View 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)

View File

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

View File

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

View File

@@ -0,0 +1 @@
- fix(oauth): Kiro import token endpoint no longer overwrites existing connection when using shared cached OIDC clientId (#9435)

View File

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

View File

@@ -0,0 +1 @@
- fix(ci): include combo-matrix tests in test-integration job (#9531)

View File

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

View File

@@ -0,0 +1 @@
- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)

View File

@@ -0,0 +1 @@
- fix(db): add transient-error retry to corruption probe to prevent data loss under concurrent load (#9541)

View File

@@ -0,0 +1 @@
- fix(search): mark searxng-search as fallbackOnly to prevent auto-select without instance (#9543)

View File

@@ -0,0 +1 @@
- fix(providers): strip provider prefix in getModelTargetFormat to route GPT-5.6 models to /v1/responses (#9545)

View File

@@ -0,0 +1 @@
- fix(model): add "aq" alias for amazon-q provider so parseModel resolves it instead of falling back to OpenAI (#9550)

View File

@@ -0,0 +1 @@
- fix(proxy): NO_PROXY now bypasses context-level proxy in resolveProxyForRequest (#9551)

View File

@@ -0,0 +1 @@
- fix(build): lazy-resolve module-level fs paths to avoid Turbopack NFT whole-source trace (#9560)

View File

@@ -0,0 +1 @@
- fix(sse): replace timer-based waits with polling to fix flaky chatCore/SSE tests under CI load (#9567)

View File

@@ -0,0 +1 @@
- **fix(translator):** restore original tool name casing in Gemini/Antigravity response translators ([#9568](https://github.com/diegosouzapw/OmniRoute/issues/9568))

View File

@@ -0,0 +1 @@
- fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)

View File

@@ -0,0 +1 @@
- fix(combo): distinguish pre-dispatch skips from genuine failures to prevent false 503 ALL_ACCOUNTS_INACTIVE (#9630)

View File

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

View File

@@ -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
}
}
}
}

View File

@@ -1,5 +1,6 @@
{
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent's conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR's own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgents conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PRs own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
"_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.",
"_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).",
"_rebaseline_2026_07_22_8131_windowshide_cloudflared_spawn": "PR #8167 (Dingding-leo, fix/windows-hide-child-process, #8131) own growth: src/lib/cloudflaredTunnel.ts 934->935 (+1, irreducible call-site wiring — the single `windowsHide: true` option added to the existing cloudflared spawn() options object so no transient conhost.exe/cmd console window flashes open on Windows). Covered by the pre-merge-fix regression test tests/unit/windows-hide-child-process-spawns-8131.test.ts (added for the two additional spawn() sites the PR missed: ServiceSupervisor.ts, versionManager/processManager.ts) plus the windowsHide assertion added to tests/unit/services/installers/runNpm-shell-5379.test.ts (installers/utils.ts buildNpmExecOptions).",
@@ -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."
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

181
docs/DEVIN_CLAUDE_BRIDGE.md Normal file
View File

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

View File

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

View File

@@ -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 |
| :--------------- | :-------------------------------------------------------------------------------------- |

View File

@@ -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.

View File

@@ -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");
}

View File

@@ -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");
}

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,9 @@ export const command_codeProvider: RegistryEntry = {
baseUrl: "https://api.commandcode.ai",
chatPath: "/alpha/generate",
modelsUrl: "https://api.commandcode.ai/provider/v1/models",
// The discovery response is a partial routing catalog; static registry
// entries omitted from it can still be accepted by the gateway.
liveCatalogAuthoritative: false,
authType: "apikey",
authHeader: "Authorization",
authPrefix: "Bearer ",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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"],
},
],
});

View File

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

View File

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

View File

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

View File

@@ -207,6 +207,7 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
maxMaxResults: 50,
timeoutMs: 10_000,
cacheTTLMs: 3 * 60 * 1000,
fallbackOnly: true,
},
"ollama-search": {

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

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