mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 22:22:57 +03:00
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:
75
.env.example
75
.env.example
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
1
changelog.d/features/8799-electron-remote-server-mode.md
Normal file
1
changelog.d/features/8799-electron-remote-server-mode.md
Normal 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
|
||||
1
changelog.d/fixes/9553-prepublish-npm-entry-posix.md
Normal file
1
changelog.d/fixes/9553-prepublish-npm-entry-posix.md
Normal 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.
|
||||
@@ -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.
|
||||
1
changelog.d/fixes/9559-mcp-audit-vitest-seam.md
Normal file
1
changelog.d/fixes/9559-mcp-audit-vitest-seam.md
Normal 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.
|
||||
1
changelog.d/fixes/prepublish-native-esbuild.md
Normal file
1
changelog.d/fixes/prepublish-native-esbuild.md
Normal 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)
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
title: "Combo Context Requirements Feature"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-06
|
||||
---
|
||||
|
||||
# Combo Context Requirements Feature
|
||||
|
||||
## Overview
|
||||
|
||||
228
docs/frameworks/MEMORY_BACKEND.md
Normal file
228
docs/frameworks/MEMORY_BACKEND.md
Normal 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
236
docs/frameworks/RADAR.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
title: "Proxy Port Clash Investigation"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-06
|
||||
---
|
||||
|
||||
# Proxy Port Clash Investigation
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
title: "Redis Production Configuration Guide"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-06
|
||||
---
|
||||
|
||||
# Redis Production Configuration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 (64–16384 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 (64–16384 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)
|
||||
|
||||
|
||||
86
electron/assets/remoteServerPrompt.html
Normal file
86
electron/assets/remoteServerPrompt.html
Normal 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>
|
||||
75
electron/lib/remoteServerPreferences.js
Normal file
75
electron/lib/remoteServerPreferences.js
Normal 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 };
|
||||
79
electron/lib/resolveRemoteServerUrl.js
Normal file
79
electron/lib/resolveRemoteServerUrl.js
Normal 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 };
|
||||
139
electron/main.js
139
electron/main.js
@@ -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);
|
||||
|
||||
@@ -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/**/*"
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
15
electron/remoteServerPromptPreload.js
Normal file
15
electron/remoteServerPromptPreload.js
Normal 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"),
|
||||
});
|
||||
40
electron/remoteServerPromptRenderer.js
Normal file
40
electron/remoteServerPromptRenderer.js
Normal 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
4
electron/types.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
228
open-sse/config/upscaleRegistry.ts
Normal file
228
open-sse/config/upscaleRegistry.ts
Normal 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)));
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
290
open-sse/executors/dario.ts
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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)`
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
110
open-sse/handlers/imageUpscale.ts
Normal file
110
open-sse/handlers/imageUpscale.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
177
open-sse/handlers/imageUpscale/adobeFirefly.ts
Normal file
177
open-sse/handlers/imageUpscale/adobeFirefly.ts
Normal 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;
|
||||
}
|
||||
391
open-sse/handlers/imageUpscale/shared.ts
Normal file
391
open-sse/handlers/imageUpscale/shared.ts
Normal 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 || "") };
|
||||
}
|
||||
335
open-sse/handlers/imageUpscale/stability.ts
Normal file
335
open-sse/handlers/imageUpscale/stability.ts
Normal 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));
|
||||
}
|
||||
271
open-sse/handlers/imageUpscale/topaz.ts
Normal file
271
open-sse/handlers/imageUpscale/topaz.ts
Normal 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";
|
||||
}
|
||||
@@ -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]) }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
158
open-sse/services/accountFallback/exactModelLock.ts
Normal file
158
open-sse/services/accountFallback/exactModelLock.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
437
open-sse/services/adobeFireflyUpscale.ts
Normal file
437
open-sse/services/adobeFireflyUpscale.ts
Normal 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 1–5 integer scale for *other* Topaz endpoints —
|
||||
* that scale is NOT accepted by upsample, so we stay on 0–1.
|
||||
*/
|
||||
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` (0–1 float).
|
||||
*
|
||||
* Precedence:
|
||||
* 1. explicit `creativityLevel` — if in (1, 5] treat as legacy 1–5 integer scale
|
||||
* and map onto 0–1 (`level / 5`); otherwise clamp to 0–1
|
||||
* 2. `creativityPercent` 0–100 → 0–1
|
||||
* 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 1–5 integer scale (discovery docs) → 0–1 wire float. Values already in 0–1 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));
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -179,6 +179,7 @@ const DEFAULT_COMBO_CONFIG = {
|
||||
contextRequirements: undefined as
|
||||
| {
|
||||
minContextWindow?: number;
|
||||
maxContextWindow?: number;
|
||||
preferLargeContext?: boolean;
|
||||
contextFilterMode?: "strict" | "lenient";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
40
scripts/build/resolveNpmEntry.ts
Normal file
40
scripts/build/resolveNpmEntry.ts
Normal 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;
|
||||
}
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user