Merge remote-tracking branch 'upstream/release/v3.8.50' into feat/agentic-conversation-tracking

# Conflicts:
#	config/quality/file-size-baseline.json
#	src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard.tsx
#	src/i18n/messages/es.json
#	src/lib/localDb.ts
#	tests/unit/catalog-order-contract.test.ts
This commit is contained in:
Markus Hartung
2026-08-06 11:57:14 +02:00
322 changed files with 28164 additions and 11040 deletions

View File

@@ -1536,6 +1536,14 @@ APP_LOG_TO_FILE=true
# Default: 86400000 (24 hours)
# OPENROUTER_CATALOG_TTL_MS=86400000
# Enrich the dashboard providers list with OpenRouter weekly ranking stats.
# ON by default; set false to skip the background fetch entirely (#9324).
# Used by: src/lib/catalog/openrouterProviderStats.ts
# OPENROUTER_PROVIDER_STATS_ENABLED=true
# Cache TTL for the OpenRouter provider stats snapshot, in ms.
# Default: 86400000 (24 hours)
# OPENROUTER_PROVIDER_STATS_TTL_MS=86400000
# ── Model catalog response shape ──
# Include display-friendly name fields in /v1/models responses.
# Disable for clients that expect model IDs only.
@@ -1556,6 +1564,13 @@ APP_LOG_TO_FILE=true
# DESIGNER_WEB_POLL_TIMEOUT_MS=60000 # Max wait for job completion (default: 60s)
# DESIGNER_WEB_POLL_INTERVAL_MS=2000 # Poll frequency (default: 2s)
# ── Adobe Firefly (Image Upscale) ──
# Base delay (ms) for the submit-retry exponential backoff when Adobe Firefly's
# upscale job submission is rate-limited. Used by:
# open-sse/services/adobeFireflyUpscale.ts::submitRetryDelayMs.
# Default: 8000 (20 under NODE_ENV=test/VITEST/NODE_TEST_CONTEXT).
# ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS=8000
# ── AWS Bedrock (Kiro / Audio) ──
# Region used to construct AWS Bedrock endpoints. Used by:
# src/lib/providers/validation.ts and open-sse/handlers/audioSpeech.ts.
@@ -1650,6 +1665,26 @@ APP_LOG_TO_FILE=true
# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts
# MUX_SERVICE_PORT=8322
# ── Dario embedded service ──
# Override the host/port the embedded Dario (Claude Code subscription proxy)
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456.
# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts,
# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts,
# open-sse/executors/dario.ts
# DARIO_HOST=127.0.0.1
# DARIO_PORT=3456
# ── Dario embedded service ──
# Override the host/port the embedded Dario (Claude Code subscription proxy)
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456.
# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts,
# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts,
# open-sse/executors/dario.ts
# DARIO_HOST=127.0.0.1
# DARIO_PORT=3456
# ── Local hostnames (Docker networking) ──
# Comma-separated additional hostnames treated as "local" for provider routing.
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
@@ -2152,6 +2187,11 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too
# MEMORY_TYPED_DECAY_ACCESS_IMMUNITY=3 # access_count >= N → immune; 0 disables access immunity
# MEMORY_TYPED_DECAY_SWEEP_INTERVAL=0 # periodic sweep interval (seconds); 0 = no periodic sweep
# ─── Memory Backend Connectors (Generic HTTP) ──────────────────────────────
# NOTION_API_KEY=
# NOTION_API_URL=
# OBSIDIAN_API_KEY=
# OBSIDIAN_API_URL=
# AgentBridge + Traffic Inspector (Group A)
# AgentBridge
@@ -2167,6 +2207,15 @@ INSPECTOR_MAX_BODY_KB=1024
INSPECTOR_MASK_SECRETS=true
INSPECTOR_LLM_HOSTS_EXTRA=
INSPECTOR_INTERNAL_INGEST_TOKEN=
# Shared secret for identity-preserving internal REST hops (#9260): when an
# OmniRoute component calls another local OmniRoute route, this token (sent as
# x-omniroute-internal-service-token) marks the request as internal so the
# original caller identity is preserved. OPT-IN: unset disables the mechanism.
# Used by: src/lib/api/internalServiceAuth.ts
# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
# File-based variant (secret-file pattern; wins only when the inline var is
# unset): path to a file whose trimmed content is the token.
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
# Quota Sharing (Group B — planos 16+22)
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
@@ -2383,3 +2432,29 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# OMNIROUTE_DATA_DIR are both unset. Locates the Notion web-thread session cache.
# ─────────────────────────────────────────────────────────────────────────────
# VIBEPROXY_DATA_DIR=
# ── Internal service auth (management-plane service-to-service calls) ─────────
# Inline token for internal service authentication; prefer the _FILE variant in
# containerized deployments so the secret never lands in the environment table.
# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
# Path to a file containing the internal service token (overrides the inline var).
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
# ═══════════════════════════════════════════════════════════════════════════════
# 26. RADAR FEED (SELF-HOSTING)
# ═══════════════════════════════════════════════════════════════════════════════
# 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.
# Base URL of the Radar feed service. Overrides the built-in default so forks
# and self-hosters can point at their own signed feed.
# RADAR_FEED_URL=https://radar.omniroute.online
# Ed25519 public key (base64-DER SPKI or PEM) used to verify the feed
# signature, replacing the pinned default key. Required when self-hosting a
# feed signed with a different key pair.
# RADAR_FEED_PUBKEY=

View File

@@ -330,7 +330,10 @@ function trimLeadingDashes(value: string): string {
* sees a consistent identifier.
*/
export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Required<
Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl" | "autoSyncIntervalMs">
Pick<
OmniRoutePluginOptions,
"providerId" | "displayName" | "modelCacheTtl" | "autoSyncIntervalMs"
>
> & {
/**
* #6859: the UNPREFIXED provider id ("omniroute", "omniroute-preprod", …).
@@ -621,7 +624,7 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook
*/
export function invalidateOmniRouteFetchCache(
cache: OmniRouteFetchCache,
baseURL?: string,
baseURL?: string
): number {
if (!baseURL) {
const n = cache.size;
@@ -645,7 +648,7 @@ export function invalidateOmniRouteFetchCache(
*/
export async function resolveOmniRouteRuntimeAuth(
resolved: ResolvedOmniRoutePluginOptions,
readAuthJson?: OmniRouteReadAuthJson,
readAuthJson?: OmniRouteReadAuthJson
): Promise<{ apiKey: string; baseURL: string; managementReadToken: string } | null> {
const reader = readAuthJson ?? defaultReadAuthJson;
let authJson: AuthJsonShape | undefined | null;
@@ -672,7 +675,7 @@ export async function resolveOmniRouteRuntimeAuth(
e &&
(e as { type?: unknown }).type === "api" &&
typeof (e as { key?: unknown }).key === "string" &&
((e as { key: string }).key).length > 0
(e as { key: string }).key.length > 0
) {
entry = e as AuthJsonApiEntry;
break;
@@ -737,7 +740,7 @@ export async function forceSyncOmniRouteModels(args: {
const auth = await resolveOmniRouteRuntimeAuth(
resolved,
args.readAuthJson ?? defaultReadAuthJson,
args.readAuthJson ?? defaultReadAuthJson
);
if (!auth) {
return {
@@ -795,7 +798,7 @@ export async function forceSyncOmniRouteModels(args: {
rawCompressionCombos = await compressionMetaFetcher(
auth.baseURL,
auth.managementReadToken,
10_000,
10_000
);
} catch {
rawCompressionCombos = [];
@@ -820,10 +823,7 @@ export async function forceSyncOmniRouteModels(args: {
rawConnections,
expiresAt: t + resolved.modelCacheTtl,
};
const cacheKey = modelsCacheKey(
auth.baseURL,
`${auth.apiKey}\0${auth.managementReadToken}`,
);
const cacheKey = modelsCacheKey(auth.baseURL, `${auth.apiKey}\0${auth.managementReadToken}`);
cache.set(cacheKey, entry);
if (wantDiskCache) {
@@ -831,7 +831,7 @@ export async function forceSyncOmniRouteModels(args: {
const fingerprint = diskSnapshotIdentityFingerprint(
auth.baseURL,
auth.apiKey,
auth.managementReadToken,
auth.managementReadToken
);
const { expiresAt: _expiresAt, ...diskEntry } = entry;
await defaultDiskSnapshotWriter(resolved.providerId, diskEntry, fingerprint);
@@ -843,7 +843,7 @@ export async function forceSyncOmniRouteModels(args: {
console.warn(
`[omniroute-plugin] force sync ok providerId=${resolved.providerId} ` +
`models=${rawModels.length} combos=${rawCombos.length} ` +
`clearedMemory=${clearedMemory + clearedAll} disk=${clearedDisk}`,
`clearedMemory=${clearedMemory + clearedAll} disk=${clearedDisk}`
);
return {
@@ -944,7 +944,7 @@ export function startOmniRouteAutoSync(args: {
const result = await forceSyncOmniRouteModels({ resolved, cache });
if (!result.ok) {
console.warn(
`[omniroute-plugin] auto-sync failed providerId=${resolved.providerId}: ${result.error}`,
`[omniroute-plugin] auto-sync failed providerId=${resolved.providerId}: ${result.error}`
);
return;
}
@@ -955,7 +955,7 @@ export function startOmniRouteAutoSync(args: {
if (result.count !== lastCount) {
console.warn(
`[omniroute-plugin] auto-sync catalog size changed ${lastCount}${result.count} ` +
`(providerId=${resolved.providerId})`,
`(providerId=${resolved.providerId})`
);
lastCount = result.count;
}
@@ -976,7 +976,7 @@ export function startOmniRouteAutoSync(args: {
}
console.warn(
`[omniroute-plugin] auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`,
`[omniroute-plugin] auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`
);
return () => {
@@ -1032,7 +1032,13 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
const cfg = input as Config & {
command?: Record<
string,
{ template: string; description?: string; agent?: string; model?: string; subtask?: boolean }
{
template: string;
description?: string;
agent?: string;
model?: string;
subtask?: boolean;
}
>;
};
if (!cfg.command) cfg.command = {};
@@ -4466,7 +4472,8 @@ export function buildStaticProviderEntry(
// (`opencode-omniroute/opencode-omniroute/<slug>`), and `parseModel()`
// resolves credentials for the nonexistent provider `opencode-omniroute`
// instead of `omniroute`. See #7976.
models[buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId).split("/").pop()!] = entry;
models[buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId).split("/").pop()!] =
entry;
// Make this combo's resolved entry available to parent combos
// that reference it via combo-ref. Use the friendly name since

View File

@@ -399,6 +399,7 @@ For any non-trivial change, read the matching deep-dive first:
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
| Skills framework | `docs/frameworks/SKILLS.md` |
| Radar (free-model catalog overlay) | `docs/frameworks/RADAR.md` |
| Memory system (FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` |
| Cloud agents | `docs/frameworks/CLOUD_AGENT.md` |
| Guardrails (PII / injection / vision) | `docs/security/GUARDRAILS.md` |
@@ -427,7 +428,7 @@ For any non-trivial change, read the matching deep-dive first:
| What | Command |
| ----------------------- | --------------------------------------------------------------------------- |
| Unit tests | `npm run test:unit` |
| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` |
| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` |
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
| E2E (Playwright) | `npm run test:e2e` |
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |

View File

@@ -299,7 +299,15 @@ async function checkNativeBinary(rootDir) {
"Release",
"better_sqlite3.node"
),
path.join(rootDir, "dist", "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
path.join(
rootDir,
"dist",
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
),
path.join(rootDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
];
const binaryPath = candidates.find((candidate) => fs.existsSync(candidate));
@@ -396,7 +404,10 @@ async function checkServerLiveness(options = {}) {
// First attempt: configured health endpoint (may require auth token).
const primary = await probeUrl(url);
if (primary.ok) {
return ok("Server liveness", "Server health endpoint is reachable", { url, status: primary.status });
return ok("Server liveness", "Server health endpoint is reachable", {
url,
status: primary.status,
});
}
// #6162: /api/health and /api/health/degradation require a management token.
@@ -427,7 +438,12 @@ async function checkServerLiveness(options = {}) {
return ok(
"Server liveness",
`Server reachable (health endpoint returned ${primary.status}, likely requires MANAGEMENT_TOKEN)`,
{ primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status }
{
primaryUrl: url,
primaryStatus: primary.status,
fallbackUrl,
fallbackStatus: fallback.status,
}
);
}
@@ -440,8 +456,7 @@ async function checkServerLiveness(options = {}) {
export async function collectDoctorChecks(context = {}, options = {}) {
const rootDir =
context.rootDir ||
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const dataDir = resolveDataDir();
const dbPath = resolveStoragePath(dataDir);

View File

@@ -159,9 +159,7 @@ async function runBrowserFlow(def, opts) {
}
const result = await exchangeRes.json();
const conn = result.connection ?? {};
process.stdout.write(
`Authorized: ${conn.email ?? conn.displayName ?? conn.id ?? "connected"}\n`
);
process.stdout.write(`Authorized: ${conn.email ?? conn.displayName ?? conn.id ?? "connected"}\n`);
}
async function safeErrorBody(res) {

View File

@@ -160,8 +160,7 @@ export async function runSetupClaudeCommand(opts = {}) {
let detail = `HTTP ${res.status}`;
try {
const errorBody = await res.json();
const serverMsg =
errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
const serverMsg = errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
if (serverMsg) detail += `${serverMsg}`;
} catch {}
throw new Error(detail);

View File

@@ -188,16 +188,18 @@ export async function runUpdateCommand(opts = {}) {
const afterVersion = await getCurrentVersion();
if (afterVersion && compareVersions(afterVersion, latest) < 0) {
printError(
`Global install updated to ${latest}, but the running binary still reports ${afterVersion}.`,
`Global install updated to ${latest}, but the running binary still reports ${afterVersion}.`
);
console.log(
" A local `node_modules/omniroute` is likely shadowing the global install on PATH.",
" A local `node_modules/omniroute` is likely shadowing the global install on PATH."
);
console.log(" Diagnose with:");
console.log(" which -a omniroute");
console.log(" command -v omniroute");
console.log(" npm prefix -g");
console.log(" Then remove the shadowing local copy (e.g. `npm uninstall omniroute` from its directory)");
console.log(
" Then remove the shadowing local copy (e.g. `npm uninstall omniroute` from its directory)"
);
console.log(" or reorder PATH so the global bin comes first.");
return 1;
}

View File

@@ -94,10 +94,12 @@ export function isBetterSqliteBinaryValid() {
const magic = buf.toString("hex");
const os = platform();
let formatOk;
if (os === "linux") formatOk = magic.startsWith("7f454c46"); // ELF
if (os === "linux")
formatOk = magic.startsWith("7f454c46"); // ELF
else if (os === "darwin")
formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
else if (os === "win32") formatOk = magic.startsWith("4d5a"); // PE/MZ
else if (os === "win32")
formatOk = magic.startsWith("4d5a"); // PE/MZ
else formatOk = true;
if (!formatOk) return false;
// File-format magic bytes alone do not guarantee the binary was built for the Node ABI

View File

@@ -0,0 +1 @@
- **feat(electron):** Desktop app can now attach to an already-running OmniRoute server (e.g. a Docker/OrbStack container) instead of always spawning its own bundled server — configurable via the tray's "Remote Server → Connect to Remote Server…" or the `OMNIROUTE_REMOTE_URL` env var ([#8799](https://github.com/diegosouzapw/OmniRoute/pull/8799)) — thanks @soulhakr

View File

@@ -0,0 +1 @@
- **fix(build):** `npm run build:cli` (prepublish) no longer fails on POSIX with "npm-cli.js not found next to the running Node binary". The #8858 shim-free npm resolver only knew the Windows layout (`<dir(node.exe)>\node_modules\npm`); on GitHub hosted runners, nvm and system installs npm lives at `<prefix>/lib/node_modules/npm` while node is `<prefix>/bin/node`, so every fresh CI checkout died installing `@omniroute/opencode-plugin` deps (Fast Production Build + dast-smoke red on all PRs). The resolver, extracted to `scripts/build/resolveNpmEntry.ts`, now tries `npm_execpath` (exported by `npm run` itself) first, then the Windows layout, then the POSIX layout — covered by `tests/unit/build/resolve-npm-entry.test.ts` including a live POSIX regression guard.

View File

@@ -0,0 +1 @@
- **fix(quality):** reconcile the accumulated file-size drift on `release/v3.8.50` — 13 files sat above their frozen LOC on the clean tip (measured by the gate itself), turning the absolute-mode check (nightly / local) permanently red while the PR-mode base-relative check (#8522) let every innocent PR pass. The per-PR rebaselines were lost across successive conflict resolutions of this hot file during the 08-05/06 merge batch. Frozen values updated to the measured tip for the 11 grown files (each annotated with its owning merged PR: #9024 #9324 #9329 #9193 #9332 #9228 #9260 #8934 #9196 #9163) and `open-sse/executors/default.ts` / `kiro.ts` (above the 1000 cap with no entry) added to the frozen set.

View File

@@ -0,0 +1 @@
- **fix(mcp):** the 3 `audit.test.ts` shutdown/fallback tests fail deterministically since #8959 switched the audit DB loader to `createRequire("better-sqlite3")``vi.doMock` only patches Vitest's ESM module graph, so the old better-sqlite3 mock never engaged and the tests hit a real empty sqlite file ("no such table: mcp_tool_audit"), redding the `Vitest (fast-path)` job on every PR (long misdiagnosed as a flake). Shutdown tests now inject the mock through the audit connection cache (`globalThis.__omnirouteMcpAuditDb`), and the node:sqlite fallback test drives a new test-only loader seam (`__setBetterSqliteLoaderForTests`) — the production `createRequire` path is unchanged. 3/3 red → 3/3 green; full `open-sse/mcp-server` vitest suite 88/88.

View File

@@ -0,0 +1 @@
- fix(build): exec native esbuild binary directly in prepublish — esbuild ≥0.25 ships an ELF at bin/esbuild and running it through node crashed every build:cli (dast-smoke red on all PRs)

View File

@@ -1,5 +1,8 @@
{
"_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.",
"_rebaseline_2026_07_25_dario_upstream_proxy_selector": "2130->2175. PR #8523 (Dario embedded service, upstream-proxy mode selector): check:complexity does not run on PR->release fast-gates, so cycle drift accrues unratcheted until a PR trips the gate (same pattern as every _rebaseline_ entry above). Measured base upstream/release/v3.8.49 tip locally at 2169 (with this PR\u0027s own commits removed); this branch measures 2173 local, 2175 on the CI runner (same local-vs-CI off-by-few convention documented in _rebaseline_2026_07_02_v3844_ci_observed). This PR\u0027s own genuine contribution is small (+4 to +6): the new mode <select> branching in ConnectionRow.tsx (Native/CLIProxyAPI/Dario/Fallback + conditional fallback-backend picker) and the probe/adopt/kill-PID branches added to the service supervisor for Dario\u0027s on-demand lifecycle. Using the CI-observed value (2175) so the gate is deterministic where it actually runs, per the established convention. Structural shrink stays tracked in #3501. Tighten via --update next cycle.",
"_rebaseline_2026_07_20_owner_night_drain": "Owner-approved (chat, 2026-07-20 ~00:50): 2072->2130. The day's 17 merged PRs consumed the entire slack (tip at 2069/2072); queue PRs #6973(+4)/#7662(+2)/#7719(+1) plus the #7744/#7779 reworks were collectively blocked. Owner chose a wide margin for the remainder of the v3.8.49 cycle instead of per-PR extraction.",
"count": 2175,
"_rebaseline_2026_07_25_8470_hyperagent_sticky_thread": "2130->2170 (+40). PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) pre-green validation. Trust-but-verify: origin/release/v3.8.49 tip alone (pristine, no PR changes) already measures 2169 with node scripts/check/check-complexity.mjs — i.e. +39 is inherited cycle drift unrelated to this PR (the cyclomatic-complexity ratchet does not run on PR->release fast-gates). This PR's OWN growth adds exactly +1: per-file eslint scoped scan on open-sse/executors/hyperagent.ts (base vs PR) shows extractMessageText() crossing the complexity>=15 threshold for the first time (new violation, complexity 25) from the new Anthropic tool_use/tool_result flattening branches; resolveHyperAgentThreadBinding's existing pre-#8470 violation (18) grows to 25 (still counted once, from the new root-key lookup tier); createHyperAgentThread and execute() are unchanged pre-existing violations. Net repo-wide total = 2169 (inherited drift) + 1 (this PR's own new violation) = 2170. Full-repo re-measurement of the merged branch was attempted but not completed live due to heavy concurrent devbox load (many other /green-prs sessions running the identical full-repo eslint scan in parallel); derived from two independently-clean measurements (base-tip full scan + per-file base-vs-PR delta). Covered by tests/unit/executor-hyperagent.test.ts (19/19). Tighten via --update next cycle.",
"_rebaseline_2026_07_25b_v3849_mergetrain_owngrowth": "Owner-approved (chat, 2026-07-25): 2169->2183 (+14). v3.8.49 /merge-prs 41-PR merge-train aggregate own-growth: measured 2183 on the combined boarded tree (tip ac15014ca7) vs 2169 on the pristine release tip. Each boarded PR sits under the ceiling individually, but the combined batch adds +14 (new branches in #8378 chatCore contextLimit / #8432 cursor native_todo / #8476 combo input-bound / #8526 combo select-all modals / etc \u2014 the pre-screen-flagged complexity-growth set). Same merge-burst-inherited-drift class as the notes below; owner chose absorbing the ceiling over per-PR helper-extraction churn. Structural shrink stays debt (#3501); tighten via --update next cycle.",
"_rebaseline_2026_07_25_v3849_mergequeue_drain": "Owner-approved (chat, 2026-07-25): 2130->2169 (+39). v3.8.49 /merge-prs queue-drain: the cycle's merge burst (the 8 base-red slices + owner PRs + parallel-session merges #8500-8508) accrued inherited cyclomatic drift the fast-path PR->release never ratchets (check:complexity does not run on PR->release). Measured 2169 on the pristine release tip 4053e2314a alone (BEFORE any queue PR boards) \u2014 so the entire +39 is base drift already on the tip, not any queued PR's own growth. Every merge-ready PR in the queue was tripping Fast Quality Gates on this shared base-red. Owner approved raising the ceiling to the measured tip value so the ~34-PR merge-train lands without per-PR helper-extraction churn. Structural shrink stays debt (#3501); tighten via --update next cycle.",

View File

@@ -1,4 +1,5 @@
{
"_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
@@ -903,21 +904,11 @@
"count": 1
}
},
"src/app/api/v1/audio/speech/route.ts": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/api/v1/audio/transcriptions/route.ts": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/api/v1/audio/translations/route.ts": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/api/v1/batches/[id]/cancel/route.ts": {
"no-restricted-imports": {
"count": 1
@@ -963,11 +954,6 @@
"count": 2
}
},
"src/app/api/v1/images/generations/route.ts": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/api/v1/management/proxies/assignments/route.ts": {
"no-restricted-imports": {
"count": 1
@@ -3229,7 +3215,7 @@
},
"tests/unit/translator-claude-to-gemini.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 17
"count": 15
}
},
"tests/unit/translator-claude-to-openai.test.ts": {
@@ -3382,4 +3368,4 @@
"count": 5
}
}
}
}

View File

@@ -13,6 +13,7 @@
"_rebaseline_2026_07_19_7546_ghe_copilot_route": "PR #7546 (GHE Copilot OAuth provider) own growth: oauth/[provider]/[action]/route.ts 960->963 (gate units, +3 = ghe-copilot device-code wiring at the existing multi-provider device-code branch — reading + HTTPS-validating the gheUrl search param (isValidGheUrl guards at both raw entry points, security-review hardening, 963->970), adding ghe-copilot to the no-PKCE provider set, and building the provider config override / threading gheUrl through poll->postExchange extraData). Mirrors the existing kiro/amazon-q startUrl override pattern right above it in the same branch; cohesive with the existing device-code dispatch chokepoint, not separately extractable without splitting a single provider-switch mid-branch. Frozen so can only shrink; structural shrink tracked in #3501.",
"_rebaseline_2026_07_19_6846_nvidia_concurrency_gate": "Issue #6846 Phase 1 (nvidia NIM local RPM budget + per-model lockout + per-connection concurrency cap) own growth: open-sse/executors/default.ts 877->890 (+13 = the irreducible call-site wiring at DefaultExecutor.execute(), the only place nvidia requests dispatch through — the existing session-pool body was extracted verbatim into a new private executeWithSessionPool() so the outer execute() can wrap it in the nvidia concurrency-gate acquire/finally-release). All actual gating logic (semaphore key + cap resolution) lives in the new leaf open-sse/executors/default/nvidiaConcurrencyGate.ts (not frozen, well under cap). Covered by tests/unit/nvidia-quota-phase1.test.ts.",
"_rebaseline_2026_07_18_v3849_provider_detail_wiring": "Merge campaign R2/R3 (2026-07-18): three authorized PRs each add irreducible call-site wiring to ProviderDetailPageClient.tsx — #7360 +5 (ProviderQuotaVisibilityToggle render, component extracted), #7419 +4 (NoAuthProviderControls wiring), #7062 +3 (Dahl provider hook) = 786->798. All three follow the extracted-component pattern (AgentrouterConsoleFields precedent); the frozen file only takes the wiring. Structural shrink tracked in #3501.",
"_rebaseline_2026_07_25_dario_upstream_proxy_selector": "PR #8523 (Dario embedded service): upstream-proxy mode selector replaces the binary CLIProxyAPI toggle with Native/CLIProxyAPI/Dario/Fallback + a fallback-backend picker. ProviderDetailPageClient.tsx 798->804 (+6, new hook fields threaded through to ConnectionsListPanel), ConnectionRow.tsx 942->958 (+16, the mode <select> + conditional fallback-backend <select> replacing a single pill button), useProviderConnections.ts 954->986 (+32, upstreamProxyMode/upstreamProxyFallbackBackend state + handleSetUpstreamProxyMode, handleToggleCliproxyapiMode kept as a thin backward-compat wrapper for the existing hook-shape test). All additive UI/state for the new modes — no unrelated refactor.",
"_rebaseline_2026_07_18_pr7653_chat_tracker_import": "PR #7653 merge-interaction growth: release moved chat.ts to its 1796 cap while this PR adds the single side-effect import 'quotaTrackersBatch.ts' (line 130) — chat.ts IS the canonical quota-fetcher registration point (codex/bailian/deepseek/openrouter/opencode/generic all import+register there), so the +1 is irreducible call-site wiring. 1796->1797. Covered by tests/unit/{agentrouter,v0,freemodel}-quota-fetcher.test.ts.",
"_rebaseline_2026_07_17_pr7653_agentrouter_console_fields": "PR #7653 own growth (missing acceptance criterion: the AgentRouter quota tracker (#6850) read providerSpecificData.consoleApiKey/newApiUserId but neither field had dashboard UI for provider agentrouter — consoleApiKey was gated to bailian-coding-plan only and newApiUserId had zero UI). AddApiKeyModal.tsx 961->967 (+6) and EditConnectionModal.tsx 1278->1286 (+8) = import + a single <AgentrouterConsoleFields .../> render call plus the newApiUserId formData init field. The actual Input rendering (both consoleApiKey reuse + the new newApiUserId field) was EXTRACTED into a new leaf src/app/(dashboard)/dashboard/providers/[id]/components/modals/AgentrouterConsoleFields.tsx (48 LOC, <cap), mirroring the QuotaScrapingFields.tsx / GlmTeamQuotaFields.tsx precedent (#6351) so the frozen modals only carry the irreducible call-site wiring. Persist logic lives in connectionProviderSpecificData.ts (not frozen). Covered by tests/unit/agentrouter-connection-modal-fields.test.ts.",
"_rebaseline_2026_07_17_v3849_6842_free_window_wiring": "PR #7651 (openrouter :free-window quota tracking) follow-up: the counter shipped built but never wired into the request pipeline, so combos kept spending guaranteed-429 requests on exhausted free-tier targets. Own growth: src/sse/services/auth.ts 2461->2462 (+1, irreducible at the existing model-aware preflight chokepoint — the `provider === \"codex\"` check that forwards requestedModel into the connection arg is extended to also cover `openrouter`, one added boolean + a doc comment, offset to a single net line by dropping the now-redundant inline condition). Enforcement itself lives in open-sse/services/openrouterQuotaFetcher.ts (not frozen) and the dispatch-time record/correct hooks live in open-sse/executors/base.ts (not frozen). Covered by tests/unit/openrouter-free-window-wiring-6842.test.ts.",
@@ -162,7 +163,136 @@
"_rebaseline_2026_08_05_9439_fold_9183_responses_api_fixes": "PR #9183 (hartmark, fix/responses-api-index-collision) folded into #9439 at the operator's request, closing #9183 in favor of this branch. Fixes output_index collisions between reasoning/message/tool-call items, a race that dropped tool calls and finish signals sharing a chunk with reasoning/text, multi-choice truncation, and DeepSeek/big-pickle reasoning_content replay across turns. open-sse/handlers/chatCore.ts 5024->5032 (+8, messageIndex now keyed off the real position in the next turn's replayed `messages` array — both the non-stream and stream reasoning-cache capture call sites — instead of a hardcoded 0). open-sse/translator/response/openai-responses.ts 1174->1180 (+6, turn-grouping fix so reasoning/content/tool_calls land in one assistant turn for strict OpenAI-compatible upstreams). Both are cohesive fixes at existing chokepoints, not extractable without splitting response translation mid-turn. tests/unit/reasoning-cache.test.ts crosses the 1000-line new-file test cap (976->1035, +59, entirely this fold's diff — cache-index regression coverage for the fixed messageIndex behavior). Covered by the PR's own 82 passing tests across reasoning-cache.test.ts, translator-helper-branches.test.ts, translator-request-openai-responses.test.ts, responses-api-truncation.test.ts (new), responses-replay-fixes.test.ts (new), responses-request-translation.test.ts (new).",
"_rebaseline_2026_08_06_9439_no_forking_redesign_and_fixes": "PR #9439 (hartmark) own growth, superseding the prior entry's 'forks a branch' description: live traffic showed the fork/tree model indenting one level per edited/duplicated turn until no room was left for content, and forking never actually reconnected — every candidate reconnect anchor for a repeated turn (a tool-polling loop's identical output, one real conversation had 28 copies of a single turn) returned whichever occurrence was tried first (the oldest, stalest), which almost always looked like a divergence. Redesigned so an edit mints its own independent conversation instead of a branch (findReconnectMatch in conversationTracker.ts now evaluates every candidate anchor and keeps the one that verifiably extends furthest, ties breaking toward the anchor with no recorded child), and the dashboard lists/paginates flat per-conversation transcripts (last 20 turns + 'Load more') instead of rendering a tree. src/sse/handlers/chat.ts 1869->1875 (+6, no material change here — drift absorbed while reconciling this rebase). open-sse/translator/response/openai-responses.ts 1180->1227 (+47): persists per-tool-call JSON-escape state (inString/pendingEscape) across SSE delta chunks instead of resetting it per call — a raw or already-escaped newline byte split across two chunks was getting corrupted in transit, a real OmniRoute bug previously mistaken for model-generation quality. src/shared/components/RequestLoggerV2.tsx 1643->1687 (+44): Previous/Next navigation resyncs the in-memory list once at the array boundary before closing the modal — list polling intentionally pauses while a detail modal is open, so hitting the boundary didn't mean there was nothing newer, only that the client hadn't fetched it yet. src/shared/components/RequestTimeline.tsx 1037->1044 (+7, drops the now-nonexistent onNavigateToLog prop passed into RequestLoggerDetail, whose cross-row next-turn navigation was removed in the same pass detailed in the prior rebaseline entry). Covered by tests/unit/conversationTracker.test.ts (duplicate-content reconnect-anchor regression test), tests/unit/translator-resp-openai-responses.test.ts (cross-chunk escape-state regression tests), tests/unit/agenticConversations.test.ts, tests/unit/inspector-conversation-normalizer.test.ts, tests/unit/request-logger-bounded-clone.test.ts, tests/unit/conversations-tree-route-seq-param.test.ts (new). Structural shrink of RequestLoggerV2.tsx/RequestTimeline.tsx tracked in #3501.",
"_rebaseline_2026_08_06_9439_merge_release_v3850_tip": "Merging the current upstream/release/v3.8.50 tip into #9439 (134 commits ahead of this branch's last sync) surfaced a migration-number collision: upstream independently added 135_migrate_model_capability_max_token.sql at the same version 135 this PR's 135_agentic_conversations.sql already occupied (this PR's tables were themselves already bumped once before, 134->135, for the identical reason per the '[Migration] CRITICAL: renumbered' warning in migrationRunner.ts — a recurring cost of a long-lived branch rebasing against a moving release target). Renumbered this PR's own two migrations upward (135_agentic_conversations.sql->137, 136_conversation_turn_nodes.sql->138) rather than touch upstream's file, leaving every other branch/PR based on the same release number unaffected. All internal comment references (agenticConversations.ts, the migration files themselves) updated to match; verified via agenticConversations.test.ts/conversationTracker.test.ts/conversations-tree-route-seq-param.test.ts (35/35) plus the newly-unblocked catalog-order-contract.test.ts (2/2, upstream's own file, was colliding on the same version number so never got to run against this tree before). Remaining file-size baseline changes in this entry are release/v3.8.50's own pre-existing drift, inherited via the merge and unrelated to this PR's commits — confirmed none of src/app/(dashboard)/dashboard/HomePageClient.tsx (1381->1388), providers/page.tsx (1923->1928), usage/components/ProviderLimits/index.tsx (1109->1119), open-sse/executors/{base,codex,default,kiro}.ts, open-sse/mcp-server/server.ts (1407->1411), or the 4 rebaselined test files (chatcore-translation-paths, combo-routing-engine, sse-auth, translator-openai-to-kiro) appear in any commit this PR authored; rebaselined here only because check:file-size runs against the full merged tree and this merge is what surfaced it, same precedent as open-sse/executors/base.ts in the first rebaseline entry above.",
"_rebaseline_2026_08_06_9439_second_merge_release_v3850_tip": "22 more commits landed on release/v3.8.50 (PR base moved again after the first merge/push above) — merging them in surfaced a MORE SEVERE version of the same migration-numbering issue: this time it was upstream colliding with ITSELF. PR #8523 (Dario embedded service) originally claimed 135_auto_restart_adopted.sql and 136_dario_fallback_backend.sql cleanly; two LATER upstream PRs each independently reused one of those numbers — #8908 added 135_migrate_model_capability_max_token.sql (collides with Dario's 135) and #9515 (radar) added 136_radar_cache_settings.sql (collides with Dario's 136) — both entirely upstream's own history, confirmed via `git ls-tree upstream/release/v3.8.50 -- src/lib/db/migrations/` showing all four files already colliding on their own tip, independent of this PR. Unlike the first collision (a soft warning), this one is NOT survivable: migrationRunner.ts hard-throws ('Migration version collision detected... Each migration file must have a unique numeric prefix') the instant ANY database initializes, which meant getDbInstance() — called by essentially every test and every request — failed outright for the whole merged tree, this PR's own tests included. Fixed by keeping Dario's original files (the chronologically first claim, per PR number) at 135/136 and renumbering the two later collisions to the next free slots: 135_migrate_model_capability_max_token.sql->139, 136_radar_cache_settings.sql->140 (with its own dedicated test, tests/unit/model-capability-overrides.test.ts, updated to match the new path). This PR's own two migrations were also shifted down one more slot in the process (137/138->137/138, unchanged net position, contiguous with Dario's 135/136) to avoid leaving a gap at the vacated 137. Final contiguous range: 134 (proxy_logs_egress_ip, upstream) / 135 (auto_restart_adopted, upstream) / 136 (dario_fallback_backend, upstream) / 137 (agentic_conversations, this PR) / 138 (conversation_turn_nodes, this PR) / 139 (migrate_model_capability_max_token, upstream, renumbered) / 140 (radar_cache_settings, upstream, renumbered) — verified via check-migration-numbering.test.ts (15/15, zero anomalies) plus a full re-run of every DB-touching test in this PR (agenticConversations/conversationTracker/conversations-tree-route-seq-param/catalog-order-contract/model-capability-overrides, all green). File-size bumps in this entry (providers/page.tsx, chat.ts, executors/base.ts, services/accountFallback.ts) are again release/v3.8.50's own inherited drift from the new commits, not this PR's — same precedent as the entries above.",
"cap": 1000,
"frozen": {
"_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.",
"_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.",
"_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \"headroom\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, <cap) and the async orderer orderTargetsByHeadroom is appended to the existing open-sse/services/combo/quotaStrategies.ts (<cap) next to its sibling reset-aware/reset-window orderers (reuses their connection-expansion machinery). headroom = 1 - max(util_5h, util_7d) from getSaturation (src/lib/quota/saturationSignals.ts), prefers the connection with the most free capacity. Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. fill-first stays default; all existing strategies untouched. Covered by tests/unit/combo-headroom-ranking.test.ts (pure helper) + tests/unit/combo-headroom-strategy.test.ts (orderer, saturation injected). Structural shrink of combo.ts tracked in #3501.",
"_rebaseline_2026_06_24_quota_share_strategy": "Dedicated quota-share strategy (Phase 3 #9): combo.ts 3180->3190 (+10 = one new `else if (strategy === \"quota-share\")` dispatch branch in handleComboChat that delegates 100% to selectQuotaShareTarget + its log line, plus the import). All the new logic lives OUT of the god-file in two new leaves under open-sse/services/combo/: quotaShareInflight.ts (in-flight counter with TTL/lease, ~150 LOC <cap) and quotaShareStrategy.ts (per-model bucket gating via isBucketSaturated + DRR proportional to weight + P2C over in-flight, ~240 LOC <cap). Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the headroom/reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. ZERO existing strategy cases were modified — only this branch was added, and the qtSd/ combos switched from fill-first to quota-share in src/lib/quota/quotaCombos.ts. Covered by tests/unit/quota-share-strategy.test.ts (gating, DRR fairness, P2C in-flight, fail-open, activation). Structural shrink of combo.ts tracked in #3501.",
"_rebaseline_2026_06_24_task_aware_routing": "Task-aware routing strategy (port PR #2045, OmniRoute #4945): combo.ts 3190->3225 (+35) = one new `else if (strategy === \"task-aware\")` dispatch branch delegating 100% to selectTaskAwareTarget + its imports/log lines. All scoring/classification logic lives OUT of the god-file in the new leaf open-sse/services/taskAwareRouting.ts (553 LOC <cap). Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors quota-share/headroom/reset-aware branches). ZERO existing strategy cases modified. Covered by tests/unit/combo-task-aware.test.ts (35 tests). Structural shrink of combo.ts tracked in #3501.",
"_rebaseline_2026_06_26_fidelity_gate_extraction": "Milestone-B fidelity-gate wiring residual: bodyToText+gateAdvance extracted to fidelityGateStep.ts (889->854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).",
"_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.",
"_rebaseline_2026_06_27_5193_antigravity_basered": "Base-red (pre-existing release drift, fast-gate PR->release skips check:file-size): accountFallback.ts 1773->1777 and src/app/api/providers/[id]/test/route.ts 924->940 were already over their frozen caps on release/v3.8.39 independent of any antigravity change. Owner chose to rebaseline (keep the documented issue-reference comments #1846/#1449/#347 etc.) rather than accept the contributor comment-stripping in #5200/#5198. Reverted #5200 to restore the comments; bumped these two frozen caps to the actual base sizes. No logic change.",
"_rebaseline_2026_06_28_5237_impersonation_ua_refresh": "PR #5237 (refresh impersonation UAs): grok-web.ts 1871->1873 (+2), muse-spark-web.ts 1284->1302 (+18), perplexity-web.ts 1013->1032 (+19). Net semantic change in each file is a single User-Agent constant (Chrome 147->149 for grok/muse; perplexity kept at Firefox 148 to stay matched with the firefox_148 TLS profile — the contributor's 152 bump was reverted to avoid a UA-vs-JA3 mismatch, #2459). The growth is Prettier reflow that lint-staged unavoidably applies to these grandfathered long-line files the moment they are touched; not extractable. src/sse/services/auth.ts 2336->2401 in the same reconcile is #5222's antigravity-LRU-retry growth that merged via --admin without a baseline bump.",
"_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop — a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (<cap); the residual growth is the duplicated thin-wrapper signatures + the extracted bodies' dispatch boundary, guarded by a byte-identical parity test (riskGateIntegration). Default off (DEFAULT_COMPRESSION_CONFIG unchanged). Not extractable without hiding the dispatch boundary, mirroring prior compression rebaselines. Structural shrink tracked in #3501.",
"_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791<cap pre-feature). Cohesive request/logging chokepoint wiring; structural shrink of chat.ts tracked in #3501.",
"_rebaseline_2026_06_29_4038_cas_guard": "PR (#4038) own growth: tokenRefresh.ts 2103->2181 (+78 = the compare-and-swap guard on the refresh persist — runWithCasGuard/getActiveCasGuard AsyncLocalStorage pair mirroring runWithOnPersist, casGuardShouldSkipPersist that rereads the row right before persisting and skips the write when a concurrent writer already rotated the refresh_token past the one presented, plus getCasGuardStats counters). Fixes the sibling-rotation-revert → token-family-revocation storm. Gated behind an active guard (opt-in; no guard => byte-identical). Wiring lives at the two persist chokepoints inside getAccessToken; the comparison reuses wasRefreshTokenRotated from refreshSerializer. Not extractable without splitting the refresh hot path.",
"_rebaseline_2026_06_29_5286_memoization": "PR #5286 own growth: strategySelector.ts 899->960 (+61 = the opt-in result-memoization branches in applyCompression/applyCompressionAsync — principal+determinism gate, makeMemoKey lookup/store with model+supportsVision folded into the key, recompute-with-memo-off). Default off (memoizeCompressionResults), so zero behavior change. The memo helpers live in the leaf resultMemo.ts (<cap); the chokepoint wiring here is not extractable. Structural shrink of this hot-path file tracked in #3501.",
"_rebaseline_2026_07_01_v3843_release_5609": "Rebaseline v3.8.43 (PR #5609 release reconciliation). DRIFT dos 109 commits do ciclo: 8 god-files existentes cresceram (ApiManagerPageClient 2983->3017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.",
"_rebaseline_2026_07_02_5816_qoder": "PR #5816 (@AgentKiller45, qoder PAT via qodercli): qoderCli.ts 666->989, new-above-cap frozen (owner-approved baseline freeze). The growth is the legitimate PAT job-token exchange + quota parsing CLI transport (the pure-JS Cosy path 500'd on every PAT request); extracting the spawn/parse helpers now would just add indirection to a contributor PR mid-merge. Test frozen also raised for this PR's coverage growth: providers-page-utils.test.ts 1052->1092. Additionally clears an inherited base-red from the already-merged #5933 (codex json_schema->text.format): translator-openai-responses-req.test.ts 1097->1172 (+75 regression tests, no offending branch left). All remain frozen (cannot grow further); release captain's rebaseline-at-release supersedes.",
"_rebaseline_2026_07_09_6126_clinepass_dual_auth": "PR #6126 (@hajilok, dual-auth ClinePass) own growth: tokenRefresh.ts 2181->2182 (+1 = a single `case \"clinepass\":` fallthrough label added to the existing `case \"cline\":` in _getAccessTokenInternal's provider switch, so clinepass token refresh dispatches to the already-shared refreshClineToken() instead of silently falling through to the generic OAuth refresh). Irreducible 1-line switch-case wiring at the existing chokepoint; the header-building logic for the same feature was extracted to a new leaf src/shared/utils/clineAuth.ts::buildClinepassHeaders() (well under cap) to avoid growing open-sse/executors/default.ts. Covered by tests/unit/clinepass-provider.test.ts.",
"_rebaseline_2026_07_09_6363_kiro_external_idp": "PR #6363 (@artickc, Kiro external IdP) own growth: tokenRefresh.ts 2182->2249 (+67 = the external_idp refresh branch inside refreshKiroToken — standard public-client OAuth2 refresh_token grant against the org IdP tokenEndpoint via buildExternalIdpRefreshParams/isExternalIdpAuthMethod from the new leaf open-sse/services/kiroExternalIdp.ts, with invalid_grant/invalid_client -> unrecoverable_refresh_error mapping). Cohesive addition at the existing refreshKiroToken chokepoint. Covered by tests/unit/kiro-external-idp.test.ts.",
"_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge.",
"_rebaseline_2026_07_09_6678_routing_strategy_9router": "#6678 (SeaXen) — 9router-parity Routing Strategy settings card + per-provider/combo sticky-round-robin override. Own growth: ProviderDetailPageClient.tsx 784->786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.",
"_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).",
"_rebaseline_2026_07_10_gcf_v3_2_decode": "PR #6838 own growth: new vendored file open-sse/services/compression/engines/headroom/gcf/decode_generic.ts frozen at 880 (> 800 cap). It is the vendored GCF generic-profile decoder (spec v3.2 nested flattening plus the prototype-pollution / hasOwnProperty hardening added in this PR's Gemini review). Kept as one file faithful to upstream gcf-typescript so re-vendoring stays a clean copy rather than a re-split each cycle (sibling generic.ts/scalar.ts stay < cap; extraction would also fragment the file's frozen eslint no-explicit-any suppressions). Round-trip + prototype-pollution regression coverage in tests/unit/compression/headroom-smartcrusher.test.ts. Frozen: only shrinks from here.",
"_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable).",
"_rebaseline_2026_07_15_7070_combos_memo": "PR #7070 (perf/p1-memo) own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4655->4656 (+1 = React.memo wrapping of ComboCard). Covered by tests/unit/ui/combos-page-smoke.test.tsx.",
"_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.",
"_rebaseline_2026_07_19_6636_codex_session_json": "#6636 own growth: OAuthModal.tsx 998->1030 (gate units, split(\"\\n\").length incl. trailing newline; +32 = session-JSON paste branch for handleManualSubmit plus a shared submitCodexAccessToken() helper extracted from the pre-existing bare-JWT branch, mirroring the #5203 oauthBlobSubmit.ts extraction precedent; the normalizer logic itself lives in the new src/lib/oauth/utils/codexSessionImport.ts leaf module, not here). Fourth irreducible wiring bump on this modal (969->989->993->998->1030); structural shrink tracked in #3501.",
"_rebaseline_2026_07_19_7546_ghe_copilot_modal": "PR #7546 (GHE Copilot OAuth provider) own growth: OAuthModal.tsx 1030->1056 (gate units). Adds a gheUrl input state, routes ghe-copilot through the existing device-code branch, and threads gheUrl into the device-code request/poll extraData at the existing provider-switch chokepoints (+~24 lines, cohesive with the same pattern as #7399/#6636). The standalone GHE enterprise-URL config step JSX (originally +31 lines inline) was extracted to the new src/shared/components/oauthModal/GheConfigStep.tsx leaf component to minimize the bump; what remains is the irreducible provider-branch wiring. Fifth bump on this modal (969->989->993->998->1030->1056); structural shrink tracked in #3501.",
"_rebaseline_2026_07_19_7787_ic2_localdb_reexports": "PR #7787 (IC2 raw connections cache + lazy-decrypt) own growth: localDb.ts 805->807 (gate units, +2). localDb.ts is the re-export-only layer (hard rule #2 — no logic); the PR adds 4 new db/readCache re-exports (touchConnectionLastUsed, getCachedRawProviderConnections, getCachedProviderConnectionById, getCachedProviderNodes) required by existing barrel importers. Irreducible for a re-export list; frozen so it can only shrink.",
"_rebaseline_2026_07_20_7779_routingcombo_thread": "PR #7779 own growth: chatHelpers.ts 876->877 (+1, thread routingComboId into executeChatWithBreaker for compression-combo assignment). Frozen so it can only shrink.",
"_rebaseline_2026_07_20_7819_autocandidateoverrides_reexport": "PR for #7819 (Level 1+2: read-only auto/* candidate transparency + per-API-key exclusions) own growth: localDb.ts 807->808 (+1). Adds a single `export * from \"./db/autoCandidateOverrides\"` barrel re-export (hard rule #2 — no logic) for the new DB module backing per-apiKey candidate exclusions. Irreducible for a re-export list; frozen so it can only shrink.",
"_rebaseline_2026_07_21_8027_grok_cli_auth_json_paste": "PR #8027 (RaviTharuma, fix(grok-cli) #7610) own growth: OAuthModal.tsx 1080->1100 (gate units). Requires the full ~/.grok/auth.json (with refresh_token) on the paste-import path instead of a bare JWT, at the existing paste-token chokepoint (renamed tab label, updated instructions/placeholder, textarea for the auth.json blob, inline error surface). The validation logic itself (parseGrokCliPasteToken, previously an inline ~75-line function) was extracted to the new src/lib/oauth/utils/grokCliAuthJson.ts leaf module — mirroring the #6636/#7546 extraction precedent — so only the irreducible UI wiring remains here. Sixth bump on this modal (969->989->993->998->1030->1056->1100); structural shrink tracked in #3501.",
"_rebaseline_2026_07_21_8034_compression_exclusions_sidebar": "#8034 (compression exclusions dashboard tab) own growth: sections.ts 796->806 (+10, one new COMPRESSION_CONTEXT_GROUP sidebar item linking /dashboard/compression/exclusions). The file was already 796/800 before this PR (organic growth from prior sidebar entries), so a single new nav item pushed it 6 lines over cap. Freezing at 806 (cannot grow further); the sidebar item array is data, not extractable logic.",
"_rebaseline_2026_07_22_7936_namespace_roundtrip": "#7936 (@RCrushMe, Responses-Chat namespace round-trip identity seam) own growth: open-sse/translator/response/openai-responses.ts 1092->1125 (+33) and open-sse/utils/stream.ts 2814->2869 (+55) — threading the namespace-identity seam through the Responses↔Chat translation + stream paths so tool-call namespaces survive the round-trip. Cohesive translation/stream wiring at existing chokepoints, frozen at new size.",
"_rebaseline_2026_07_22_8010_codex_responses_engine": "PR #8010 (@JxnLexn) own growth: open-sse/mcp-server/schemas/tools.ts 1497->1505 (+8 = threading the new \"codex-responses\" literal into the compressionConfigureInput strategy/autoTriggerMode Zod enums and setCompressionEngineInput engine enum, mirroring the existing rtk/omniglyph enum entries; no new tool). open-sse/services/compression/strategySelector.ts 1043->1054 (+11 = one new `if (mode === \"codex-responses\")` dispatch branch in runCompression that delegates 100% to the new codexResponsesEngine.apply, mirroring the existing rtk single-mode dispatch, plus threading config.codexResponsesConfig.preserveToolNames into the shared adaptBodyForCompression call at the 3 existing call sites). src/lib/db/compression.ts (untracked, new-file cap 800) 794->845 (+51 = normalizeCodexResponsesConfig, mirroring the existing normalizeRtkConfig normalizer, plus registering \"codex-responses\" in the COMPRESSION_MODES/STACKED_PIPELINE_ENGINE_IDS/SINGLE_MODE_ENGINE sets and the getCompressionSettings load/save switch) — added to the baseline at its current size. All three are cohesive dispatch/normalizer wiring at existing chokepoints (mirroring the prior compression-mode rebaselines #6534/#6556), not extractable without hiding the mode-dispatch boundary. Covered by tests/unit/compression/codex-responses.test.ts (6) + omniglyph-registries.test.ts/types.test.ts (22, updated for the new mode).",
"_rebaseline_2026_07_22_8034_compression_exclusions_persistence": "#8034 (compression exclusions) own growth: src/lib/db/compression.ts 845->850 (+5 = threading the new compressionExclusions field through the existing getCompressionSettings/saveCompressionSettings load/save switch over the shared key_value compression namespace — no new table, no raw SQL). Mirrors the prior compression-field rebaselines (#8010 codex-responses normalizer at the same chokepoint); the load/save switch is a single dispatch boundary, not extractable without hiding it. Covered by the PR's 8 node:test + 3 vitest cases.",
"_rebaseline_2026_07_22_8050_model_lockout_exact_family": "#8050 (@AndrianBalanescu) own growth: accountFallback.ts 1864->1892 (+28) — exact-vs-family model-lockout scoping (getModelLockKey/isModelLocked/clearModelLock/getModelLockoutInfo) so an Antigravity 404 for one bare model no longer hijacks the whole family cooldown. Cohesive lockout logic; frozen at new size.",
"_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size.",
"_rebaseline_2026_07_22_8081_reasoning_placeholder_guard": "#8081 (@Dingding-leo) own growth: openai-responses.ts 1125->1137 (+12) restructuring the reasoning-placeholder guard so it skips only the empty content block and still emits finish_reason/tool_calls in the same chunk. Cohesive translator wiring; frozen at new size.",
"_rebaseline_2026_07_22_8210_openrouter_midstream_error": "PR #8210 (hartmark, fix/openrouter-midstream-error-surfacing) own growth: open-sse/translator/response/openai-responses.ts 1137->1163 (+26) measured on the merged tip (release 1137 + this PR own growth). Adds a single new branch inside openaiToOpenAIResponsesResponse() that detects an OpenRouter-style mid-stream aggregator error (HTTP 200 SSE chunk with empty choices + a top-level error object) and surfaces it as state.upstreamError instead of silently falling through to the no-op/awaitingTrailingUsage path, which previously masked the failure as a false empty-success completion and skipped combo fallback. Irreducible call-site addition at the existing chunk-dispatch chokepoint (mirrors the Gemini-to-OpenAI translator's #4177 precedent for the same class of upstream error surfacing). Note: this baseline entry does NOT cover the separate pre-existing +11 drift already on the release tip from #8081/#8162 (1125->1136, unrelated reasoning-placeholder-stripping fix merged after this PR branched) — that drift belongs to the maintainer's rebaseline, not this PR.",
"_rebaseline_2026_07_22_8211_gemini_malformed_tool_choice": "PR #8211 (hartmark, fix/gemini-malformed-function-call-tool-choice) own growth: open-sse/translator/response/gemini-to-openai.ts 771->821 (+50, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds MALFORMED_FUNCTION_CALL/UNEXPECTED_TOOL_CALL handling inside geminiToOpenAIResponse(): synthesizes a `malformed_tool_call` tool_calls entry so finish_reason normalizes to the standard \"tool_calls\" instead of an unrecognized raw enum value that OpenAI-compatible clients (e.g. OpenClaw) silently ignore, and always synthesizes (rather than skipping when a real tool call already exists) so a malformed attempt alongside a real one in the same turn is not silently discarded. Irreducible cohesive addition at the existing candidate/finishReason translation chokepoint (mirrors the 9router#2462 raw-finish-reason precedent immediately below it in the same function). Covered by the PR's own tests/unit test additions for both the malformed-only and malformed-plus-real-call cases.",
"_rebaseline_2026_07_22_8213_chat_abandoned_target_abort": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/sse/handlers/chat.ts 1794->1860 (+66, measured against the PR's own merge-base — the release tip separately carries an unrelated -5 net shrink from #8013's antigravity callable-catalog alignment, which this PR's branch does not include and this entry does not cover). Adds resolveDispatchClientRawRequest(): merges a per-target modelAbortSignal into clientRawRequest.signal (via mergeAbortSignals) so a combo target abandoned by comboTargetTimeoutMs actually observes its own abort and reaches its cleanup path, instead of hanging forever inside withRateLimit/acquireAccountSemaphore and leaking a permanent 'pending' dashboard entry (live incident, log id 1784418258231-14961a). Also wires combo-exhausted rejection logging to capture request body + attempted models via the new rejectedRequestUsage helper. Irreducible additions at the existing chat dispatch chokepoint. Covered by the PR's own combo-config + integration test additions.",
"_rebaseline_2026_07_22_8213_combo_cooldown_wait_recording": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: open-sse/services/combo.ts 3548->3604 (+56, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Fixes combo cooldown-wait state recording so a bogus 503 is no longer crystallized when the cooldown-wait vars reset every setTry, adds an OpenAI-format SSE error frame path for combo-exhausted rejections (capturing request body + attempted models), and gives an abandoned per-target dispatch its own timeout instead of leaking a permanent 'pending' dashboard entry. Irreducible additions at the existing handleComboChat dispatch/retry chokepoint (mirrors the prior quota-share/headroom/task-aware strategy-branch precedents already frozen in this file). Covered by the PR's own combo-config + Gemini TPM-ceiling benchmark test additions.",
"_rebaseline_2026_07_22_8213_gemini_tpm_quota_cooldown_wait": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: open-sse/services/accountFallback.ts 1857->1932 on the merged tip (release 1892 incl #8050 +35, plus this PR own growth +40); measured against the PR own merge-base was 1857->1898 (+41 — the release tip separately carries an unrelated +34 from #8050's antigravity 404 model-not-found lockout scoping, which this PR's branch does not include and this entry does not cover). Own growth is the Gemini TPM-ceiling classification + cooldown-wait wiring feeding into the combo cooldown-wait state machine (rate-limit wedge recovery) introduced by this PR's commit series. Irreducible additions at the existing account-fallback/model-lockout chokepoint. Covered by the PR's own gemini-rate-limit-tracker and TPM-ceiling benchmark test additions.",
"_rebaseline_2026_07_22_8213_health_unblock_model_cooldowns": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/app/(dashboard)/dashboard/health/page.tsx 1094->1165 (+71, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds handleUnblockAll/handleUnblockOne dashboard actions (DELETE /api/resilience/model-cooldowns) so an operator can manually clear a Gemini TPM-wedge model lockout surfaced by this PR's cooldown-wait fixes, instead of waiting out the ceiling. Irreducible UI wiring at the existing health-page action chokepoint. Covered by the PR's own dashboard/resilience test additions.",
"_rebaseline_2026_07_22_8213_requestloggerdetail_unblock_ui": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/shared/components/RequestLoggerDetail.tsx 799->941 (+142, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip; crosses the general 800-line new-file cap so is frozen here for the first time). Adds a collapsible section header (open/expand-less toggle) plus per-log-entry unblock (`unblocking`/`cleared` state, isCombo503 detection) so the request-logger detail panel surfaces the same Gemini TPM cooldown-wait / model-lockout unblock action introduced by this PR at the individual-request level (mirrors the health-page bulk unblock action added in the same PR). Covered by the PR's own dashboard/resilience test additions.",
"_rebaseline_2026_07_22_fusion_8013_8098_antigravity": "Fusion of #8013 (backryun, catalog/IDE-CLI-split rewrite) + #8098 (nguyenha935, protocol-fidelity/fail-closed/credits/tool-cloaking): open-sse/services/usage/antigravity.ts NEW 802 (>cap 800, +2 — #8098 credits/tier usage service on #8013's profile-aware headers). Test growth (models-catalog-route 1605->1608, provider-models-route 1752->1757 from #8013 Gemini 3.6 catalog) tracked in testFrozen.",
"_rebaseline_2026_07_23_8127_grok_weekly_quota": "#8127 (@apoapostolov) own growth: src/sse/handlers/chat.ts 1861->1865 (+4) — weekly quota tracking for grok-web wires a quota-fetch hook at the existing dispatch chokepoint. Thin wiring mirroring adjacent provider-quota branches; not extractable. Covered by tests/unit/grok-quota-fetcher.test.ts.",
"_rebaseline_2026_07_23_8143_empty_catch_logging": "#8143 (@chirag127) own growth: open-sse/utils/stream.ts 2869->2887 (+18) — replacing empty catch blocks in the SSE stream subsystem with console.debug logging (Rule #6 silent-swallow fix, issues #8138-#8142). Cohesive logging additions at the existing catch chokepoints, not extractable; frozen at new size. Covered by tests/unit/stream-handler-catch-logging-8143.test.ts.",
"_rebaseline_2026_07_23_8219_cache_ttl_settings_sidebar": "#8219 (@oyi77) own growth: sections.ts 806->813 (+7) — configurable model-catalog cache-TTL settings adds a new sidebar nav entry + its visibility wiring. Sidebar item array is data, not extractable logic; frozen at new size.",
"_rebaseline_2026_07_23_8247_8248_model_unhealthy": "#8247+#8248 own growth: accountFallback.ts 1940->1941 (+1, irreducible import statement only — the substantive #8248 DEGRADED-pattern classifier was extracted into open-sse/config/errorConfig.ts, which has ample headroom, instead of growing this frozen file; #8247's fix is a single existing-line condition change, net zero lines). Scoping the credits-exhausted 403/429 branch to isCompatibleProvider() (per-model-quota openai/anthropic-compatible-* nicknames) so it stays model-scoped instead of terminalling the whole connection, and classifying NVIDIA NIM 'Function ... DEGRADED' 400 bodies as model-access-denied instead of a raw passthrough 400. Covered by tests/unit/8247-accountfallback-model-unhealthy.test.ts and tests/unit/8248-accountfallback-nvidia-degraded.test.ts.",
"_rebaseline_2026_07_23_8252_combo_400_advance": "#8252 (@RaviTharuma) own growth: accountFallback.ts 1932->1940 (+8) + combo.ts 3604->3630 (+26) — advance combo on model-scoped 400s wrapped as invalid/Bad-Request. Irreducible wiring at existing account-fallback + combo dispatch chokepoints. Covered by combo-model-scoped-400-advance.test.ts.",
"_rebaseline_2026_07_23_8266_alibaba_media": "#8266 (@backryun) own growth: imageRegistry.ts 821->979 (+158) — Alibaba-family media models (Qwen image/video, Bailian, Wan) added to the image/video registry. Registry model data, not extractable logic; frozen at new size.",
"_rebaseline_2026_07_24_8388_compression_detail_persist": "#8388 (compression engine DETAIL settings — Headroom/session-dedup/CCR — dropped on save) own growth: src/lib/db/compression.ts 866->872 (+6 = irreducible call-site wiring at the existing getCompressionSettings/updateCompressionSettings chokepoint: one import line, one `...buildDetailConfigDefaults()` spread in the seed config, and one `case \"sessionDedup\": case \"ccr\": applyDetailConfigUpdate(config, key, parsed); break;` load-switch case, mirroring the existing headroom/#8056 case immediately above it). The actual normalizer logic (normalizeSessionDedupConfig/normalizeCcrConfig, matching SESSION_DEDUP_SCHEMA/CCR_SCHEMA bounds) was EXTRACTED into a new leaf src/lib/db/compressionDetailNormalizers.ts (well under cap) so this frozen file only carries the minimal dispatch wiring. Covered by tests/unit/8388-compression-detail-persist.test.ts (schema-accept + full DB save->reload round-trip for both new sub-objects, plus a no-regression assertion on the existing headroom round-trip).",
"_rebaseline_2026_07_24_responses_toolcalls_log_summary": "hartmark, fix/responses-tool-calls-log-summary own growth: open-sse/translator/response/openai-responses.ts 1163->1174 (+11). closeToolCall() now also writes the completed tool call into the shared state.toolCalls Map (already populated by the openai-to-claude / claude-to-openai / gemini-to-openai response translators) so stream.ts's completion-log summary builder (which reads state.toolCalls, not this translator's own funcCallIds/funcNames/funcArgsBuf bookkeeping) reports finish_reason \"tool_calls\" and message.tool_calls for openai->openai-responses translated streams instead of always logging \"stop\" with no tool_calls — the actual client-facing SSE events were already correct; only the persisted call-log summary was wrong. Irreducible call-site addition at the existing tool-call-close chokepoint. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts.",
"_rebaseline_2026_07_25_8476_combo_input_bound_homogeneous_scope": "PR #8476 (herjarsa, fix/8375-8459-combo-image-fixes, #8375) own growth: open-sse/services/combo.ts 3642->3679 (+37 net: +29 the PR's own isInputBoundFailure short-circuit for deterministic context_length_exceeded/context_window_exceeded failures, +8 a /green-prs pre-merge fix scoping that short-circuit to homogeneous remainders only — the shipped code fired unconditionally on ANY target, regressing the intentional heterogeneous-combo fallback #6637/isContextOverflow400 protects, exactly as flagged by this PR's own review evidence but never actually implemented in the branch). The fix compares orderedTargets[i+1..] modelStr against the failing target's modelStr at the existing executeTarget dispatch chokepoint (mirrors the sameProviderNext precedent a few lines below) — irreducible call-site wiring, not extractable without hiding the dispatch boundary. Covered by tests/unit/combo-input-bound-failure-8375.test.ts (homogeneous pool still short-circuits) and the new tests/unit/combo-input-bound-heterogeneous-8375.test.ts (heterogeneous combo now correctly falls through to the larger-context target).",
"_rebaseline_2026_07_25_adobe_firefly_reference_images": "Follow-up to #8006: storage upload + referenceBlobs for image/video and /v1/images/edits dispatch. adobeFireflyClient.ts 1958->2317 (+upload helpers, extract sources, resolve blob ids). Note: 2317 not 2316 — check-file-size.mjs counts LOC via split(\"\\n\").length (counts the trailing-newline empty element), which is 1 higher than `wc -l` on a file ending in \\n; the PR's original entry (2316) was measured with wc -l and undercounted by 1 against the actual gate.",
"_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": 1640,
"open-sse/executors/chatgpt-web.ts": 3241,
"open-sse/executors/codex.ts": 1562,
"open-sse/executors/cursor.ts": 1560,
"open-sse/executors/deepseek-web.ts": 1148,
"open-sse/executors/grok-web.ts": 1044,
"open-sse/executors/muse-spark-web.ts": 1405,
"open-sse/handlers/chatCore.ts": 5034,
"open-sse/handlers/imageGeneration.ts": 3101,
"open-sse/handlers/responseSanitizer.ts": 1128,
"open-sse/handlers/search.ts": 1536,
"open-sse/handlers/videoGeneration.ts": 1063,
"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": 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/translator/response/openai-responses.ts": 1257,
"open-sse/utils/cursorAgentProtobuf.ts": 1505,
"open-sse/utils/stream.ts": 2889,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1388,
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3117,
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1067,
"src/app/(dashboard)/dashboard/combos/page.tsx": 4647,
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1283,
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1022,
"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": 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,
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1123,
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629,
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1573,
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028,
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1119,
"src/app/api/providers/[id]/models/route.ts": 2250,
"src/app/api/v1/models/catalog.ts": 1549,
"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/models.ts": 1097,
"src/lib/db/providers.ts": 1034,
"src/lib/memory/retrieval.ts": 1073,
"src/lib/tailscaleTunnel.ts": 1202,
"src/lib/usage/providerLimits.ts": 1013,
"src/shared/components/OAuthModal.tsx": 1134,
"src/shared/components/RequestLoggerV2.tsx": 1687,
"src/shared/components/analytics/charts.tsx": 1035,
"src/shared/services/cliRuntime.ts": 1122,
"src/sse/handlers/chat.ts": 1909,
"src/shared/components/RequestLoggerDetail.tsx": 1260,
"src/shared/components/RequestTimeline.tsx": 1044,
"src/sse/services/auth.ts": 2508,
"tests/unit/account-fallback-service.test.ts": 1572,
"tests/unit/provider-validation-specialty.test.ts": 2985,
"open-sse/executors/hyperagent.ts": 1026,
"open-sse/executors/default.ts": 1042,
"open-sse/executors/kiro.ts": 1069
},
"testCap": 1000,
"testFrozen": {
"_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).",
@@ -289,134 +419,6 @@
"_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.",
"_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)",
"_rebaseline_2026_08_02_9242_token_health_transient": "PR #9242 (fix/refresh-circuit-transient): src/lib/tokenHealthCheck.ts 1021 (new file, above cap 1000). The file consolidates token-refresh health checking logic that was previously scattered across auth.ts and tokenRefresh.ts. Cohesive single-responsibility module for refresh circuit state management; not extractable without splitting the refresh state machine. Covered by tests/unit/tokenHealthCheck-transient.test.ts.",
"frozen": {
"_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.",
"_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.",
"_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \"headroom\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, <cap) and the async orderer orderTargetsByHeadroom is appended to the existing open-sse/services/combo/quotaStrategies.ts (<cap) next to its sibling reset-aware/reset-window orderers (reuses their connection-expansion machinery). headroom = 1 - max(util_5h, util_7d) from getSaturation (src/lib/quota/saturationSignals.ts), prefers the connection with the most free capacity. Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. fill-first stays default; all existing strategies untouched. Covered by tests/unit/combo-headroom-ranking.test.ts (pure helper) + tests/unit/combo-headroom-strategy.test.ts (orderer, saturation injected). Structural shrink of combo.ts tracked in #3501.",
"_rebaseline_2026_06_24_quota_share_strategy": "Dedicated quota-share strategy (Phase 3 #9): combo.ts 3180->3190 (+10 = one new `else if (strategy === \"quota-share\")` dispatch branch in handleComboChat that delegates 100% to selectQuotaShareTarget + its log line, plus the import). All the new logic lives OUT of the god-file in two new leaves under open-sse/services/combo/: quotaShareInflight.ts (in-flight counter with TTL/lease, ~150 LOC <cap) and quotaShareStrategy.ts (per-model bucket gating via isBucketSaturated + DRR proportional to weight + P2C over in-flight, ~240 LOC <cap). Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the headroom/reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. ZERO existing strategy cases were modified — only this branch was added, and the qtSd/ combos switched from fill-first to quota-share in src/lib/quota/quotaCombos.ts. Covered by tests/unit/quota-share-strategy.test.ts (gating, DRR fairness, P2C in-flight, fail-open, activation). Structural shrink of combo.ts tracked in #3501.",
"_rebaseline_2026_06_24_task_aware_routing": "Task-aware routing strategy (port PR #2045, OmniRoute #4945): combo.ts 3190->3225 (+35) = one new `else if (strategy === \"task-aware\")` dispatch branch delegating 100% to selectTaskAwareTarget + its imports/log lines. All scoring/classification logic lives OUT of the god-file in the new leaf open-sse/services/taskAwareRouting.ts (553 LOC <cap). Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors quota-share/headroom/reset-aware branches). ZERO existing strategy cases modified. Covered by tests/unit/combo-task-aware.test.ts (35 tests). Structural shrink of combo.ts tracked in #3501.",
"_rebaseline_2026_06_26_fidelity_gate_extraction": "Milestone-B fidelity-gate wiring residual: bodyToText+gateAdvance extracted to fidelityGateStep.ts (889->854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).",
"_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.",
"_rebaseline_2026_06_27_5193_antigravity_basered": "Base-red (pre-existing release drift, fast-gate PR->release skips check:file-size): accountFallback.ts 1773->1777 and src/app/api/providers/[id]/test/route.ts 924->940 were already over their frozen caps on release/v3.8.39 independent of any antigravity change. Owner chose to rebaseline (keep the documented issue-reference comments #1846/#1449/#347 etc.) rather than accept the contributor comment-stripping in #5200/#5198. Reverted #5200 to restore the comments; bumped these two frozen caps to the actual base sizes. No logic change.",
"_rebaseline_2026_06_28_5237_impersonation_ua_refresh": "PR #5237 (refresh impersonation UAs): grok-web.ts 1871->1873 (+2), muse-spark-web.ts 1284->1302 (+18), perplexity-web.ts 1013->1032 (+19). Net semantic change in each file is a single User-Agent constant (Chrome 147->149 for grok/muse; perplexity kept at Firefox 148 to stay matched with the firefox_148 TLS profile — the contributor's 152 bump was reverted to avoid a UA-vs-JA3 mismatch, #2459). The growth is Prettier reflow that lint-staged unavoidably applies to these grandfathered long-line files the moment they are touched; not extractable. src/sse/services/auth.ts 2336->2401 in the same reconcile is #5222's antigravity-LRU-retry growth that merged via --admin without a baseline bump.",
"_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop — a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (<cap); the residual growth is the duplicated thin-wrapper signatures + the extracted bodies' dispatch boundary, guarded by a byte-identical parity test (riskGateIntegration). Default off (DEFAULT_COMPRESSION_CONFIG unchanged). Not extractable without hiding the dispatch boundary, mirroring prior compression rebaselines. Structural shrink tracked in #3501.",
"_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791<cap pre-feature). Cohesive request/logging chokepoint wiring; structural shrink of chat.ts tracked in #3501.",
"_rebaseline_2026_06_29_4038_cas_guard": "PR (#4038) own growth: tokenRefresh.ts 2103->2181 (+78 = the compare-and-swap guard on the refresh persist — runWithCasGuard/getActiveCasGuard AsyncLocalStorage pair mirroring runWithOnPersist, casGuardShouldSkipPersist that rereads the row right before persisting and skips the write when a concurrent writer already rotated the refresh_token past the one presented, plus getCasGuardStats counters). Fixes the sibling-rotation-revert → token-family-revocation storm. Gated behind an active guard (opt-in; no guard => byte-identical). Wiring lives at the two persist chokepoints inside getAccessToken; the comparison reuses wasRefreshTokenRotated from refreshSerializer. Not extractable without splitting the refresh hot path.",
"_rebaseline_2026_06_29_5286_memoization": "PR #5286 own growth: strategySelector.ts 899->960 (+61 = the opt-in result-memoization branches in applyCompression/applyCompressionAsync — principal+determinism gate, makeMemoKey lookup/store with model+supportsVision folded into the key, recompute-with-memo-off). Default off (memoizeCompressionResults), so zero behavior change. The memo helpers live in the leaf resultMemo.ts (<cap); the chokepoint wiring here is not extractable. Structural shrink of this hot-path file tracked in #3501.",
"_rebaseline_2026_07_01_v3843_release_5609": "Rebaseline v3.8.43 (PR #5609 release reconciliation). DRIFT dos 109 commits do ciclo: 8 god-files existentes cresceram (ApiManagerPageClient 2983->3017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.",
"_rebaseline_2026_07_02_5816_qoder": "PR #5816 (@AgentKiller45, qoder PAT via qodercli): qoderCli.ts 666->989, new-above-cap frozen (owner-approved baseline freeze). The growth is the legitimate PAT job-token exchange + quota parsing CLI transport (the pure-JS Cosy path 500'd on every PAT request); extracting the spawn/parse helpers now would just add indirection to a contributor PR mid-merge. Test frozen also raised for this PR's coverage growth: providers-page-utils.test.ts 1052->1092. Additionally clears an inherited base-red from the already-merged #5933 (codex json_schema->text.format): translator-openai-responses-req.test.ts 1097->1172 (+75 regression tests, no offending branch left). All remain frozen (cannot grow further); release captain's rebaseline-at-release supersedes.",
"_rebaseline_2026_07_09_6126_clinepass_dual_auth": "PR #6126 (@hajilok, dual-auth ClinePass) own growth: tokenRefresh.ts 2181->2182 (+1 = a single `case \"clinepass\":` fallthrough label added to the existing `case \"cline\":` in _getAccessTokenInternal's provider switch, so clinepass token refresh dispatches to the already-shared refreshClineToken() instead of silently falling through to the generic OAuth refresh). Irreducible 1-line switch-case wiring at the existing chokepoint; the header-building logic for the same feature was extracted to a new leaf src/shared/utils/clineAuth.ts::buildClinepassHeaders() (well under cap) to avoid growing open-sse/executors/default.ts. Covered by tests/unit/clinepass-provider.test.ts.",
"_rebaseline_2026_07_09_6363_kiro_external_idp": "PR #6363 (@artickc, Kiro external IdP) own growth: tokenRefresh.ts 2182->2249 (+67 = the external_idp refresh branch inside refreshKiroToken — standard public-client OAuth2 refresh_token grant against the org IdP tokenEndpoint via buildExternalIdpRefreshParams/isExternalIdpAuthMethod from the new leaf open-sse/services/kiroExternalIdp.ts, with invalid_grant/invalid_client -> unrecoverable_refresh_error mapping). Cohesive addition at the existing refreshKiroToken chokepoint. Covered by tests/unit/kiro-external-idp.test.ts.",
"_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge.",
"_rebaseline_2026_07_09_6678_routing_strategy_9router": "#6678 (SeaXen) — 9router-parity Routing Strategy settings card + per-provider/combo sticky-round-robin override. Own growth: ProviderDetailPageClient.tsx 784->786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.",
"_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).",
"_rebaseline_2026_07_10_gcf_v3_2_decode": "PR #6838 own growth: new vendored file open-sse/services/compression/engines/headroom/gcf/decode_generic.ts frozen at 880 (> 800 cap). It is the vendored GCF generic-profile decoder (spec v3.2 nested flattening plus the prototype-pollution / hasOwnProperty hardening added in this PR's Gemini review). Kept as one file faithful to upstream gcf-typescript so re-vendoring stays a clean copy rather than a re-split each cycle (sibling generic.ts/scalar.ts stay < cap; extraction would also fragment the file's frozen eslint no-explicit-any suppressions). Round-trip + prototype-pollution regression coverage in tests/unit/compression/headroom-smartcrusher.test.ts. Frozen: only shrinks from here.",
"_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable).",
"_rebaseline_2026_07_15_7070_combos_memo": "PR #7070 (perf/p1-memo) own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4655->4656 (+1 = React.memo wrapping of ComboCard). Covered by tests/unit/ui/combos-page-smoke.test.tsx.",
"_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.",
"_rebaseline_2026_07_19_6636_codex_session_json": "#6636 own growth: OAuthModal.tsx 998->1030 (gate units, split(\"\\n\").length incl. trailing newline; +32 = session-JSON paste branch for handleManualSubmit plus a shared submitCodexAccessToken() helper extracted from the pre-existing bare-JWT branch, mirroring the #5203 oauthBlobSubmit.ts extraction precedent; the normalizer logic itself lives in the new src/lib/oauth/utils/codexSessionImport.ts leaf module, not here). Fourth irreducible wiring bump on this modal (969->989->993->998->1030); structural shrink tracked in #3501.",
"_rebaseline_2026_07_19_7546_ghe_copilot_modal": "PR #7546 (GHE Copilot OAuth provider) own growth: OAuthModal.tsx 1030->1056 (gate units). Adds a gheUrl input state, routes ghe-copilot through the existing device-code branch, and threads gheUrl into the device-code request/poll extraData at the existing provider-switch chokepoints (+~24 lines, cohesive with the same pattern as #7399/#6636). The standalone GHE enterprise-URL config step JSX (originally +31 lines inline) was extracted to the new src/shared/components/oauthModal/GheConfigStep.tsx leaf component to minimize the bump; what remains is the irreducible provider-branch wiring. Fifth bump on this modal (969->989->993->998->1030->1056); structural shrink tracked in #3501.",
"_rebaseline_2026_07_19_7787_ic2_localdb_reexports": "PR #7787 (IC2 raw connections cache + lazy-decrypt) own growth: localDb.ts 805->807 (gate units, +2). localDb.ts is the re-export-only layer (hard rule #2 — no logic); the PR adds 4 new db/readCache re-exports (touchConnectionLastUsed, getCachedRawProviderConnections, getCachedProviderConnectionById, getCachedProviderNodes) required by existing barrel importers. Irreducible for a re-export list; frozen so it can only shrink.",
"_rebaseline_2026_07_20_7779_routingcombo_thread": "PR #7779 own growth: chatHelpers.ts 876->877 (+1, thread routingComboId into executeChatWithBreaker for compression-combo assignment). Frozen so it can only shrink.",
"_rebaseline_2026_07_20_7819_autocandidateoverrides_reexport": "PR for #7819 (Level 1+2: read-only auto/* candidate transparency + per-API-key exclusions) own growth: localDb.ts 807->808 (+1). Adds a single `export * from \"./db/autoCandidateOverrides\"` barrel re-export (hard rule #2 — no logic) for the new DB module backing per-apiKey candidate exclusions. Irreducible for a re-export list; frozen so it can only shrink.",
"_rebaseline_2026_07_21_8027_grok_cli_auth_json_paste": "PR #8027 (RaviTharuma, fix(grok-cli) #7610) own growth: OAuthModal.tsx 1080->1100 (gate units). Requires the full ~/.grok/auth.json (with refresh_token) on the paste-import path instead of a bare JWT, at the existing paste-token chokepoint (renamed tab label, updated instructions/placeholder, textarea for the auth.json blob, inline error surface). The validation logic itself (parseGrokCliPasteToken, previously an inline ~75-line function) was extracted to the new src/lib/oauth/utils/grokCliAuthJson.ts leaf module — mirroring the #6636/#7546 extraction precedent — so only the irreducible UI wiring remains here. Sixth bump on this modal (969->989->993->998->1030->1056->1100); structural shrink tracked in #3501.",
"_rebaseline_2026_07_21_8034_compression_exclusions_sidebar": "#8034 (compression exclusions dashboard tab) own growth: sections.ts 796->806 (+10, one new COMPRESSION_CONTEXT_GROUP sidebar item linking /dashboard/compression/exclusions). The file was already 796/800 before this PR (organic growth from prior sidebar entries), so a single new nav item pushed it 6 lines over cap. Freezing at 806 (cannot grow further); the sidebar item array is data, not extractable logic.",
"_rebaseline_2026_07_22_7936_namespace_roundtrip": "#7936 (@RCrushMe, Responses-Chat namespace round-trip identity seam) own growth: open-sse/translator/response/openai-responses.ts 1092->1125 (+33) and open-sse/utils/stream.ts 2814->2869 (+55) — threading the namespace-identity seam through the Responses↔Chat translation + stream paths so tool-call namespaces survive the round-trip. Cohesive translation/stream wiring at existing chokepoints, frozen at new size.",
"_rebaseline_2026_07_22_8010_codex_responses_engine": "PR #8010 (@JxnLexn) own growth: open-sse/mcp-server/schemas/tools.ts 1497->1505 (+8 = threading the new \"codex-responses\" literal into the compressionConfigureInput strategy/autoTriggerMode Zod enums and setCompressionEngineInput engine enum, mirroring the existing rtk/omniglyph enum entries; no new tool). open-sse/services/compression/strategySelector.ts 1043->1054 (+11 = one new `if (mode === \"codex-responses\")` dispatch branch in runCompression that delegates 100% to the new codexResponsesEngine.apply, mirroring the existing rtk single-mode dispatch, plus threading config.codexResponsesConfig.preserveToolNames into the shared adaptBodyForCompression call at the 3 existing call sites). src/lib/db/compression.ts (untracked, new-file cap 800) 794->845 (+51 = normalizeCodexResponsesConfig, mirroring the existing normalizeRtkConfig normalizer, plus registering \"codex-responses\" in the COMPRESSION_MODES/STACKED_PIPELINE_ENGINE_IDS/SINGLE_MODE_ENGINE sets and the getCompressionSettings load/save switch) — added to the baseline at its current size. All three are cohesive dispatch/normalizer wiring at existing chokepoints (mirroring the prior compression-mode rebaselines #6534/#6556), not extractable without hiding the mode-dispatch boundary. Covered by tests/unit/compression/codex-responses.test.ts (6) + omniglyph-registries.test.ts/types.test.ts (22, updated for the new mode).",
"_rebaseline_2026_07_22_8034_compression_exclusions_persistence": "#8034 (compression exclusions) own growth: src/lib/db/compression.ts 845->850 (+5 = threading the new compressionExclusions field through the existing getCompressionSettings/saveCompressionSettings load/save switch over the shared key_value compression namespace — no new table, no raw SQL). Mirrors the prior compression-field rebaselines (#8010 codex-responses normalizer at the same chokepoint); the load/save switch is a single dispatch boundary, not extractable without hiding it. Covered by the PR's 8 node:test + 3 vitest cases.",
"_rebaseline_2026_07_22_8050_model_lockout_exact_family": "#8050 (@AndrianBalanescu) own growth: accountFallback.ts 1864->1892 (+28) — exact-vs-family model-lockout scoping (getModelLockKey/isModelLocked/clearModelLock/getModelLockoutInfo) so an Antigravity 404 for one bare model no longer hijacks the whole family cooldown. Cohesive lockout logic; frozen at new size.",
"_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size.",
"_rebaseline_2026_07_22_8081_reasoning_placeholder_guard": "#8081 (@Dingding-leo) own growth: openai-responses.ts 1125->1137 (+12) restructuring the reasoning-placeholder guard so it skips only the empty content block and still emits finish_reason/tool_calls in the same chunk. Cohesive translator wiring; frozen at new size.",
"_rebaseline_2026_07_22_8210_openrouter_midstream_error": "PR #8210 (hartmark, fix/openrouter-midstream-error-surfacing) own growth: open-sse/translator/response/openai-responses.ts 1137->1163 (+26) measured on the merged tip (release 1137 + this PR own growth). Adds a single new branch inside openaiToOpenAIResponsesResponse() that detects an OpenRouter-style mid-stream aggregator error (HTTP 200 SSE chunk with empty choices + a top-level error object) and surfaces it as state.upstreamError instead of silently falling through to the no-op/awaitingTrailingUsage path, which previously masked the failure as a false empty-success completion and skipped combo fallback. Irreducible call-site addition at the existing chunk-dispatch chokepoint (mirrors the Gemini-to-OpenAI translator's #4177 precedent for the same class of upstream error surfacing). Note: this baseline entry does NOT cover the separate pre-existing +11 drift already on the release tip from #8081/#8162 (1125->1136, unrelated reasoning-placeholder-stripping fix merged after this PR branched) — that drift belongs to the maintainer's rebaseline, not this PR.",
"_rebaseline_2026_07_22_8211_gemini_malformed_tool_choice": "PR #8211 (hartmark, fix/gemini-malformed-function-call-tool-choice) own growth: open-sse/translator/response/gemini-to-openai.ts 771->821 (+50, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds MALFORMED_FUNCTION_CALL/UNEXPECTED_TOOL_CALL handling inside geminiToOpenAIResponse(): synthesizes a `malformed_tool_call` tool_calls entry so finish_reason normalizes to the standard \"tool_calls\" instead of an unrecognized raw enum value that OpenAI-compatible clients (e.g. OpenClaw) silently ignore, and always synthesizes (rather than skipping when a real tool call already exists) so a malformed attempt alongside a real one in the same turn is not silently discarded. Irreducible cohesive addition at the existing candidate/finishReason translation chokepoint (mirrors the 9router#2462 raw-finish-reason precedent immediately below it in the same function). Covered by the PR's own tests/unit test additions for both the malformed-only and malformed-plus-real-call cases.",
"_rebaseline_2026_07_22_8213_chat_abandoned_target_abort": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/sse/handlers/chat.ts 1794->1860 (+66, measured against the PR's own merge-base — the release tip separately carries an unrelated -5 net shrink from #8013's antigravity callable-catalog alignment, which this PR's branch does not include and this entry does not cover). Adds resolveDispatchClientRawRequest(): merges a per-target modelAbortSignal into clientRawRequest.signal (via mergeAbortSignals) so a combo target abandoned by comboTargetTimeoutMs actually observes its own abort and reaches its cleanup path, instead of hanging forever inside withRateLimit/acquireAccountSemaphore and leaking a permanent 'pending' dashboard entry (live incident, log id 1784418258231-14961a). Also wires combo-exhausted rejection logging to capture request body + attempted models via the new rejectedRequestUsage helper. Irreducible additions at the existing chat dispatch chokepoint. Covered by the PR's own combo-config + integration test additions.",
"_rebaseline_2026_07_22_8213_combo_cooldown_wait_recording": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: open-sse/services/combo.ts 3548->3604 (+56, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Fixes combo cooldown-wait state recording so a bogus 503 is no longer crystallized when the cooldown-wait vars reset every setTry, adds an OpenAI-format SSE error frame path for combo-exhausted rejections (capturing request body + attempted models), and gives an abandoned per-target dispatch its own timeout instead of leaking a permanent 'pending' dashboard entry. Irreducible additions at the existing handleComboChat dispatch/retry chokepoint (mirrors the prior quota-share/headroom/task-aware strategy-branch precedents already frozen in this file). Covered by the PR's own combo-config + Gemini TPM-ceiling benchmark test additions.",
"_rebaseline_2026_07_22_8213_gemini_tpm_quota_cooldown_wait": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: open-sse/services/accountFallback.ts 1857->1932 on the merged tip (release 1892 incl #8050 +35, plus this PR own growth +40); measured against the PR own merge-base was 1857->1898 (+41 — the release tip separately carries an unrelated +34 from #8050's antigravity 404 model-not-found lockout scoping, which this PR's branch does not include and this entry does not cover). Own growth is the Gemini TPM-ceiling classification + cooldown-wait wiring feeding into the combo cooldown-wait state machine (rate-limit wedge recovery) introduced by this PR's commit series. Irreducible additions at the existing account-fallback/model-lockout chokepoint. Covered by the PR's own gemini-rate-limit-tracker and TPM-ceiling benchmark test additions.",
"_rebaseline_2026_07_22_8213_health_unblock_model_cooldowns": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/app/(dashboard)/dashboard/health/page.tsx 1094->1165 (+71, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds handleUnblockAll/handleUnblockOne dashboard actions (DELETE /api/resilience/model-cooldowns) so an operator can manually clear a Gemini TPM-wedge model lockout surfaced by this PR's cooldown-wait fixes, instead of waiting out the ceiling. Irreducible UI wiring at the existing health-page action chokepoint. Covered by the PR's own dashboard/resilience test additions.",
"_rebaseline_2026_07_22_8213_requestloggerdetail_unblock_ui": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/shared/components/RequestLoggerDetail.tsx 799->941 (+142, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip; crosses the general 800-line new-file cap so is frozen here for the first time). Adds a collapsible section header (open/expand-less toggle) plus per-log-entry unblock (`unblocking`/`cleared` state, isCombo503 detection) so the request-logger detail panel surfaces the same Gemini TPM cooldown-wait / model-lockout unblock action introduced by this PR at the individual-request level (mirrors the health-page bulk unblock action added in the same PR). Covered by the PR's own dashboard/resilience test additions.",
"_rebaseline_2026_07_22_fusion_8013_8098_antigravity": "Fusion of #8013 (backryun, catalog/IDE-CLI-split rewrite) + #8098 (nguyenha935, protocol-fidelity/fail-closed/credits/tool-cloaking): open-sse/services/usage/antigravity.ts NEW 802 (>cap 800, +2 — #8098 credits/tier usage service on #8013's profile-aware headers). Test growth (models-catalog-route 1605->1608, provider-models-route 1752->1757 from #8013 Gemini 3.6 catalog) tracked in testFrozen.",
"_rebaseline_2026_07_23_8127_grok_weekly_quota": "#8127 (@apoapostolov) own growth: src/sse/handlers/chat.ts 1861->1865 (+4) — weekly quota tracking for grok-web wires a quota-fetch hook at the existing dispatch chokepoint. Thin wiring mirroring adjacent provider-quota branches; not extractable. Covered by tests/unit/grok-quota-fetcher.test.ts.",
"_rebaseline_2026_07_23_8143_empty_catch_logging": "#8143 (@chirag127) own growth: open-sse/utils/stream.ts 2869->2887 (+18) — replacing empty catch blocks in the SSE stream subsystem with console.debug logging (Rule #6 silent-swallow fix, issues #8138-#8142). Cohesive logging additions at the existing catch chokepoints, not extractable; frozen at new size. Covered by tests/unit/stream-handler-catch-logging-8143.test.ts.",
"_rebaseline_2026_07_23_8219_cache_ttl_settings_sidebar": "#8219 (@oyi77) own growth: sections.ts 806->813 (+7) — configurable model-catalog cache-TTL settings adds a new sidebar nav entry + its visibility wiring. Sidebar item array is data, not extractable logic; frozen at new size.",
"_rebaseline_2026_07_23_8247_8248_model_unhealthy": "#8247+#8248 own growth: accountFallback.ts 1940->1941 (+1, irreducible import statement only — the substantive #8248 DEGRADED-pattern classifier was extracted into open-sse/config/errorConfig.ts, which has ample headroom, instead of growing this frozen file; #8247's fix is a single existing-line condition change, net zero lines). Scoping the credits-exhausted 403/429 branch to isCompatibleProvider() (per-model-quota openai/anthropic-compatible-* nicknames) so it stays model-scoped instead of terminalling the whole connection, and classifying NVIDIA NIM 'Function ... DEGRADED' 400 bodies as model-access-denied instead of a raw passthrough 400. Covered by tests/unit/8247-accountfallback-model-unhealthy.test.ts and tests/unit/8248-accountfallback-nvidia-degraded.test.ts.",
"_rebaseline_2026_07_23_8252_combo_400_advance": "#8252 (@RaviTharuma) own growth: accountFallback.ts 1932->1940 (+8) + combo.ts 3604->3630 (+26) — advance combo on model-scoped 400s wrapped as invalid/Bad-Request. Irreducible wiring at existing account-fallback + combo dispatch chokepoints. Covered by combo-model-scoped-400-advance.test.ts.",
"_rebaseline_2026_07_23_8266_alibaba_media": "#8266 (@backryun) own growth: imageRegistry.ts 821->979 (+158) — Alibaba-family media models (Qwen image/video, Bailian, Wan) added to the image/video registry. Registry model data, not extractable logic; frozen at new size.",
"_rebaseline_2026_07_24_8388_compression_detail_persist": "#8388 (compression engine DETAIL settings — Headroom/session-dedup/CCR — dropped on save) own growth: src/lib/db/compression.ts 866->872 (+6 = irreducible call-site wiring at the existing getCompressionSettings/updateCompressionSettings chokepoint: one import line, one `...buildDetailConfigDefaults()` spread in the seed config, and one `case \"sessionDedup\": case \"ccr\": applyDetailConfigUpdate(config, key, parsed); break;` load-switch case, mirroring the existing headroom/#8056 case immediately above it). The actual normalizer logic (normalizeSessionDedupConfig/normalizeCcrConfig, matching SESSION_DEDUP_SCHEMA/CCR_SCHEMA bounds) was EXTRACTED into a new leaf src/lib/db/compressionDetailNormalizers.ts (well under cap) so this frozen file only carries the minimal dispatch wiring. Covered by tests/unit/8388-compression-detail-persist.test.ts (schema-accept + full DB save->reload round-trip for both new sub-objects, plus a no-regression assertion on the existing headroom round-trip).",
"_rebaseline_2026_07_24_responses_toolcalls_log_summary": "hartmark, fix/responses-tool-calls-log-summary own growth: open-sse/translator/response/openai-responses.ts 1163->1174 (+11). closeToolCall() now also writes the completed tool call into the shared state.toolCalls Map (already populated by the openai-to-claude / claude-to-openai / gemini-to-openai response translators) so stream.ts's completion-log summary builder (which reads state.toolCalls, not this translator's own funcCallIds/funcNames/funcArgsBuf bookkeeping) reports finish_reason \"tool_calls\" and message.tool_calls for openai->openai-responses translated streams instead of always logging \"stop\" with no tool_calls — the actual client-facing SSE events were already correct; only the persisted call-log summary was wrong. Irreducible call-site addition at the existing tool-call-close chokepoint. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts.",
"_rebaseline_2026_07_25_8476_combo_input_bound_homogeneous_scope": "PR #8476 (herjarsa, fix/8375-8459-combo-image-fixes, #8375) own growth: open-sse/services/combo.ts 3642->3679 (+37 net: +29 the PR's own isInputBoundFailure short-circuit for deterministic context_length_exceeded/context_window_exceeded failures, +8 a /green-prs pre-merge fix scoping that short-circuit to homogeneous remainders only — the shipped code fired unconditionally on ANY target, regressing the intentional heterogeneous-combo fallback #6637/isContextOverflow400 protects, exactly as flagged by this PR's own review evidence but never actually implemented in the branch). The fix compares orderedTargets[i+1..] modelStr against the failing target's modelStr at the existing executeTarget dispatch chokepoint (mirrors the sameProviderNext precedent a few lines below) — irreducible call-site wiring, not extractable without hiding the dispatch boundary. Covered by tests/unit/combo-input-bound-failure-8375.test.ts (homogeneous pool still short-circuits) and the new tests/unit/combo-input-bound-heterogeneous-8375.test.ts (heterogeneous combo now correctly falls through to the larger-context target).",
"_rebaseline_2026_07_25_adobe_firefly_reference_images": "Follow-up to #8006: storage upload + referenceBlobs for image/video and /v1/images/edits dispatch. adobeFireflyClient.ts 1958->2317 (+upload helpers, extract sources, resolve blob ids). Note: 2317 not 2316 — check-file-size.mjs counts LOC via split(\"\\n\").length (counts the trailing-newline empty element), which is 1 higher than `wc -l` on a file ending in \\n; the PR's original entry (2316) was measured with wc -l and undercounted by 1 against the actual gate.",
"_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/chatgpt-web.ts": 3241,
"open-sse/executors/codex.ts": 1562,
"open-sse/executors/cursor.ts": 1560,
"open-sse/executors/deepseek-web.ts": 1148,
"open-sse/executors/grok-web.ts": 1044,
"open-sse/executors/muse-spark-web.ts": 1405,
"open-sse/handlers/chatCore.ts": 5034,
"open-sse/handlers/imageGeneration.ts": 3101,
"open-sse/handlers/responseSanitizer.ts": 1128,
"open-sse/handlers/search.ts": 1536,
"open-sse/handlers/videoGeneration.ts": 1063,
"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/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/translator/response/openai-responses.ts": 1257,
"open-sse/utils/cursorAgentProtobuf.ts": 1505,
"open-sse/utils/stream.ts": 2889,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1388,
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3117,
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1067,
"src/app/(dashboard)/dashboard/combos/page.tsx": 4647,
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1283,
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1022,
"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/runtime/RuntimePageClient.tsx": 1201,
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1019,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1464,
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1123,
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629,
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1573,
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028,
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1119,
"src/app/api/providers/[id]/models/route.ts": 2250,
"src/app/api/v1/models/catalog.ts": 1549,
"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/models.ts": 1097,
"src/lib/db/providers.ts": 1034,
"src/lib/memory/retrieval.ts": 1073,
"src/lib/tailscaleTunnel.ts": 1202,
"src/lib/usage/providerLimits.ts": 1013,
"src/shared/components/OAuthModal.tsx": 1134,
"src/shared/components/RequestLoggerV2.tsx": 1687,
"src/shared/components/analytics/charts.tsx": 1035,
"src/shared/services/cliRuntime.ts": 1122,
"src/sse/handlers/chat.ts": 1906,
"src/shared/components/RequestLoggerDetail.tsx": 1260,
"src/shared/components/RequestTimeline.tsx": 1044,
"src/sse/services/auth.ts": 2508,
"tests/unit/account-fallback-service.test.ts": 1572,
"tests/unit/provider-validation-specialty.test.ts": 2985,
"open-sse/executors/hyperagent.ts": 1026,
"open-sse/executors/default.ts": 1042,
"open-sse/executors/kiro.ts": 1069
},
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
"_rebaseline_2026_07_27_v3849_train3": "Merge-train 3 (13 PRs) — owner-approved 2026-07-27. Both entries are genuine irreducible growth at existing chokepoints, not new branches: src/lib/db/apiKeys.ts 1518->1529 (#8805 cx/* ≡ codex/* API-key model permissions); open-sse/handlers/chatCore.ts 5006->5020 (#8806 real response payload into plugin onResponse hooks). Covered by tests/unit/db-apiKeys-crud.test.ts (4 new cases) and the two plugin-hook test files updated in #8806 respectively.",
"_rebaseline_2026_07_28_8842_antigravity_projectid_refresh": "PR #8842 (fix/antigravity-projectid-refresh) own growth: open-sse/executors/antigravity.ts 1493->1528 (+35 = projectId discovery in refreshCredentials: import ensureAntigravityProjectAssigned + trim projectId + call ensureAntigravityProjectAssigned with 8s timeout + persistDiscoveredAntigravityProjectId + log success/failure). Irreducible wiring at the existing credential-refresh chokepoint. Covered by tests/unit/executor-antigravity.test.ts (4 new test cases).",
@@ -430,5 +432,6 @@
"_rebaseline_2026_08_01_8964_response_sanitizer": "PR #8964 own growth: responseSanitizer.ts 1115->1128. Keep cost_in_usd_ticks / server_side_tool_usage(_details) through sanitizeResponsesApiResponse allowlists so native xAI tool responses retain usage.",
"_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_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)."
}

View File

@@ -109,6 +109,8 @@
"_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle."
},
"cognitiveComplexity": {
"value": 957,
"_rebaseline_2026_07_25_dario_upstream_proxy_selector": "951->957 (+6). Same cycle-drift + own-growth split as the complexity-baseline.json note dated 2026-07-25 (PR #8523, Dario embedded service): cognitive-complexity does not run on PR->release fast-gates, so drift accrues unratcheted. Base upstream/release/v3.8.49 tip measures 956 locally with this PR\u0027s commits removed; this branch measures 957 both locally and on the CI runner. This PR\u0027s own genuine contribution is +1: the new mode-selector conditional rendering (Native/CLIProxyAPI/Dario/Fallback branches plus the fallback-backend picker) in ConnectionRow.tsx. Structural shrink stays tracked in #3501. Tighten via --update next cycle.",
"value": 1223,
"_rebaseline_2026_07_25_8470_hyperagent_sticky_thread": "951->957 (+6). PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) pre-green validation. Trust-but-verify: origin/release/v3.8.49 tip alone (pristine, no PR changes) already measures 956 with node scripts/check/check-cognitive-complexity.mjs — i.e. +5 is inherited cycle drift unrelated to this PR (cognitive-complexity does not run on PR->release fast-gates). This PR's OWN growth adds exactly +1: per-file eslint scoped scan (eslint --config eslint.complexity-ratchets.config.mjs open-sse/executors/hyperagent.ts) on base vs PR shows extractMessageText() crossing the threshold for the first time (new sonarjs/cognitive-complexity violation, 26 > 15) from the new Anthropic tool_use/tool_result flattening branches; resolveHyperAgentThreadBinding's existing pre-#8470 violation (16) grows to 21 (still counted once) from the new root-key lookup tier; createHyperAgentThread and execute() are unchanged pre-existing violations. Net repo-wide total = 956 (inherited drift) + 1 (this PR's own new violation) = 957. Full-repo re-measurement of the merged branch was attempted but not completed live due to heavy concurrent devbox load (many other /green-prs sessions running the identical full-repo eslint scan in parallel); the value here is derived from two independently-clean measurements (base-tip full scan + per-file base-vs-PR delta) rather than a third full-repo run. Covered by tests/unit/executor-hyperagent.test.ts (19/19). Tighten via --update next cycle.",
"_rebaseline_2026_07_25b_v3849_mergetrain_owngrowth": "Owner-approved (chat, 2026-07-25): 956->968 (+12). v3.8.49 /merge-prs 41-PR merge-train aggregate own-growth: measured 968 on the combined boarded tree (tip ac15014ca7) vs 956 on the pristine release tip. The batch's new over-threshold functions come from the pre-screen-flagged complexity-growth set (#8378/#8432/#8476/#8526 etc); each PR is under-ceiling alone, the combined batch adds +12. Same merge-burst class as the notes below; owner chose ceiling-absorb over per-PR extraction. Structural shrink tracked in #3501; tighten via --update next cycle.",

View File

@@ -1,3 +1,9 @@
---
title: "Incident Response Runbook — OmniRoute (2026-06-18)"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Incident Response Runbook — OmniRoute (2026-06-18)
**Status**: Authoritative. The 71-pillar audit (L61) references this doc

View File

@@ -1,3 +1,9 @@
---
title: "Performance Budgets — OmniRoute (2026-06-18)"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Performance Budgets — OmniRoute (2026-06-18)
**Status**: Authoritative. SLO targets that the 71-pillar audit (L13)

View File

@@ -105,6 +105,7 @@ Pluggable subsystems exposed to clients, agents, and operators.
- [PLUGINS.md](frameworks/PLUGINS.md) — CLI plugin system overview.
- [PLUGIN_SDK.md](frameworks/PLUGIN_SDK.md) — plugin SDK reference.
- [PLUGIN_MARKETPLACE.md](frameworks/PLUGIN_MARKETPLACE.md) — plugin marketplace.
- [RADAR.md](frameworks/RADAR.md) — Radar free-model catalog overlay (optional, off by default).
## routing/

View File

@@ -1,3 +1,9 @@
---
title: "OmniRoute Roadmap"
version: 3.8.50
lastUpdated: 2026-08-06
---
# OmniRoute Roadmap
> Version-gated, not date-gated: each milestone ships when its quality gates pass.

View File

@@ -184,6 +184,7 @@ src/
| `config/` | Runtime config helpers |
| `db/` | 95+ domain DB modules + 110+ migrations (always go through here for SQLite) |
| `quota/` | Quota Sharing Engine: `dimensions.ts` (types/Zod), `types.ts` (QuotaStore interface), `sqliteQuotaStore.ts`, `redisQuotaStore.ts`, `storeFactory.ts`, `fairShare.ts`, `burnRate.ts`, `planResolver.ts`, `planRegistry.ts`, `saturationSignals.ts`, `enforce.ts`, `spendRecorder.ts` — see `docs/routing/QUOTA_SHARE.md` |
| `radar/` | Radar free-model catalog client: `feedSchema.ts`, `pinnedKeys.ts`, `verify.ts`, `sync.ts`, `applyFeed.ts`, `index.ts` (`getRadarCatalog()`) — see `docs/frameworks/RADAR.md` |
| `display/` | UI formatting helpers (cost, latency, etc.) |
| `embeddings/` | Embeddings service helpers |
| `env/` | Env variable parsing + validation |
@@ -410,6 +411,7 @@ open-sse/
| `AGENT_PROTOCOLS_GUIDE.md` | Unified guide: A2A vs ACP vs Cloud Agents |
| `CLOUD_AGENT.md` | Codex Cloud / Devin / Jules orchestration |
| `SKILLS.md` | Skills framework (built-in + marketplace + SkillsSH + sandbox) |
| `RADAR.md` | Radar free-model catalog overlay (`RADAR_ENABLED`, off by default) |
| `MEMORY.md` | Memory system (SQLite FTS5 + Qdrant) |
| `EVALS.md` | Eval framework (suites, runs, rubrics) |
| `GUARDRAILS.md` | PII masker, prompt injection, vision bridge |

View File

@@ -1,3 +1,9 @@
---
title: "Flag icons"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Flag icons
SVG country flags used by the language selector in the root `README.md`.

View File

@@ -1,3 +1,9 @@
---
title: "Combo Context Requirements Feature"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Combo Context Requirements Feature
## Overview

View File

@@ -0,0 +1,228 @@
---
title: "MemoryBackend Provider Pattern"
version: 3.8.49
lastUpdated: 2026-07-28
---
# MemoryBackend Provider Pattern
> **Source of truth:** `src/lib/memory/backend.ts`, `src/lib/memory/genericBackend.ts`, `src/lib/memory/manager.ts`
> **Tests:** `src/lib/memory/__tests__/generic-backend.test.ts`
The MemoryBackend provider pattern introduces a **pluggable backend abstraction layer** over the existing memory engine. Instead of being tied to a single storage implementation, the memory system now supports multiple backends (SQLite, Obsidian, Notion, custom HTTP backends) with configurable primary/fallback routing.
## Architecture
```
┌──────────────────────────────────────────────────────────┐
│ API Routes │
│ (src/app/api/memory/route.ts) │
└──────────────────────┬───────────────────────────────────┘
┌──────────────────────▼───────────────────────────────────┐
│ MemoryManager │
│ Singleton orchestrator (manager.ts) │
│ │
│ Primary ──► Backend A (e.g. SQLite) │
│ Fallback ─► Backend B (e.g. Obsidian) │
│ Backend C (e.g. Notion via GenericBackend) │
└──────────────────────┬───────────────────────────────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌──────────────────┐
│ SQLite │ │ Obsidian │ │ GenericMemory │
│ Backend │ │ Backend │ │ Backend (HTTP) │
└────────────┘ └────────────┘ └──────────────────┘
```
### Core Interface (`backend.ts`)
Every backend must implement the `MemoryBackend` interface:
```typescript
interface MemoryBackend {
readonly id: string;
readonly displayName: string;
// CRUD
create(input: CreateMemoryInput): Promise<Memory>;
get(id: string): Promise<Memory | null>;
update(id: string, updates: Partial<...>): Promise<boolean>;
delete(id: string): Promise<boolean>;
list(filter: MemoryFilter): Promise<{ data: Memory[]; total: number; byType: Record<string, number> }>;
// Search
search(config: SearchConfig): Promise<Memory[]>;
// Health
health(): Promise<HealthCheckResult>;
// Lifecycle (optional)
initialize?(): Promise<void>;
shutdown?(): Promise<void>;
}
```
### MemoryManager (`manager.ts`)
Singleton orchestrator that:
- **Registers** backends via `register(backend)` — called at boot from `index.ts`
- **Configures** primary + fallback via `configure(primary, fallbacks)`
- **Routes** CRUD/search to the primary, with fallback chain on failure
- **Health checks** all backends periodically
**Fallback behavior:**
| Operation | Primary | Fallbacks |
| --------- | -------------------- | ----------------------- |
| `create` | ✅ Primary only | ❌ |
| `get` | ✅ Try primary first | ✅ Fallback if null |
| `update` | ✅ Primary only | ✅ Fire-and-forget sync |
| `delete` | ✅ Primary only | ✅ Fire-and-forget sync |
| `list` | ✅ Primary only | ❌ |
| `search` | ✅ Primary first | ✅ Fallback on error |
### GenericMemoryBackend (`genericBackend.ts`)
A generic HTTP connector that adapts any REST API into a MemoryBackend. Useful for:
- **Notion** — connect via Notion API
- **Obsidian** — connect via Obsidian Local REST API
- **Custom backends** — any service that exposes a RESTful memory API
**Configuration:**
```typescript
interface GenericBackendConfig {
baseUrl: string; // Base URL of the backend API
apiKey?: string; // Bearer token for auth
headers?: Record<string, string>; // Custom HTTP headers
timeout?: number; // Request timeout (default: 30000ms)
backendType?: string; // For logging
// Endpoint overrides (defaults use REST conventions)
endpoints?: {
search?: string; // default: "/memories/search"
create?: string; // default: "/memories"
list?: string; // default: "/memories"
get?: string; // default: "/memories/{id}"
update?: string; // default: "/memories/{id}"
delete?: string; // default: "/memories/{id}"
health?: string; // default: "/health"
};
// Query parameter name mappings
queryParams?: {
query?/apiKeyId?/limit?/offset?/strategy?/maxTokens?/type?/sessionId?/orderBy?/orderDir?/options?
};
// Path parameter name mappings
pathParams?: {
id?/memoryId?
};
}
```
**Known backends** are pre-configured in `KNOWN_BACKENDS`:
```typescript
createKnownBackend("obsidian"); // → GenericMemoryBackend pointed at localhost:27123
createKnownBackend("notion"); // → GenericMemoryBackend pointed at api.notion.com/v1
```
### Built-in Backends
#### SQLiteBackend (`sqliteBackend.ts`)
The default primary backend. Wraps the existing SQLite-based memory store using `src/lib/memory/store.ts`. Automatically registered at boot.
```typescript
import { sqliteBackend } from "./sqliteBackend";
memoryManager.register(sqliteBackend);
```
#### ObsidianBackend (`obsidianBackend.ts`)
Wraps the existing Obsidian integration (`src/lib/memory/obsidianBackend.ts`). Connects to an Obsidian vault via the Obsidian Local REST API.
## Settings
Memory backend settings are stored in the app settings table and managed via `src/lib/memory/settings.ts`:
| Setting | Env/Config Key | Default | Description |
| ----------------- | ------------------------ | ---------- | ---------------------------- |
| Primary backend | `memoryPrimaryBackend` | `"sqlite"` | ID of the primary backend |
| Fallback backends | `memoryFallbackBackends` | `[]` | Ordered fallback backend IDs |
| Backend configs | `memoryBackendConfigs` | `{}` | Per-backend config overrides |
Settings are normalized via `normalizeMemorySettings()` and cached at `getMemorySettings()`.
## Initialization Flow
```
App bootstrap
→ index.ts imports (side-effect): registers SQLiteBackend
→ initMemoryBackends() called from app lifecycle:
1. Load settings (getMemorySettings)
2. Configure primary + fallback
3. Initialize all backends (health check)
4. Ready for requests
```
## Adding a New Backend
1. **Implement `MemoryBackend`** interface in `src/lib/memory/<name>Backend.ts`
2. **Export** from `src/lib/memory/index.ts`
3. **Register** with `memoryManager.register(yourBackend)` at boot
4. **Configure** via settings: set `memoryPrimaryBackend` to your backend ID
5. **Test** with `src/lib/memory/__tests__/generic-backend.test.ts` as reference
### Example: Brain Backend
```typescript
import { createGenericMemoryBackend } from "./genericBackend";
const brainBackend = createGenericMemoryBackend("brain", "BK-Brain", {
baseUrl: process.env.BRAIN_API_URL || "http://localhost:9099",
apiKey: process.env.BRAIN_API_KEY,
endpoints: {
search: "/api/memory/search",
create: "/api/memory",
health: "/api/health",
},
});
memoryManager.register(brainBackend);
```
## Verification
### Unit tests
```bash
npx vitest run src/lib/memory/__tests__/generic-backend.test.ts --reporter=verbose
```
Expected output: **26 tests, all passing** covering:
- Constructor (2)
- Health check (4) — success, failure 500, network error, latency
- Initialize (2) — success, failure
- Create (2) — default endpoint, custom endpoint
- Get (4) — success, 404 → null, non-404 throw, custom path params
- Update (2) — success, 404 → false
- Delete (2) — success, 404 → false
- List (2) — query params, custom param names
- Search (3) — query params, custom endpoint, options serialization
- Auth headers (2) — Bearer token, custom headers
- Factory (1)
### Type check
```bash
npm run typecheck:core
```
Expected: **0 errors**.

236
docs/frameworks/RADAR.md Normal file
View File

@@ -0,0 +1,236 @@
---
title: "Radar Free-Model Catalog"
version: 3.8.50
lastUpdated: 2026-08-05
---
# 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
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
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.
**Nothing that is free today stops being free.** Radar never removes or paywalls a
baseline entry; it only refreshes limits/status fields at read time and can layer in
newly-discovered free models between releases. The baseline catalog itself is never
mutated on disk — see [Read-time overlay merge rules](#read-time-overlay-merge-rules)
below.
---
## Flag: `RADAR_ENABLED` (default off)
Radar is gated end-to-end by the `RADAR_ENABLED` feature flag
(`src/shared/constants/featureFlagDefinitions.ts`, category `policies`,
`defaultValue: "false"`).
**When the flag is off, the surface does not exist:**
- `GET /api/radar/catalog`, `POST /api/radar/sync`, `POST /api/radar/settings` all
return `404` before touching any Radar module.
- The dashboard screens (`/dashboard/radar`, `/dashboard/radar/setup`) render
`notFound()`.
- `getRadarCatalog()` (`src/lib/radar/index.ts`) returns the untouched baseline —
same entry count, same values, every entry tagged `origin: "baseline"` — and never
reads the feed cache.
- No network call is ever made; `syncRadar()` (`src/lib/radar/sync.ts`) returns
`{ status: "disabled" }` at step 1 without touching `fetch`.
This is a strict superset gate: flipping the flag on unlocks the _screens_, nothing
more. It does not upload data, does not start a background sync, and does not change
routing or model selection — see the separate opt-in below.
---
## Data sync is a SEPARATE opt-in — the privacy promise
Turning `RADAR_ENABLED` on only unlocks the UI. Syncing the feed requires a second,
independent opt-in stored in `radar_settings.opt_in` (`src/lib/db/radar.ts`,
migration `136_radar_cache_settings.sql`). `syncRadar()` checks the flag _and_ the
opt-in before making any network call:
```
Flag off → { status: "disabled" } — no network call
Opt-in false → { status: "opt_out" } — no network call
```
When both are on, the sync path is:
1. `GET <feed base URL>/v1/catalog/latest` with an optional `Authorization: Bearer
<supporter key>` header (see below).
2. Nothing about the request, the operator, or their traffic is uploaded — it is a
plain, unauthenticated-by-default GET. OmniRoute never posts usage data, provider
configuration, or model traffic to the feed service.
3. The response is verified, validated, and cached locally (see
[Security model](#security-model)). Nothing else touches the network for Radar.
The **supporter key** is an optional Bearer token (`radar_settings.supporter_key`)
that lets the feed service decide which tier to serve (see
[Tiers](#tiers-community-and-live)). It is:
- Stored **encrypted at rest** with the same AES-256-GCM `encrypt()`/`decrypt()`
helpers (`src/lib/db/encryption.ts`) used for provider credentials.
- Set via `POST /api/radar/settings` (`{ supporterKey: "omr_" + 40 hex chars }`) and
**never echoed back** — the response returns a masked form (`omr_****abcd`).
- Sent to the feed service as a Bearer token on the sync GET — nothing else about the
key ever leaves the client.
---
## Security model
### Ed25519 signature over exact bytes
The feed payload is signed with Ed25519. `verifyFeedBytes()`
(`src/lib/radar/verify.ts`) verifies the signature over the **exact response bytes**
received over the wire — the payload is never re-serialized before verification, so a
byte-for-byte re-encoding cannot silently invalidate or bypass the signature check.
Verification failure (`invalid_signature`) aborts the sync before the payload is ever
parsed or cached.
### Pinned public key + rotation
The verifying public key is pinned in `src/lib/radar/pinnedKeys.ts`
(`PINNED_FEED_PUBLIC_KEYS`), an array so a new key can be prepended ahead of a
rotation while old cached feeds signed with a previous key remain valid until
re-synced.
### Fork-friendly env overrides
Two env vars let forks and self-hosters point the client at their own feed instead of
the default OmniRoute service — see
[How to self-host a feed](#how-to-self-host-a-feed) below:
| Var | Purpose |
| ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `RADAR_FEED_URL` | Overrides the feed base URL (default `https://radar.omniroute.online`). |
| `RADAR_FEED_PUBKEY` | Overrides the pinned public key (base64-DER SPKI or PEM), replacing the built-in array with this single key. |
### Version floor
`syncRadar()` rejects a downloaded feed whose `version` is not strictly newer than the
currently cached version (`compareVersions()`, dotted `YYYY.MM.DD.n` comparison) —
`{ status: "stale" }`. This prevents a compromised or misconfigured feed endpoint from
rolling a client back to an older, differently-signed payload.
### Schema validation
The downloaded bytes are parsed and validated against `RadarFeedSchema`
(`src/lib/radar/feedSchema.ts`, a Zod schema) **after** signature verification. A
schema mismatch returns `{ status: "invalid_schema" }` and the cache is left
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.
---
## Tiers: `community` and `live`
The feed schema carries a `tier: "community" | "live"` field, decided **server-side**
by the feed service based on the request (presence and validity of the supporter key)
— the client never decides its own tier.
- **`community`** — the free catalog delayed by roughly 30 days behind the freshest
data. This is what an unauthenticated or invalid-key request receives.
- **`live`** — the freshest catalog, served to requests carrying a valid supporter
key.
**An invalid or expired supporter key degrades to `community` — it is never an
error.** The sync path only distinguishes signature/schema/version failures (all
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.
---
## Read-time overlay merge rules
`applyFeed()` (`src/lib/radar/applyFeed.ts`) merges the cached feed **over** the
static baseline at **read time**, inside `getRadarCatalog()`. The baseline array
(`FREE_MODEL_BUDGETS`) is never mutated — a `MergedEntry[]` is computed fresh on every
call.
Four rules, in order of precedence:
1. **Feed never overwrites a local override.** Per-field: if the operator has
customized a field on an entry (`localOverrides` map, keyed `provider:modelId`),
the feed's value for that specific field is skipped — the operator's value wins.
2. **`enabled: false` disables the entry, with provenance.** A feed entry that turns
an entry off sets `enabled: false` and `disabledBy: "radar"` on the merged result,
so the UI can explain _why_ an entry went from available to disabled.
3. **A user-added entry not present in the feed survives untouched.** Entries that
only exist in the baseline (or were added locally) and have no corresponding feed
entry pass through unchanged.
4. **A tombstoned entry is never resurrected.** If the operator explicitly deleted an
entry (`tombstones` set), the feed re-adding that `provider:modelId` in a later
version does not bring it back.
### Provenance markers
Every merged entry carries an `origin` field the UI renders as a badge:
- `"baseline"` — untouched from the static release catalog.
- `"radar"` — one or more fields were refreshed by the feed.
- `"local"` — the operator has at least one local override on this entry (local
overrides always win over the feed per rule 1, regardless of what the feed says).
---
## Local surfaces — never a feed proxy
Three 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. |
**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
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
[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`).
---
## How to self-host a feed
A fork or self-hoster that wants full control over the catalog can run their own feed
service without touching client code:
1. Serve a `GET /v1/catalog/latest` endpoint returning a JSON body that satisfies
`RadarFeedSchema` (`src/lib/radar/feedSchema.ts`) — top-level `feed:
"omniroute-radar"`, `schemaVersion: 1`, `version`, `tier`, `providers`, `models`,
`quirks`, and `totals`.
2. Sign the exact response bytes with an Ed25519 key pair and return the base64
signature in the `x-omniroute-feed-signature` response header.
3. Set `RADAR_FEED_URL` to the new base URL and `RADAR_FEED_PUBKEY` to the matching
public key (base64-DER SPKI or PEM) — see the
[env var reference](../reference/ENVIRONMENT.md#27-radar-feed-self-hosting).
4. Enable `RADAR_ENABLED` and opt in via `POST /api/radar/settings`
(`{ optIn: true }`).
No other code changes are required — `verifyFeedBytes()` picks up the override
automatically (`getFeedPublicKeys()` in `src/lib/radar/pinnedKeys.ts`), and version
comparison, schema validation, and the merge rules apply identically to a self-hosted
feed.
---
## Related docs
- [`docs/security/ERROR_SANITIZATION.md`](../security/ERROR_SANITIZATION.md) — the
error-response pattern the three `/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

@@ -1,3 +1,9 @@
---
title: "Auto-Combo: Let OmniRoute Pick the Best AI for You"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Auto-Combo: Let OmniRoute Pick the Best AI for You
> **TL;DR**: Set your model to `auto` and OmniRoute automatically picks the best AI provider for each request. No configuration needed.

View File

@@ -1,3 +1,9 @@
---
title: "Free Tiers Guide: Get Free AI Without a Credit Card"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Free Tiers Guide: Get Free AI Without a Credit Card
> **TL;DR**: OmniRoute aggregates free tiers from 50+ providers. Connect multiple free providers for unlimited free AI with automatic fallback.

View File

@@ -1,3 +1,9 @@
---
title: "Providers Guide: Connect AI Models to OmniRoute"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Providers Guide: Connect AI Models to OmniRoute
> **TL;DR**: A provider is a connection to an AI service (like OpenAI, Anthropic, Google). You need at least one provider to use OmniRoute.

View File

@@ -1,3 +1,9 @@
---
title: "Quick Start: Get OmniRoute Running in 3 Minutes"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Quick Start: Get OmniRoute Running in 3 Minutes
> **TL;DR**: Install → Connect a free provider → Point your IDE to OmniRoute. Done.

View File

@@ -1,3 +1,9 @@
---
title: "Docker Release Channels"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Docker Release Channels
OmniRoute publishes separate Docker channels for stable releases, active release-branch testing, and development builds.

View File

@@ -3823,6 +3823,33 @@ paths:
"400":
description: Invalid request body
/api/services/9router/auto-restart-adopted:
post:
tags: [Embedded Services]
summary: Toggle 9Router auto-restart-when-adopted
description: >-
When enabled, an externally-adopted (not OmniRoute-spawned) 9Router
process is restarted under OmniRoute's own supervisor on the next
health-check cycle instead of being left as adopted-only.
**LOCAL_ONLY** — loopback only.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [enabled]
properties:
enabled:
type: boolean
responses:
"204":
description: Flag updated
"400":
description: Invalid request body
"500":
description: Update failed
/api/services/cliproxy/install:
post:
tags: [Embedded Services]
@@ -3984,6 +4011,33 @@ paths:
"400":
description: Invalid request body
/api/services/cliproxy/auto-restart-adopted:
post:
tags: [Embedded Services]
summary: Toggle CLIProxyAPI auto-restart-when-adopted
description: >-
When enabled, an externally-adopted (not OmniRoute-spawned) CLIProxyAPI
process is restarted under OmniRoute's own supervisor on the next
health-check cycle instead of being left as adopted-only.
**LOCAL_ONLY** — loopback only.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [enabled]
properties:
enabled:
type: boolean
responses:
"204":
description: Flag updated
"400":
description: Invalid request body
"500":
description: Update failed
/api/services/mux/install:
post:
tags: [Embedded Services]
@@ -4144,6 +4198,33 @@ paths:
"400":
description: Invalid request body
/api/services/mux/auto-restart-adopted:
post:
tags: [Embedded Services]
summary: Toggle Mux auto-restart-when-adopted
description: >-
When enabled, an externally-adopted (not OmniRoute-spawned) Mux
process is restarted under OmniRoute's own supervisor on the next
health-check cycle instead of being left as adopted-only.
**LOCAL_ONLY** — loopback only.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [enabled]
properties:
enabled:
type: boolean
responses:
"204":
description: Flag updated
"400":
description: Invalid request body
"500":
description: Update failed
/api/services/bifrost/install:
post:
tags: [Embedded Services]
@@ -4254,6 +4335,459 @@ paths:
"400":
description: Invalid request body
/api/services/bifrost/auto-restart-adopted:
post:
tags: [Embedded Services]
summary: Toggle Bifrost auto-restart-when-adopted
description: >-
When enabled, an externally-adopted (not OmniRoute-spawned) Bifrost
process is restarted under OmniRoute's own supervisor on the next
health-check cycle instead of being left as adopted-only.
**LOCAL_ONLY** — loopback only.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [enabled]
properties:
enabled:
type: boolean
responses:
"204":
description: Flag updated
"400":
description: Invalid request body
"500":
description: Update failed
/api/services/dario/install:
post:
tags: [Embedded Services]
summary: Install Dario from npm
description: >-
Installs the `@askalf/dario` npm package (Claude-account-pool proxy) under
DATA_DIR/services/dario/. Uses execFile (no shell interpolation — hard rule
#13). **LOCAL_ONLY** — loopback only.
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
version:
type: string
default: latest
description: npm version tag or semver to install
responses:
"200":
description: Install succeeded
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
installedVersion:
type: string
path:
type: string
"400":
description: Invalid request body
"500":
description: npm install failed
/api/services/dario/start:
post:
tags: [Embedded Services]
summary: Start Dario
description: >-
Spawns the Dario process. Idempotent if already running.
**LOCAL_ONLY** — loopback only.
responses:
"200":
description: Service started (or already running)
content:
application/json:
schema:
$ref: "#/components/schemas/ServiceStatus"
"409":
description: Dario is not installed
"503":
description: Start failed
/api/services/dario/stop:
post:
tags: [Embedded Services]
summary: Stop Dario
description: >-
Gracefully stops Dario. Idempotent — returns a stopped status even if no
supervisor is currently tracking the process.
**LOCAL_ONLY** — loopback only.
responses:
"200":
description: Service stopped
content:
application/json:
schema:
$ref: "#/components/schemas/ServiceStatus"
"500":
description: Stop failed
/api/services/dario/restart:
post:
tags: [Embedded Services]
summary: Restart Dario
description: >-
Equivalent to stop() then start() under the operation lock.
**LOCAL_ONLY** — loopback only.
responses:
"200":
description: Service restarted
content:
application/json:
schema:
$ref: "#/components/schemas/ServiceStatus"
"409":
description: Dario is not installed
"503":
description: Restart failed
/api/services/dario/update:
post:
tags: [Embedded Services]
summary: Update Dario to a newer npm version
description: >-
Stops the service (if running), installs the newer npm version, then
restarts it if it was running before the update. **LOCAL_ONLY** — loopback
only.
responses:
"200":
description: Update result (no-op if already on the latest version)
content:
application/json:
schema:
type: object
properties:
updated:
type: boolean
installedVersion:
type: string
latestVersion:
type: string
oldVersion:
type: string
nullable: true
newVersion:
type: string
"500":
description: Update failed
/api/services/dario/status:
get:
tags: [Embedded Services]
summary: Get Dario status
description: >-
Returns combined live supervisor state and DB metadata, including the
auto-start / auto-restart-adopted flags and whether an update is available.
**LOCAL_ONLY** — loopback only.
responses:
"200":
description: Status response
content:
application/json:
schema:
$ref: "#/components/schemas/ServiceStatusExtended"
"500":
description: Status read failed
/api/services/dario/auto-start:
post:
tags: [Embedded Services]
summary: Toggle Dario auto-start
description: >-
When enabled, Dario starts automatically on the next OmniRoute boot.
**LOCAL_ONLY** — loopback only.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [enabled]
properties:
enabled:
type: boolean
responses:
"204":
description: Auto-start flag updated
"400":
description: Invalid request body
"500":
description: Update failed
/api/services/dario/auto-restart-adopted:
post:
tags: [Embedded Services]
summary: Toggle Dario auto-restart-when-adopted
description: >-
When enabled, an externally-adopted (not OmniRoute-spawned) Dario process
is restarted under OmniRoute's own supervisor on the next health-check
cycle instead of being left as adopted-only.
**LOCAL_ONLY** — loopback only.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [enabled]
properties:
enabled:
type: boolean
responses:
"204":
description: Flag updated
"400":
description: Invalid request body
"500":
description: Update failed
/api/services/dario/admin/login-start:
post:
tags: [Embedded Services]
summary: Start a Dario account-pool login (device-code style)
description: >-
Forwards to the running Dario instance's `POST /admin/login/start` using
the stored admin token. The operator opens the returned `authorize_url`,
approves in their own Claude account, then posts the displayed code to
`/admin/login-complete`. **LOCAL_ONLY** — loopback only.
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
alias:
type: string
description: Optional account alias; Dario assigns one if omitted.
responses:
"200":
description: Login challenge created
content:
application/json:
schema:
type: object
properties:
alias:
type: string
authorize_url:
type: string
expires_at:
type: string
instructions:
type: string
"400":
description: Invalid request body
"401":
description: Missing or invalid admin auth
"502":
description: Dario did not respond or Dario is not running
/api/services/dario/admin/login-complete:
post:
tags: [Embedded Services]
summary: Complete a Dario account-pool login
description: >-
Forwards to the running Dario instance's `POST /admin/login/complete`.
On success the account becomes routable immediately (Dario hot-reloads
its pool). **LOCAL_ONLY** — loopback only.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [alias, code]
properties:
alias:
type: string
code:
type: string
responses:
"200":
description: Account added
content:
application/json:
schema:
type: object
properties:
alias:
type: string
status:
type: string
expires_at:
type: string
"400":
description: Invalid request body
"401":
description: Missing or invalid admin auth
"502":
description: Dario did not respond or Dario is not running
/api/services/dario/admin/accounts:
get:
tags: [Embedded Services]
summary: List Dario account-pool accounts
description: >-
Forwards to the running Dario instance's `GET /admin/accounts`.
**LOCAL_ONLY** — loopback only.
responses:
"200":
description: Account list
content:
application/json:
schema:
type: object
properties:
accounts:
type: array
items:
type: object
count:
type: integer
"401":
description: Missing or invalid admin auth
"502":
description: Dario did not respond or Dario is not running
delete:
tags: [Embedded Services]
summary: Remove a Dario account-pool account
description: >-
Forwards to the running Dario instance's `DELETE /admin/accounts/<alias>`.
The alias is taken from a `?alias=` query param or a `{ alias }` JSON body.
**LOCAL_ONLY** — loopback only.
parameters:
- name: alias
in: query
required: false
schema:
type: string
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
alias:
type: string
responses:
"200":
description: Account removed
content:
application/json:
schema:
type: object
properties:
alias:
type: string
removed:
type: boolean
"400":
description: Missing alias
"401":
description: Missing or invalid admin auth
"502":
description: Dario did not respond or Dario is not running
/api/services/dario/admin/import-from-omniroute:
get:
tags: [Embedded Services]
summary: List OmniRoute claude connections eligible for Dario import
description: >-
Returns eligible OmniRoute `claude` OAuth provider connections (metadata
only — id/name/email/org tier, never tokens) so the UI can offer a picker
when more than one exists. **LOCAL_ONLY** — loopback only.
responses:
"200":
description: Eligible connections
content:
application/json:
schema:
type: object
properties:
connections:
type: array
items:
type: object
properties:
id:
type: string
name:
type: string
email:
type: string
nullable: true
organizationType:
type: string
nullable: true
organizationRateLimitTier:
type: string
nullable: true
"401":
description: Missing or invalid admin auth
post:
tags: [Embedded Services]
summary: Import an OmniRoute claude connection's OAuth tokens into Dario
description: >-
Writes the source connection's access/refresh token pair directly into
Dario's own account-file store (`~/.dario/accounts/<alias>.json`), reusing
the shared Claude Code OAuth client_id, then restarts the Dario supervisor
so it picks up the new account. **LOCAL_ONLY** — loopback only.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [connectionId]
properties:
connectionId:
type: string
alias:
type: string
description: Optional custom alias; derived from the source email if omitted.
responses:
"200":
description: Account imported
content:
application/json:
schema:
type: object
properties:
alias:
type: string
imported:
type: boolean
sourceConnectionId:
type: string
sourceEmail:
type: string
nullable: true
"400":
description: Invalid request body, unsupported connection, or missing tokens
"401":
description: Missing or invalid admin auth
"404":
description: Connection not found
"500":
description: Import failed
/api/services/{name}/logs:
get:
tags: [Embedded Services]

View File

@@ -1,3 +1,9 @@
---
title: "Proxy Port Clash Investigation"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Proxy Port Clash Investigation
## Summary

View File

@@ -1,3 +1,9 @@
---
title: "Operator Proxy Subscriptions (Karing-style)"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Operator Proxy Subscriptions (Karing-style)
> Feature design + implementation notes for OmniRoute's operator-level proxy

View File

@@ -1,3 +1,9 @@
---
title: "Redis Production Configuration Guide"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Redis Production Configuration Guide
## Overview

View File

@@ -43,6 +43,7 @@ lastUpdated: 2026-06-28
- [22. Debugging](#22-debugging)
- [23. GitHub Integration](#23-github-integration)
- [24. Skills Sandbox (v3.8.0+)](#24-skills-sandbox-v380)
- [27. Radar Feed (Self-Hosting)](#27-radar-feed-self-hosting)
- [Deployment Scenarios](#deployment-scenarios)
- [Audit: Removed / Dead Variables](#audit-removed--dead-variables)
@@ -766,9 +767,13 @@ Embedding layer, vector store and reranking knobs for the persistent memory subs
| `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | HF repo id for the opt-in `@huggingface/transformers` local MiniLM pipeline (~23 MB int8, ~400 MB RAM). |
| `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | HF repo id for the static potion/Model2Vec lookup-table embedder. Downloaded lazily into the cache dir. |
| `MEMORY_STATIC_CACHE_DIR` | `<DATA_DIR>/embeddings` | Directory used to cache the static potion model files. Defaults under `DATA_DIR` when unset. |
| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). |
| `MEMORY_VEC_TOP_K` | `20` | Default top-K used by the `sqlite-vec` brute-force vector search inside `src/lib/memory/vectorStore.ts`. |
| `MEMORY_RRF_K` | `60` | Reciprocal Rank Fusion constant `k` for hybrid FTS5 + vector retrieval (sqlite-vec recipe). |
| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). |
| `NOTION_API_KEY` | _(unset)_ | API key for Notion backend (used by `genericBackend.ts` known backend preset). |
| `NOTION_API_URL` | `https://api.notion.com/v1`| Base URL for Notion API (can override for self-hosted Notion alternatives). |
| `OBSIDIAN_API_KEY` | _(unset)_ | API key for Obsidian Vault backend (used by `genericBackend.ts` known backend preset). |
| `OBSIDIAN_API_URL` | `http://localhost:27123` | Base URL for Obsidian Vault API (can override for remote vault). |
| `MEMORY_TYPED_DECAY_ENABLED` | `false` | TV6 typed memory decay master switch. **Opt-in (default off)** — the sweep **deletes** decayed memories. With it off, `access_count`/`last_accessed_at` are pure telemetry and nothing is ever deleted. |
| `MEMORY_TYPED_DECAY_EPISODIC_DAYS` | `30` | TTL (days) after which an unused `episodic` memory decays. `0` makes episodic immune too. Durable types (`factual`/`procedural`/`semantic`) are always immune. The decay clock re-bases on `last_accessed_at`. |
| `MEMORY_TYPED_DECAY_ACCESS_IMMUNITY` | `3` | A memory injected `>=` this many times becomes immune to decay regardless of type. `0` disables access immunity. |
@@ -850,6 +855,7 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov
| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. |
| `DESIGNER_WEB_POLL_TIMEOUT_MS` | `60000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | Max wait for microsoft-designer-web image generation jobs. |
| `DESIGNER_WEB_POLL_INTERVAL_MS` | `2000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | microsoft-designer-web job polling frequency. |
| `ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS` | `8000` | `open-sse/services/adobeFireflyUpscale.ts` | Base delay for the Adobe Firefly upscale submit-retry exponential backoff. |
| `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). |
| `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. |
| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. |
@@ -871,6 +877,10 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov
| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. |
| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. |
| `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). |
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |
| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. |
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |
| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. |
| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). |
`ENABLE_CC_COMPATIBLE_PROVIDER` is only for third-party relays that accept Claude Code clients
@@ -1151,6 +1161,13 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(unset)_ | `src/lib/security/localEndpoints.ts` | Bearer token for `/api/local/*` callers that aren't on loopback (e.g. the desktop app). When set, requests from non-loopback IPs must carry `Authorization: Bearer <token>`. Required when `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` in non-loopback deployments. |
| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. |
| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. |
| `OMNIROUTE_REDIS_BIND_HOST` | `127.0.0.1` | `bin/cli/commands/redis.mjs` | Host interface the 1-click Redis launcher publishes on. The launcher starts Redis WITHOUT a password, so binding `0.0.0.0` hands every host on your LAN an unauthenticated Redis — only widen this if you also set a password on the instance yourself. |
| `REDIS_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Redis sidecar on (#9286). The compose Redis runs without `requirepass`; app containers reach it over the compose network (`redis:6379`) — the published port exists only for host-side tooling. `0.0.0.0` exposes an unauthenticated Redis to the whole LAN. |
| `REDIS_PORT` | `6379` | `docker-compose.yml` | Host port for the compose Redis sidecar. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | _(unset — mechanism disabled)_ | `src/lib/api/internalServiceAuth.ts` | Shared secret for identity-preserving internal REST hops (#9260): OmniRoute components calling other local OmniRoute routes send it as `x-omniroute-internal-service-token` so the original caller identity is preserved. Compared with `timingSafeEqual`. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | _(unset)_ | `src/lib/api/internalServiceAuth.ts` | Secret-file variant of the internal service token: path to a file whose trimmed content is the token. Only consulted when the inline var is unset. |
| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | `src/lib/catalog/openrouterProviderStats.ts` | Enrich the dashboard providers list with OpenRouter weekly ranking stats (#9324). On by default; set `false` to skip the background fetch entirely (non-blocking, never fatal). |
| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterProviderStats.ts` | Cache TTL for the OpenRouter provider-stats snapshot, in milliseconds. |
| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. |
| `QDRANT_HOST` | `qdrant` | _(opt-in cluster profile)_ | Hostname of the Qdrant sidecar when `--profile memory` is active. Default points to the in-network qdrant service name; override for an external deployment. Only consumed when `qdrantEnabled` is `true` in code (`src/lib/memory/vectorStore.ts:108`). |
| `QDRANT_PORT` | `6333` | _(opt-in cluster profile)_ | REST port of the Qdrant sidecar. |
@@ -1240,6 +1257,22 @@ that should be able to run the docs translator.
---
## 27. Radar Feed (Self-Hosting)
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.
| 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. |
---
## Audit: Removed / Dead Variables
The following variables appeared in previous versions of `.env.example` but have **no runtime references** in the current codebase. They have been removed:
@@ -1306,3 +1339,25 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro
| `OMNIROUTE_VNC_READY_MS` | `45000` | `src/lib/vncSession/manifest.ts` | Browser readiness timeout (ms). |
| `OMNIROUTE_VNC_HARVEST_MS` | `20000` | `src/lib/vncSession/manifest.ts` | Harvest/cleanup timeout (ms). |
| `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | Directory for Notion thread session persistence. |
### Internal service auth
| Variable | Default | Description |
| --- | --- | --- |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | | Inline token for management-plane service-to-service authentication. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | | Path to a file containing the internal service token (preferred in containers; overrides the inline variable). |
### OpenRouter provider stats
| Variable | Default | Description |
| --- | --- | --- |
| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | Set to `false` to skip fetching OpenRouter per-provider stats for catalog enrichment. |
| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `3600000` | Cache TTL (ms) for the fetched OpenRouter provider stats. |
### Embedded Redis binding
| Variable | Default | Description |
| --- | --- | --- |
| `REDIS_BIND_HOST` | `127.0.0.1` | Bind address for the embedded Redis service. |
| `REDIS_PORT` | `6379` | Port for the embedded Redis service. |
| `OMNIROUTE_REDIS_BIND_HOST` | | OmniRoute-scoped override for the embedded Redis bind address. |

View File

@@ -1,3 +1,9 @@
---
title: "Issue-Agent Executable Triage: Session Overview"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Issue-Agent Executable Triage: Session Overview
Machine status: `in_progress`

View File

@@ -1,3 +1,9 @@
---
title: "Issue-Agent Executable Triage: Research"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Issue-Agent Executable Triage: Research
Machine status: `complete_for_current_phase`

View File

@@ -1,3 +1,9 @@
---
title: "Issue-Agent Executable Triage: Specifications"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Issue-Agent Executable Triage: Specifications
Machine status: `in_progress`

View File

@@ -1,3 +1,9 @@
---
title: "Issue-Agent Executable Triage: DAG and WBS"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Issue-Agent Executable Triage: DAG and WBS
Machine status: `in_progress`

View File

@@ -1,3 +1,9 @@
---
title: "Issue-Agent Executable Triage: Implementation Strategy"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Issue-Agent Executable Triage: Implementation Strategy
Machine status: `in_progress`

View File

@@ -1,3 +1,9 @@
---
title: "Issue-Agent Executable Triage: Known Issues"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Issue-Agent Executable Triage: Known Issues
Machine status: `open`

View File

@@ -1,3 +1,9 @@
---
title: "Issue-Agent Executable Triage: Testing Strategy"
version: 3.8.50
lastUpdated: 2026-08-06
---
# Issue-Agent Executable Triage: Testing Strategy
Machine status: `in_progress`

View File

@@ -114,19 +114,23 @@ Built applications are placed in `dist-electron/`:
4. Launch from Applications.
> ⚠️ **Note:** The app is not signed with an Apple Developer certificate yet. If macOS blocks the app, run:
>
> ```bash
> xattr -cr /Applications/OmniRoute.app
> ```
>
> Or right-click the app → Open → Open (to bypass Gatekeeper on first launch).
### Windows
**Installer (Recommended):**
1. Download `OmniRoute.Setup.*.exe` from [Releases](https://github.com/diegosouzapw/OmniRoute/releases).
2. Run the installer.
3. Launch from Start Menu or Desktop shortcut.
**Portable (No Installation):**
1. Download `OmniRoute.exe` from [Releases](https://github.com/diegosouzapw/OmniRoute/releases).
2. Run directly from any folder.
@@ -147,20 +151,44 @@ Built applications are placed in `dist-electron/`:
- **Server Readiness** — Waits for health check before showing window
- **System Tray** — Minimize to tray with quick actions (open, port change, quit)
- **Port Management** — Change port from tray menu (server restarts automatically)
- **Remote Server Mode** — Point the shell at an already-running OmniRoute server (e.g. a Docker/OrbStack container, or another machine) instead of spawning a local one — see below
- **Window Controls** — Custom minimize, maximize, close via IPC
- **Content Security Policy** — Restrictive CSP via session headers
- **Offline Support** — Bundled Next.js standalone server
- **Single Instance** — Only one app instance can run at a time
## Remote Server Mode
By default the desktop shell spawns and manages its own bundled Next.js server. If you
already run OmniRoute elsewhere — most commonly in a Docker/OrbStack container, so
provider credentials and env-var handling stay isolated from the host — you can point the
shell at that instance instead, so it's purely a native window + tray onto a server you
already run.
**Via the tray menu:** _Remote Server → Connect to Remote Server…_, enter the server's
URL (e.g. `http://localhost:20128`), and save. Leave the field blank and save to
disconnect and go back to the local embedded server. The preference persists across
restarts in `<data dir>/electron-preferences.json` (see `DATA_DIR` above for where that
lives on your platform).
**Via environment variable:** set `OMNIROUTE_REMOTE_URL` before launching the app (e.g.
`OMNIROUTE_REMOTE_URL=http://localhost:20128 npm run dev`, or export it in the
environment that launches the packaged app). The env var always wins over the persisted
preference and is session-scoped — it doesn't get written to the prefs file.
Only `http://` and `https://` URLs are accepted; anything else is rejected before the
window loads.
## Configuration
### Environment Variables
| Variable | Default | Description |
| --------------------- | ------------ | --------------------------------- |
| `OMNIROUTE_PORT` | `20128` | Server port |
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit (6416384 MB) |
| `NODE_ENV` | `production` | Set to `development` for dev mode |
| Variable | Default | Description |
| ---------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
| `OMNIROUTE_PORT` | `20128` | Server port |
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit (6416384 MB) |
| `OMNIROUTE_REMOTE_URL` | _(unset)_ | Attach to this server instead of spawning a local one — see [Remote Server Mode](#remote-server-mode) |
| `NODE_ENV` | `production` | Set to `development` for dev mode |
### Custom Icon
@@ -175,12 +203,12 @@ Place your icons in `assets/`:
### Invoke (Renderer → Main, async)
| Channel | Returns | Description |
| ---------------- | ------------- | --------------------------------------------- |
| `get-app-info` | `AppInfo` | App name, version, platform, isDev, port |
| `open-external` | `void` | Open URL in default browser (http/https only) |
| `get-data-dir` | `string` | Get userData directory path |
| `restart-server` | `{ success }` | Stop + restart server (5s timeout + SIGKILL) |
| Channel | Returns | Description |
| ---------------- | ------------- | --------------------------------------------------------- |
| `get-app-info` | `AppInfo` | App name, version, platform, isDev, port, remoteServerUrl |
| `open-external` | `void` | Open URL in default browser (http/https only) |
| `get-data-dir` | `string` | Get userData directory path |
| `restart-server` | `{ success }` | Stop + restart server (5s timeout + SIGKILL) |
### Send (Renderer → Main, fire-and-forget)

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'self'"
/>
<title>Connect to Remote Server</title>
<style>
body {
margin: 0;
padding: 20px;
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: #1a1a1a;
color: #e5e5e5;
user-select: none;
}
p {
margin: 0 0 12px;
font-size: 13px;
color: #a3a3a3;
}
input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
font-size: 13px;
border-radius: 6px;
border: 1px solid #3f3f3f;
background: #262626;
color: #e5e5e5;
}
.error {
color: #f87171;
font-size: 12px;
min-height: 16px;
margin-top: 6px;
}
.actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
button {
padding: 7px 14px;
font-size: 13px;
border-radius: 6px;
border: 1px solid #3f3f3f;
background: #262626;
color: #e5e5e5;
cursor: pointer;
}
button.primary {
background: #ff586b;
border-color: #ff586b;
color: #fff;
}
</style>
</head>
<body>
<p>
Point this desktop app at an already-running OmniRoute server (e.g. a Docker/OrbStack
container) instead of spawning a local one. Leave blank and Save to disconnect.
</p>
<input
id="url-input"
type="text"
placeholder="http://localhost:20128"
autocomplete="off"
spellcheck="false"
/>
<div class="error" id="error"></div>
<div class="actions">
<button id="cancel-btn">Cancel</button>
<button id="save-btn" class="primary">Save</button>
</div>
<script src="../remoteServerPromptRenderer.js"></script>
</body>
</html>

View File

@@ -0,0 +1,75 @@
"use strict";
const fs = require("fs");
const path = require("path");
/**
* remoteServerPreferences.js — pure read/write helpers for the small JSON
* preferences file that persists the operator-configured remote server URL
* across app restarts (see resolveRemoteServerUrl.js for how it's consumed).
*
* Deliberately a plain flat JSON file rather than the app's SQLite database:
* this preference must be readable before deciding whether to spawn (or even
* reach) the local server, so it cannot depend on any server-owned storage.
*
* Extracted as pure, dependency-injectable helpers so they can be unit-tested
* without importing the full Electron main process.
*
* @param {string} prefsPath - absolute path to electron-preferences.json
* @param {(p: string) => boolean} [existsSync]
* @param {(p: string, enc: string) => string} [readFileSync]
* @returns {{remoteServerUrl: string|null}}
*/
function readPreferences(prefsPath, existsSync = fs.existsSync, readFileSync = fs.readFileSync) {
if (!existsSync(prefsPath)) return { remoteServerUrl: null };
try {
const parsed = JSON.parse(readFileSync(prefsPath, "utf8"));
const remoteServerUrl =
typeof parsed.remoteServerUrl === "string" && parsed.remoteServerUrl.trim()
? parsed.remoteServerUrl.trim()
: null;
return { remoteServerUrl };
} catch {
return { remoteServerUrl: null };
}
}
/**
* Persist the remote server URL preference. Pass `null` to clear it (reverts
* to spawning the local embedded server on next restart).
*
* @param {string} prefsPath
* @param {string|null} remoteServerUrl
* @param {(p: string) => boolean} [existsSync]
* @param {(p: string, enc: string) => string} [readFileSync]
* @param {(p: string, data: string, enc: string) => void} [writeFileSync]
* @param {(p: string, opts: object) => void} [mkdirSync]
*/
function writeRemoteServerUrl(
prefsPath,
remoteServerUrl,
{
existsSync = fs.existsSync,
readFileSync = fs.readFileSync,
writeFileSync = fs.writeFileSync,
mkdirSync = fs.mkdirSync,
} = {}
) {
try {
const dir = path.dirname(prefsPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const current = readPreferences(prefsPath, existsSync, readFileSync);
const next = { ...current, remoteServerUrl: remoteServerUrl || null };
writeFileSync(prefsPath, JSON.stringify(next, null, 2) + "\n", "utf8");
} catch (err) {
console.error(
`[remoteServerPreferences] Failed to write preferences to ${prefsPath}:`,
err instanceof Error ? err.message : String(err)
);
}
}
module.exports = { readPreferences, writeRemoteServerUrl };

View File

@@ -0,0 +1,79 @@
"use strict";
const fs = require("fs");
/**
* resolveRemoteServerUrl.js — pure helper for resolving an operator-configured
* remote OmniRoute server URL, so the Electron shell can attach to an
* already-running instance (e.g. a Docker/OrbStack container, or a server on
* another machine on the LAN) instead of spawning its own bundled Next.js
* server.
*
* Some environments make the bundled local server impractical — for example,
* a host that injects provider API keys via a secrets manager in a way the
* packaged app's env-file loading doesn't expect. Running the real server in
* an isolated container and pointing the desktop shell at it sidesteps that
* entirely.
*
* Precedence:
* 1. OMNIROUTE_REMOTE_URL env var (explicit, session-scoped override)
* 2. `remoteServerUrl` key in <dataDir>/electron-preferences.json (persisted
* via the tray menu's "Connect to Remote Server…" prompt)
* 3. null — caller falls back to spawning the local embedded server
*
* Extracted as a pure helper (env + fs injectable) so it can be unit-tested
* without importing the full Electron main process (which requires the
* Electron binary).
*
* @param {object} opts
* @param {NodeJS.ProcessEnv} opts.env - injectable process.env (for tests)
* @param {string} opts.prefsPath - absolute path to electron-preferences.json
* @param {(p: string) => boolean} [opts.existsSync] - injectable fs.existsSync
* @param {(p: string, enc: string) => string} [opts.readFileSync] - injectable fs.readFileSync
* @returns {string|null} the validated http(s) remote URL (no trailing slash), or null if none configured
*/
function resolveRemoteServerUrl({
env,
prefsPath,
existsSync = fs.existsSync,
readFileSync = fs.readFileSync,
}) {
const candidate = readCandidate({ env, prefsPath, existsSync, readFileSync });
if (!candidate) return null;
return isValidHttpUrl(candidate) ? stripTrailingSlash(candidate) : null;
}
function readCandidate({ env, prefsPath, existsSync, readFileSync }) {
const fromEnv = (env.OMNIROUTE_REMOTE_URL || "").trim();
if (fromEnv) return fromEnv;
if (!prefsPath || !existsSync(prefsPath)) return null;
try {
const prefs = JSON.parse(readFileSync(prefsPath, "utf8"));
const fromPrefs = typeof prefs.remoteServerUrl === "string" ? prefs.remoteServerUrl.trim() : "";
return fromPrefs || null;
} catch {
// Corrupt/partial prefs file — fall back to spawning the local server
// rather than crashing the app on startup.
return null;
}
}
/**
* @param {string} candidate
* @returns {boolean}
*/
function isValidHttpUrl(candidate) {
try {
const parsed = new URL(candidate);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function stripTrailingSlash(url) {
return url.replace(/\/+$/, "");
}
module.exports = { resolveRemoteServerUrl, isValidHttpUrl };

View File

@@ -37,6 +37,8 @@ const { loginManager } = require("./loginManager");
const { killProcessTree } = require("./processTree");
const { resolveServerEntry } = require("./lib/resolveServerEntry");
const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper");
const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl");
const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences");
// ── Single Instance Lock ───────────────────────────────────
const gotTheLock = app.requestSingleInstanceLock();
@@ -67,8 +69,23 @@ let tray = null;
let nextServer = null;
let serverPort = 20128;
let isServerStopped = false;
let remoteServerPromptWindow = null;
const getServerUrl = () => `http://localhost:${serverPort}`;
// ── Remote Server Mode ──────────────────────────────────────
// Lets the desktop shell attach to an already-running OmniRoute server (e.g. a
// Docker/OrbStack container, or another machine) instead of spawning its own
// bundled Next.js server. See lib/resolveRemoteServerUrl.js for precedence
// (OMNIROUTE_REMOTE_URL env var, then the persisted prefs file below).
const REMOTE_SERVER_PREFS_PATH = path.join(
resolveDataDir(null, process.env),
"electron-preferences.json"
);
let remoteServerUrl = resolveRemoteServerUrl({
env: process.env,
prefsPath: REMOTE_SERVER_PREFS_PATH,
});
const getServerUrl = () => remoteServerUrl || `http://localhost:${serverPort}`;
function resolveNodeExecutable(env = process.env) {
// #1081: Ensure Next.js standalone runs using Electron's Node runtime
@@ -456,6 +473,23 @@ function createTray() {
{ label: "3000", click: () => changePort(3000) },
{ label: "8080", click: () => changePort(8080) },
],
enabled: !remoteServerUrl,
},
{
label: "Remote Server",
submenu: [
{
label: remoteServerUrl ? `Connected: ${remoteServerUrl}` : "Using local embedded server",
enabled: false,
},
{ type: "separator" },
{ label: "Connect to Remote Server…", click: () => showRemoteServerPrompt() },
{
label: "Disconnect (use Local Server)",
enabled: Boolean(remoteServerUrl),
click: () => setRemoteServerUrl(null),
},
],
},
{ type: "separator" },
{
@@ -512,8 +546,97 @@ async function changePort(newPort) {
console.log(`[Electron] Port changed: ${oldPort}${serverPort}`);
}
// ── Remote Server Mode: prompt window ──────────────────────
function showRemoteServerPrompt() {
if (remoteServerPromptWindow && !remoteServerPromptWindow.isDestroyed()) {
remoteServerPromptWindow.show();
remoteServerPromptWindow.focus();
return;
}
remoteServerPromptWindow = new BrowserWindow({
width: 480,
height: 210,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
title: "Connect to Remote Server",
parent: mainWindow || undefined,
modal: Boolean(mainWindow),
webPreferences: {
preload: path.join(__dirname, "remoteServerPromptPreload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
remoteServerPromptWindow.setMenuBarVisibility(false);
remoteServerPromptWindow.loadFile(path.join(__dirname, "assets", "remoteServerPrompt.html"));
remoteServerPromptWindow.on("closed", () => {
remoteServerPromptWindow = null;
});
}
// ── Remote Server Mode: apply a new URL (or clear it) ──────
async function setRemoteServerUrl(nextUrl) {
const normalized = (nextUrl || "").trim() || null;
if (normalized === remoteServerUrl) return;
// Reject invalid URLs — only http:// and https:// are accepted.
if (normalized !== null && !isValidHttpUrl(normalized)) {
console.warn("[Electron] Rejected invalid remote server URL:", normalized);
return;
}
sendToRenderer("server-status", { status: "restarting", port: serverPort });
// Stop any locally-spawned server before switching modes in either direction.
const serverToStop = nextServer;
stopNextServer();
await waitForServerExit(serverToStop);
remoteServerUrl = normalized;
writeRemoteServerUrl(REMOTE_SERVER_PREFS_PATH, remoteServerUrl);
startNextServer();
try {
await waitForServer(`${getServerUrl()}/api/monitoring/health`);
} catch (err) {
console.warn("[Electron] Server did not become ready after remote-server change:", err.message);
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.loadURL(getServerUrl());
}
createTray();
sendToRenderer("server-status", {
status: "running",
port: serverPort,
remoteUrl: remoteServerUrl,
});
console.log(
remoteServerUrl
? `[Electron] Now connected to remote server: ${remoteServerUrl}`
: "[Electron] Disconnected from remote server — spawning local server again"
);
}
// ── Server Lifecycle (#1, #5, #10) ─────────────────────────
function startNextServer() {
if (remoteServerUrl) {
console.log("[Electron] Remote server mode — connecting to", remoteServerUrl);
sendToRenderer("server-status", {
status: "running",
port: serverPort,
remoteUrl: remoteServerUrl,
});
return;
}
if (isDev) {
console.log("[Electron] Dev mode — connect to existing Next.js server");
sendToRenderer("server-status", { status: "running", port: serverPort });
@@ -777,8 +900,22 @@ function setupIpcHandlers() {
platform: process.platform,
isDev,
port: serverPort,
remoteServerUrl,
}));
// ── Remote Server Mode: prompt window IPC (main-process-only trust
// boundary — this window never loads remote/untrusted content) ──
ipcMain.handle("remote-server-prompt:get-initial-url", () => remoteServerUrl || "");
ipcMain.on("remote-server-prompt:submit", (_event, url) => {
remoteServerPromptWindow?.close();
void setRemoteServerUrl(url);
});
ipcMain.on("remote-server-prompt:cancel", () => {
remoteServerPromptWindow?.close();
});
ipcMain.handle("open-external", (_event, url) => {
try {
const parsedUrl = new URL(url);

View File

@@ -60,8 +60,13 @@
"loginManager.js",
"processTree.js",
"sqlite-inspection.js",
"remoteServerPromptPreload.js",
"remoteServerPromptRenderer.js",
"lib/resolveServerEntry.js",
"lib/resolveNodeHelper.js",
"lib/resolveRemoteServerUrl.js",
"lib/remoteServerPreferences.js",
"assets/remoteServerPrompt.html",
"package.json",
"node_modules/**/*"
],

View File

@@ -106,8 +106,15 @@ const VALID_CHANNELS = {
"login:start",
"login:cancel",
"login:status",
"remote-server-prompt:get-initial-url",
],
send: [
"window-minimize",
"window-maximize",
"window-close",
"remote-server-prompt:submit",
"remote-server-prompt:cancel",
],
send: ["window-minimize", "window-maximize", "window-close"],
receive: ["server-status", "port-changed", "update-status", "login:status"],
};
@@ -160,6 +167,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
// ── Receive (event listeners) ────────────────────────────
// Fix #6: Returns a disposer function for precise cleanup
// "server-status" payloads include remoteUrl when running in Remote Server
// Mode (see electron/main.js setRemoteServerUrl) — surfaced here read-only;
// the actual URL is configured via the tray menu, not the renderer.
onServerStatus: (callback) => safeOn("server-status", callback),
onPortChanged: (callback) => safeOn("port-changed", callback),
onUpdateStatus: (callback) => safeOn("update-status", callback),

View File

@@ -0,0 +1,15 @@
/**
* Preload for the small "Connect to Remote Server" prompt window.
*
* Kept separate from the main preload.js — this window only ever loads our
* own bundled remoteServerPrompt.html (never remote/untrusted content), but we
* still keep contextIsolation on and expose the minimum surface needed.
*/
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("remoteServerPrompt", {
getInitialUrl: () => ipcRenderer.invoke("remote-server-prompt:get-initial-url"),
submit: (url) => ipcRenderer.send("remote-server-prompt:submit", url),
cancel: () => ipcRenderer.send("remote-server-prompt:cancel"),
});

View File

@@ -0,0 +1,40 @@
(function () {
const input = document.getElementById("url-input");
const errorEl = document.getElementById("error");
const saveBtn = document.getElementById("save-btn");
const cancelBtn = document.getElementById("cancel-btn");
function isValidOrEmpty(value) {
const trimmed = value.trim();
if (!trimmed) return true; // empty = disconnect, handled by main process
try {
const parsed = new URL(trimmed);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
window.remoteServerPrompt.getInitialUrl().then((url) => {
input.value = url || "";
input.focus();
});
saveBtn.addEventListener("click", () => {
const value = input.value.trim();
if (!isValidOrEmpty(value)) {
errorEl.textContent = "Enter a valid http:// or https:// URL, or leave blank to disconnect.";
return;
}
window.remoteServerPrompt.submit(value);
});
cancelBtn.addEventListener("click", () => {
window.remoteServerPrompt.cancel();
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") saveBtn.click();
if (event.key === "Escape") cancelBtn.click();
});
})();

4
electron/types.d.ts vendored
View File

@@ -14,11 +14,15 @@ export interface AppInfo {
platform: "win32" | "darwin" | "linux";
isDev: boolean;
port: number;
/** Set when Remote Server Mode is active (tray → Remote Server → Connect…). */
remoteServerUrl: string | null;
}
export interface ServerStatus {
status: "starting" | "running" | "stopped" | "restarting" | "error";
port: number;
/** Present only while connected to a remote server instead of the embedded one. */
remoteUrl?: string;
}
export interface ElectronAPI {

View File

@@ -711,6 +711,20 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
name: "Firefly Runway Gen-4 Image",
inputModalities: ["text", "image"],
},
// Topaz Labs upscalers (inputMediaUseCase: ["upscaling"]).
// Served by firefly-3p /v2/3p-images/upsample — see config/upscaleRegistry.ts.
{
id: "topaz-standard",
name: "Firefly Topaz Upscale (Standard)",
inputModalities: ["image"],
imageRequired: true,
},
{
id: "topaz-bloom",
name: "Firefly Topaz Bloom (Creative Upscale)",
inputModalities: ["image"],
imageRequired: true,
},
],
supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "1024x1024", "1792x1024", "1024x1792"],
},
@@ -943,7 +957,6 @@ export function getImageModelAliases() {
export function isRegisteredImageModel(providerId, modelId) {
return Boolean(findImageModelConfig(providerId, modelId));
}
export function getImageModelEntry(modelStr) {
if (!modelStr) return null;

View File

@@ -0,0 +1,228 @@
/**
* Image Upscale Provider Registry
*
* Providers that serve `POST /v1/images/upscale` — image→image super-resolution.
* Upscaling is a distinct capability from generation: there is no text-to-image
* path, an input image is always mandatory, and the meaningful controls are the
* scale factor and (for generative upscalers) a creativity level.
*
* Only providers whose upscale API is already implemented here are listed:
* - adobe-firefly → Topaz models on firefly-3p `/v2/3p-images/upsample`
* - stability-ai → `/v2beta/stable-image/upscale/{fast,conservative,creative}`
* - topaz → Topaz Labs `/image/v1/enhance` (native API key)
*
* Credentials/proxy resolution reuses each provider's existing connection, so a
* configured Adobe Firefly / Stability AI / Topaz Labs account works with no
* extra setup.
*/
import { parseModelFromRegistry, getAllModelsFromRegistry } from "./registryUtils.ts";
/** Scale factors offered by default when a model does not restrict them. */
export const DEFAULT_UPSCALE_FACTORS: readonly number[] = Object.freeze([2, 4]);
export interface UpscaleModelEntry {
id: string;
name: string;
/** Discrete scale factors the upstream accepts (in x). */
factors: number[];
/** Model exposes a creativity / re-imagine control (0-100 % on the wire-agnostic API). */
supportsCreativity?: boolean;
/** Model accepts an optional guidance prompt. */
supportsPrompt?: boolean;
/** Upstream rejects the request without a prompt. */
promptRequired?: boolean;
description?: string;
}
export interface UpscaleProviderConfig {
id: string;
alias?: string;
baseUrl: string;
authType: "apikey" | "none";
authHeader: string;
format: "adobe-firefly-upscale" | "stability-upscale" | "topaz-upscale";
models: UpscaleModelEntry[];
}
export const UPSCALE_PROVIDERS: Record<string, UpscaleProviderConfig> = {
// Adobe Firefly (unofficial) — Topaz Labs models exposed through the Firefly 3P
// async upsample job API. Live capture: web_providers/upsample.txt.
// Discovery (web_providers/upscale.txt) lists modelId "topaz" with the image
// modelVersions default/standard/reimagine carrying inputMediaUseCase ["upscaling"];
// starlight-*/astra-2 are video upscalers and intentionally excluded here.
"adobe-firefly": {
id: "adobe-firefly",
alias: "firefly",
baseUrl: "https://firefly-3p.ff.adobe.io/v2/3p-images/upsample",
authType: "apikey",
authHeader: "bearer",
format: "adobe-firefly-upscale",
models: [
{
id: "topaz",
name: "Firefly Topaz Upscale",
factors: [2, 4],
description: "Topaz Labs detail-preserving upscale (standard).",
},
{
id: "topaz-standard",
name: "Firefly Topaz Upscale (Standard)",
factors: [2, 4],
description: "Topaz Labs detail-preserving upscale — no invented detail.",
},
{
id: "topaz-bloom",
name: "Firefly Topaz Bloom (Creative)",
factors: [2, 4],
supportsCreativity: true,
description: "Topaz Bloom generative upscale — creativity adds synthesized detail.",
},
],
},
// Stability AI stable-image upscale family. `fast` is a 4x deterministic pass;
// `conservative` and `creative` are prompt-guided (creative is an async job).
"stability-ai": {
id: "stability-ai",
baseUrl: "https://api.stability.ai",
authType: "apikey",
authHeader: "bearer",
format: "stability-upscale",
models: [
{
id: "fast",
name: "Stability Fast Upscale (4x)",
factors: [4],
description: "Lightweight 4x upscale, no prompt.",
},
{
id: "conservative",
name: "Stability Conservative Upscale",
factors: [4],
supportsPrompt: true,
promptRequired: true,
description: "Up to ~4 MP while preserving every detail. Prompt required upstream.",
},
{
id: "creative",
name: "Stability Creative Upscale",
factors: [4],
supportsCreativity: true,
supportsPrompt: true,
promptRequired: true,
description: "Heavily reimagines low-quality inputs (async job). Prompt required upstream.",
},
],
},
// Topaz Labs native Image API (own api key, synchronous).
topaz: {
id: "topaz",
baseUrl: "https://api.topazlabs.com",
authType: "apikey",
authHeader: "x-api-key",
format: "topaz-upscale",
models: [
{
id: "topaz-enhance",
name: "Topaz Labs Enhance",
factors: [2, 4],
description: "Topaz Labs Image Enhance (auto model selection).",
},
],
},
};
export function getUpscaleProvider(providerId: string | null | undefined): UpscaleProviderConfig | null {
if (!providerId) return null;
return UPSCALE_PROVIDERS[providerId] || null;
}
/** Parse `provider/model` (or a bare, unambiguous model id) against the upscale registry. */
export function parseUpscaleModel(modelStr: string | null) {
return parseModelFromRegistry(modelStr, UPSCALE_PROVIDERS);
}
/** Flat catalog for `GET /v1/images/upscale`. */
export function getAllUpscaleModels() {
return getAllModelsFromRegistry(UPSCALE_PROVIDERS, (_providerId, config) => ({
format: config.format,
}));
}
/** Registry row for a `provider/model` string, or null when unknown. */
export function getUpscaleModelEntry(
modelStr: string | null
): { provider: string; providerConfig: UpscaleProviderConfig; entry: UpscaleModelEntry } | null {
const { provider, model } = parseUpscaleModel(modelStr);
if (!provider || !model) return null;
const providerConfig = UPSCALE_PROVIDERS[provider];
if (!providerConfig) return null;
const entry = providerConfig.models.find((m) => m.id === model);
if (!entry) return null;
return { provider, providerConfig, entry };
}
/** True when `provider/model` (or bare id) names a registered upscale model. */
export function isRegisteredUpscaleModel(modelStr: string | null): boolean {
return getUpscaleModelEntry(modelStr) !== null;
}
/**
* Normalize a requested scale factor to one the model actually supports.
*
* Accepts numbers and the loose strings clients send (`"2"`, `"2x"`, `"x4"`, `"4X"`).
* Unparseable/out-of-range values snap to the nearest allowed factor rather than
* failing the request — a 3x ask on a {2,4} model is better served at 4x than 400ed.
*/
export function normalizeUpscaleFactor(
value: unknown,
allowed: readonly number[] = DEFAULT_UPSCALE_FACTORS
): number {
const factors = allowed.length > 0 ? [...allowed] : [...DEFAULT_UPSCALE_FACTORS];
const fallback = factors.includes(2) ? 2 : factors[0]!;
let n: number = NaN;
if (typeof value === "number") {
n = value;
} else if (typeof value === "string") {
const match = /(\d+(?:\.\d+)?)/.exec(value.trim());
if (match) n = Number(match[1]);
}
if (!Number.isFinite(n) || n <= 0) return fallback;
let best = factors[0]!;
let bestDelta = Math.abs(factors[0]! - n);
for (const f of factors) {
const delta = Math.abs(f - n);
if (delta < bestDelta) {
best = f;
bestDelta = delta;
}
}
return best;
}
/**
* Normalize a creativity input to a 0-100 percentage.
*
* The public API is percentage-based so every provider gets the same control
* regardless of its native scale (Firefly uses an integer level, Stability a
* 0.1-0.5 float). A fractional value strictly between 0 and 1 is read as a
* fraction (0.35 → 35 %); everything else is read as a percentage, so an
* integer `1` stays 1 % instead of silently becoming 100 %.
*/
export function normalizeCreativityPercent(value: unknown, fallback = 0): number {
let n: number = NaN;
if (typeof value === "number") n = value;
else if (typeof value === "string" && value.trim()) n = Number(value.trim().replace("%", ""));
if (!Number.isFinite(n)) return clampPercent(fallback);
if (n > 0 && n < 1) return clampPercent(n * 100);
return clampPercent(n);
}
function clampPercent(n: number): number {
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, Math.round(n)));
}

View File

@@ -13,6 +13,7 @@ import {
getAntigravityOAuthUserAgent,
} from "../services/antigravityHeaders.ts";
import { classify429, decide429, type Decision } from "../services/antigravity429Engine.ts";
import { lockExactModel } from "../services/accountFallback.ts";
import {
shouldRetryWithCredits,
shouldUseCreditsFirst,
@@ -1424,6 +1425,7 @@ export class AntigravityExecutor extends BaseExecutor {
const {
response,
url,
model,
headers,
transformedBody,
credentials,
@@ -1443,10 +1445,9 @@ export class AntigravityExecutor extends BaseExecutor {
// 1. Try to parse explicit retry time from message
const parsedRetryMs = this.parseRetryFromErrorMessage(errorMessage);
// 2. Classify 429, then decide the final retry time BEFORE the credits
// retry so that full_quota_exhausted can skip the credits attempt
// entirely (avoids ~41s hold on an already-exhausted account) and
// persist the cooldown to DB for post-restart routing.
// 2. Classify 429, then decide the final retry time BEFORE the credits retry so
// full_quota_exhausted can skip the credits attempt entirely (avoids ~41s hold
// on an already-exhausted account) and locks only this exact model.
const category = classify429(errorMessage);
const decision: Decision = decide429(category, parsedRetryMs);
const retryMs = decision.retryAfterMs;
@@ -1460,10 +1461,9 @@ export class AntigravityExecutor extends BaseExecutor {
!creditsRetryState.attempted &&
shouldRetryWithCredits(credentials?.accessToken || "", creditsMode);
// Retry mode gets one credits attempt before the account cooldown is persisted.
// All other full-quota paths fail closed immediately.
// Retry mode gets one credits attempt before the exact-model lock is persisted.
if (decision.kind === "full_quota_exhausted" && retryMs && !creditsRetryEligible) {
markConnectionQuotaExhausted(accountId, retryMs);
lockExactModel(this.provider, accountId, model, "quota_exhausted", retryMs);
}
if (category === "quota_exhausted" && creditsAlreadyInjected) {

View File

@@ -324,7 +324,11 @@ export function stripVersionedToolModelPrefix(tools: unknown): void {
for (const t of tools as Array<Record<string, unknown>>) {
if (typeof t.model !== "string") continue;
const model = t.model;
if (typeof t.type === "string" && /^[a-z][a-z0-9_]*_\d{8}$/.test(t.type) && model.includes("/")) {
if (
typeof t.type === "string" &&
/^[a-z][a-z0-9_]*_\d{8}$/.test(t.type) &&
model.includes("/")
) {
t.model = model.split("/").pop();
} else {
const prefix = CLAUDE_TOOL_MODEL_PREFIXES.find((candidate) => model.startsWith(candidate));
@@ -1571,7 +1575,8 @@ export class BaseExecutor {
if (/content[_-]blocked/i.test(wafErrText)) {
retryAttemptsByUrl[urlIndex] = (retryAttemptsByUrl[urlIndex] ?? 0) + 1;
const wafAttempt = retryAttemptsByUrl[urlIndex];
const wafBackoff = BaseExecutor.WAF_RETRY_CONFIG.delayMs *
const wafBackoff =
BaseExecutor.WAF_RETRY_CONFIG.delayMs *
Math.pow(BaseExecutor.WAF_RETRY_CONFIG.backoffMultiplier, wafAttempt - 1);
log?.debug?.(
"WAF_RETRY",

View File

@@ -378,8 +378,7 @@ export function selectBetaFlags(
// Code sends effort on every request and never sends ATU, so treating effort as
// a proxy for ATU force-injects the heavy-agent pair the client never negotiated —
// the same class of mutation #3415 closed. Opaque clients keep the full set.
const allowHeavy =
clientBetaSet === null || clientBetaSet.has("advanced-tool-use-2025-11-20");
const allowHeavy = clientBetaSet === null || clientBetaSet.has("advanced-tool-use-2025-11-20");
const hasSystem =
!!b.system &&
(typeof b.system === "string" || (Array.isArray(b.system) && b.system.length > 0));

290
open-sse/executors/dario.ts Normal file
View File

@@ -0,0 +1,290 @@
/**
* Dario Executor — routes requests to a local Dario (@askalf/dario) instance.
*
* Dario is a local OpenAI- and Anthropic-compatible proxy that authenticates
* with the operator's own Claude Pro/Max subscription (Claude Code OAuth) and
* rebuilds every request into Claude Code's exact wire shape. It plays the same
* role for the `claude` provider that CLIProxyAPI's "claude-native" deep mode
* does — an alternative/failover backend for Claude-Code-shaped proxying.
*
* Unlike CliproxyapiExecutor this is a deliberately MINIMAL passthrough:
* - shape detection (Anthropic Messages vs OpenAI Chat Completions) + endpoint
* routing only — the same dual-shape convention CLIProxyAPI uses;
* - NO MCP tool-name rewriting, NO Anthropic-extras stripping.
* Dario is a different, actively-maintained project explicitly built to track
* Anthropic's wire-shape drift itself (live capture off an installed `claude`
* binary), so the extras-billing-gate workarounds CliproxyapiExecutor carries
* are Dario's own responsibility, not ours. Add such request-mangling here only
* if live testing proves Dario needs it too — start clean.
*
* Activation (parallel to, and independent of, CLIProxyAPI):
* 1. Per-connection darioMode === "claude-native" in providerSpecificData (UI)
* 2. Per-provider upstream_proxy_config (mode="dario", or mode="fallback" with
* fallbackBackend="dario"). See handlers/chatCore/executorProxy.ts.
*/
import {
BaseExecutor,
mergeUpstreamExtraHeaders,
mergeAbortSignals,
type ProviderCredentials,
type ExecutorLog,
} from "./base.ts";
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { getProviderPluginManifestHeader } from "../config/providerPluginManifestUrl.ts";
const DEFAULT_PORT = 3456;
const DEFAULT_HOST = "127.0.0.1";
const HEALTH_CHECK_TIMEOUT_MS = 5000;
// Cached URL from settings (loaded once, invalidated via clearDarioUrlCache).
let _cachedSettingsUrl: { url: string; ts: number } | null = null;
const URL_CACHE_TTL_MS = 60_000;
export function clearDarioUrlCache() {
_cachedSettingsUrl = null;
}
// Pre-load settings URL at module init so the sync path has a cache hit.
// Runs once when the executor module is first imported (mirrors cliproxyapi.ts).
(async () => {
try {
const { getSettings } = await import("@/lib/db/settings");
const settings = await getSettings();
if (typeof settings.dario_url === "string" && settings.dario_url.trim()) {
_cachedSettingsUrl = { url: settings.dario_url.trim(), ts: Date.now() };
}
} catch {
/* env vars will be used as fallback */
}
})();
/**
* Resolve Dario base URL. Priority:
* 1. Settings table `dario_url` (set via UI)
* 2. Environment variables DARIO_HOST / DARIO_PORT
* 3. Defaults (127.0.0.1:3456)
*/
async function resolveDarioBaseUrl(): Promise<string> {
if (_cachedSettingsUrl && Date.now() - _cachedSettingsUrl.ts < URL_CACHE_TTL_MS) {
return _cachedSettingsUrl.url;
}
try {
const { getSettings } = await import("@/lib/db/settings");
const settings = await getSettings();
if (typeof settings.dario_url === "string" && settings.dario_url.trim()) {
const url = settings.dario_url.trim();
_cachedSettingsUrl = { url, ts: Date.now() };
return url;
}
} catch {
/* fall through to env vars */
}
const host = process.env.DARIO_HOST || DEFAULT_HOST;
const port = parseInt(process.env.DARIO_PORT || String(DEFAULT_PORT), 10);
const url = `http://${host}:${port}`;
_cachedSettingsUrl = { url, ts: Date.now() };
return url;
}
// Sync wrapper for backward compatibility (constructor default, health checks, tests).
function resolveDarioBaseUrlSync(): string {
if (_cachedSettingsUrl && Date.now() - _cachedSettingsUrl.ts < URL_CACHE_TTL_MS) {
return _cachedSettingsUrl.url;
}
const host = process.env.DARIO_HOST || DEFAULT_HOST;
const port = parseInt(process.env.DARIO_PORT || String(DEFAULT_PORT), 10);
return `http://${host}:${port}`;
}
export { resolveDarioBaseUrl };
/**
* Check if a connection has Dario deep mode enabled via UI toggle.
* Mirrors isCliproxyapiDeepModeEnabled but keys off a SEPARATE field
* (`darioMode`) so a connection can opt into Dario or CLIProxyAPI independently.
* Used by chatCore's resolveExecutorWithProxy to decide routing.
*/
export function isDarioDeepModeEnabled(
providerSpecificData?: Record<string, unknown> | null
): boolean {
return providerSpecificData?.darioMode === "claude-native";
}
export class DarioExecutor extends BaseExecutor {
private readonly upstreamBaseUrl: string;
constructor(baseUrl?: string) {
const effectiveBase = baseUrl ?? resolveDarioBaseUrlSync();
super("dario", {
id: "dario",
baseUrl: effectiveBase + "/v1/chat/completions",
headers: { "Content-Type": "application/json" },
});
this.upstreamBaseUrl = effectiveBase;
}
buildUrl(
_model: string,
_stream: boolean,
_urlIndex = 0,
_credentials: ProviderCredentials | null = null
): string {
// Default endpoint when called without body context (kept for back-compat).
// execute() picks the right endpoint from the body shape; see selectEndpoint().
return `${this.upstreamBaseUrl}/v1/chat/completions`;
}
/**
* Returns true when the body matches the Anthropic Messages wire shape.
* Same detection heuristics as CliproxyapiExecutor.isAnthropicShape: an
* Anthropic-source client (`/v1/messages`, anthropic-version header, claude/*
* model) is not openai-translated by chatCore, so the executor sees the
* original Anthropic body. Dario exposes both `/v1/messages` (Anthropic SSE)
* and `/v1/chat/completions` (OpenAI SSE) on the same port with the shape
* auto-detected — route to the matching one so Anthropic-SDK clients get
* proper `event: message_start` / `content_block_delta` frames.
*/
private isAnthropicShape(body: unknown): boolean {
if (!body || typeof body !== "object") return false;
const b = body as Record<string, unknown>;
// Top-level `system` is unique to the Anthropic Messages API.
if (b.system !== undefined) return true;
// Top-level `thinking` is Anthropic-only (OpenAI uses reasoning*).
if (b.thinking !== undefined) return true;
// metadata.user_id is the CC wire-image identifier; OpenAI bodies lack it.
if (
b.metadata &&
typeof b.metadata === "object" &&
(b.metadata as Record<string, unknown>).user_id !== undefined
)
return true;
// messages[0].content as an array of Anthropic content blocks.
const msgs = b.messages;
if (Array.isArray(msgs) && msgs.length > 0) {
const first = msgs[0] as Record<string, unknown>;
if (Array.isArray(first?.content)) return true;
}
return false;
}
private selectEndpoint(body: unknown): string {
return this.isAnthropicShape(body) ? "/v1/messages" : "/v1/chat/completions";
}
buildHeaders(credentials: ProviderCredentials | null, stream = true): Record<string, string> {
// On loopback-only LLM routes Dario does not require a real bearer token
// (its proxy-key auth is mandatory only when binding non-loopback). We still
// forward whatever key is on the credentials if present — harmless — and
// default to the documented "dario" placeholder so an Authorization header
// is always present.
const key = credentials?.apiKey || credentials?.accessToken || "dario";
const headers: Record<string, string> = {
"Content-Type": "application/json",
...getProviderPluginManifestHeader(),
};
headers["Authorization"] = `Bearer ${key}`;
if (stream) {
headers["Accept"] = "text/event-stream";
}
return headers;
}
transformRequest(
model: string,
body: unknown,
_stream: boolean,
_credentials: ProviderCredentials | null
): unknown {
// Minimal passthrough: only ensure the model field matches the routed model.
// Dario handles Claude-Code wire-shape reconstruction itself.
if (!body || typeof body !== "object") return body;
const transformed = { ...(body as Record<string, unknown>) };
if (transformed.model !== model) {
transformed.model = model;
}
return transformed;
}
async execute(input: {
model: string;
body: unknown;
stream: boolean;
credentials: ProviderCredentials;
signal?: AbortSignal | null;
log?: ExecutorLog | null;
upstreamExtraHeaders?: Record<string, string> | null;
}) {
// Resolve URL dynamically so settings table dario_url is respected.
// Uses 60s cache to avoid DB reads on every request.
const baseUrl = await resolveDarioBaseUrl();
const endpoint = this.selectEndpoint(input.body);
const url = `${baseUrl}${endpoint}`;
const shape = endpoint === "/v1/messages" ? "anthropic" : "openai";
const headers = this.buildHeaders(input.credentials, input.stream);
const transformedBody = this.transformRequest(
input.model,
input.body,
input.stream,
input.credentials
);
mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders);
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const combinedSignal = input.signal
? mergeAbortSignals(input.signal, timeoutSignal)
: timeoutSignal;
input.log?.info?.("DARIO", `Dario → ${url} (model: ${input.model}, shape: ${shape})`);
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal: combinedSignal,
});
if (response.status === HTTP_STATUS.RATE_LIMITED) {
input.log?.warn?.("DARIO", `Dario rate limited: ${response.status}`);
}
return { response, url, headers, transformedBody };
}
/**
* Health check — verifies Dario is reachable.
*
* Dario's `/health` returns 200 {status:"ok"} once ≥1 healthy account exists
* and 503 {status:"degraded"} while zero accounts are configured (or all are
* in auth-cooldown). We treat this as a plain `res.ok` check: 503-while-empty
* is semantically correct ("reachable but not yet useful"), so the dashboard
* shows running+degraded until the operator completes the Claude OAuth login.
*/
async healthCheck(): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
const start = Date.now();
try {
const baseUrl = await resolveDarioBaseUrl();
const res = await fetch(`${baseUrl}/health`, {
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
});
return {
ok: res.ok,
latencyMs: Date.now() - start,
...(!res.ok ? { error: `HTTP ${res.status}` } : {}),
};
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}
}
export default DarioExecutor;

View File

@@ -15,6 +15,7 @@ import { OpencodeExecutor } from "./opencode.ts";
import { PuterExecutor } from "./puter.ts";
import { VertexExecutor } from "./vertex.ts";
import { CliproxyapiExecutor } from "./cliproxyapi.ts";
import { DarioExecutor } from "./dario.ts";
import { NineRouterExecutor } from "./ninerouter.ts";
import { PerplexityWebExecutor } from "./perplexity-web.ts";
import { GrokWebExecutor } from "./grok-web.ts";
@@ -104,6 +105,8 @@ const executors = {
"vertex-partner": new VertexExecutor(),
cliproxyapi: new CliproxyapiExecutor(),
cpa: new CliproxyapiExecutor(), // Alias
dario: new DarioExecutor(),
dr: new DarioExecutor(), // Alias
"9router": new NineRouterExecutor(),
nr: new NineRouterExecutor(), // Alias
"perplexity-web": new PerplexityWebExecutor(),
@@ -244,6 +247,7 @@ export { CloudflareAIExecutor } from "./cloudflare-ai.ts";
export { OpencodeExecutor } from "./opencode.ts";
export { PuterExecutor } from "./puter.ts";
export { CliproxyapiExecutor } from "./cliproxyapi.ts";
export { DarioExecutor } from "./dario.ts";
export { NineRouterExecutor } from "./ninerouter.ts";
export { VertexExecutor } from "./vertex.ts";
export { PerplexityWebExecutor } from "./perplexity-web.ts";

View File

@@ -304,6 +304,7 @@ import {
markBlocked as markAccountSemaphoreBlocked,
} from "../services/accountSemaphore.ts";
import { lockModel, lockModelIfPerModelQuota } from "../services/accountFallback.ts";
import { lockExactModel } from "../services/accountFallback.ts";
import {
generateSignature,
getCachedResponse,
@@ -3705,7 +3706,8 @@ export async function handleChatCore({
markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs);
}
if (isModelScope() && errorConnectionId) {
lockModel(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs);
const lockFn = provider === "antigravity" ? lockExactModel : lockModel;
lockFn(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs);
console.warn(
`[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)`
);

View File

@@ -1,4 +1,5 @@
import { getUpstreamProxyConfig } from "@/lib/localDb";
import type { FallbackBackend } from "@/lib/db/upstreamProxy";
/**
* Module-level cache for upstream proxy config (shared across all requests).
@@ -8,6 +9,8 @@ type UpstreamProxyConfigCacheEntry = {
mode: string;
enabled: boolean;
cliproxyapiModelMapping: Record<string, unknown> | null;
// #dario: retry-leg backend when mode === "fallback".
fallbackBackend: FallbackBackend;
ts: number;
};
@@ -67,9 +70,16 @@ export async function getUpstreamProxyConfigCached(providerId: string) {
mode: cfg.mode,
enabled: cfg.enabled,
cliproxyapiModelMapping: cfg.cliproxyapiModelMapping ?? null,
fallbackBackend: cfg.fallbackBackend,
ts: Date.now(),
}
: { mode: "native" as const, enabled: false, cliproxyapiModelMapping: null, ts: Date.now() };
: {
mode: "native" as const,
enabled: false,
cliproxyapiModelMapping: null,
fallbackBackend: "cliproxyapi" as const,
ts: Date.now(),
};
_proxyConfigCache.set(providerId, result);
return result;
}

View File

@@ -4,15 +4,24 @@
*
* Extracted from handleChatCore: resolves the executor for a provider honoring the configured
* upstream proxy mode. `native` / disabled → the provider's own executor; `cliproxyapi` → the
* CLIProxyAPI passthrough executor; `fallback` → a wrapper that tries the native executor first and
* retries via CLIProxyAPI on configured failure codes (default 5xx + 429 + network) or on a thrown
* error. Behaviour is byte-identical to the previous inline closure (it only captured `log`).
* CLIProxyAPI passthrough executor; `dario` → the Dario passthrough executor; `fallback` → a
* wrapper that tries the native executor first and retries via the configured fallback backend
* (CLIProxyAPI by default, or Dario) on configured failure codes (default 5xx + 429 + network)
* or on a thrown error.
*
* Dario (@askalf/dario) is wired as a parallel, independent backend choice at both levels
* (per-connection `darioMode` + provider `mode`/`fallbackBackend`) WITHOUT changing any existing
* CLIProxyAPI behaviour. Dario needs neither the dedicated-credential substitution nor the
* per-provider model-mapping wrappers CLIProxyAPI uses: it authenticates via its own OAuth
* account pool (not a configured bearer key) and has its own server-side model-alias mechanism.
*/
import { getExecutor } from "../../executors/index.ts";
import { isCliproxyapiDeepModeEnabled } from "../../executors/cliproxyapi.ts";
import { isDarioDeepModeEnabled } from "../../executors/dario.ts";
import { getCachedSettings } from "@/lib/db/readCache";
import { getUpstreamProxyConfigCached } from "./comboContextCache.ts";
import type { FallbackBackend } from "@/lib/db/upstreamProxy";
import { wrapExecutorWithCliproxyapiModelMapping } from "./cliproxyModelMapping.ts";
import {
resolveDedicatedCliproxyapiApiKey,
@@ -62,6 +71,21 @@ async function loadCliproxyapiSettings(): Promise<{
}
}
/**
* Resolve the CLIProxyAPI passthrough executor with its model-mapping +
* dedicated-credential wrappers applied. Used by the direct `cliproxyapi` leg
* and the CLIProxyAPI branch of `fallback`.
*/
function resolveCliproxyapiExecutor(
cliproxyapiModelMapping: Record<string, unknown> | null,
dedicatedApiKey: string | null
) {
return wrapExecutorWithCliproxyapiCredentials(
wrapExecutorWithCliproxyapiModelMapping(getExecutor("cliproxyapi"), cliproxyapiModelMapping),
dedicatedApiKey
);
}
export async function resolveExecutorWithProxy(
prov: string,
log?: LoggerLike,
@@ -81,29 +105,50 @@ export async function resolveExecutorWithProxy(
return getExecutor("cliproxyapi");
}
// Sibling per-connection override for Dario (#dario). Checked AFTER the
// CLIProxyAPI check above by deliberate design: if a connection somehow sets
// BOTH cliproxyapiMode and darioMode to "claude-native", CLIProxyAPI's
// existing behaviour keeps winning — the least-surprising precedence for
// configs that predate this field, and the simplest to reason about.
if (isDarioDeepModeEnabled(providerSpecificData)) {
log?.info?.(
"UPSTREAM_PROXY",
`${prov} routed through Dario (per-connection claude-native override)`
);
return getExecutor("dario");
}
const cfg = await getUpstreamProxyConfigCached(prov);
if (!cfg.enabled || cfg.mode === "native") return getExecutor(prov);
if (cfg.mode === "cliproxyapi") {
log?.info?.("UPSTREAM_PROXY", `${prov} routed through CLIProxyAPI (passthrough)`);
const { dedicatedApiKey } = await loadCliproxyapiSettings();
return wrapExecutorWithCliproxyapiCredentials(
wrapExecutorWithCliproxyapiModelMapping(getExecutor("cliproxyapi"), cfg.cliproxyapiModelMapping),
dedicatedApiKey
);
return resolveCliproxyapiExecutor(cfg.cliproxyapiModelMapping, dedicatedApiKey);
}
// mode === "fallback": try native first, retry via CLIProxyAPI on specific failures.
// The model mapping applies only to the CLIProxyAPI retry leg (proxyExec) — the
// native leg must keep seeing the original, unmapped model.
if (cfg.mode === "dario") {
// Direct Dario passthrough. No credential/model-mapping wrappers: Dario
// authenticates via its own OAuth pool and has its own model-alias layer.
log?.info?.("UPSTREAM_PROXY", `${prov} routed through Dario (passthrough)`);
return getExecutor("dario");
}
// mode === "fallback": try native first, retry via the configured fallback
// backend on specific failures. The backend defaults to CLIProxyAPI so every
// pre-existing fallback config behaves exactly as before; fallbackBackend
// === "dario" opts the retry leg over to Dario instead.
const nativeExec = getExecutor(prov);
const fallbackBackend: FallbackBackend = cfg.fallbackBackend;
const { fallbackCodes, dedicatedApiKey } = await loadCliproxyapiSettings();
// #7645: the CLIProxyAPI retry leg must authenticate with the dedicated
// key, never the native provider's own (already-failed) credential.
const proxyExec = wrapExecutorWithCliproxyapiCredentials(
wrapExecutorWithCliproxyapiModelMapping(getExecutor("cliproxyapi"), cfg.cliproxyapiModelMapping),
dedicatedApiKey
);
// The model mapping applies only to the CLIProxyAPI retry leg (proxyExec) —
// the native leg must keep seeing the original, unmapped model.
const proxyExec =
fallbackBackend === "dario"
? getExecutor("dario")
: resolveCliproxyapiExecutor(cfg.cliproxyapiModelMapping, dedicatedApiKey);
const backendLabel = fallbackBackend === "dario" ? "Dario" : "CLIProxyAPI";
const isRetryableStatus = (s: number) => fallbackCodes.includes(s) || s === 0;
const wrapper = Object.create(nativeExec);
@@ -121,12 +166,12 @@ export async function resolveExecutorWithProxy(
result = await nativeExec.execute(input);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log?.info?.("UPSTREAM_PROXY", `${prov} native error (${errMsg}), retrying via CLIProxyAPI`);
log?.info?.("UPSTREAM_PROXY", `${prov} native error (${errMsg}), retrying via ${backendLabel}`);
try {
return await proxyExec.execute(input);
} catch (proxyErr) {
const proxyMsg = proxyErr instanceof Error ? proxyErr.message : String(proxyErr);
log?.error?.("UPSTREAM_PROXY", `${prov} CLIProxyAPI fallback also failed: ${proxyMsg}`);
log?.error?.("UPSTREAM_PROXY", `${prov} ${backendLabel} fallback also failed: ${proxyMsg}`);
throw proxyErr;
}
}
@@ -136,13 +181,13 @@ export async function resolveExecutorWithProxy(
}
log?.info?.(
"UPSTREAM_PROXY",
`${prov} native failed (${result.response.status}), retrying via CLIProxyAPI`
`${prov} native failed (${result.response.status}), retrying via ${backendLabel}`
);
try {
return await proxyExec.execute(input);
} catch (proxyErr) {
const proxyMsg = proxyErr instanceof Error ? proxyErr.message : String(proxyErr);
log?.error?.("UPSTREAM_PROXY", `${prov} CLIProxyAPI fallback also failed: ${proxyMsg}`);
log?.error?.("UPSTREAM_PROXY", `${prov} ${backendLabel} fallback also failed: ${proxyMsg}`);
throw proxyErr;
}
};

View File

@@ -21,6 +21,8 @@ import {
resolveAdobeSourceImageIds,
resolveAdobeImageModel,
} from "../../../services/adobeFireflyClient.ts";
import { isAdobeFireflyUpscaleModel } from "../../../services/adobeFireflyUpscale.ts";
import { handleAdobeFireflyImageUpscale } from "../../imageUpscale/adobeFirefly.ts";
export async function handleAdobeFireflyImageGeneration({
model,
@@ -54,6 +56,19 @@ export async function handleAdobeFireflyImageGeneration({
}) {
const startTime = Date.now();
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
// Topaz upscalers share adobe-firefly but use /v2/3p-images/upsample (no prompt).
if (isAdobeFireflyUpscaleModel(model)) {
return handleAdobeFireflyImageUpscale({
model,
provider,
body: body as Record<string, unknown>,
credentials,
log,
fetchImpl,
});
}
if (!prompt) {
return saveImageErrorResult({
provider,

View File

@@ -0,0 +1,110 @@
/**
* Image Upscale Handler
*
* Handles `POST /v1/images/upscale` — image→image super-resolution.
*
* Request (OpenAI-adjacent, deliberately minimal):
* {
* "model": "adobe-firefly/topaz-bloom",
* "image": "data:image/png;base64,...", // or image_url / http(s) URL
* "factor": 2, // 2 | 4 (snapped to what the model supports)
* "creativity": 40, // 0-100 % (generative upscalers only)
* "prompt": "…", // required by Stability conservative/creative
* "response_format": "url" | "b64_json"
* }
*
* Response is shaped like `/v1/images/generations` (`{ created, data: [{ url | b64_json }] }`)
* plus an `upscale` metadata block, so existing image clients need no changes.
*/
import { getUpscaleProvider, parseUpscaleModel } from "../config/upscaleRegistry.ts";
import { handleAdobeFireflyImageUpscale } from "./imageUpscale/adobeFirefly.ts";
import { handleStabilityImageUpscale } from "./imageUpscale/stability.ts";
import { handleTopazImageUpscale } from "./imageUpscale/topaz.ts";
import type {
UpscaleCredentials,
UpscaleHandlerResult,
UpscaleLogger,
} from "./imageUpscale/shared.ts";
export type { UpscaleHandlerResult } from "./imageUpscale/shared.ts";
export async function handleImageUpscale({
body,
credentials,
log,
fetchImpl,
}: {
body: Record<string, unknown>;
credentials: UpscaleCredentials | null;
log?: UpscaleLogger;
fetchImpl?: typeof fetch;
}): Promise<UpscaleHandlerResult> {
const requestedModel = typeof body.model === "string" ? body.model : "";
const { provider, model } = parseUpscaleModel(requestedModel);
if (!provider || !model) {
return {
success: false,
status: 400,
error:
`Invalid upscale model: ${requestedModel || "(missing)"}. ` +
`Use format: provider/model (e.g. adobe-firefly/topaz-bloom).`,
};
}
const providerConfig = getUpscaleProvider(provider);
if (!providerConfig) {
return { success: false, status: 400, error: `Unknown upscale provider: ${provider}` };
}
if (!providerConfig.models.some((entry) => entry.id === model)) {
return {
success: false,
status: 400,
error:
`Unsupported upscale model for ${provider}: ${model}. ` +
`Available: ${providerConfig.models.map((entry) => entry.id).join(", ")}.`,
};
}
const resolvedCredentials = credentials ?? {};
switch (providerConfig.format) {
case "adobe-firefly-upscale":
return handleAdobeFireflyImageUpscale({
model,
provider,
body,
credentials: resolvedCredentials,
log,
...(fetchImpl ? { fetchImpl } : {}),
});
case "stability-upscale":
return handleStabilityImageUpscale({
model,
provider,
providerConfig,
body,
credentials: resolvedCredentials,
log,
...(fetchImpl ? { fetchImpl } : {}),
});
case "topaz-upscale":
return handleTopazImageUpscale({
model,
provider,
providerConfig,
body,
credentials: resolvedCredentials,
log,
...(fetchImpl ? { fetchImpl } : {}),
});
default:
return {
success: false,
status: 400,
error: `Upscale is not implemented for provider format: ${providerConfig.format}`,
};
}
}

View File

@@ -0,0 +1,177 @@
/**
* Adobe Firefly upscale handler — Topaz Labs models on firefly-3p `/v2/3p-images/upsample`.
*
* Flow (mirrors the SPA and the Firefly generate path):
* 1. Resolve the durable session (JWT + Cookie → ARP rebuild, sticky ARP, submit gate).
* 2. Upload the source image to `/v2/storage/image` → blob id, reusing that ARP.
* 3. POST the upsample job, poll the BKS result link, return the presigned URL.
*/
import {
AdobeFireflyError,
resolveAdobeAccessToken,
resolveAdobeSourceImageIds,
} from "../../services/adobeFireflyClient.ts";
import {
adobeFireflyUpscaleImage,
resolveAdobeUpscaleModel,
} from "../../services/adobeFireflyUpscale.ts";
import { sanitizeErrorMessage } from "../../utils/error.ts";
import {
extractUpscaleSourceImage,
saveUpscaleErrorResult,
saveUpscaleSuccessResult,
type UpscaleCredentials,
type UpscaleHandlerResult,
type UpscaleLogger,
} from "./shared.ts";
export async function handleAdobeFireflyImageUpscale({
model,
provider,
body,
credentials,
log,
fetchImpl = fetch,
}: {
model: string;
provider: string;
body: Record<string, unknown>;
credentials: UpscaleCredentials;
log?: UpscaleLogger;
fetchImpl?: typeof fetch;
}): Promise<UpscaleHandlerResult> {
const startTime = Date.now();
const resolved = resolveAdobeUpscaleModel(model);
if (!resolved) {
return saveUpscaleErrorResult({
provider,
model,
status: 400,
startTime,
error: `Unsupported Adobe Firefly upscale model: ${model}. Use topaz-standard or topaz-bloom.`,
});
}
if (!extractUpscaleSourceImage(body)) {
return saveUpscaleErrorResult({
provider,
model,
status: 400,
startTime,
error: "Adobe Firefly upscale requires a source image",
});
}
try {
const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl);
// Keep the raw credential blob for Cookie + sherlockToken (x-arp-session-id).
const psd = (credentials as { providerSpecificData?: { cookie?: string } })?.providerSpecificData;
const sessionCookie =
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
(typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";")
? credentials.accessToken
: undefined);
// Upscale consumes exactly one source; upload it under the same ARP as submit.
const blobIds = await resolveAdobeSourceImageIds({
accessToken,
body,
max: 1,
sessionCookie,
prompt: "upsample",
fetchImpl,
log,
});
if (blobIds.length === 0) {
return saveUpscaleErrorResult({
provider,
model,
status: 400,
startTime,
error: "Adobe Firefly upscale could not resolve the source image",
});
}
const timeoutMs = normalizePositiveNumber(body.timeout_ms, 0);
const result = await adobeFireflyUpscaleImage({
accessToken,
model,
blobId: blobIds[0]!,
upsamplerFactor: readFactor(body),
creativityPercent: readCreativityPercent(body),
creativityLevel: body.creativity_level ?? body.creativityLevel,
sessionCookie,
...(timeoutMs > 0 ? { timeoutMs } : {}),
fetchImpl,
log,
});
log?.info?.(
"IMAGE",
`${provider}/${model} (adobe-firefly upsample) | ${result.factor}x` +
(resolved.spec.supportsCreativity ? ` | creativityLevel=${result.creativityLevel}` : "")
);
return saveUpscaleSuccessResult({
provider,
model,
startTime,
images: [{ url: result.url }],
meta: {
provider,
model,
factor: result.factor,
...(resolved.spec.supportsCreativity ? { creativity_level: result.creativityLevel } : {}),
},
});
} catch (err) {
if (err instanceof AdobeFireflyError) {
log?.error?.("IMAGE", `${provider} adobe-firefly upscale error ${err.status}: ${err.message}`);
return saveUpscaleErrorResult({
provider,
model,
status: err.status,
startTime,
error: err.message,
});
}
const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
log?.error?.("IMAGE", `${provider} adobe-firefly upscale exception: ${errorText}`);
return saveUpscaleErrorResult({
provider,
model,
status: 500,
startTime,
error: errorText,
});
}
}
function readFactor(body: Record<string, unknown>): unknown {
return (
body.factor ??
body.scale ??
body.upscale_factor ??
body.upscaleFactor ??
body.upsampler_factor ??
body.upsamplerFactor
);
}
function readCreativityPercent(body: Record<string, unknown>): number | undefined {
const raw = body.creativity ?? body.creativity_percent ?? body.creativityPercent;
if (raw === undefined || raw === null) return undefined;
const n = typeof raw === "number" ? raw : Number(String(raw).replace("%", "").trim());
if (!Number.isFinite(n)) return undefined;
if (n > 0 && n < 1) return Math.max(0, Math.min(100, n * 100));
return Math.max(0, Math.min(100, n));
}
function normalizePositiveNumber(value: unknown, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : fallback;
}

View File

@@ -0,0 +1,391 @@
/**
* Shared plumbing for the `/v1/images/upscale` provider handlers.
*
* Kept separate from `handlers/imageGeneration.ts` on purpose: upscaling needs raw
* source bytes + pixel dimensions (to turn a 2x/4x factor into an output size for
* providers that only accept absolute targets), neither of which the generation
* handler exposes.
*/
import { saveCallLog } from "@/lib/usageDb";
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
export const UPSCALE_CALL_LOG_PATH = "/v1/images/upscale";
/** Hard cap on a decoded source image (matches the Firefly storage upload limit). */
export const MAX_UPSCALE_SOURCE_BYTES = 20 * 1024 * 1024;
export interface UpscaleImageSource {
buffer: Buffer;
base64: string;
contentType: string;
}
export interface UpscaleHandlerResult {
success: boolean;
status?: number;
error?: unknown;
data?: unknown;
}
export interface UpscaleLogger {
info?: (scope: string, message: string) => void;
error?: (scope: string, message: string) => void;
}
/**
* Credential shape the upscale handlers need. Mirrors what
* `getProviderCredentialsWithQuotaPreflight` yields for these providers: an API key or
* access token, plus (for Adobe Firefly) the connection's `providerSpecificData`, which
* is where a pasted firefly.adobe.com Cookie lives.
*/
export interface UpscaleCredentials {
apiKey?: string;
accessToken?: string;
providerSpecificData?: {
cookie?: unknown;
access_token?: unknown;
accessToken?: unknown;
} | null;
}
/**
* `Buffer` is typed as `Buffer<ArrayBufferLike>`, which TypeScript will not accept as a
* `BlobPart` (a Blob part must be backed by a plain `ArrayBuffer`). Copy the bytes into a
* fresh `ArrayBuffer` so multipart bodies typecheck without an unsafe cast.
*/
export function toBlobBytes(buffer: Buffer): ArrayBuffer {
const out = new ArrayBuffer(buffer.byteLength);
new Uint8Array(out).set(buffer);
return out;
}
/**
* Collect the source image from an OpenAI-ish / Media-page body.
*
* Only ONE image is meaningful for an upscale, so the first resolvable candidate
* wins. Field order mirrors `extractAdobeSourceImageSources` so a body built for
* generation keeps working here.
*/
export function extractUpscaleSourceImage(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
const providerOptions =
b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options)
? (b.provider_options as Record<string, unknown>)
: {};
const keys = [
"image_url",
"imageUrl",
"input_image",
"source_image",
"promptImage",
"prompt_image",
"image",
"images",
"image_urls",
"imageUrls",
"input_images",
"reference_images",
"referenceImages",
"reference_image",
];
for (const key of keys) {
const found = firstImageCandidate(b[key]) || firstImageCandidate(providerOptions[key]);
if (found) return found;
}
if (Array.isArray(b.messages)) {
for (const msg of b.messages) {
if (!msg || typeof msg !== "object") continue;
const content = (msg as Record<string, unknown>).content;
if (!Array.isArray(content)) continue;
for (const part of content) {
if (!part || typeof part !== "object") continue;
const p = part as Record<string, unknown>;
if (p.type === "image_url" || p.type === "image") {
const found = firstImageCandidate(p.image_url ?? p.image ?? p.url);
if (found) return found;
}
}
}
}
return null;
}
function firstImageCandidate(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed || trimmed === "null" || trimmed === "undefined") return null;
return trimmed;
}
if (Array.isArray(value)) {
for (const item of value) {
const found = firstImageCandidate(item);
if (found) return found;
}
return null;
}
if (value && typeof value === "object") {
const o = value as Record<string, unknown>;
if (typeof o.url === "string") return firstImageCandidate(o.url);
if (typeof o.image_url === "string") return firstImageCandidate(o.image_url);
if (o.image_url && typeof o.image_url === "object") {
return firstImageCandidate((o.image_url as Record<string, unknown>).url);
}
if (typeof o.b64_json === "string") return `data:image/png;base64,${o.b64_json}`;
if (typeof o.base64 === "string") return `data:image/png;base64,${o.base64}`;
}
return null;
}
/** Decode a data URL / http(s) URL / bare base64 string into bytes. */
export async function resolveUpscaleImageSource(source: string): Promise<UpscaleImageSource> {
const trimmed = String(source || "").trim();
if (!trimmed) throw new Error("Invalid image source");
const dataUri = /^data:([^;,]+)?(?:;charset=[^;,]+)?;base64,([\s\S]+)$/i.exec(trimmed);
if (dataUri) {
const contentType = (dataUri[1] || "image/png").trim().toLowerCase();
const base64 = (dataUri[2] || "").replace(/\s/g, "");
const buffer = Buffer.from(base64, "base64");
assertSourceBytes(buffer);
return {
buffer,
base64,
contentType: contentType.startsWith("image/") ? contentType : "image/png",
};
}
if (/^https?:\/\//i.test(trimmed)) {
const remote = await fetchRemoteImage(trimmed);
assertSourceBytes(remote.buffer);
// fetchRemoteImage falls back to application/octet-stream; sniff whenever the
// server did not send a usable image/* type so multipart uploads stay correct.
const declared = (remote.contentType || "").split(";")[0]!.trim().toLowerCase();
return {
buffer: remote.buffer,
base64: remote.buffer.toString("base64"),
contentType: declared.startsWith("image/") ? declared : sniffImageMime(remote.buffer),
};
}
const buffer = Buffer.from(trimmed.replace(/\s/g, ""), "base64");
assertSourceBytes(buffer);
return { buffer, base64: buffer.toString("base64"), contentType: sniffImageMime(buffer) };
}
function assertSourceBytes(buffer: Buffer): void {
if (!buffer.length) throw new Error("Source image decoded to empty bytes");
if (buffer.length > MAX_UPSCALE_SOURCE_BYTES) {
throw new Error(
`Source image too large (${buffer.length} bytes; max ${MAX_UPSCALE_SOURCE_BYTES})`
);
}
}
/** Best-effort MIME sniff from the magic bytes (falls back to PNG). */
export function sniffImageMime(buffer: Buffer): string {
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return "image/jpeg";
}
if (buffer.length >= 8 && buffer[0] === 0x89 && buffer.toString("ascii", 1, 4) === "PNG") {
return "image/png";
}
if (buffer.length >= 6 && buffer.toString("ascii", 0, 3) === "GIF") return "image/gif";
if (
buffer.length >= 12 &&
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
) {
return "image/webp";
}
if (buffer.length >= 2 && buffer.toString("ascii", 0, 2) === "BM") return "image/bmp";
return "image/png";
}
/**
* Read pixel dimensions straight from the container header — no image library needed.
* Supports PNG, JPEG (SOFn scan), GIF, WebP (VP8 / VP8L / VP8X) and BMP.
* Returns null when the format is unknown or the header is truncated.
*/
export function readImageDimensions(buffer: Buffer): { width: number; height: number } | null {
try {
if (
buffer.length >= 24 &&
buffer[0] === 0x89 &&
buffer.toString("ascii", 1, 4) === "PNG"
) {
// IHDR is always the first chunk: 8-byte signature + 4 length + 4 "IHDR".
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
}
if (buffer.length >= 6 && buffer.toString("ascii", 0, 3) === "GIF") {
return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
}
if (buffer.length >= 26 && buffer.toString("ascii", 0, 2) === "BM") {
return { width: buffer.readInt32LE(18), height: Math.abs(buffer.readInt32LE(22)) };
}
if (
buffer.length >= 30 &&
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
) {
return readWebpDimensions(buffer);
}
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
return readJpegDimensions(buffer);
}
} catch {
return null;
}
return null;
}
function readWebpDimensions(buffer: Buffer): { width: number; height: number } | null {
const chunk = buffer.toString("ascii", 12, 16);
if (chunk === "VP8 " && buffer.length >= 30) {
// Lossy: 3-byte frame tag + 3-byte sync code, then 14-bit width/height.
return {
width: buffer.readUInt16LE(26) & 0x3fff,
height: buffer.readUInt16LE(28) & 0x3fff,
};
}
if (chunk === "VP8L" && buffer.length >= 25) {
const bits = buffer.readUInt32LE(21);
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
}
if (chunk === "VP8X" && buffer.length >= 30) {
const width = 1 + (buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16));
const height = 1 + (buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16));
return { width, height };
}
return null;
}
function readJpegDimensions(buffer: Buffer): { width: number; height: number } | null {
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = buffer[offset + 1]!;
// Standalone markers (no length payload).
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
offset += 2;
continue;
}
const length = buffer.readUInt16BE(offset + 2);
// SOF0..SOF15 except DHT(c4)/JPGA(c8)/DAC(cc) carry the frame dimensions.
const isSof =
marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
if (isSof) {
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
}
if (length <= 0) return null;
offset += 2 + length;
}
return null;
}
/**
* Absolute output size for a scale factor, clamped to `maxEdge` so a 4x pass on an
* already-large source cannot ask for an impossible canvas. Returns null when the
* source dimensions could not be read.
*/
export function scaleDimensions(
buffer: Buffer,
factor: number,
maxEdge = 32000
): { width: number; height: number } | null {
const source = readImageDimensions(buffer);
if (!source || source.width <= 0 || source.height <= 0) return null;
const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 2;
const scale = Math.min(
safeFactor,
maxEdge / Math.max(source.width, source.height)
);
return {
width: Math.max(1, Math.round(source.width * Math.max(1, scale))),
height: Math.max(1, Math.round(source.height * Math.max(1, scale))),
};
}
/** OpenAI-images-shaped success envelope + call log. */
export function saveUpscaleSuccessResult(opts: {
provider: string;
model: string;
startTime: number;
images: Array<Record<string, unknown>>;
requestBody?: unknown;
responseBody?: unknown;
meta?: Record<string, unknown>;
}): UpscaleHandlerResult {
saveCallLog({
method: "POST",
path: UPSCALE_CALL_LOG_PATH,
status: 200,
model: `${opts.provider}/${opts.model}`,
provider: opts.provider,
duration: Date.now() - opts.startTime,
requestBody: opts.requestBody ?? null,
responseBody: opts.responseBody ?? { images_count: opts.images.length },
}).catch(() => {});
return {
success: true,
data: {
created: Math.floor(Date.now() / 1000),
data: opts.images,
...(opts.meta ? { upscale: opts.meta } : {}),
},
};
}
export function saveUpscaleErrorResult(opts: {
provider: string;
model: string;
status: number;
startTime: number;
error: unknown;
requestBody?: unknown;
}): UpscaleHandlerResult {
saveCallLog({
method: "POST",
path: UPSCALE_CALL_LOG_PATH,
status: opts.status,
model: `${opts.provider}/${opts.model}`,
provider: opts.provider,
duration: Date.now() - opts.startTime,
error:
typeof opts.error === "string"
? opts.error.slice(0, 500)
: String(opts.error).slice(0, 500),
requestBody: opts.requestBody ?? null,
}).catch(() => {});
return { success: false, status: opts.status, error: opts.error };
}
/** `{ url }` or `{ b64_json }` depending on the requested response_format. */
export function buildUpscaleImageEntry(opts: {
buffer?: Buffer | null;
contentType?: string;
url?: string | null;
responseFormat?: unknown;
}): Record<string, unknown> {
const wantsBase64 = String(opts.responseFormat ?? "").toLowerCase() === "b64_json";
if (opts.buffer && opts.buffer.length > 0) {
const base64 = opts.buffer.toString("base64");
const mime = opts.contentType || sniffImageMime(opts.buffer);
return wantsBase64 ? { b64_json: base64 } : { url: `data:${mime};base64,${base64}` };
}
return { url: String(opts.url || "") };
}

View File

@@ -0,0 +1,335 @@
/**
* Stability AI upscale handler — `/v2beta/stable-image/upscale/{fast,conservative,creative}`.
*
* Wire contract (platform.stability.ai):
* - all three take multipart/form-data with an `image` part
* - `Accept: application/json` → `{ image: <base64>, finish_reason, seed }`
* - `fast` : no prompt, fixed 4x
* - `conservative` : prompt REQUIRED, `creativity` 0.2-0.5 (default 0.35), synchronous
* - `creative` : prompt REQUIRED, `creativity` 0-0.35 (default 0.3), **async** —
* responds `{ id }`, then `GET /v2beta/results/{id}` returns 202 while
* running and 200 with the base64 image when finished.
*
* The generation handler's stability path does not poll, so the async `creative`
* variant is implemented here rather than delegated.
*/
import {
buildUpscaleImageEntry,
extractUpscaleSourceImage,
resolveUpscaleImageSource,
saveUpscaleErrorResult,
saveUpscaleSuccessResult,
toBlobBytes,
type UpscaleCredentials,
type UpscaleHandlerResult,
type UpscaleLogger,
} from "./shared.ts";
import { sanitizeErrorMessage } from "../../utils/error.ts";
const UPSCALE_ENDPOINTS: Record<string, string> = {
fast: "/v2beta/stable-image/upscale/fast",
conservative: "/v2beta/stable-image/upscale/conservative",
creative: "/v2beta/stable-image/upscale/creative",
};
/** Documented `creativity` range per model — a 0-100 % request is mapped into it. */
const CREATIVITY_RANGES: Record<string, { min: number; max: number; fallback: number }> = {
conservative: { min: 0.2, max: 0.5, fallback: 0.35 },
creative: { min: 0, max: 0.35, fallback: 0.3 },
};
/** Models whose upstream rejects a request without a prompt. */
const PROMPT_REQUIRED = new Set(["conservative", "creative"]);
/** `creative` is an async job. */
const ASYNC_MODELS = new Set(["creative"]);
const RESULT_POLL_INTERVAL_MS = 3000;
const DEFAULT_RESULT_TIMEOUT_MS = 300_000;
const ALLOWED_OUTPUT_FORMATS = ["png", "jpeg", "webp"];
export async function handleStabilityImageUpscale({
model,
provider,
providerConfig,
body,
credentials,
log,
fetchImpl = fetch,
}: {
model: string;
provider: string;
providerConfig: { baseUrl: string };
body: Record<string, unknown>;
credentials: UpscaleCredentials;
log?: UpscaleLogger;
fetchImpl?: typeof fetch;
}): Promise<UpscaleHandlerResult> {
const startTime = Date.now();
const endpoint = UPSCALE_ENDPOINTS[model];
if (!endpoint) {
return saveUpscaleErrorResult({
provider,
model,
status: 400,
startTime,
error: `Unsupported Stability AI upscale model: ${model}. Use fast, conservative or creative.`,
});
}
const token = credentials.apiKey || credentials.accessToken;
if (!token) {
return saveUpscaleErrorResult({
provider,
model,
status: 401,
startTime,
error: "Missing Stability AI API key",
});
}
const source = extractUpscaleSourceImage(body);
if (!source) {
return saveUpscaleErrorResult({
provider,
model,
status: 400,
startTime,
error: `Stability AI upscale model ${model} requires a source image`,
});
}
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
if (PROMPT_REQUIRED.has(model) && !prompt) {
return saveUpscaleErrorResult({
provider,
model,
status: 400,
startTime,
error:
`Stability AI "${model}" upscale requires a prompt describing the image. ` +
`Use the "fast" model for a prompt-free 4x upscale.`,
});
}
const outputFormat = normalizeOutputFormat(body.output_format ?? body.format);
const creativity = CREATIVITY_RANGES[model]
? mapCreativity(body, CREATIVITY_RANGES[model]!)
: null;
const requestSummary: Record<string, unknown> = { model, output_format: outputFormat };
if (prompt) requestSummary.prompt = prompt;
if (creativity !== null) requestSummary.creativity = creativity;
try {
const imageSource = await resolveUpscaleImageSource(source);
const formData = new FormData();
formData.append(
"image",
new Blob([toBlobBytes(imageSource.buffer)], { type: imageSource.contentType || "image/png" }),
"image"
);
formData.append("output_format", outputFormat);
if (prompt) formData.append("prompt", prompt);
if (typeof body.negative_prompt === "string" && body.negative_prompt.trim()) {
formData.append("negative_prompt", body.negative_prompt.trim());
}
if (creativity !== null) formData.append("creativity", String(creativity));
if (body.seed !== undefined && body.seed !== null && String(body.seed).trim()) {
formData.append("seed", String(body.seed));
}
if (typeof body.style_preset === "string" && body.style_preset.trim()) {
formData.append("style_preset", body.style_preset.trim());
}
log?.info?.(
"IMAGE",
`${provider}/${model} (stability upscale)` +
(creativity !== null ? ` | creativity=${creativity}` : "") +
` | output=${outputFormat}`
);
const baseUrl = providerConfig.baseUrl.replace(/\/$/, "");
const response = await fetchImpl(`${baseUrl}${endpoint}`, {
method: "POST",
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
body: formData,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
log?.error?.(
"IMAGE",
`${provider} stability upscale error ${response.status}: ${errorText.slice(0, 200)}`
);
return saveUpscaleErrorResult({
provider,
model,
status: response.status,
startTime,
error: errorText || `HTTP ${response.status}`,
requestBody: requestSummary,
});
}
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
let finalPayload = payload;
if (ASYNC_MODELS.has(model) && typeof payload.id === "string" && payload.id) {
finalPayload = await pollStabilityResult({
baseUrl,
token,
id: payload.id,
timeoutMs: normalizePositiveNumber(body.timeout_ms, DEFAULT_RESULT_TIMEOUT_MS),
fetchImpl,
log,
});
}
const finishReason = String(finalPayload.finish_reason ?? "").toUpperCase();
if (finishReason === "CONTENT_FILTERED") {
return saveUpscaleErrorResult({
provider,
model,
status: 400,
startTime,
error: "Stability AI filtered the upscale result (CONTENT_FILTERED)",
requestBody: requestSummary,
});
}
const base64 = typeof finalPayload.image === "string" ? finalPayload.image : "";
if (!base64) {
return saveUpscaleErrorResult({
provider,
model,
status: 502,
startTime,
error: "Stability AI upscale returned no image",
requestBody: requestSummary,
});
}
const buffer = Buffer.from(base64, "base64");
return saveUpscaleSuccessResult({
provider,
model,
startTime,
requestBody: requestSummary,
images: [
buildUpscaleImageEntry({
buffer,
contentType: `image/${outputFormat === "jpeg" ? "jpeg" : outputFormat}`,
responseFormat: body.response_format,
}),
],
meta: {
provider,
model,
factor: 4,
...(creativity !== null ? { creativity } : {}),
...(finalPayload.seed !== undefined ? { seed: finalPayload.seed } : {}),
},
});
} catch (err) {
const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
log?.error?.("IMAGE", `${provider} stability upscale exception: ${errorText}`);
return saveUpscaleErrorResult({
provider,
model,
status: 502,
startTime,
error: `Image upscale provider error: ${errorText}`,
requestBody: requestSummary,
});
}
}
/** Poll `GET /v2beta/results/{id}` until the async creative upscale finishes. */
async function pollStabilityResult(opts: {
baseUrl: string;
token: string;
id: string;
timeoutMs: number;
fetchImpl: typeof fetch;
log?: UpscaleLogger;
}): Promise<Record<string, unknown>> {
const deadline = Date.now() + opts.timeoutMs;
let attempt = 0;
while (Date.now() < deadline) {
attempt += 1;
const response = await opts.fetchImpl(
`${opts.baseUrl}/v2beta/results/${encodeURIComponent(opts.id)}`,
{
method: "GET",
headers: { Accept: "application/json", Authorization: `Bearer ${opts.token}` },
}
);
if (response.status === 202) {
opts.log?.info?.("IMAGE", `stability creative upscale pending #${attempt}`);
await sleep(RESULT_POLL_INTERVAL_MS);
continue;
}
if (!response.ok) {
const text = await response.text().catch(() => "");
if (response.status === 429 || response.status >= 500) {
await sleep(RESULT_POLL_INTERVAL_MS);
continue;
}
throw new Error(
`Stability AI upscale result failed (${response.status}): ${text.slice(0, 300)}`
);
}
return (await response.json().catch(() => ({}))) as Record<string, unknown>;
}
throw new Error("Stability AI creative upscale timed out");
}
function normalizeOutputFormat(value: unknown): string {
const raw = String(value ?? "").trim().toLowerCase();
if (raw === "jpg") return "jpeg";
return ALLOWED_OUTPUT_FORMATS.includes(raw) ? raw : "png";
}
/**
* Map the API's 0-100 % creativity onto the model's documented float range.
* An explicit in-range float (`creativity: 0.4`) is passed through untouched so
* power users keep exact control.
*/
function mapCreativity(
body: Record<string, unknown>,
range: { min: number; max: number; fallback: number }
): number {
const raw = body.creativity ?? body.creativity_percent ?? body.creativityPercent;
if (raw === undefined || raw === null || String(raw).trim() === "") return range.fallback;
const n = typeof raw === "number" ? raw : Number(String(raw).replace("%", "").trim());
if (!Number.isFinite(n)) return range.fallback;
// Values that already look like a native Stability creativity float (< 1 and not a
// whole percent) are honored as-is, clamped to the documented range.
if (n > 0 && n < 1) return round2(Math.max(range.min, Math.min(range.max, n)));
const percent = Math.max(0, Math.min(100, n));
return round2(range.min + ((range.max - range.min) * percent) / 100);
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function normalizePositiveNumber(value: unknown, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,271 @@
/**
* Topaz Labs upscale handler — native Image API `POST /image/v1/enhance`.
*
* Wire contract (docs.topazlabs.com Image API v1):
* headers: X-API-Key: <key>, accept: image/<format>
* multipart/form-data:
* image (required) source bytes
* model (optional) e.g. "Standard V2" / "High Fidelity V2" / "Low Resolution V2"
* output_width (optional) absolute target width
* output_height (optional) absolute target height
* output_format (optional) jpeg | png | webp
* sharpen / denoise / fix_compression (optional) 0-1 strengths
* face_enhancement (optional) boolean
* → raw image bytes of the enhanced result.
*
* The endpoint only accepts an ABSOLUTE target size, so a 2x/4x factor is turned into
* `output_width`/`output_height` by reading the source dimensions out of the container
* header (`scaleDimensions`). When the dimensions cannot be read the factor is dropped
* and Topaz's own default upscale applies, rather than failing the request.
*/
import {
buildUpscaleImageEntry,
extractUpscaleSourceImage,
resolveUpscaleImageSource,
saveUpscaleErrorResult,
saveUpscaleSuccessResult,
scaleDimensions,
sniffImageMime,
toBlobBytes,
type UpscaleCredentials,
type UpscaleHandlerResult,
type UpscaleLogger,
} from "./shared.ts";
import { sanitizeErrorMessage } from "../../utils/error.ts";
/** Topaz caps a single output edge well below this; keeps a 4x pass on a huge source sane. */
const MAX_OUTPUT_EDGE = 16000;
const ALLOWED_OUTPUT_FORMATS = ["png", "jpeg", "webp"];
export async function handleTopazImageUpscale({
model,
provider,
providerConfig,
body,
credentials,
log,
fetchImpl = fetch,
}: {
model: string;
provider: string;
providerConfig: { baseUrl: string };
body: Record<string, unknown>;
credentials: UpscaleCredentials;
log?: UpscaleLogger;
fetchImpl?: typeof fetch;
}): Promise<UpscaleHandlerResult> {
const startTime = Date.now();
const token = credentials.apiKey || credentials.accessToken;
if (!token) {
return saveUpscaleErrorResult({
provider,
model,
status: 401,
startTime,
error: "Missing Topaz Labs API key",
});
}
const source = extractUpscaleSourceImage(body);
if (!source) {
return saveUpscaleErrorResult({
provider,
model,
status: 400,
startTime,
error: `Topaz Labs upscale model ${model} requires a source image`,
});
}
const factor = normalizeFactor(body);
const outputFormat = normalizeOutputFormat(body.output_format ?? body.format);
const requestSummary: Record<string, unknown> = { model, factor, output_format: outputFormat };
try {
const imageSource = await resolveUpscaleImageSource(source);
const formData = new FormData();
formData.append(
"image",
new Blob([toBlobBytes(imageSource.buffer)], { type: imageSource.contentType || "image/png" }),
"image"
);
formData.append("output_format", outputFormat);
const explicitSize = parseExplicitSize(body.size ?? body.output_size);
const target = explicitSize ?? scaleDimensions(imageSource.buffer, factor, MAX_OUTPUT_EDGE);
if (target) {
formData.append("output_width", String(target.width));
formData.append("output_height", String(target.height));
requestSummary.output_width = target.width;
requestSummary.output_height = target.height;
} else {
log?.info?.(
"IMAGE",
`${provider}/${model} (topaz upscale) | source dimensions unknown — using Topaz default scale`
);
}
const topazModel = typeof body.topaz_model === "string" ? body.topaz_model.trim() : "";
if (topazModel) {
formData.append("model", topazModel);
requestSummary.topaz_model = topazModel;
}
appendUnitFloat(formData, "sharpen", body.sharpen, requestSummary);
appendUnitFloat(formData, "denoise", body.denoise, requestSummary);
appendUnitFloat(formData, "fix_compression", body.fix_compression, requestSummary);
if (body.face_enhancement !== undefined && body.face_enhancement !== null) {
const enabled = toBoolean(body.face_enhancement);
formData.append("face_enhancement", enabled ? "true" : "false");
requestSummary.face_enhancement = enabled;
// Topaz exposes creativity/strength only when face enhancement is on.
if (enabled) {
appendUnitFloat(
formData,
"face_enhancement_creativity",
body.creativity ?? body.face_enhancement_creativity,
requestSummary,
/* percentAware */ true
);
appendUnitFloat(
formData,
"face_enhancement_strength",
body.face_enhancement_strength,
requestSummary
);
}
}
log?.info?.(
"IMAGE",
`${provider}/${model} (topaz upscale) | ${factor}x` +
(target ? `${target.width}x${target.height}` : "") +
` | output=${outputFormat}`
);
const baseUrl = providerConfig.baseUrl.replace(/\/$/, "");
const response = await fetchImpl(`${baseUrl}/image/v1/enhance`, {
method: "POST",
headers: {
Accept: `image/${outputFormat}`,
"X-API-Key": token,
},
body: formData,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
log?.error?.(
"IMAGE",
`${provider} topaz upscale error ${response.status}: ${errorText.slice(0, 200)}`
);
return saveUpscaleErrorResult({
provider,
model,
status: response.status,
startTime,
error: errorText || `HTTP ${response.status}`,
requestBody: requestSummary,
});
}
const buffer = Buffer.from(await response.arrayBuffer());
if (!buffer.length) {
return saveUpscaleErrorResult({
provider,
model,
status: 502,
startTime,
error: "Topaz Labs upscale returned an empty body",
requestBody: requestSummary,
});
}
const declared = (response.headers.get("content-type") || "").split(";")[0]!.trim().toLowerCase();
const contentType = declared.startsWith("image/") ? declared : sniffImageMime(buffer);
return saveUpscaleSuccessResult({
provider,
model,
startTime,
requestBody: requestSummary,
images: [
buildUpscaleImageEntry({ buffer, contentType, responseFormat: body.response_format }),
],
meta: { provider, model, factor, ...(target ? { width: target.width, height: target.height } : {}) },
});
} catch (err) {
const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
log?.error?.("IMAGE", `${provider} topaz upscale exception: ${errorText}`);
return saveUpscaleErrorResult({
provider,
model,
status: 502,
startTime,
error: `Image upscale provider error: ${errorText}`,
requestBody: requestSummary,
});
}
}
function normalizeFactor(body: Record<string, unknown>): number {
const raw =
body.factor ??
body.scale ??
body.upscale_factor ??
body.upscaleFactor ??
body.upsampler_factor ??
body.upsamplerFactor;
let n = typeof raw === "number" ? raw : Number(String(raw ?? "").replace(/[^\d.]/g, ""));
if (!Number.isFinite(n) || n <= 0) return 2;
return Math.abs(n - 4) < Math.abs(n - 2) ? 4 : 2;
}
function normalizeOutputFormat(value: unknown): string {
const raw = String(value ?? "").trim().toLowerCase();
if (raw === "jpg") return "jpeg";
return ALLOWED_OUTPUT_FORMATS.includes(raw) ? raw : "png";
}
function parseExplicitSize(value: unknown): { width: number; height: number } | null {
if (typeof value !== "string") return null;
const match = /^(\d+)\s*[x×]\s*(\d+)$/i.exec(value.trim());
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
return {
width: Math.min(width, MAX_OUTPUT_EDGE),
height: Math.min(height, MAX_OUTPUT_EDGE),
};
}
/**
* Append a 0-1 strength. Percent-aware fields also accept 0-100 (the shared UI
* creativity slider), which is divided down; anything non-numeric is skipped.
*/
function appendUnitFloat(
formData: FormData,
key: string,
value: unknown,
summary: Record<string, unknown>,
percentAware = false
): void {
if (value === undefined || value === null || String(value).trim() === "") return;
let n = typeof value === "number" ? value : Number(String(value).replace("%", "").trim());
if (!Number.isFinite(n)) return;
if (percentAware && n > 1) n = n / 100;
n = Math.max(0, Math.min(1, n));
const rounded = Math.round(n * 100) / 100;
formData.append(key, String(rounded));
summary[key] = rounded;
}
function toBoolean(value: unknown): boolean {
if (typeof value === "boolean") return value;
const raw = String(value ?? "").trim().toLowerCase();
return raw === "true" || raw === "1" || raw === "yes" || raw === "on";
}

View File

@@ -713,8 +713,7 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
// part. Place deltas at summary[summary_index] (growing the array) so
// segments are preserved for later "\n\n" joining on the non-stream path,
// instead of overwriting summary[0] regardless of index.
const summaryIndex =
typeof evt.summary_index === "number" ? evt.summary_index : 0;
const summaryIndex = typeof evt.summary_index === "number" ? evt.summary_index : 0;
const part =
summary[summaryIndex] && typeof summary[summaryIndex] === "object"
? { ...toRecord(summary[summaryIndex]) }
@@ -733,8 +732,7 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) {
);
const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : [];
// #9500 — respect summary_index on the terminal done event too.
const summaryIndex =
typeof evt.summary_index === "number" ? evt.summary_index : 0;
const summaryIndex = typeof evt.summary_index === "number" ? evt.summary_index : 0;
const part =
summary[summaryIndex] && typeof summary[summaryIndex] === "object"
? { ...toRecord(summary[summaryIndex]) }

View File

@@ -18,6 +18,13 @@ function createStatementMock() {
};
}
// #8959 made the production loader use createRequire() (Electron/global-install
// resolution), which vi.doMock CANNOT intercept — it only patches Vitest's ESM
// module graph. The old better-sqlite3 doMock therefore never engaged: the code
// opened a REAL sqlite file in the temp DATA_DIR ("no such table" on stderr)
// and every mock assertion counted 0 calls. The shutdown tests now inject the
// mock through the audit connection cache (globalThis.__omnirouteMcpAuditDb),
// and the fallback test uses the __setBetterSqliteLoaderForTests seam.
describe("MCP audit shutdown", () => {
let dataDir: string;
let dbFile: string;
@@ -46,15 +53,10 @@ describe("MCP audit shutdown", () => {
close: vi.fn(),
open: true,
};
const MockDatabase = vi.fn(function MockDatabase() {
return mockDb;
});
vi.doMock("better-sqlite3", () => ({
default: MockDatabase,
}));
const audit = await import("../audit.ts");
// Inject through the connection cache — the seam the module itself uses.
globalThis.__omnirouteMcpAuditDb = mockDb as unknown as typeof globalThis.__omnirouteMcpAuditDb;
await audit.logToolCall("omniroute_get_health", { ok: true }, { ok: true }, 12, true);
expect(mockDb.prepare).toHaveBeenCalledTimes(1);
@@ -80,15 +82,9 @@ describe("MCP audit shutdown", () => {
close: vi.fn(),
open: true,
};
const MockDatabase = vi.fn(function MockDatabase() {
return mockDb;
});
vi.doMock("better-sqlite3", () => ({
default: MockDatabase,
}));
const audit = await import("../audit.ts");
globalThis.__omnirouteMcpAuditDb = mockDb as unknown as typeof globalThis.__omnirouteMcpAuditDb;
await audit.logToolCall("omniroute_get_health", {}, {}, 5, true);
expect(audit.closeAuditDb()).toBe(true);
@@ -103,26 +99,16 @@ describe("MCP audit shutdown", () => {
// Simulate a global-install scenario where the bundled native binary
// never landed in dist/node_modules/better-sqlite3/build/Release/.
// Thrown from the loader seam because the real load path is
// createRequire("better-sqlite3"), unreachable by vi.doMock.
const bindingErr = new Error(
"Could not locate the bindings file. Tried: …/better_sqlite3.node"
) as Error & { code?: string };
bindingErr.code = "MODULE_NOT_FOUND";
// Simulate the binding-missing failure as the better-sqlite3 default
// constructor throwing — this matches reality (`new Database()` throws
// "Could not locate the bindings file" when the prebuilt .node is absent)
// and reaches the adapter's `catch (nativeErr)`. A factory that itself
// throws is reported by vitest as a mock-setup error and never reaches
// the code under test.
const ThrowingDatabase = vi.fn(function ThrowingDatabase() {
throw bindingErr;
});
vi.doMock("better-sqlite3", () => ({
default: ThrowingDatabase,
}));
// node:sqlite's DatabaseSync does not expose a boolean `open` property,
// so the mock intentionally omits it — the adapter tracks open state in
// a local closure and exposes it via a getter.
// node:sqlite IS loaded via dynamic import(), so doMock works for it.
// Its DatabaseSync does not expose a boolean `open` property — the
// adapter tracks open state in a local closure.
const mockNodeDb = {
prepare: vi.fn(() => createStatementMock()),
exec: vi.fn(),
@@ -134,17 +120,24 @@ describe("MCP audit shutdown", () => {
vi.doMock("node:sqlite", () => ({ DatabaseSync }));
const audit = await import("../audit.ts");
audit.__setBetterSqliteLoaderForTests(() => {
throw bindingErr;
});
await audit.logToolCall("omniroute_get_health", { ok: true }, { ok: true }, 4, true);
expect(DatabaseSync).toHaveBeenCalledWith(dbFile);
expect(mockNodeDb.prepare).toHaveBeenCalled();
try {
await audit.logToolCall("omniroute_get_health", { ok: true }, { ok: true }, 4, true);
expect(DatabaseSync).toHaveBeenCalledWith(dbFile);
expect(mockNodeDb.prepare).toHaveBeenCalled();
expect(audit.closeAuditDb()).toBe(true);
expect(mockNodeDb.exec).toHaveBeenCalledWith("PRAGMA wal_checkpoint(TRUNCATE)");
expect(mockNodeDb.close).toHaveBeenCalledTimes(1);
expect(audit.closeAuditDb()).toBe(true);
expect(mockNodeDb.exec).toHaveBeenCalledWith("PRAGMA wal_checkpoint(TRUNCATE)");
expect(mockNodeDb.close).toHaveBeenCalledTimes(1);
// Cache is cleared after close, so a second close is a no-op.
expect(audit.closeAuditDb()).toBe(false);
expect(mockNodeDb.close).toHaveBeenCalledTimes(1);
// Cache is cleared after close, so a second close is a no-op.
expect(audit.closeAuditDb()).toBe(false);
expect(mockNodeDb.close).toHaveBeenCalledTimes(1);
} finally {
audit.__setBetterSqliteLoaderForTests(null);
}
});
});

View File

@@ -206,11 +206,27 @@ function toString(value: unknown): string {
return typeof value === "string" ? value : "";
}
/**
* Test-only seam: the production load path uses `createRequire()` (so the
* Electron/global-install resolution works — #8959), which `vi.doMock` cannot
* intercept (it only patches Vitest's ESM module graph). Tests inject a
* throwing/mocked loader here to exercise the node:sqlite fallback.
*/
let betterSqliteLoaderForTests: (() => unknown) | null = null;
export function __setBetterSqliteLoaderForTests(loader: (() => unknown) | null): void {
betterSqliteLoaderForTests = loader;
}
async function openBetterSqliteAuditDb(dbPath: string): Promise<AuditDatabase> {
const { createRequire } = await import("node:module");
const _require = createRequire(import.meta.url);
const mod = _require("better-sqlite3");
const Database = (mod?.default || mod) as unknown as new (
let mod: unknown;
if (betterSqliteLoaderForTests) {
mod = betterSqliteLoaderForTests();
} else {
const { createRequire } = await import("node:module");
const _require = createRequire(import.meta.url);
mod = _require("better-sqlite3");
}
const Database = ((mod as { default?: unknown })?.default || mod) as unknown as new (
dbPath: string
) => AuditDatabase;
return new Database(dbPath);

View File

@@ -7,7 +7,10 @@ import {
clearAllModelLockouts,
getModelLockoutInfo,
isModelLocked,
lockModelIfPerModelQuota,
lockExactModel,
recordModelLockoutFailure,
clearModelLock,
} from "@omniroute/open-sse/services/accountFallback.ts";
const provider = "antigravity";
@@ -74,6 +77,38 @@ describe("Antigravity account quota-family cooldown", () => {
expect(isModelLocked(provider, "account-a", "gemini-3.5-flash-low")).toBe(false);
});
it("can isolate a confirmed Antigravity quota exhaustion to one exact model", () => {
lockExactModel(
provider,
"account-a",
"claude-opus-4-6-thinking",
"quota_exhausted",
60_000
);
expect(isModelLocked(provider, "account-a", "claude-opus-4-6-thinking")).toBe(true);
expect(isModelLocked(provider, "account-a", "claude-sonnet-4-6-thinking")).toBe(false);
expect(isModelLocked(provider, "account-a", "gemini-3.5-flash-medium")).toBe(false);
expect(clearModelLock(provider, "account-a", "claude-opus-4-6-thinking")).toBe(true);
expect(isModelLocked(provider, "account-a", "claude-opus-4-6-thinking")).toBe(false);
});
it("uses an exact model lock for Antigravity in the generic per-model quota path", () => {
expect(
lockModelIfPerModelQuota(
provider,
"account-a",
"claude-opus-4-6-thinking",
"quota_exhausted",
60_000
)
).toBe(true);
expect(isModelLocked(provider, "account-a", "claude-opus-4-6-thinking")).toBe(true);
expect(isModelLocked(provider, "account-a", "claude-sonnet-4-6-thinking")).toBe(false);
});
it("honors exact upstream cooldowns and otherwise uses bounded inferred cooldown", () => {
const upstream = recordModelLockoutFailure(
provider,

View File

@@ -273,9 +273,15 @@ describe("claudeTlsClient", () => {
await tlsFetchClaude("https://claude.ai/test", {});
// The proxyUrl should reflect environment resolution
// The testOverride is called with the raw options object BEFORE proxy
// resolution occurs (see claudeTlsClient.ts line 258:
// `if (testOverride) return testOverride(url, options)`).
// Proxy resolution (env var → proxyUrl) only runs inside the real
// tls-client path, which is bypassed when an override is active.
// So callOptions here is exactly the {} we passed — no proxyUrl injected.
expect(mockFn).toHaveBeenCalledOnce();
const callOptions = mockFn.mock.calls[0][1];
expect(callOptions).toHaveProperty("proxyUrl");
expect(callOptions.proxyUrl).toBeUndefined();
__setTlsFetchOverrideForTesting(null);
delete process.env.HTTPS_PROXY;

View File

@@ -1,5 +1,4 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { describe, it, expect } from "vitest";
import {
generateRoutingHints,
compareByCostEffectiveness,
@@ -27,8 +26,8 @@ describe("ManifestAdapter", () => {
const hints = generateRoutingHints([], {
messages: [{ content: "Hello" }],
});
assert.equal(hints.strategyModifier, "prefer-free");
assert.equal(hints.specificityLevel, "trivial");
expect(hints.strategyModifier).toBe("prefer-free");
expect(hints.specificityLevel).toBe("trivial");
});
});
@@ -43,7 +42,7 @@ describe("ManifestAdapter", () => {
],
});
const validModifiers = ["prefer-free", "prefer-cheap", "require-premium", "default"];
assert.ok(validModifiers.includes(hints.strategyModifier));
expect(validModifiers.includes(hints.strategyModifier)).toBe(true);
});
});
@@ -53,15 +52,15 @@ describe("ManifestAdapter", () => {
const hints = generateRoutingHints(targets, {
messages: [{ content: "Hi" }],
});
assert.ok(hints.eligibleTargets.length >= 0);
expect(hints.eligibleTargets.length).toBeGreaterThanOrEqual(0);
});
it("handles empty targets array gracefully", () => {
const hints = generateRoutingHints([], {
messages: [{ content: "Hello" }],
});
assert.equal(hints.eligibleTargets.length, 0);
assert.equal(hints.underqualifiedTargets.length, 0);
expect(hints.eligibleTargets.length).toBe(0);
expect(hints.underqualifiedTargets.length).toBe(0);
});
it("classifies mixed targets for simple query", () => {
@@ -69,7 +68,7 @@ describe("ManifestAdapter", () => {
const hints = generateRoutingHints(targets, {
messages: [{ content: "Hello" }],
});
assert.ok(hints.eligibleTargets.length >= 0);
expect(hints.eligibleTargets.length).toBeGreaterThanOrEqual(0);
});
});
@@ -81,7 +80,7 @@ describe("ManifestAdapter", () => {
messages: [{ content: "Test" }],
});
const result = compareByCostEffectiveness(a, b, hints);
assert.equal(typeof result, "number");
expect(typeof result).toBe("number");
});
it("returns negative when a is cheaper than b", () => {
@@ -91,7 +90,7 @@ describe("ManifestAdapter", () => {
messages: [{ content: "Test" }],
});
const result = compareByCostEffectiveness(a, b, hints);
assert.ok(result < 0, "deepseek should be cheaper than openai");
expect(result, "deepseek should be cheaper than openai").toBeLessThan(0);
});
});
@@ -99,19 +98,19 @@ describe("ManifestAdapter", () => {
it("returns 0 for free providers", () => {
const target = makeTarget("kiro", "claude-sonnet-4.5");
const cost = estimateRequestCost(target, 1000, 500);
assert.equal(cost, 0);
expect(cost).toBe(0);
});
it("returns non-zero for premium provider", () => {
const target = makeTarget("openai", "gpt-4o");
const cost = estimateRequestCost(target, 1000000, 500000);
assert.ok(cost > 0, "gpt-4o should have non-zero cost");
expect(cost, "gpt-4o should have non-zero cost").toBeGreaterThan(0);
});
it("handles zero tokens", () => {
const target = makeTarget("openai", "gpt-4o");
const cost = estimateRequestCost(target, 0, 0);
assert.equal(cost, 0);
expect(cost).toBe(0);
});
});
@@ -120,17 +119,17 @@ describe("ManifestAdapter", () => {
const hints = generateRoutingHints([], {
messages: [{ content: "Hello" }],
});
assert.equal(hints.eligibleTargets.length, 0);
assert.equal(hints.underqualifiedTargets.length, 0);
expect(hints.eligibleTargets.length).toBe(0);
expect(hints.underqualifiedTargets.length).toBe(0);
});
it("returns valid hints structure with no targets", () => {
const hints = generateRoutingHints([], {
messages: [{ content: "Test" }],
});
assert.ok("specificityLevel" in hints);
assert.ok("strategyModifier" in hints);
assert.ok("recommendedMinTier" in hints);
expect("specificityLevel" in hints).toBe(true);
expect("strategyModifier" in hints).toBe(true);
expect("recommendedMinTier" in hints).toBe(true);
});
});
});

View File

@@ -1,5 +1,4 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { describe, it, expect } from "vitest";
import {
analyzeSpecificity,
getSpecificityLevel,
@@ -12,13 +11,13 @@ describe("SpecificityDetector", () => {
describe("analyzeSpecificity - trivial query", () => {
it("returns score <= 5 for greeting", () => {
const result = analyzeSpecificity({ messages: [{ content: "Hello, how are you?" }] });
assert.ok(result.score <= 5);
expect(result.score).toBeLessThanOrEqual(5);
});
it("level is 'trivial' for greeting", () => {
const result = analyzeSpecificity({ messages: [{ content: "Hi there!" }] });
const level = getSpecificityLevel(result.score);
assert.equal(level, "trivial");
expect(level).toBe("trivial");
});
});
@@ -27,8 +26,8 @@ describe("SpecificityDetector", () => {
const result = analyzeSpecificity({
messages: [{ content: "What is the capital of France?" }],
});
assert.ok(result.score >= 0);
assert.ok(result.score <= 20);
expect(result.score).toBeGreaterThanOrEqual(0);
expect(result.score).toBeLessThanOrEqual(20);
});
it("returns 'simple' or lower for factual question", () => {
@@ -36,7 +35,7 @@ describe("SpecificityDetector", () => {
messages: [{ content: "Who invented Python?" }],
});
const level = getSpecificityLevel(result.score);
assert.ok(["trivial", "simple"].includes(level));
expect(["trivial", "simple"].includes(level)).toBe(true);
});
});
@@ -45,14 +44,14 @@ describe("SpecificityDetector", () => {
const result = analyzeSpecificity({
messages: [{ content: "```ts\nfunction foo(){}\n```" }],
});
assert.ok(result.score >= 5, `Expected >= 5, got ${result.score}`);
expect(result.score, `Expected >= 5, got ${result.score}`).toBeGreaterThanOrEqual(5);
});
it("code complexity is detected in code blocks", () => {
const result = analyzeSpecificity({
messages: [{ content: "```ts\nfunction foo(){}\n```" }],
});
assert.ok(result.breakdown.codeComplexity > 0);
expect(result.breakdown.codeComplexity).toBeGreaterThan(0);
});
it("returns higher score for code + reasoning", () => {
@@ -66,7 +65,7 @@ describe("SpecificityDetector", () => {
{ content: "```typescript\nclass BST<T> { insert(val: T): void {} }\n```" },
],
});
assert.ok(result.score >= 10, `Expected >= 10, got ${result.score}`);
expect(result.score, `Expected >= 10, got ${result.score}`).toBeGreaterThanOrEqual(10);
});
});
@@ -80,82 +79,82 @@ describe("SpecificityDetector", () => {
},
],
});
assert.ok(result.breakdown.reasoningDepth > 0);
expect(result.breakdown.reasoningDepth).toBeGreaterThan(0);
});
});
describe("getSpecificityLevel", () => {
it("returns 'trivial' for score 0-5", () => {
assert.equal(getSpecificityLevel(0), "trivial");
assert.equal(getSpecificityLevel(3), "trivial");
assert.equal(getSpecificityLevel(5), "trivial");
expect(getSpecificityLevel(0)).toBe("trivial");
expect(getSpecificityLevel(3)).toBe("trivial");
expect(getSpecificityLevel(5)).toBe("trivial");
});
it("returns 'simple' for score 6-20", () => {
assert.equal(getSpecificityLevel(6), "simple");
assert.equal(getSpecificityLevel(10), "simple");
assert.equal(getSpecificityLevel(20), "simple");
expect(getSpecificityLevel(6)).toBe("simple");
expect(getSpecificityLevel(10)).toBe("simple");
expect(getSpecificityLevel(20)).toBe("simple");
});
it("returns 'moderate' for score 6-40", () => {
assert.equal(getSpecificityLevel(21), "moderate");
assert.equal(getSpecificityLevel(30), "moderate");
assert.equal(getSpecificityLevel(40), "moderate");
expect(getSpecificityLevel(21)).toBe("moderate");
expect(getSpecificityLevel(30)).toBe("moderate");
expect(getSpecificityLevel(40)).toBe("moderate");
});
it("returns 'complex' for score 41+", () => {
assert.equal(getSpecificityLevel(41), "complex");
assert.equal(getSpecificityLevel(46), "complex");
assert.equal(getSpecificityLevel(65), "complex");
expect(getSpecificityLevel(41)).toBe("complex");
expect(getSpecificityLevel(46)).toBe("complex");
expect(getSpecificityLevel(65)).toBe("complex");
});
it("returns 'expert' for score 66+", () => {
assert.equal(getSpecificityLevel(66), "expert");
assert.equal(getSpecificityLevel(80), "expert");
assert.equal(getSpecificityLevel(100), "expert");
expect(getSpecificityLevel(66)).toBe("expert");
expect(getSpecificityLevel(80)).toBe("expert");
expect(getSpecificityLevel(100)).toBe("expert");
});
});
describe("getRecommendedMinTier", () => {
it("returns 'free' for 'trivial'", () => {
assert.equal(getRecommendedMinTier("trivial"), "free");
expect(getRecommendedMinTier("trivial")).toBe("free");
});
it("returns 'free' for 'simple'", () => {
assert.equal(getRecommendedMinTier("simple"), "free");
expect(getRecommendedMinTier("simple")).toBe("free");
});
it("returns 'cheap' for 'moderate'", () => {
assert.equal(getRecommendedMinTier("moderate"), "cheap");
expect(getRecommendedMinTier("moderate")).toBe("cheap");
});
it("returns 'premium' for 'complex'", () => {
assert.equal(getRecommendedMinTier("complex"), "cheap");
expect(getRecommendedMinTier("complex")).toBe("cheap");
});
it("returns 'premium' for 'expert'", () => {
assert.equal(getRecommendedMinTier("expert"), "premium");
expect(getRecommendedMinTier("expert")).toBe("premium");
});
});
describe("isHighSpecificity", () => {
it("returns false for trivial query", () => {
const result = analyzeSpecificity({ messages: [{ content: "Hi" }] });
assert.equal(isHighSpecificity(result), false);
expect(isHighSpecificity(result)).toBe(false);
});
it("returns false for simple query", () => {
const result = analyzeSpecificity({
messages: [{ content: "What is Python?" }],
});
assert.equal(isHighSpecificity(result), false);
expect(isHighSpecificity(result)).toBe(false);
});
});
describe("isLowSpecificity", () => {
it("returns true for trivial query", () => {
const result = analyzeSpecificity({ messages: [{ content: "Hi" }] });
assert.equal(isLowSpecificity(result), true);
expect(isLowSpecificity(result)).toBe(true);
});
it("returns false for complex query", () => {
@@ -172,45 +171,45 @@ describe("SpecificityDetector", () => {
},
],
});
assert.equal(isLowSpecificity(result), false);
expect(isLowSpecificity(result)).toBe(false);
});
});
describe("analyzeSpecificity returns complete result", () => {
it("returns score, breakdown, rulesTriggered, inputTokens, confidence", () => {
const result = analyzeSpecificity({ messages: [{ content: "Test" }] });
assert.ok("score" in result);
assert.ok("breakdown" in result);
assert.ok("rulesTriggered" in result);
assert.ok("inputTokens" in result);
assert.ok("confidence" in result);
expect("score" in result).toBe(true);
expect("breakdown" in result).toBe(true);
expect("rulesTriggered" in result).toBe(true);
expect("inputTokens" in result).toBe(true);
expect("confidence" in result).toBe(true);
});
it("returns all 6 breakdown categories", () => {
const result = analyzeSpecificity({ messages: [{ content: "Test" }] });
assert.ok("codeComplexity" in result.breakdown);
assert.ok("mathComplexity" in result.breakdown);
assert.ok("reasoningDepth" in result.breakdown);
assert.ok("contextSize" in result.breakdown);
assert.ok("toolCalling" in result.breakdown);
assert.ok("domainSpecificity" in result.breakdown);
expect("codeComplexity" in result.breakdown).toBe(true);
expect("mathComplexity" in result.breakdown).toBe(true);
expect("reasoningDepth" in result.breakdown).toBe(true);
expect("contextSize" in result.breakdown).toBe(true);
expect("toolCalling" in result.breakdown).toBe(true);
expect("domainSpecificity" in result.breakdown).toBe(true);
});
it("returns non-negative scores for all categories", () => {
const result = analyzeSpecificity({ messages: [{ content: "Hello" }] });
assert.ok(result.breakdown.codeComplexity >= 0);
assert.ok(result.breakdown.mathComplexity >= 0);
assert.ok(result.breakdown.reasoningDepth >= 0);
assert.ok(result.breakdown.contextSize >= 0);
assert.ok(result.breakdown.toolCalling >= 0);
assert.ok(result.breakdown.domainSpecificity >= 0);
expect(result.breakdown.codeComplexity).toBeGreaterThanOrEqual(0);
expect(result.breakdown.mathComplexity).toBeGreaterThanOrEqual(0);
expect(result.breakdown.reasoningDepth).toBeGreaterThanOrEqual(0);
expect(result.breakdown.contextSize).toBeGreaterThanOrEqual(0);
expect(result.breakdown.toolCalling).toBeGreaterThanOrEqual(0);
expect(result.breakdown.domainSpecificity).toBeGreaterThanOrEqual(0);
});
});
describe("tool calling detection", () => {
it("returns 0 when no tools defined", () => {
const result = analyzeSpecificity({ messages: [{ content: "Hello" }] });
assert.equal(result.breakdown.toolCalling, 0);
expect(result.breakdown.toolCalling).toBe(0);
});
it("returns positive score when tools present", () => {
@@ -221,7 +220,7 @@ describe("SpecificityDetector", () => {
{ type: "function", function: { name: "weather", description: "get weather" } },
],
});
assert.ok(result.breakdown.toolCalling > 0);
expect(result.breakdown.toolCalling).toBeGreaterThan(0);
});
});
@@ -234,7 +233,7 @@ describe("SpecificityDetector", () => {
const t0 = performance.now();
analyzeSpecificity({ messages: msgs });
const elapsed = performance.now() - t0;
assert.ok(elapsed < 5, `Expected < 5ms, got ${elapsed.toFixed(2)}ms`);
expect(elapsed, `Expected < 5ms, got ${elapsed.toFixed(2)}ms`).toBeLessThan(5);
});
});
});

View File

@@ -3,8 +3,7 @@
* Tests: classifyTier, setTierConfig, clearTierCache, getTierStats, classifyTiers
*/
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { describe, it, expect, beforeEach } from "vitest";
import {
classifyTier,
setTierConfig,
@@ -27,94 +26,94 @@ describe("TierResolver", () => {
describe("classifyTier - free providers", () => {
it("classifies Kiro as free", () => {
const result = classifyTier("kiro", "claude-sonnet-4.5");
assert.equal(result.tier, PROVIDER_TIER.FREE);
assert.equal(result.hasFreeTier, true);
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
});
it("classifies Qoder as free", () => {
const result = classifyTier("qoder", "kimi-k2-thinking");
assert.equal(result.tier, PROVIDER_TIER.FREE);
assert.equal(result.hasFreeTier, true);
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
});
it("classifies Pollinations as free", () => {
const result = classifyTier("pollinations", "gpt-5");
assert.equal(result.tier, PROVIDER_TIER.FREE);
assert.equal(result.hasFreeTier, true);
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
});
it("classifies LongCat as free", () => {
const result = classifyTier("longcat", "LongCat-2.0");
assert.equal(result.tier, PROVIDER_TIER.FREE);
assert.equal(result.hasFreeTier, true);
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
});
it("classifies Cloudflare AI as free", () => {
const result = classifyTier("cloudflare-ai", "llama-3.3-70b");
assert.equal(result.tier, PROVIDER_TIER.FREE);
assert.equal(result.hasFreeTier, true);
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
});
it("classifies NVIDIA NIM as free", () => {
const result = classifyTier("nvidia-nim", "llama-3.1-8b");
assert.equal(result.tier, PROVIDER_TIER.FREE);
assert.equal(result.hasFreeTier, true);
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
});
it("classifies Cerebras as free", () => {
const result = classifyTier("cerebras", "llama-3.1-70b");
assert.equal(result.tier, PROVIDER_TIER.FREE);
assert.equal(result.hasFreeTier, true);
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
});
it("classifies Groq as free", () => {
const result = classifyTier("groq", "llama-3.3-70b");
assert.equal(result.tier, PROVIDER_TIER.FREE);
assert.equal(result.hasFreeTier, true);
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
});
it("sets costPer1MInput to 0 for free providers", () => {
const result = classifyTier("kiro", "claude-sonnet-4.5");
assert.equal(result.costPer1MInput, 0);
assert.equal(result.costPer1MOutput, 0);
expect(result.costPer1MInput).toBe(0);
expect(result.costPer1MOutput).toBe(0);
});
});
describe("classifyTier - cost-based classification", () => {
it("classifies DeepSeek as cheap ($0.27/M < $1.00/M)", () => {
const result = classifyTier("deepseek", "deepseek-chat");
assert.equal(result.tier, PROVIDER_TIER.CHEAP);
assert.ok(result.costPer1MInput <= 1.0);
expect(result.tier).toBe(PROVIDER_TIER.CHEAP);
expect(result.costPer1MInput).toBeLessThanOrEqual(1.0);
});
it("classifies GLM as cheap ($0.60/M < $1.00/M)", () => {
const result = classifyTier("glm", "glm-4.7");
assert.equal(result.tier, PROVIDER_TIER.CHEAP);
assert.ok(result.costPer1MInput <= 1.0);
expect(result.tier).toBe(PROVIDER_TIER.CHEAP);
expect(result.costPer1MInput).toBeLessThanOrEqual(1.0);
});
it("classifies MiniMax as cheap ($0.20/M < $1.00/M)", () => {
const result = classifyTier("minimax", "minimax-m2.1");
assert.equal(result.tier, PROVIDER_TIER.CHEAP);
assert.ok(result.costPer1MInput <= 1.0);
expect(result.tier).toBe(PROVIDER_TIER.CHEAP);
expect(result.costPer1MInput).toBeLessThanOrEqual(1.0);
});
it("classifies GPT-4o as premium ($2.50/M > $1.00/M)", () => {
const result = classifyTier("openai", "gpt-4o");
assert.equal(result.tier, PROVIDER_TIER.PREMIUM);
assert.ok(result.costPer1MInput > 1.0);
expect(result.tier).toBe(PROVIDER_TIER.PREMIUM);
expect(result.costPer1MInput).toBeGreaterThan(1.0);
});
it("classifies Claude Opus as premium ($15.00/M > $1.00/M)", () => {
const result = classifyTier("anthropic", "claude-opus-4-7");
assert.equal(result.tier, PROVIDER_TIER.PREMIUM);
assert.ok(result.costPer1MInput > 1.0);
expect(result.tier).toBe(PROVIDER_TIER.PREMIUM);
expect(result.costPer1MInput).toBeGreaterThan(1.0);
});
it("defaults unknown providers to premium", () => {
const result = classifyTier("unknown-provider", "unknown-model");
assert.equal(result.tier, PROVIDER_TIER.PREMIUM);
assert.equal(result.costPer1MInput, 5.0); // default premium pricing
expect(result.tier).toBe(PROVIDER_TIER.PREMIUM);
expect(result.costPer1MInput).toBe(5.0); // default premium pricing
});
});
@@ -122,8 +121,8 @@ describe("TierResolver", () => {
it("respects provider-level tier override", () => {
setTierConfig({ providerOverrides: [{ provider: "openai", tier: "cheap" }] });
const result = classifyTier("openai", "gpt-4o");
assert.equal(result.tier, PROVIDER_TIER.CHEAP);
assert.ok(result.reason.includes("override"));
expect(result.tier).toBe(PROVIDER_TIER.CHEAP);
expect(result.reason.includes("override")).toBe(true);
});
it("respects model-level glob pattern override", () => {
@@ -131,7 +130,7 @@ describe("TierResolver", () => {
modelOverrides: [{ provider: "openai", modelPattern: "gpt-4o-mini*", tier: "cheap" }],
});
const result = classifyTier("openai", "gpt-4o-mini-2024-07-18");
assert.equal(result.tier, PROVIDER_TIER.CHEAP);
expect(result.tier).toBe(PROVIDER_TIER.CHEAP);
});
it("glob pattern gpt-4o-mini* matches gpt-4o-mini-2024-07-18", () => {
@@ -139,15 +138,15 @@ describe("TierResolver", () => {
modelOverrides: [{ provider: "openai", modelPattern: "gpt-4o-mini*", tier: "cheap" }],
});
const result = classifyTier("openai", "gpt-4o-mini-2024-07-18");
assert.equal(result.tier, PROVIDER_TIER.CHEAP);
expect(result.tier).toBe(PROVIDER_TIER.CHEAP);
});
it("config change invalidates cache", () => {
const before = classifyTier("openai", "gpt-4o");
assert.equal(before.tier, PROVIDER_TIER.PREMIUM);
expect(before.tier).toBe(PROVIDER_TIER.PREMIUM);
setTierConfig({ providerOverrides: [{ provider: "openai", tier: "free" }] });
const after = classifyTier("openai", "gpt-4o");
assert.equal(after.tier, PROVIDER_TIER.FREE);
expect(after.tier).toBe(PROVIDER_TIER.FREE);
});
});
@@ -157,15 +156,15 @@ describe("TierResolver", () => {
const t0 = performance.now();
classifyTier("openai", "gpt-4o");
const elapsed = performance.now() - t0;
assert.ok(elapsed < 0.1, "cache hit should be <0.1ms");
expect(elapsed, "cache hit should be <0.1ms").toBeLessThan(0.1);
});
it("clearTierCache() forces re-classification", () => {
const first = classifyTier("openai", "gpt-4o");
clearTierCache();
const second = classifyTier("openai", "gpt-4o");
assert.equal(first.tier, second.tier);
assert.ok(second.costPer1MInput > 0);
expect(first.tier).toBe(second.tier);
expect(second.costPer1MInput).toBeGreaterThan(0);
});
});
@@ -185,11 +184,11 @@ describe("TierResolver", () => {
{ provider: "unknown", model: "unknown-model" },
];
const results = classifyTiers(targets);
assert.equal(results.length, 9);
assert.equal(results[0].tier, PROVIDER_TIER.FREE); // kiro
assert.equal(results[1].tier, PROVIDER_TIER.PREMIUM); // openai gpt-4o ($2.50/M)
assert.equal(results[2].tier, PROVIDER_TIER.CHEAP); // deepseek
assert.equal(results[8].tier, PROVIDER_TIER.PREMIUM); // unknown
expect(results.length).toBe(9);
expect(results[0].tier).toBe(PROVIDER_TIER.FREE); // kiro
expect(results[1].tier).toBe(PROVIDER_TIER.PREMIUM); // openai gpt-4o ($2.50/M)
expect(results[2].tier).toBe(PROVIDER_TIER.CHEAP); // deepseek
expect(results[8].tier).toBe(PROVIDER_TIER.PREMIUM); // unknown
});
it("uses cache for repeated models", () => {
@@ -198,7 +197,7 @@ describe("TierResolver", () => {
{ provider: "openai", model: "gpt-4o" },
]);
// If cache works, second call should be instant; test passes if no error
assert.ok(true);
expect(true).toBe(true);
});
});
@@ -208,8 +207,8 @@ describe("TierResolver", () => {
classifyTier("kiro", "claude-sonnet-4.5");
classifyTier("deepseek", "deepseek-chat");
const stats = getTierStats();
assert.ok(stats[PROVIDER_TIER.FREE] >= 1);
assert.ok(stats[PROVIDER_TIER.CHEAP] >= 1);
expect(stats[PROVIDER_TIER.FREE]).toBeGreaterThanOrEqual(1);
expect(stats[PROVIDER_TIER.CHEAP]).toBeGreaterThanOrEqual(1);
});
});
@@ -227,58 +226,64 @@ describe("TierResolver", () => {
"cerebras",
"groq",
]) {
assert.ok(LEGACY_FREE_PROVIDERS.includes(id), `expected ${id} in LEGACY_FREE_PROVIDERS`);
expect(LEGACY_FREE_PROVIDERS.includes(id), `expected ${id} in LEGACY_FREE_PROVIDERS`).toBe(
true
);
}
});
it("deriveNoAuthFreeProviders includes all chat-tier noAuth providers", () => {
const derived = deriveNoAuthFreeProviders();
// opencode + mimocode are the ones the bug report called out
assert.ok(derived.includes("opencode"), "opencode should be in derived noAuth-free list");
assert.ok(derived.includes("mimocode"), "mimocode should be in derived noAuth-free list");
assert.ok(derived.includes("duckduckgo-web"));
expect(derived.includes("opencode"), "opencode should be in derived noAuth-free list").toBe(
true
);
expect(derived.includes("mimocode"), "mimocode should be in derived noAuth-free list").toBe(
true
);
expect(derived.includes("duckduckgo-web")).toBe(true);
});
it("deriveNoAuthFreeProviders excludes non-LLM noAuth providers", () => {
const derived = deriveNoAuthFreeProviders();
assert.ok(
!derived.includes("veoaifree-web"),
expect(
derived.includes("veoaifree-web"),
"veoaifree-web (serviceKinds: video) must not be classified as chat-free"
);
).toBe(false);
});
it("DEFAULT_TIER_CONFIG.freeProviders contains the union of legacy + noAuth-derived", () => {
const expected = new Set([...LEGACY_FREE_PROVIDERS, ...deriveNoAuthFreeProviders()]);
const actual = new Set(DEFAULT_TIER_CONFIG.freeProviders);
assert.deepEqual(actual, expected, "freeProviders must be the union, deduplicated");
expect(actual).toEqual(expected);
});
it("classifyTier classifies opencode/big-pickle as free via noAuth derivation", () => {
// No provider override, no cost-based match (big-pickle has no KNOWN_MODEL_PRICING row).
// The fix is that 'opencode' is now in freeProviders.
const result = classifyTier("opencode", "big-pickle");
assert.equal(result.tier, PROVIDER_TIER.FREE);
assert.equal(result.hasFreeTier, true);
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
});
it("classifyTier classifies mimocode/mimo-auto as free via noAuth derivation", () => {
const result = classifyTier("mimocode", "mimo-auto");
assert.equal(result.tier, PROVIDER_TIER.FREE);
assert.equal(result.hasFreeTier, true);
expect(result.tier).toBe(PROVIDER_TIER.FREE);
expect(result.hasFreeTier).toBe(true);
});
it("classifyTier still returns cheap for paid glm-5.1 (no regression)", () => {
// glm-5.1 is not in freeProviders, costs $0.50/M → cheap tier.
// Make sure the new noAuth derivation didn't accidentally pull it into free.
const result = classifyTier("opencode-go", "glm-5.1");
assert.equal(result.tier, PROVIDER_TIER.CHEAP);
expect(result.tier).toBe(PROVIDER_TIER.CHEAP);
});
it("userConfig.freeProviders is merged on top of the noAuth-derived list", () => {
// Re-merge with a new free provider (e.g. local-llama) and confirm it's added.
setTierConfig({ freeProviders: ["local-llama"] });
const result = classifyTier("local-llama", "anything");
assert.equal(result.tier, PROVIDER_TIER.FREE);
expect(result.tier).toBe(PROVIDER_TIER.FREE);
clearTierCache();
});
});

View File

@@ -1,5 +1,12 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { describe, it, expect, vi } from "vitest";
// Mock the DB so recommendStrategyOverride sees adaptiveVolumeRouting = true.
// Without this the real getSettings() throws (no SQLite in test env), the
// catch block fires, and the function returns noOverride before any rule runs.
vi.mock("@/lib/localDb", () => ({
getSettings: vi.fn().mockResolvedValue({ adaptiveVolumeRouting: true }),
}));
import { detectVolumeSignals, recommendStrategyOverride } from "../volumeDetector";
describe("volumeDetector", async () => {
@@ -9,11 +16,11 @@ describe("volumeDetector", async () => {
messages: [{ role: "user", content: "Hello" }],
};
const signals = detectVolumeSignals(body);
assert.equal(signals.batchSize, 1);
assert.ok(signals.estimatedTokens < 100);
assert.equal(signals.toolCount, 0);
assert.equal(signals.hasBrowser, false);
assert.equal(signals.complexity, "trivial");
expect(signals.batchSize).toBe(1);
expect(signals.estimatedTokens).toBeLessThan(100);
expect(signals.toolCount).toBe(0);
expect(signals.hasBrowser).toBe(false);
expect(signals.complexity).toBe("trivial");
});
it("detects tool-heavy request as high complexity", async () => {
@@ -27,8 +34,8 @@ describe("volumeDetector", async () => {
],
};
const signals = detectVolumeSignals(body);
assert.equal(signals.toolCount, 4);
assert.equal(signals.complexity, "critical");
expect(signals.toolCount).toBe(4);
expect(signals.complexity).toBe("critical");
});
it("detects browser keywords", async () => {
@@ -36,7 +43,7 @@ describe("volumeDetector", async () => {
messages: [{ role: "user", content: "Navigate to the page and take a screenshot" }],
};
const signals = detectVolumeSignals(body);
assert.equal(signals.hasBrowser, true);
expect(signals.hasBrowser).toBe(true);
});
it("detects batch from multi-part content", async () => {
@@ -48,7 +55,7 @@ describe("volumeDetector", async () => {
messages: [{ role: "user", content: parts }],
};
const signals = detectVolumeSignals(body);
assert.equal(signals.batchSize, 20);
expect(signals.batchSize).toBe(20);
});
it("detects security keywords as high complexity", async () => {
@@ -56,10 +63,10 @@ describe("volumeDetector", async () => {
messages: [{ role: "user", content: "Refactor the authentication module for production" }],
};
const signals = detectVolumeSignals(body);
assert.ok(
expect(
signals.complexity === "critical" || signals.complexity === "high",
`expected critical or high, got ${signals.complexity}`
);
).toBe(true);
});
});
@@ -67,9 +74,9 @@ describe("volumeDetector", async () => {
it("recommends round-robin for large batches", async () => {
const signals = detectVolumeSignals({ input: Array(60).fill("item") });
const override = await recommendStrategyOverride(signals, "priority");
assert.equal(override.shouldOverride, true);
assert.equal(override.strategy, "round-robin");
assert.equal(override.preferEconomy, true);
expect(override.shouldOverride).toBe(true);
expect(override.strategy).toBe("round-robin");
expect(override.preferEconomy).toBe(true);
});
it("recommends premium-first for browser tasks", async () => {
@@ -82,9 +89,9 @@ describe("volumeDetector", async () => {
complexity: "high" as const,
};
const override = await recommendStrategyOverride(signals, "round-robin");
assert.equal(override.shouldOverride, true);
assert.equal(override.strategy, "priority");
assert.equal(override.forcePremium, true);
expect(override.shouldOverride).toBe(true);
expect(override.strategy).toBe("priority");
expect(override.forcePremium).toBe(true);
});
it("flags economy for tiny requests without changing strategy", async () => {
@@ -97,8 +104,8 @@ describe("volumeDetector", async () => {
complexity: "trivial" as const,
};
const override = await recommendStrategyOverride(signals, "priority");
assert.equal(override.shouldOverride, false);
assert.equal(override.preferEconomy, true);
expect(override.shouldOverride).toBe(false);
expect(override.preferEconomy).toBe(true);
});
it("no override for normal medium requests", async () => {
@@ -111,8 +118,8 @@ describe("volumeDetector", async () => {
complexity: "low" as const,
};
const override = await recommendStrategyOverride(signals, "priority");
assert.equal(override.shouldOverride, false);
assert.equal(override.preferEconomy, false);
expect(override.shouldOverride).toBe(false);
expect(override.preferEconomy).toBe(false);
});
});
});

View File

@@ -58,6 +58,7 @@ import { evictLockoutOverflow } from "./accountFallback/lockoutEviction.ts";
export { MODEL_LOCKOUT_EVICTION_CAP } from "./accountFallback/lockoutEviction.ts";
import { capScaledCooldownMs } from "./accountFallback/cooldownCap.ts";
import { resolveApiKeyForbiddenFallback } from "./accountFallback/nonRetryableUpstream.ts";
import * as exactModelLock from "./accountFallback/exactModelLock.ts";
export type ProviderProfile = {
baseCooldownMs: number;
useUpstreamRetryHints: boolean;
@@ -183,6 +184,12 @@ export const CREDITS_EXHAUSTED_SIGNALS = [
"out of credits",
"payment required",
"free tier of the model has been exhausted",
// #8631: narrower than a bare "has been exhausted" — that generic phrase also
// appears in Gemini's transient RPM/TPM 429 body ("Resource has been exhausted
// (e.g. check quota)."), which must stay RATE_LIMIT_EXCEEDED, not terminal.
// Anchoring on "tier" keeps free-tier depletion wording matched while excluding
// Gemini's "resource has been exhausted" rate-limit phrasing.
"tier has been exhausted",
// #5239: providers (e.g. DeepSeek/GLM-style) return "Insufficient account balance"
// on a depleted key. 402 is already terminalized by status, but catch non-402
// out-of-credit bodies here too.
@@ -442,6 +449,12 @@ function getModelLockKey(
return `${canonicalProvider}:${connectionId}:${lockModel}`;
}
const buildExactKey = exactModelLock.buildExactModelLockKey; // see exactModelLock.ts
const getModelLockKeys = exactModelLock.createGetModelLockKeys(
getModelLockKey,
getCanonicalLockProvider
);
function getFailureWindowMs(profile: ProviderProfile | null = null, fallbackMs = 30 * 60 * 1000) {
const configured = profile?.resetTimeoutMs;
return typeof configured === "number" && configured > 0 ? configured : fallbackMs;
@@ -559,6 +572,14 @@ export function lockModel(
});
}
// Lock only this exact provider/account/model tuple, never a quota family — see exactModelLock.ts.
export const lockExactModel = exactModelLock.createLockExactModel(
modelLockouts,
ensureCleanupTimer,
cleanupModelLockKey,
getCanonicalLockProvider
);
/**
* Pick the `exactCooldownMs` to apply to a model lockout (#1308).
*
@@ -591,6 +612,7 @@ export function recordModelLockoutFailure(
options: {
exactCooldownMs?: number | null;
maxCooldownMs?: number;
scope?: "exact" | "quota_family";
/**
* #6863 vs #7940: set true only when `exactCooldownMs` came from an actual
* upstream signal (Retry-After header, X-RateLimit-Reset, or a reset parsed
@@ -606,7 +628,10 @@ export function recordModelLockoutFailure(
} = {}
) {
ensureCleanupTimer();
const key = getModelLockKey(provider, connectionId, model, reason, status);
const key =
options.scope === "exact"
? buildExactKey(getCanonicalLockProvider(provider), connectionId, model)
: getModelLockKey(provider, connectionId, model, reason, status);
const now = Date.now();
cleanupModelLockKey(key, now);
@@ -656,7 +681,8 @@ export function recordModelLockoutFailure(
lastCooldownMs: cooldownMs,
});
lockModel(provider, connectionId, model, reason, cooldownMs, {
const lockFn = options.scope === "exact" ? lockExactModel : lockModel;
lockFn(provider, connectionId, model, reason, cooldownMs, {
failureCount,
lastFailureAt: now,
resetAfterMs,
@@ -675,16 +701,11 @@ export function clearModelLock(
model: string | null | undefined
): boolean {
if (!model) return false;
const familyKey = getModelLockKey(provider, connectionId, model);
const exactKey = `${getCanonicalLockProvider(provider)}:${connectionId}:${model}`;
const hadLock1 = modelLockouts.delete(familyKey);
const hadFailure1 = modelFailureState.delete(familyKey);
const hadLock2 = modelLockouts.delete(exactKey);
const hadFailure2 = modelFailureState.delete(exactKey);
return hadLock1 || hadFailure1 || hadLock2 || hadFailure2;
return exactModelLock.clearMultiKeyLock(
modelLockouts,
modelFailureState,
getModelLockKeys(provider, connectionId, model)
);
}
/**
@@ -708,6 +729,7 @@ export function hasPerModelQuota(
return connectionPassthroughModels;
}
if (!provider) return false;
if (getCanonicalLockProvider(provider) === "antigravity") return true;
if (getCanonicalLockProvider(provider) === "codex") return true;
if (provider === "gemini" || provider === "github") return true;
if (getPassthroughProviders().has(provider)) return true;
@@ -731,7 +753,8 @@ export function lockModelIfPerModelQuota(
// Skip model-level lock if the entire provider is in circuit-breaker cooldown.
// The provider cooldown already prevents all requests, so a model lock is redundant.
if (isProviderInCooldown(provider)) return false;
lockModel(provider, connectionId, model, reason, cooldownMs);
const lockFn = getCanonicalLockProvider(provider) === "antigravity" ? lockExactModel : lockModel;
lockFn(provider, connectionId, model, reason, cooldownMs);
return true;
}
@@ -800,14 +823,11 @@ export function isModelLocked(
model: string | null | undefined
): boolean {
if (!model) return false;
const exactKey = `${getCanonicalLockProvider(provider)}:${connectionId}:${model}`;
cleanupModelLockKey(exactKey);
if (modelLockouts.has(exactKey)) return true;
const familyKey = getModelLockKey(provider, connectionId, model);
cleanupModelLockKey(familyKey);
return modelLockouts.has(familyKey);
return exactModelLock.isAnyKeyLocked(
modelLockouts,
cleanupModelLockKey,
getModelLockKeys(provider, connectionId, model)
);
}
/**
@@ -819,32 +839,18 @@ export function getModelLockoutInfo(
model: string | null | undefined
) {
if (!model) return null;
const exactKey = `${getCanonicalLockProvider(provider)}:${connectionId}:${model}`;
cleanupModelLockKey(exactKey);
const exactEntry = modelLockouts.get(exactKey);
if (exactEntry) {
return {
reason: exactEntry.reason,
remainingMs: exactEntry.until - Date.now(),
lockedAt: new Date(exactEntry.lockedAt).toISOString(),
failureCount: exactEntry.failureCount,
};
}
const familyKey = getModelLockKey(provider, connectionId, model);
cleanupModelLockKey(familyKey);
const familyEntry = modelLockouts.get(familyKey);
if (familyEntry) {
return {
reason: familyEntry.reason,
remainingMs: familyEntry.until - Date.now(),
lockedAt: new Date(familyEntry.lockedAt).toISOString(),
failureCount: familyEntry.failureCount,
};
}
return null;
const entry = exactModelLock.findLatestLockEntry(
modelLockouts,
cleanupModelLockKey,
getModelLockKeys(provider, connectionId, model)
);
if (!entry) return null;
return {
reason: entry.reason,
remainingMs: entry.until - Date.now(),
lockedAt: new Date(entry.lockedAt).toISOString(),
failureCount: entry.failureCount,
};
}
export type ModelLockoutInfo = {

View File

@@ -0,0 +1,158 @@
/**
* accountFallback/exactModelLock.ts — exact-model (non-family-scoped) lockout key + entry math.
*
* Extracted from services/accountFallback.ts (file-size gate, #8630): pure helpers for the
* opt-in "exact model" lockout scope introduced for Antigravity — a confirmed exhaustion on
* one specific model (e.g. one Claude model) must not lock the whole quota family (Gemini or
* other Claude models on the same account). Pure w.r.t. module state — accountFallback.ts
* still owns the modelLockouts/modelFailureState maps, canonical-provider resolution, and the
* cleanup timer; it calls into these with its own map instances.
*/
import type { ModelLockoutEntry, ModelFailureState } from "../accountFallback.ts";
/** Build the "exact" scoped lockout key — a distinct namespace from the quota-family key. */
export function buildExactModelLockKey(
canonicalProvider: string,
connectionId: string,
model: string
): string {
return `${canonicalProvider}:${connectionId}:exact:${model.trim().toLowerCase()}`;
}
/** Dedupe the 3 lockout key shapes callers must check: quota-family, #8050 not_found, exact. */
export function collectModelLockKeys(
familyKey: string,
notFoundKey: string,
exactKey: string
): string[] {
return Array.from(new Set([familyKey, notFoundKey, exactKey]));
}
/**
* DI factory for `getModelLockKeys` — accountFallback.ts's own `getModelLockKey` (quota-family
* scoping) and `getCanonicalLockProvider` (alias resolution) are private, so this closes over
* them here rather than duplicating that logic in the leaf.
*/
export function createGetModelLockKeys(
getModelLockKey: (
provider: string,
connectionId: string,
model: string,
reason?: string | null,
status?: number | null
) => string,
getCanonicalLockProvider: (provider: string) => string
) {
return function getModelLockKeys(provider: string, connectionId: string, model: string) {
return collectModelLockKeys(
getModelLockKey(provider, connectionId, model),
getModelLockKey(provider, connectionId, model, "not_found", 404),
buildExactModelLockKey(getCanonicalLockProvider(provider), connectionId, model)
);
};
}
/**
* Compute the next ModelLockoutEntry for an exact-model lock, merging with any existing entry
* the same way lockModel() does (extend failureCount on a shorter re-lock instead of shrinking
* the remaining cooldown). Returns null when the caller should leave state untouched.
*/
export function computeExactModelLockEntry(
existing: ModelLockoutEntry | undefined,
reason: string,
cooldownMs: number,
metadata: Partial<ModelLockoutEntry>
): ModelLockoutEntry | null {
const now = Date.now();
const newUntil = now + cooldownMs;
if (existing && existing.until > newUntil) {
if (!metadata.failureCount || metadata.failureCount <= existing.failureCount) return null;
return {
...existing,
failureCount: metadata.failureCount,
lastFailureAt: metadata.lastFailureAt ?? existing.lastFailureAt,
resetAfterMs: metadata.resetAfterMs ?? existing.resetAfterMs,
};
}
return {
reason,
until: newUntil,
lockedAt: now,
failureCount: metadata.failureCount ?? existing?.failureCount ?? 1,
lastFailureAt: metadata.lastFailureAt ?? now,
resetAfterMs: metadata.resetAfterMs ?? existing?.resetAfterMs ?? 0,
};
}
/**
* Delete every one of the 3 lockout key shapes from both maps — a success on any one
* of them must clear the lock regardless of which reason originally wrote it.
*/
export function clearMultiKeyLock(
modelLockouts: Map<string, ModelLockoutEntry>,
modelFailureState: Map<string, ModelFailureState>,
keys: string[]
): boolean {
let cleared = false;
for (const key of keys) {
cleared = modelLockouts.delete(key) || cleared;
cleared = modelFailureState.delete(key) || cleared;
}
return cleared;
}
/** True when any of the 3 lockout key shapes is currently active (post-cleanup). */
export function isAnyKeyLocked(
modelLockouts: Map<string, ModelLockoutEntry>,
cleanup: (key: string) => void,
keys: string[]
): boolean {
return keys.some((key) => {
cleanup(key);
return modelLockouts.has(key);
});
}
/** The active entry with the most remaining time across the 3 lockout key shapes. */
export function findLatestLockEntry(
modelLockouts: Map<string, ModelLockoutEntry>,
cleanup: (key: string) => void,
keys: string[]
): ModelLockoutEntry | undefined {
return keys
.map((key) => {
cleanup(key);
return modelLockouts.get(key);
})
.filter((value): value is ModelLockoutEntry => Boolean(value))
.sort((a, b) => b.until - a.until)[0];
}
/**
* DI factory for the exported `lockExactModel` — accountFallback.ts owns the
* modelLockouts map + cleanup timer/key private functions and closes over them here so
* the full lock-only-this-exact-tuple implementation lives in this leaf, not the god-file.
*/
export function createLockExactModel(
modelLockouts: Map<string, ModelLockoutEntry>,
ensureCleanupTimer: () => void,
cleanupModelLockKey: (key: string) => void,
getCanonicalLockProvider: (provider: string) => string
) {
return function lockExactModel(
provider: string,
connectionId: string,
model: string | null | undefined,
reason: string,
cooldownMs: number,
metadata: Partial<ModelLockoutEntry> = {}
): void {
if (!model) return;
ensureCleanupTimer();
const key = buildExactModelLockKey(getCanonicalLockProvider(provider), connectionId, model);
cleanupModelLockKey(key);
const next = computeExactModelLockEntry(modelLockouts.get(key), reason, cooldownMs, metadata);
if (next) modelLockouts.set(key, next);
};
}

View File

@@ -2026,7 +2026,7 @@ async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function pollAdobeJob(opts: {
export async function pollAdobeJob(opts: {
pollUrl: string;
accessToken: string;
kind: "image" | "video";

View File

@@ -0,0 +1,437 @@
/**
* Adobe Firefly (unofficial) image **upsample** client — Topaz Labs models.
*
* Wire contract from a live firefly.adobe.com capture (web_providers/upsample.txt):
*
* POST https://firefly-3p.ff.adobe.io/v2/3p-images/upsample
* headers: Authorization: Bearer <IMS JWT>
* x-api-key: clio-playground-web
* x-arp-session-id: <sid+ark+ftr> (NO x-nonce on this endpoint)
* content-type: application/json
* body: {
* "modelId": "topaz",
* "modelVersion": "reimagine",
* "generationMetadata": { "module": "image-editing", "submodule": "ff-image-editor", ... },
* "referenceBlobs": [{ "id": "<storage blob id>", "usage": "general" }],
* "upsamplerFactor": 2,
* "creativityLevel": 0
* }
* → 200 { "links": { "cancel": {...}, "result": { "href": ".../jobs/result/<id>" } } }
*
* The job link is polled with the same BKS rewrite + status semantics as
* generate-async, so `pollAdobeJob` from `adobeFireflyClient.ts` is reused verbatim.
*
* Model discovery (web_providers/upscale.txt) lists modelId `topaz` with image
* modelVersions `default` / `standard` / `reimagine`, each carrying
* `inputMediaUseCase: ["upscaling"]`. `starlight-*` and `astra-2` are the VIDEO
* upscalers of the same family (`acModelFamilyId: topaz-video`) and are not served
* by this image endpoint, so they are deliberately absent.
*/
import {
AdobeFireflyError,
buildAdobeArpSessionId,
buildAdobeSubmitHeaders,
extractAdobeArpSessionId,
extractAdobeCookieHeader,
extractAdobeResultLink,
formatAdobeSystemUnderLoadError,
isAdobeTransientSubmitError,
normalizeAdobePollUrl,
pollAdobeJob,
} from "./adobeFireflyClient.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
export const ADOBE_FIREFLY_IMAGE_UPSAMPLE_URL =
"https://firefly-3p.ff.adobe.io/v2/3p-images/upsample";
/** Firefly image upscale timeout — Topaz jobs are slower than a 1K generate. */
export const ADOBE_FIREFLY_UPSCALE_TIMEOUT_MS = 300_000;
/** Same submit-retry budget as generate-async (colligo 408 recovery). */
const SUBMIT_MAX_ATTEMPTS = 5;
/**
* Firefly Topaz upsample wire range for `creativityLevel`.
*
* Live colligo on `/v2/3p-images/upsample` rejects values > 1
* (`less_than_equal`, `le: 1.0`). The browser capture sends `0` (off).
* Discovery docs mention a 15 integer scale for *other* Topaz endpoints —
* that scale is NOT accepted by upsample, so we stay on 01.
*/
export const ADOBE_FIREFLY_MAX_CREATIVITY_LEVEL = 1;
export type AdobeFireflyUpscaleModelId = "topaz" | "topaz-standard" | "topaz-bloom";
export interface AdobeFireflyUpscaleModelSpec {
upstreamModelId: string;
upstreamModelVersion: string;
/** Scale factors accepted for this version. */
factors: number[];
/** `creativityLevel` is only meaningful on the generative (reimagine) version. */
supportsCreativity: boolean;
}
export const ADOBE_FIREFLY_UPSCALE_MODELS: Record<
AdobeFireflyUpscaleModelId,
AdobeFireflyUpscaleModelSpec
> = {
// Bare `topaz` maps to the standard version rather than the discovery-listed
// "default" alias: both resolve to bksGenerationModel firefly_3p:external:topaz_standard,
// and pinning the explicit version avoids depending on an alias we have not captured.
topaz: {
upstreamModelId: "topaz",
upstreamModelVersion: "standard",
factors: [2, 4],
supportsCreativity: false,
},
"topaz-standard": {
upstreamModelId: "topaz",
upstreamModelVersion: "standard",
factors: [2, 4],
supportsCreativity: false,
},
"topaz-bloom": {
upstreamModelId: "topaz",
upstreamModelVersion: "reimagine",
factors: [2, 4],
supportsCreativity: true,
},
};
/**
* Resolve a catalog id (with or without an `adobe-firefly/` prefix) to its upstream
* modelId/modelVersion pair. Returns null for anything that is not a Firefly image
* upscaler, so callers can fall through instead of silently upscaling with a default.
*/
export function resolveAdobeUpscaleModel(model: string): {
id: AdobeFireflyUpscaleModelId;
spec: AdobeFireflyUpscaleModelSpec;
} | null {
const raw = String(model || "")
.trim()
.toLowerCase()
.replace(/^adobe-firefly\//, "")
.replace(/^firefly\//, "");
if (!raw) return null;
if (raw in ADOBE_FIREFLY_UPSCALE_MODELS) {
const id = raw as AdobeFireflyUpscaleModelId;
return { id, spec: ADOBE_FIREFLY_UPSCALE_MODELS[id] };
}
// Accept the upstream version names and common spellings.
if (raw.includes("bloom") || raw.includes("reimagine")) {
return { id: "topaz-bloom", spec: ADOBE_FIREFLY_UPSCALE_MODELS["topaz-bloom"] };
}
if (raw.includes("topaz")) {
return { id: "topaz-standard", spec: ADOBE_FIREFLY_UPSCALE_MODELS["topaz-standard"] };
}
return null;
}
/** True when the model id names a Firefly image upscaler (used to split the generate path). */
export function isAdobeFireflyUpscaleModel(model: string): boolean {
return resolveAdobeUpscaleModel(model) !== null;
}
/**
* Map a 0-100 creativity percentage onto Firefly upsample's `creativityLevel` (01 float).
*
* Precedence:
* 1. explicit `creativityLevel` — if in (1, 5] treat as legacy 15 integer scale
* and map onto 01 (`level / 5`); otherwise clamp to 01
* 2. `creativityPercent` 0100 → 01
* 3. default 0 (browser default / off)
*/
export function resolveAdobeCreativityLevel(opts: {
creativityPercent?: number | null;
creativityLevel?: unknown;
}): number {
const explicit = opts.creativityLevel;
if (typeof explicit === "number" && Number.isFinite(explicit)) {
return clampLevel(normalizeExplicitCreativity(explicit));
}
if (typeof explicit === "string" && explicit.trim() && Number.isFinite(Number(explicit))) {
return clampLevel(normalizeExplicitCreativity(Number(explicit)));
}
const percent = typeof opts.creativityPercent === "number" && Number.isFinite(opts.creativityPercent)
? Math.max(0, Math.min(100, opts.creativityPercent))
: 0;
return clampLevel(percent / 100);
}
/** Legacy 15 integer scale (discovery docs) → 01 wire float. Values already in 01 pass through. */
function normalizeExplicitCreativity(value: number): number {
if (value > ADOBE_FIREFLY_MAX_CREATIVITY_LEVEL && value <= 5) {
return value / 5;
}
return value;
}
/** Clamp to the upsample wire range [0, 1], two decimal places. */
function clampLevel(value: number): number {
if (!Number.isFinite(value)) return 0;
const clamped = Math.max(0, Math.min(ADOBE_FIREFLY_MAX_CREATIVITY_LEVEL, value));
return Math.round(clamped * 100) / 100;
}
/**
* Headers for the upsample submit.
*
* Identical to generate-async EXCEPT `x-nonce`, which the live upsample request does
* not send (there is no prompt to derive a deterministic nonce from). We mirror the
* capture exactly rather than adding a header colligo never sees from the SPA.
*/
export function buildAdobeUpsampleHeaders(
accessToken: string,
extras?: { arpSessionId?: string; cookie?: string }
): Record<string, string> {
const headers = buildAdobeSubmitHeaders(accessToken, {
arpSessionId: extras?.arpSessionId,
cookie: extras?.cookie,
prompt: "upsample",
});
delete headers["x-nonce"];
return headers;
}
export function buildAdobeUpsamplePayload(opts: {
modelSpec: AdobeFireflyUpscaleModelSpec;
blobId: string;
upsamplerFactor: number;
creativityLevel?: number;
}): Record<string, unknown> {
const payload: Record<string, unknown> = {
modelId: opts.modelSpec.upstreamModelId,
modelVersion: opts.modelSpec.upstreamModelVersion,
generationMetadata: {
module: "image-editing",
submodule: "ff-image-editor",
sourceDocumentId: null,
originalPrompt: null,
filterString: null,
subPrompts: null,
canvasImageReference: null,
},
referenceBlobs: [{ id: String(opts.blobId), usage: "general" }],
upsamplerFactor: opts.upsamplerFactor,
};
// creativityLevel is optional/nullable upstream — only the generative version
// consumes it, so the standard pass omits it entirely.
if (opts.modelSpec.supportsCreativity) {
payload.creativityLevel = Number.isFinite(opts.creativityLevel as number)
? (opts.creativityLevel as number)
: 0;
}
return payload;
}
/**
* Submit + poll a Firefly Topaz upscale job.
*
* `blobId` must already be a Firefly storage id — callers upload the source image with
* `resolveAdobeSourceImageIds`/`uploadAdobeFireflyImage` first, reusing the same ARP so
* colligo sees one coherent risk session for upload + submit.
*/
export async function adobeFireflyUpscaleImage(opts: {
accessToken: string;
model: string;
blobId: string;
upsamplerFactor?: unknown;
creativityPercent?: number;
creativityLevel?: unknown;
sessionCookie?: string;
arpSessionId?: string;
sessionFingerprint?: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
}): Promise<{ url: string; latest: unknown; factor: number; creativityLevel: number }> {
const fetchImpl = opts.fetchImpl || fetch;
const resolved = resolveAdobeUpscaleModel(opts.model);
if (!resolved) {
throw new AdobeFireflyError(
`Unsupported Adobe Firefly upscale model: ${opts.model}. ` +
`Use topaz-standard or topaz-bloom.`,
400,
"bad_model"
);
}
const { spec } = resolved;
const blobId = String(opts.blobId || "").trim();
if (!blobId) {
throw new AdobeFireflyError(
"Adobe Firefly upscale requires a source image",
400,
"bad_image"
);
}
const factor = normalizeFactor(opts.upsamplerFactor, spec.factors);
const creativityLevel = spec.supportsCreativity
? resolveAdobeCreativityLevel({
creativityPercent: opts.creativityPercent ?? null,
creativityLevel: opts.creativityLevel,
})
: 0;
const payload = buildAdobeUpsamplePayload({
modelSpec: spec,
blobId,
upsamplerFactor: factor,
creativityLevel,
});
const sessionCookie = String(opts.sessionCookie || "").trim();
const cookieHeader = extractAdobeCookieHeader(sessionCookie);
const browserArp = extractAdobeArpSessionId(cookieHeader || sessionCookie);
const hadBrowserArp = Boolean(browserArp);
let arpSessionId =
(opts.arpSessionId && String(opts.arpSessionId).trim()) ||
browserArp ||
buildAdobeArpSessionId();
const accessToken = opts.accessToken;
let submitData: unknown = {};
let submitHeaders: Headers | Record<string, string | null | undefined> = new Headers();
let lastSubmitError = "";
let sawSystemUnderLoad = false;
let submitted = false;
for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) {
const submitResp = await fetchImpl(ADOBE_FIREFLY_IMAGE_UPSAMPLE_URL, {
method: "POST",
headers: buildAdobeUpsampleHeaders(accessToken, {
arpSessionId,
cookie: cookieHeader || undefined,
}),
body: JSON.stringify(payload),
});
if (submitResp.status === 401 || submitResp.status === 403) {
if ((submitResp.headers.get("x-access-error") || "") === "taste_exhausted") {
throw new AdobeFireflyError(
"Adobe Firefly quota exhausted for this account",
429,
"quota_exhausted"
);
}
throw new AdobeFireflyError(
"Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on " +
"firefly-3p) plus the firefly.adobe.com Cookie once.",
401,
"auth"
);
}
if (!submitResp.ok) {
const text = await submitResp.text().catch(() => "");
if (isAdobeTransientSubmitError(submitResp.status, text)) sawSystemUnderLoad = true;
lastSubmitError =
`Adobe Firefly image upscale submit failed (${submitResp.status}): ` +
sanitizeErrorMessage(text.slice(0, 300));
if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) {
// Rotate synthetic ARP on transient 408; real browser ARP is reused as-is.
if (!hadBrowserArp) {
arpSessionId = buildAdobeArpSessionId();
}
const delay = submitRetryDelayMs(attempt);
opts.log?.info?.(
"ADOBE-FIREFLY",
`upscale submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms`
);
await sleep(delay);
continue;
}
if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) {
throw new AdobeFireflyError(
formatAdobeSystemUnderLoadError("image", attempt),
408,
"system_under_load"
);
}
throw new AdobeFireflyError(
lastSubmitError,
submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502
);
}
submitData = await submitResp.json().catch(() => ({}));
submitHeaders = submitResp.headers;
submitted = true;
break;
}
if (!submitted) {
throw new AdobeFireflyError(
lastSubmitError || "Adobe Firefly upscale submit failed after retries",
502
);
}
let pollUrl = extractAdobeResultLink(submitHeaders, submitData);
if (!pollUrl) {
if (sawSystemUnderLoad) {
throw new AdobeFireflyError(
formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS),
408,
"system_under_load"
);
}
throw new AdobeFireflyError(
lastSubmitError || "Adobe Firefly upscale submit succeeded but no poll URL was returned",
502
);
}
pollUrl = normalizeAdobePollUrl(pollUrl);
const { mediaUrl, latest } = await pollAdobeJob({
pollUrl,
accessToken,
kind: "image",
timeoutMs:
opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : ADOBE_FIREFLY_UPSCALE_TIMEOUT_MS,
fetchImpl,
log: opts.log,
});
return { url: mediaUrl, latest, factor, creativityLevel };
}
function normalizeFactor(value: unknown, allowed: readonly number[]): number {
const factors = allowed.length > 0 ? [...allowed] : [2, 4];
let n = typeof value === "number" ? value : Number(String(value ?? "").replace(/[^\d.]/g, ""));
if (!Number.isFinite(n) || n <= 0) n = 2;
let best = factors[0]!;
let bestDelta = Math.abs(best - n);
for (const f of factors) {
const delta = Math.abs(f - n);
if (delta < bestDelta) {
best = f;
bestDelta = delta;
}
}
return best;
}
function submitRetryDelayMs(attempt: number): number {
const raw = process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS;
const base =
raw != null && raw !== ""
? Math.max(0, Number(raw) || 0)
: process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT
? 20
: 8000;
if (base <= 50) return base;
return Math.min(90_000, base * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 1500);
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -61,6 +61,14 @@ const FULL_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours
export function classify429(errorMessage: string): Category {
const lower = (errorMessage || "").toLowerCase();
// Cloud Code may report an exhausted-capacity message with a zero reset
// window for a burst/RPM throttle. The explicit zero reset is stronger
// evidence than the generic wording, so retry briefly instead of applying
// the durable quota cooldown.
if (/\breset\s+(?:after|in)\s+0s\b/.test(lower)) {
return "rate_limited";
}
// Check for quota exhaustion first (most specific)
for (const kw of QUOTA_EXHAUSTED_KEYWORDS) {
if (lower.includes(kw)) return "quota_exhausted";

View File

@@ -90,7 +90,7 @@ export function __resetHttpBackedChatOverrideForTesting(): void {
// Helper to make Playwright waitForTimeout abortable via AbortSignal
function waitWithSignal(ms: number, signal?: AbortSignal | null): Promise<void> {
return new Promise((resolve, reject) => {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
const onAbort = () => {
clearTimeout(timer);

View File

@@ -87,9 +87,18 @@ export function obfuscateInBody(body: Record<string, unknown>): void {
if (typeof content === "string") {
msg.content = obfuscateSensitiveWords(content);
} else if (Array.isArray(content)) {
for (const block of content as Array<Record<string, unknown>>) {
if (typeof block.text === "string") {
block.text = obfuscateSensitiveWords(block.text);
// Anthropic verifies a signature over a thinking turn. Mutating a text
// sibling in that same turn invalidates it and makes the next request
// fail with `Invalid signature in thinking block`.
const blocks = content as Array<Record<string, unknown>>;
const hasSignedThinking = blocks.some(
(block) => block?.type === "thinking" || block?.type === "redacted_thinking"
);
if (!hasSignedThinking) {
for (const block of blocks) {
if (typeof block.text === "string") {
block.text = obfuscateSensitiveWords(block.text);
}
}
}
}

View File

@@ -9,6 +9,7 @@ import type { ComboLogger, ResolvedComboTarget } from "./types.ts";
export interface ContextRequirements {
minContextWindow?: number;
maxContextWindow?: number;
preferLargeContext?: boolean;
contextFilterMode?: "strict" | "lenient";
}
@@ -51,10 +52,15 @@ export function applyContextRequirements(
): ResolvedComboTarget[] {
if (!requirements || targets.length === 0) return targets;
const { minContextWindow, preferLargeContext, contextFilterMode = "lenient" } = requirements;
const {
minContextWindow,
maxContextWindow,
preferLargeContext,
contextFilterMode = "lenient",
} = requirements;
// No requirements specified
if (!minContextWindow && !preferLargeContext) return targets;
if (!minContextWindow && !maxContextWindow && !preferLargeContext) return targets;
let filtered = targets;
@@ -108,6 +114,34 @@ export function applyContextRequirements(
}
}
// Apply maxContextWindow filtering
if (maxContextWindow && maxContextWindow > 0) {
const beforeFilterCount = filtered.length;
filtered = filtered.filter((target) => {
const contextWindow = getTargetContextWindow(target);
// Unknown context limit handling
if (contextWindow === null) {
return contextFilterMode === "lenient";
}
// Known context limit - check threshold
return contextWindow <= maxContextWindow;
});
if (filtered.length < beforeFilterCount) {
log.info(
"COMBO",
`Context requirements: filtered ${beforeFilterCount}${filtered.length} targets (maxContextWindow: ${maxContextWindow}, mode: ${contextFilterMode})`
);
log.debug?.(
"COMBO",
`Context requirements: kept models ${filtered.map((t) => t.modelStr).join(", ")}`
);
}
}
// Apply preferLargeContext sorting
if (preferLargeContext && filtered.length > 1) {
filtered = [...filtered].sort((a, b) => {

View File

@@ -179,6 +179,7 @@ const DEFAULT_COMBO_CONFIG = {
contextRequirements: undefined as
| {
minContextWindow?: number;
maxContextWindow?: number;
preferLargeContext?: boolean;
contextFilterMode?: "strict" | "lenient";
}

View File

@@ -341,9 +341,17 @@ function applyObfuscateWords(body: RequestBody, op: ObfuscateWordsOp): void {
if (typeof content === "string") {
msg.content = obfuscateWithList(content, words);
} else if (Array.isArray(content)) {
for (const block of content as Array<Record<string, unknown>>) {
if (typeof block.text === "string") {
block.text = obfuscateWithList(block.text, words);
// A signed Anthropic thinking turn covers its text siblings too. Leave
// the entire turn byte-for-byte intact so its signature remains valid.
const blocks = content as Array<Record<string, unknown>>;
const hasSignedThinking = blocks.some(
(block) => block?.type === "thinking" || block?.type === "redacted_thinking"
);
if (!hasSignedThinking) {
for (const block of blocks) {
if (typeof block.text === "string") {
block.text = obfuscateWithList(block.text, words);
}
}
}
}

View File

@@ -161,9 +161,7 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
}
parts.push({
...(embeddedThoughtSignature
? { thoughtSignature: embeddedThoughtSignature }
: {}),
...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}),
functionCall: {
...(stripFunctionCallId ? {} : { id: block.id }),
name: sanitizeToolName(block.name),

View File

@@ -108,14 +108,14 @@ export function geminiToClaudeResponse(chunk, state) {
}
const fc = part.functionCall;
const rawToolName = fc.name;
const restoredToolName = normalizeToolName(state.toolNameMap?.get(rawToolName) || rawToolName);
const restoredToolName = normalizeToolName(
state.toolNameMap?.get(rawToolName) || rawToolName
);
const idx = state.contentBlockIndex++;
const toolId = fc.id || `toolu_${Date.now()}_${idx}`;
const signatureForToolCall =
(typeof hasThoughtSig === "string" && hasThoughtSig.length > 0
? hasThoughtSig
: null) ||
(typeof hasThoughtSig === "string" && hasThoughtSig.length > 0 ? hasThoughtSig : null) ||
(typeof state.pendingThoughtSignature === "string" &&
state.pendingThoughtSignature.length > 0
? state.pendingThoughtSignature

View File

@@ -1238,10 +1238,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
!(state.reasoningItemsWithDelta instanceof Set && state.reasoningItemsWithDelta.size > 0);
if (emittedForItem || emittedWithoutItemId) return null;
// #7095/#7176 reconciliation: computed WITHOUT mutating `item`, so an
// encrypted-only reasoning item (and its `encrypted_content`) is never
// rewritten with a fabricated `summary` — the placeholder only feeds this
// synthetic client-facing delta chunk.
// #7176/#7243: only synthesize from real upstream plaintext — never mutate
// `item` and never fabricate placeholder text for encrypted-only reasoning.
const summaryText = getVisibleResponsesReasoningSummaryText(item);
if (!summaryText) return null;
return buildResponsesReasoningDeltaChunk(state, summaryText);

View File

@@ -177,27 +177,19 @@ export function extractResponsesReasoningSummaryText(item) {
.join("\n\n");
}
// #7095/#7176 — when Codex exposes a reasoning item only as encrypted private
// reasoning (no plaintext summary), chat clients would otherwise see nothing in
// their thinking panel. Reconciles two goals that used to be in tension:
// - #7095 wants a visible placeholder in the chat client.
// - #7176 wants the upstream response item left untouched, so `encrypted_content`
// (needed by Codex for subsequent requests) is never overwritten by a
// fabricated `summary`.
// This function computes the placeholder text WITHOUT mutating `item` — callers
// use the returned text for synthetic client-facing events only.
const ENCRYPTED_REASONING_PLACEHOLDER =
"Codex is reasoning, but the upstream Responses API exposed this reasoning block only as encrypted private reasoning. OmniRoute cannot recover the plaintext.";
// #7095/#7176/#7243 — when Codex exposes a reasoning item only as encrypted
// private reasoning (no plaintext summary), callers may synthesize client-facing
// reasoning summary events from this helper. Reconciles three goals:
// - #7176: never mutate the upstream item — `encrypted_content` (needed by
// Codex for subsequent requests) must not be overwritten with a fabricated
// `summary`.
// - #7095: real plaintext summaries from upstream are forwarded to chat
// clients that render a thinking panel.
// - #7243: when upstream provides no plaintext summary, do NOT fabricate an
// alarming error-like paragraph into `reasoning_summary_text.delta` — clients
// would display it as if it were real reasoning. Return empty so synthetic
// summary events are suppressed; the reasoning item (with `encrypted_content`)
// still arrives on `response.output_item.done`.
export function getVisibleResponsesReasoningSummaryText(item) {
const existingSummary = extractResponsesReasoningSummaryText(item);
if (existingSummary) return existingSummary;
const hasEncryptedReasoning =
item &&
item.type === "reasoning" &&
typeof item.encrypted_content === "string" &&
item.encrypted_content.length > 0;
return hasEncryptedReasoning ? ENCRYPTED_REASONING_PLACEHOLDER : "";
return extractResponsesReasoningSummaryText(item);
}

View File

@@ -684,7 +684,8 @@ export function createSSEStream(options: StreamOptions = {}) {
// Responses API, Anthropic SSE, and Antigravity/cloudcode terminate on
// their own protocol events (response.completed / message_stop / last
// response candidate respectively).
const shouldEmitDoneTerminator = !clientExpectsResponsesStream && !clientExpectsClaudeStream && !clientExpectsAntigravityStream;
const shouldEmitDoneTerminator =
!clientExpectsResponsesStream && !clientExpectsClaudeStream && !clientExpectsAntigravityStream;
let buffer = "";
let usage: UsageTokenRecord | null = null;
@@ -1049,9 +1050,9 @@ export function createSSEStream(options: StreamOptions = {}) {
return;
}
// #7095/#7176 reconciliation: compute the visible placeholder WITHOUT
// mutating `item` — the encrypted reasoning item (and its `encrypted_content`,
// required by Codex for subsequent requests) is forwarded to the client intact.
// #7176/#7243: only synthesize summary events from real upstream plaintext —
// never mutate `item` and never fabricate alarming placeholder text for
// encrypted-only reasoning (`encrypted_content` still forwards intact).
const visibleSummary = getVisibleResponsesReasoningSummaryText(item);
if (!visibleSummary) {

View File

@@ -24,7 +24,15 @@
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/7802
*/
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -78,7 +86,8 @@ function patchNodeGypCommonGypi() {
const variablesMatch = content.match(/('variables'\s*:\s*\{)/);
if (variablesMatch) {
const insertPos = content.indexOf(variablesMatch[0]) + variablesMatch[0].length;
content = content.slice(0, insertPos) + "\n 'android_ndk_path%': ''," + content.slice(insertPos);
content =
content.slice(0, insertPos) + "\n 'android_ndk_path%': ''," + content.slice(insertPos);
writeFileSync(commonGypi, content, "utf8");
console.log(` ✅ Patched common.gypi for Android at ${commonGypi}`);
}

View File

@@ -22,11 +22,15 @@ import {
readdirSync,
statSync,
chmodSync,
openSync,
readSync,
closeSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { assembleStandalone } from "./assembleStandalone.mjs";
import { resolveBundledNpmEntry } from "./resolveNpmEntry.ts";
import {
APP_STAGING_ALLOWED_EXACT_PATHS,
APP_STAGING_ALLOWED_PATH_PREFIXES,
@@ -64,16 +68,35 @@ function resolveLocalBinEntry(packageName: string, binName: string): string | nu
}
}
function resolveBundledNpmEntry(name: "npm-cli.js" | "npx-cli.js"): string | null {
const candidate = join(dirname(process.execPath), "node_modules", "npm", "bin", name);
return existsSync(candidate) ? candidate : null;
}
/**
* Runs a build tool without ever touching a `.cmd` shim. `packageName` is where the
* tool lives in the local dependency tree; when it is not installed there the call
* falls back to the Node-resolved `npx` entry point, and only then to the shim.
*/
/**
* esbuild ≥0.25 ships its `bin/esbuild` as the NATIVE platform executable on
* Linux/macOS (ELF / Mach-O) instead of a JS shim — running it through
* `process.execPath` makes Node parse machine code as JavaScript and crash with
* "SyntaxError: Invalid or unexpected token". Sniff the magic bytes and exec
* native entries directly; JS entries keep going through this Node binary.
*/
function isNativeExecutable(entryPath: string): boolean {
try {
const fd = openSync(entryPath, "r");
const head = Buffer.alloc(4);
readSync(fd, head, 0, 4, 0);
closeSync(fd);
return (
(head[0] === 0x7f && head[1] === 0x45 && head[2] === 0x4c && head[3] === 0x46) || // ELF
head.readUInt32BE(0) === 0xfeedfacf || // Mach-O 64
head.readUInt32BE(0) === 0xcffaedfe || // Mach-O 64 (LE on disk)
(head[0] === 0x4d && head[1] === 0x5a) // PE (Windows MZ)
);
} catch {
return false;
}
}
function runBuildTool(
packageName: string,
binName: string,
@@ -82,6 +105,10 @@ function runBuildTool(
): void {
const localEntry = resolveLocalBinEntry(packageName, binName);
if (localEntry) {
if (isNativeExecutable(localEntry)) {
execFileSync(localEntry, [...args], options);
return;
}
execFileSync(process.execPath, [localEntry, ...args], options);
return;
}
@@ -409,15 +436,23 @@ if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package
// types). Without this install a fresh CI publish fails at this step.
if (!existsSync(join(opencodePluginSrc, "node_modules"))) {
const npmEntry = resolveBundledNpmEntry("npm-cli.js");
if (!npmEntry) {
if (npmEntry) {
execFileSync(process.execPath, [npmEntry, "install", "--no-audit", "--no-fund"], {
cwd: opencodePluginSrc,
stdio: "inherit",
});
} else if (process.platform !== "win32") {
// No bundled npm entry found (non-standard Node layout). Plain `npm` is
// safe here — the .cmd-shim hazard #8858 guards against is Windows-only.
execFileSync("npm", ["install", "--no-audit", "--no-fund"], {
cwd: opencodePluginSrc,
stdio: "inherit",
});
} else {
throw new Error(
"npm-cli.js not found next to the running Node binary; cannot install the plugin dependencies without falling back to a .cmd shim."
);
}
execFileSync(process.execPath, [npmEntry, "install", "--no-audit", "--no-fund"], {
cwd: opencodePluginSrc,
stdio: "inherit",
});
}
runBuildTool("tsup", "tsup", [], {
cwd: opencodePluginSrc,

View File

@@ -0,0 +1,40 @@
import { existsSync } from "fs";
import { dirname, join } from "path";
/** Injectable seams for {@link resolveBundledNpmEntry} (all default to the real ones). */
export interface ResolveNpmEntryDeps {
execPath?: string;
/** `process.env.npm_execpath` — set by npm itself when running under `npm run`. */
npmExecPath?: string;
exists?: (p: string) => boolean;
}
/**
* Locate `npm-cli.js` / `npx-cli.js` so build steps can run npm/npx through
* `process.execPath` directly and never touch a `.cmd` shim (#8858), covering
* BOTH install layouts:
* - Windows: `<dir(node.exe)>\node_modules\npm\bin\<name>` (npm beside the binary)
* - POSIX: `<dir(node)>/../lib/node_modules/npm/bin/<name>` (node under `<prefix>/bin`,
* the shape of GitHub hosted runners, nvm and system installs)
* When the script itself runs under `npm run`, npm exports `npm_execpath` pointing at
* its own npm-cli.js — the most reliable source, tried first (npx-cli.js is its sibling).
*/
export function resolveBundledNpmEntry(
name: "npm-cli.js" | "npx-cli.js",
deps: ResolveNpmEntryDeps = {}
): string | null {
const execPath = deps.execPath ?? process.execPath;
const exists = deps.exists ?? existsSync;
const npmExecPath = deps.npmExecPath ?? process.env.npm_execpath;
const binDir = dirname(execPath);
const candidates: string[] = [];
if (npmExecPath) candidates.push(join(dirname(npmExecPath), name));
candidates.push(join(binDir, "node_modules", "npm", "bin", name));
candidates.push(join(binDir, "..", "lib", "node_modules", "npm", "bin", name));
for (const candidate of candidates) {
if (exists(candidate)) return candidate;
}
return null;
}

View File

@@ -108,6 +108,7 @@ export const COLLECTORS = [
sources: ["vitest.mcp.config.ts"],
},
{ glob: "tests/unit/autoCombo/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/lib/memory/__tests__/generic-backend.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "tests/unit/encryption.spec.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/shared/components/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/shared/hooks/__tests__/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },

View File

@@ -1,12 +1,16 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.resolve(process.env.USERPROFILE, '.omniroute', 'storage.sqlite');
const Database = require("better-sqlite3");
const path = require("path");
const dbPath = path.resolve(process.env.USERPROFILE, ".omniroute", "storage.sqlite");
try {
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare(`SELECT id, provider, name, auth_type, is_active, api_key IS NOT NULL as has_api_key, access_token IS NOT NULL as has_access_token, refresh_token IS NOT NULL as has_refresh, last_error, test_status, provider_specific_data, updated_at FROM provider_connections WHERE provider LIKE '%anthropic%' OR provider='anthropic' OR provider LIKE '%claude%' OR provider='claude'`).all();
const rows = db
.prepare(
`SELECT id, provider, name, auth_type, is_active, api_key IS NOT NULL as has_api_key, access_token IS NOT NULL as has_access_token, refresh_token IS NOT NULL as has_refresh, last_error, test_status, provider_specific_data, updated_at FROM provider_connections WHERE provider LIKE '%anthropic%' OR provider='anthropic' OR provider LIKE '%claude%' OR provider='claude'`
)
.all();
console.log(JSON.stringify(rows, null, 2));
db.close();
} catch (err) {
console.error('ERROR', err && err.message);
console.error("ERROR", err && err.message);
process.exit(2);
}

View File

@@ -116,6 +116,8 @@ export default function ProviderDetailPageClient() {
proxyConfig,
connProxyMap,
cpaProviderEnabled,
upstreamProxyMode,
upstreamProxyFallbackBackend,
refreshingId,
setPage,
setHealthFilter,
@@ -133,6 +135,7 @@ export default function ProviderDetailPageClient() {
handleToggleClaudeExtraUsage,
handleToggleCodexLimit,
handleToggleCliproxyapiMode,
handleSetUpstreamProxyMode,
handleToggleProxyEnabled,
handleTogglePerKeyProxyEnabled,
handleRetestConnection,
@@ -609,6 +612,9 @@ export default function ProviderDetailPageClient() {
canAutoSync={!usesCuratedModelsOnly && compatibleSupportsModelImport}
handleToggleConnectionAutoSync={handleToggleConnectionAutoSync}
handleToggleCliproxyapiMode={handleToggleCliproxyapiMode}
handleSetUpstreamProxyMode={handleSetUpstreamProxyMode}
upstreamProxyMode={upstreamProxyMode}
upstreamProxyFallbackBackend={upstreamProxyFallbackBackend}
handleToggleCodexLimit={handleToggleCodexLimit}
handleToggleProxyEnabled={handleToggleProxyEnabled}
handleTogglePerKeyProxyEnabled={handleTogglePerKeyProxyEnabled}

View File

@@ -71,6 +71,13 @@ export interface ConnectionRowProps {
isCcCompatible?: boolean;
cliproxyapiEnabled?: boolean;
onToggleCliproxyapiMode?: (enabled?: boolean) => void;
/** Provider-level upstream proxy routing mode (native/CLIProxyAPI/Dario/fallback). */
upstreamProxyMode?: "native" | "cliproxyapi" | "dario" | "fallback";
upstreamProxyFallbackBackend?: "cliproxyapi" | "dario";
onSetUpstreamProxyMode?: (
mode: "native" | "cliproxyapi" | "dario" | "fallback",
fallbackBackend?: "cliproxyapi" | "dario"
) => void;
onRetest: () => void;
isRetesting?: boolean;
onEdit: () => void;
@@ -341,6 +348,9 @@ export default function ConnectionRow({
codexGlobalServiceMode,
isCcCompatible,
cliproxyapiEnabled,
upstreamProxyMode,
upstreamProxyFallbackBackend,
onSetUpstreamProxyMode,
isFirst,
isLast,
isSelected,
@@ -511,7 +521,11 @@ export default function ConnectionRow({
? isClaudeExtraUsageBlockEnabled("claude", connection.providerSpecificData)
: false;
const codexPlanLabel = getCodexPlanLabel(!!isCodex, connection.providerSpecificData);
const cliproxyapiDeepMode = !!cliproxyapiEnabled;
// #dario: this control is now a full mode selector (native/CLIProxyAPI/
// Dario/fallback), not a binary toggle — cliproxyapiEnabled/
// onToggleCliproxyapiMode are kept on the props interface for any other
// consumer but are no longer read here.
const effectiveUpstreamProxyMode = upstreamProxyMode ?? "native";
const autoSyncEnabled = !!(connection.providerSpecificData as Record<string, unknown> | undefined)
?.autoSync;
@@ -673,21 +687,47 @@ export default function ConnectionRow({
</button>
</>
)}
{isCcCompatible && (
{/* #dario: upstream proxy routing selector. Gated on isClaude (the
real, built-in "claude" provider — the primary intended use
case for CLIProxyAPI/Dario failover) OR isCcCompatible (a
custom Claude-Code-protocol-compatible node). Previously this
only checked isCcCompatible, which never covered the built-in
Claude provider at all — the control was unreachable for the
one connection type it was actually built for. */}
{(isClaude || isCcCompatible) && (
<>
<span className="text-text-muted/30 select-none">|</span>
<button
onClick={() => onToggleCliproxyapiMode?.(!cliproxyapiDeepMode)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
cliproxyapiDeepMode
? "bg-indigo-500/15 text-indigo-500 hover:bg-indigo-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title={cliproxyapiDeepMode ? t("cpaModeEnabledTitle") : t("cpaModeDisabledTitle")}
<select
value={effectiveUpstreamProxyMode}
onChange={(e) =>
onSetUpstreamProxyMode?.(
e.target.value as "native" | "cliproxyapi" | "dario" | "fallback"
)
}
className="text-xs font-medium rounded px-1.5 py-0.5 border-0 bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/70 hover:text-text-muted cursor-pointer focus:outline-none focus:ring-1 focus:ring-primary/30"
title="Upstream proxy routing for Claude Code traffic"
>
<span className="material-symbols-outlined text-[13px]">swap_horiz</span>
CPA {cliproxyapiDeepMode ? t("toggleOnShort") : t("toggleOffShort")}
</button>
<option value="native">Native</option>
<option value="cliproxyapi">CLIProxyAPI</option>
<option value="dario">Dario</option>
<option value="fallback">Fallback</option>
</select>
{effectiveUpstreamProxyMode === "fallback" && (
<select
value={upstreamProxyFallbackBackend ?? "cliproxyapi"}
onChange={(e) =>
onSetUpstreamProxyMode?.(
"fallback",
e.target.value as "cliproxyapi" | "dario"
)
}
className="text-xs font-medium rounded px-1.5 py-0.5 border-0 bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/70 hover:text-text-muted cursor-pointer focus:outline-none focus:ring-1 focus:ring-primary/30"
title="Fallback retry backend"
>
<option value="cliproxyapi"> CLIProxyAPI</option>
<option value="dario"> Dario</option>
</select>
)}
</>
)}
{isCodex && (

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