mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-10 09:12:27 +03:00
Compare commits
71 Commits
chloeassis
...
maint/cher
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f224be89f3 | ||
|
|
0f5699165b | ||
|
|
714a36cf99 | ||
|
|
bbe5c78f4d | ||
|
|
2d49f1c743 | ||
|
|
d11b99f6cc | ||
|
|
065fa67f63 | ||
|
|
4d8506c2c5 | ||
|
|
918647af69 | ||
|
|
f39d74b83a | ||
|
|
aae408f585 | ||
|
|
3e1c31c606 | ||
|
|
2e12ee89f7 | ||
|
|
e0ce95c592 | ||
|
|
13dcbfd117 | ||
|
|
ec02945d97 | ||
|
|
cd121844ce | ||
|
|
8b29e73b27 | ||
|
|
b7864cdb6c | ||
|
|
77cce62357 | ||
|
|
c5f0ce01bc | ||
|
|
00b79b5507 | ||
|
|
b5e17bdbde | ||
|
|
aefa2b665b | ||
|
|
df1ea5bd77 | ||
|
|
a90c5e5aba | ||
|
|
c88b96244f | ||
|
|
93ee4dce9f | ||
|
|
6c95e2b354 | ||
|
|
3835f318d0 | ||
|
|
d0047ee615 | ||
|
|
064a19b2d1 | ||
|
|
492f9ddc4a | ||
|
|
e7d9055314 | ||
|
|
2ed487583b | ||
|
|
a6b3b4f57a | ||
|
|
cf31b795b7 | ||
|
|
6b706f6b5e | ||
|
|
8706e717a5 | ||
|
|
c960b091a2 | ||
|
|
9c343237d3 | ||
|
|
3cae1b1480 | ||
|
|
4095cc0532 | ||
|
|
29439c9b11 | ||
|
|
57ee73451c | ||
|
|
faffd0aa31 | ||
|
|
670e8314cc | ||
|
|
e545e68a68 | ||
|
|
08d1809b6e | ||
|
|
c7e20e95de | ||
|
|
69647b3b94 | ||
|
|
24bdae29ca | ||
|
|
63cf354129 | ||
|
|
9e5fca685c | ||
|
|
36abd86929 | ||
|
|
617e2d9ecc | ||
|
|
5199f464c1 | ||
|
|
2e1320796e | ||
|
|
a1c864373a | ||
|
|
0d7c019eec | ||
|
|
d7a7302ec8 | ||
|
|
19666060b2 | ||
|
|
d73a1e6fc8 | ||
|
|
b0a377e66c | ||
|
|
52a6b04f8e | ||
|
|
4f8dccc8a3 | ||
|
|
71c85f31cd | ||
|
|
caf768e3c4 | ||
|
|
fd0e8da8ff | ||
|
|
acfb844852 | ||
|
|
723ce0b166 |
134
.github/workflows/quality.yml
vendored
134
.github/workflows/quality.yml
vendored
@@ -151,63 +151,6 @@ jobs:
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:fetch-targets
|
||||
# docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
|
||||
- run: npm run check:deps
|
||||
# #8522: --base-ref mode for PR events — compare against max(frozen, base) so
|
||||
# inherited drift (base already over frozen cap) doesn't red an innocent PR.
|
||||
# workflow_dispatch (no PR base) falls back to absolute comparison.
|
||||
- name: File-size ratchet (base-relative on PR)
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ -n "$PR_BASE_SHA" ]; then
|
||||
npm run check:file-size -- --base-ref "$PR_BASE_SHA"
|
||||
else
|
||||
npm run check:file-size
|
||||
fi
|
||||
- run: npm run check:error-helper
|
||||
- run: npm run check:migration-numbering
|
||||
- run: npm run check:public-creds
|
||||
- run: npm run check:db-rules
|
||||
- run: npm run check:known-symbols
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:test-runner-api
|
||||
# Guards tap.testFiles drift: a covering unit test absent from stryker.conf.json
|
||||
# tap.testFiles makes its module's mutants survive on a cold nightly-mutation run,
|
||||
# false-failing the blocking mutationScore ratchet. See check-mutation-test-coverage.mjs.
|
||||
- run: npm run check:mutation-test-coverage
|
||||
- run: npm run check:any-budget:t11
|
||||
# Build-scope guard: fails if worktrees/cruft leak into the tsconfig include
|
||||
# scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031.
|
||||
- run: npm run check:build-scope
|
||||
# Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file
|
||||
# leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
|
||||
# the release PR's heavy Package Artifact job.
|
||||
- run: npm run check:pack-policy
|
||||
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
|
||||
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
|
||||
- run: npm run check:complexity-ratchets
|
||||
# ── G0 (trilho .50): gates do trilho A que faltavam no trilho B ──────────────
|
||||
# The god-file refactor happens in PRs→release/**; without these, the release
|
||||
# rail never sees a new import cycle, dead code, duplication or a security
|
||||
# regression until the release PR to main. Deliberately NOT brought here:
|
||||
# bundle-size (self-skips without a build — this rail's build job is advisory
|
||||
# and uploads nothing, so it would be dead configuration) and the coverage
|
||||
# run (fast-unit already runs the full suite; the coverage ratchet stays on
|
||||
# the main rail via --allow-missing in lint-guard).
|
||||
- run: npm run check:cycles
|
||||
- run: npm run check:lockfile
|
||||
- name: Duplication ratchet
|
||||
run: npm run check:duplication
|
||||
- name: Dead-code ratchet (knip)
|
||||
run: npm run check:dead-code
|
||||
- name: Type coverage ratchet
|
||||
run: npm run check:type-coverage
|
||||
- name: Compression budget ratchet
|
||||
run: npm run check:compression-budget
|
||||
# Security scanners — same hardened install as ci.yml quality-extended
|
||||
# (gh release download = authenticated, 5000 req/hr; curl to api.github.com
|
||||
# is rate-limited to 60/hr and silently no-ops when throttled). The blocking
|
||||
@@ -251,30 +194,63 @@ jobs:
|
||||
"$HOME/.local/bin/osv-scanner" --version || true
|
||||
"$HOME/.local/bin/oasdiff" --version || true
|
||||
zizmor --version || true
|
||||
- name: Secret scan (gitleaks, ratchet, blocking)
|
||||
run: npm run check:secrets -- --ratchet
|
||||
- name: Vulnerability ratchet (osv-scanner, ratchet, blocking)
|
||||
run: npm run check:vuln-ratchet -- --ratchet
|
||||
- name: Workflow lint (actionlint+zizmor, ratchet, blocking)
|
||||
run: npm run check:workflows -- --ratchet
|
||||
# BASE_REF is read by the script from the env (never interpolated into a
|
||||
# shell body) — workflow-injection-safe. actions/checkout fetches remote
|
||||
# refs, not a local branch named github.base_ref, so prefix origin/ or this
|
||||
# gate self-skips every PR with reason=base-unresolved.
|
||||
- name: OpenAPI breaking-change (oasdiff, ratchet, blocking)
|
||||
# Quality gates (all, non-fail-fast) — #8542: replaces 17 bare check:* steps,
|
||||
# 6 G0 gates, 4 ratchet gates, and 3 typecheck steps with a single aggregation
|
||||
# step. Each gate runs in a loop with ::group::; failures are collected and
|
||||
# reported at the end. set -uo pipefail (NOT set -e) so one failing gate does
|
||||
# not abort the job and mask every later gate. Release-added gates are folded
|
||||
# in: open-sse typecheck (#8781) and file-size base-relative mode (#8522).
|
||||
- name: Quality gates (all, non-fail-fast)
|
||||
env:
|
||||
# #8522: base-relative file-size mode on PR events — inherited drift (base
|
||||
# already over frozen cap) must not red an innocent PR. Unset on
|
||||
# workflow_dispatch (no PR base) → absolute comparison.
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
|
||||
run: npm run check:openapi-breaking -- --ratchet
|
||||
- name: Typecheck (core)
|
||||
run: npm run typecheck:core
|
||||
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
|
||||
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
|
||||
- name: Typecheck (dashboard)
|
||||
run: npm run check:dashboard-typecheck
|
||||
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
|
||||
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
|
||||
- name: Typecheck (open-sse)
|
||||
run: npm run check:open-sse-typecheck
|
||||
run: |
|
||||
set -uo pipefail
|
||||
gates=(
|
||||
provider-consistency fetch-targets deps file-size error-helper
|
||||
migration-numbering public-creds db-rules known-symbols
|
||||
route-guard-membership test-discovery test-runner-api
|
||||
mutation-test-coverage any-budget:t11 build-scope pack-policy
|
||||
complexity-ratchets
|
||||
cycles lockfile duplication dead-code type-coverage compression-budget
|
||||
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
|
||||
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
|
||||
open-sse-typecheck
|
||||
)
|
||||
ratchet_gates=(
|
||||
secrets vuln-ratchet workflows openapi-breaking
|
||||
)
|
||||
failed=()
|
||||
for g in "${gates[@]}"; do
|
||||
echo "::group::check:$g"
|
||||
# #8522: file-size is base-relative on PR events (compare against
|
||||
# max(frozen, base)) so inherited drift doesn't red an innocent PR;
|
||||
# workflow_dispatch (no PR base) falls back to absolute comparison.
|
||||
if [ "$g" = "file-size" ] && [ -n "${PR_BASE_SHA:-}" ]; then
|
||||
npm run "check:$g" -- --base-ref "$PR_BASE_SHA" || failed+=("$g")
|
||||
else
|
||||
npm run "check:$g" || failed+=("$g")
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
for g in "${ratchet_gates[@]}"; do
|
||||
echo "::group::check:$g (ratchet)"
|
||||
npm run "check:$g" -- --ratchet || failed+=("$g")
|
||||
echo "::endgroup::"
|
||||
done
|
||||
echo "::group::typecheck:core"
|
||||
npm run typecheck:core || failed+=("typecheck:core")
|
||||
echo "::endgroup::"
|
||||
echo "::group::check:dashboard-typecheck"
|
||||
npm run check:dashboard-typecheck || failed+=("check:dashboard-typecheck")
|
||||
echo "::endgroup::"
|
||||
if (( ${#failed[@]} )); then
|
||||
printf '::error::%d gate(s) failed: %s\n' "${#failed[@]}" "${failed[*]}"
|
||||
exit 1
|
||||
fi
|
||||
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
|
||||
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
|
||||
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -265,3 +265,7 @@ docker-compose.yml.bak
|
||||
# output/**/.playwright-cli/ (covered above), but anything written directly to
|
||||
# output/ would otherwise show up as untracked.
|
||||
/output/
|
||||
|
||||
# _tasks e um repo git SEPARADO (ver AGENTS.md). _tasks/ (com barra) NAO ignora um
|
||||
# SYMLINK _tasks; /_tasks (ancorado) cobre symlink/dir na raiz (incidente 2026-08-08).
|
||||
/_tasks
|
||||
|
||||
@@ -4403,11 +4403,11 @@ export function buildStaticProviderEntry(
|
||||
entry.release_date = raw.release_date;
|
||||
}
|
||||
|
||||
// OC's static-catalog reader parses each key on `/` and rejects the
|
||||
// entire provider block if ANY key resolves to a parsed providerID that
|
||||
// has no corresponding provider block. So bare keys (no `/`) MUST be
|
||||
// prefixed with the resolved providerId. Already-prefixed keys
|
||||
// (e.g. `cc/claude-opus-4-7`) are left as-is to avoid double-prefixing.
|
||||
// #9175: OC's `getModel` looks the model up by BARE id — the part after
|
||||
// the first `/` in the user's request — so a dict key with an embedded
|
||||
// provider prefix (`<providerId>/<raw-id>`) is unreachable. Keys are the
|
||||
// raw id verbatim; ids that already contain `/` (e.g. `cc/claude-opus-4-7`)
|
||||
// keep it because the slash is part of the upstream model id itself.
|
||||
models[raw.id] = entry;
|
||||
}
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
// Stripped per-model shape: name + cap flags + modalities + (optional)
|
||||
// cost. OC's SDK static schema accepts only `limit.{context,output}` —
|
||||
// `limit.input` is NOT in the SDK shape and gets dropped silently.
|
||||
const claude = entry.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
const claude = entry.models["claude-sonnet-4-6"];
|
||||
assert.ok(claude, "claude model surfaced");
|
||||
assert.equal(claude.name, "claude-sonnet-4-6");
|
||||
assert.equal(claude.attachment, true);
|
||||
@@ -248,7 +248,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
|
||||
// Combo surfaces under bare key + LCD'd
|
||||
// (gemini's reasoning=false → combo reasoning=false).
|
||||
const combo = entry.models["omniroute/claude-tier"];
|
||||
const combo = entry.models["claude-tier"];
|
||||
assert.ok(combo, "combo surfaced under bare key");
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false");
|
||||
@@ -471,10 +471,10 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
assert.ok(entry);
|
||||
const ids = Object.keys(entry.models).sort();
|
||||
assert.deepEqual(ids, [
|
||||
"opencode-omniroute/claude-sonnet-4-6",
|
||||
"opencode-omniroute/gemini-3-flash",
|
||||
"claude-sonnet-4-6",
|
||||
"gemini-3-flash",
|
||||
]);
|
||||
assert.equal(entry.models["omniroute/claude-tier"], undefined, "no combo entry");
|
||||
assert.equal(entry.models["claude-tier"], undefined, "no combo entry");
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
"combos-fetch breadcrumb emitted"
|
||||
@@ -723,7 +723,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro
|
||||
}
|
||||
|
||||
// Sanity: claude entry has all expected stripped fields.
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
assert.equal(typeof claude.name, "string");
|
||||
assert.equal(typeof claude.attachment, "boolean");
|
||||
assert.equal(typeof claude.reasoning, "boolean");
|
||||
@@ -748,8 +748,8 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["omniroute/claude-tier"], undefined);
|
||||
assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]);
|
||||
assert.equal(block.models["claude-tier"], undefined);
|
||||
assert.ok(block.models["claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -765,7 +765,7 @@ test("buildStaticProviderEntry: emits modalities.input from raw.input_modalities
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
assert.deepEqual(claude.modalities?.input, ["text", "image"]);
|
||||
assert.deepEqual(claude.modalities?.output, ["text"]);
|
||||
});
|
||||
@@ -779,7 +779,7 @@ test("buildStaticProviderEntry: never emits limit.input (OC SDK rejects it)", ()
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
assert.equal((claude.limit as Record<string, unknown>).input, undefined);
|
||||
assert.equal(typeof claude.limit?.context, "number");
|
||||
assert.equal(typeof claude.limit?.output, "number");
|
||||
@@ -807,7 +807,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", ()
|
||||
"sk-test",
|
||||
enrichment
|
||||
);
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
assert.equal(claude.cost?.input, 3);
|
||||
assert.equal(claude.cost?.output, 15);
|
||||
assert.equal(claude.cost?.cache_read, 0.3);
|
||||
@@ -828,8 +828,8 @@ test("buildStaticProviderEntry: emits release_date when raw carries it; omits wh
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["opencode-omniroute/claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["opencode-omniroute/gemini-3-flash"].release_date, undefined);
|
||||
assert.equal(block.models["claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["gemini-3-flash"].release_date, undefined);
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => {
|
||||
@@ -858,7 +858,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const combo = block.models["omniroute/mixed-tier"];
|
||||
const combo = block.models["mixed-tier"];
|
||||
assert.ok(combo, "combo emitted under slug key");
|
||||
// claude has text+image, text-only has text → intersection drops image.
|
||||
assert.deepEqual(combo.modalities?.input, ["text"]);
|
||||
@@ -967,10 +967,10 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
// Combo names still come from /api/combos — enrichment overlay does NOT touch combos.
|
||||
assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier");
|
||||
assert.equal(entry.models["claude-tier"].name, "Claude Tier");
|
||||
assert.equal(enrichmentFetcher.callCount(), 1);
|
||||
});
|
||||
|
||||
@@ -1000,7 +1000,7 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
|
||||
assert.ok(entry);
|
||||
assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag");
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
@@ -1027,7 +1027,7 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata
|
||||
];
|
||||
assert.ok(entry, "static block still published on enrichment failure");
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
@@ -1229,11 +1229,11 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
entry.models["claude-sonnet-4-6"],
|
||||
"stale snapshot hydrated into static block"
|
||||
);
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6 (cached)",
|
||||
"stale enrichment also reused"
|
||||
);
|
||||
@@ -1281,7 +1281,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1332,12 +1332,12 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini - Gemini 3 Flash");
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini - Gemini 3 Flash");
|
||||
// Combos stay untouched — `Combo: ` prefix already conveys multi-upstream.
|
||||
assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier");
|
||||
assert.equal(entry.models["claude-tier"].name, "Claude Tier");
|
||||
});
|
||||
|
||||
test("config: providerTag=false suppresses the suffix", async () => {
|
||||
@@ -1364,7 +1364,7 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6",
|
||||
"enriched name kept, provider tag suppressed"
|
||||
);
|
||||
@@ -1396,7 +1396,7 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => {
|
||||
@@ -1423,7 +1423,7 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => {
|
||||
@@ -1451,7 +1451,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryA.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
entryA.models["claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
|
||||
@@ -1462,7 +1462,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryB.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
entryB.models["claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
});
|
||||
@@ -1516,7 +1516,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros
|
||||
);
|
||||
// Pre-fix: Parent would advertise 200_000 (only raw-big counted).
|
||||
// Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck).
|
||||
const parent = block.models["omniroute/parent"];
|
||||
const parent = block.models["parent"];
|
||||
assert.ok(parent, "Parent combo must be in the static catalog");
|
||||
assert.equal(parent.limit?.context, 8_000);
|
||||
});
|
||||
|
||||
@@ -111,7 +111,9 @@ test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID
|
||||
// `opencode-omniroute`. Confirmed against the issue's own curl repro
|
||||
// (`model: "opencode-omniroute/hermes-smart-stack"` → "No active
|
||||
// credentials for provider: opencode-omniroute").
|
||||
test("#7976: buildStaticProviderEntry keys bare-slug combo ids with the unprefixed omnirouteProviderId (no double OC-gate prefix)", () => {
|
||||
// #9175 tightened this further: OC's `getModel` looks models up by BARE id,
|
||||
// so combo dict keys now carry NO prefix at all (not even `omniroute/`).
|
||||
test("#7976/#9175: buildStaticProviderEntry keys combos by bare slug (no prefix at all — never the OC-gate providerId)", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
|
||||
assert.equal(resolved.providerId, "opencode-omniroute");
|
||||
assert.equal(resolved.omnirouteProviderId, "omniroute");
|
||||
@@ -131,7 +133,7 @@ test("#7976: buildStaticProviderEntry keys bare-slug combo ids with the unprefix
|
||||
"sk-test"
|
||||
);
|
||||
|
||||
assert.deepEqual(Object.keys(block.models), ["omniroute/hermes-smart-stack"]);
|
||||
assert.deepEqual(Object.keys(block.models), ["hermes-smart-stack"]);
|
||||
assert.equal(
|
||||
block.models["opencode-omniroute/hermes-smart-stack"],
|
||||
undefined,
|
||||
|
||||
@@ -52,8 +52,11 @@ export class ServerSupervisor {
|
||||
// silently, so a boot that never becomes ready looked like a dead hang with zero
|
||||
// output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside
|
||||
// stderr so a readiness timeout can surface what the child actually printed.
|
||||
// #9156: macOS launchd cannot resolve bare "node" because its PATH is
|
||||
// minimal. Always use process.execPath (the absolute path to the running
|
||||
// Node.js binary) so the supervisor never depends on PATH resolution.
|
||||
this.child = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
process.execPath,
|
||||
process.versions.bun
|
||||
? [this.serverPath]
|
||||
: buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath),
|
||||
|
||||
1
changelog.d/fixes/7754-best-free-fallback.md
Normal file
1
changelog.d/fixes/7754-best-free-fallback.md
Normal file
@@ -0,0 +1 @@
|
||||
- test(combo): guard auto/best-free never leaks the combo name as a model (#7754)
|
||||
1
changelog.d/fixes/8542-fix.plan.md
Normal file
1
changelog.d/fixes/8542-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(ci): aggregate all fast-gates into non-fail-fast loop so one red gate no longer masks later gates (#8542)
|
||||
1
changelog.d/fixes/8847-bun-prebuilds.md
Normal file
1
changelog.d/fixes/8847-bun-prebuilds.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(build): include better-sqlite3 prebuilds in standalone bun bundle
|
||||
1
changelog.d/fixes/8906-quota-pool-combo-cleanup.md
Normal file
1
changelog.d/fixes/8906-quota-pool-combo-cleanup.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(quota):** Deleting a quota pool now removes its scoped managed combos without racing in-flight pool mutations ([#8906](https://github.com/diegosouzapw/OmniRoute/pull/8906)) — thanks @xiaoyaner0201
|
||||
1
changelog.d/fixes/8951-fix.plan.md
Normal file
1
changelog.d/fixes/8951-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(github): add targetFormat to GPT-5.6 Sol/Terra/Luna models (#8951)
|
||||
1
changelog.d/fixes/8997-gpt56-max-reasoning.plan.md
Normal file
1
changelog.d/fixes/8997-gpt56-max-reasoning.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** the Responses-to-Chat promotion path called `normalizeResponsesReasoningEffort` without the model argument, so GPT-5.6 Sol/Terra/Luna requests with `reasoning.effort: "max""` were downgraded to `"xhigh"`. The model is now threaded through, preserving `max` for GPT-5.6 while keeping the legacy downgrade for older models ([#8997](https://github.com/diegosouzapw/OmniRoute/pull/8997))
|
||||
1
changelog.d/fixes/9034-fix.plan.md
Normal file
1
changelog.d/fixes/9034-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): use configured prefix instead of raw node UUID for alias-backed model id in /v1/models (#9034)
|
||||
1
changelog.d/fixes/9057-fix.plan.md
Normal file
1
changelog.d/fixes/9057-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): auto/* routing aliases bypass API-key allowedConnections/disableNonPublicModels (#9057)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(providers): admit audio-speech/audio-transcriptions apiType in audio route provider-node filters (#9096)
|
||||
1
changelog.d/fixes/9134-fix.plan.md
Normal file
1
changelog.d/fixes/9134-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): resolve combo names in audio transcriptions route so /v1/models stays honest (#9134)
|
||||
1
changelog.d/fixes/9140-fix.plan.md
Normal file
1
changelog.d/fixes/9140-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(vscode): allow built-in auto-routing models in VS Code model filter (#9140)
|
||||
1
changelog.d/fixes/9142-fix.plan.md
Normal file
1
changelog.d/fixes/9142-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(background): detect Anthropic top-level system prompts for background task detection (#9142)
|
||||
1
changelog.d/fixes/9156-macos-autostart-execpath.md
Normal file
1
changelog.d/fixes/9156-macos-autostart-execpath.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cli): use process.execPath for macOS launchd autostart
|
||||
1
changelog.d/fixes/9160-fix.plan.md
Normal file
1
changelog.d/fixes/9160-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(model-discovery): ingest capabilities.effort_tiers for synced models (#9160)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(translator): buffer and normalize upstream tool-call argument deltas so optional null values are stripped before reaching the client (#9168)
|
||||
1
changelog.d/fixes/9177-fix.plan.md
Normal file
1
changelog.d/fixes/9177-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(translator): avoid double-normalizing tool names in Gemini-to-Claude response path (#9177)
|
||||
1
changelog.d/fixes/9269-fix.plan.md
Normal file
1
changelog.d/fixes/9269-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(classify429):** add missing `have exhausted their quota` pattern so the synthetic 429 from auth.ts is recognized as quota exhaustion, preventing the combo loop from burning retries against the same provider instead of falling back to a healthy one ([#9269](https://github.com/diegosouzapw/OmniRoute/issues/9269))
|
||||
1
changelog.d/fixes/9305-fix.plan.md
Normal file
1
changelog.d/fixes/9305-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): broaden OMNIROUTE_SSE_COMMENTS to accept 'false','0','no' and gate metadata comment emission (#9305)
|
||||
1
changelog.d/fixes/9306-fix.plan.md
Normal file
1
changelog.d/fixes/9306-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(lmarena): encode SSE stream chunks as Uint8Array to prevent TextDecoder TypeError (#9306)
|
||||
1
changelog.d/fixes/9486-claude-400-quota.md
Normal file
1
changelog.d/fixes/9486-claude-400-quota.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): classify 400 out of extra usage as quota_exhausted for Anthropic OAuth
|
||||
1
changelog.d/fixes/9615-docker-colocate-partial-trace.md
Normal file
1
changelog.d/fixes/9615-docker-colocate-partial-trace.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(docker):** standalone co-location now completes packages Next's file tracing materialized partially (package.json without its `main` payload) — unblocks the Docker Hub publish that failed on every v3.8.50 push with `Cannot find module '@atjsh/llmlingua-2/dist/index.js'` ([#9615](https://github.com/diegosouzapw/OmniRoute/pull/9615))
|
||||
1
changelog.d/fixes/9623-connection-test-recovery.md
Normal file
1
changelog.d/fixes/9623-connection-test-recovery.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(resilience): failed connection test now sets a short cooldown so connections recover after transient outages (#9623)
|
||||
1
changelog.d/fixes/9624-telemetry-cleanup-wiring.md
Normal file
1
changelog.d/fixes/9624-telemetry-cleanup-wiring.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): wire telemetry cleanup scheduler in Next.js startup path (#9624)
|
||||
1
changelog.d/fixes/9625-domain-cost-ms.md
Normal file
1
changelog.d/fixes/9625-domain-cost-ms.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): align domain_cost_history cleanup cutoff with millisecond column (#9625)
|
||||
1
changelog.d/fixes/9626-playground-errors.md
Normal file
1
changelog.d/fixes/9626-playground-errors.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(playground): surface provider model loading errors and offer retry (#9626)
|
||||
1
changelog.d/fixes/9633-npm-build-files.md
Normal file
1
changelog.d/fixes/9633-npm-build-files.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(build): add build-next-isolated.mjs sibling imports to package.json files array
|
||||
1
changelog.d/fixes/9737-memory-id-route-backend.md
Normal file
1
changelog.d/fixes/9737-memory-id-route-backend.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fixed `GET`/`PUT`/`DELETE /api/memory/[id]` always failing with a 500 (`Primary backend "sqlite" not registered`) when the route was reached before any other memory endpoint in the same process.
|
||||
1
changelog.d/fixes/9737-route-body-validation.md
Normal file
1
changelog.d/fixes/9737-route-body-validation.md
Normal file
@@ -0,0 +1 @@
|
||||
- Replaced hand-rolled body type checks with Zod validation in the plugins marketplace install route and the three Dario admin routes, restoring the `t06:route-validation` gate (Hard Rule #7).
|
||||
1
changelog.d/fixes/9737-vietnamese-locale-parity.md
Normal file
1
changelog.d/fixes/9737-vietnamese-locale-parity.md
Normal file
@@ -0,0 +1 @@
|
||||
- Restore Vietnamese locale parity after the entity-normalization sync dropped Radar, provider, and mini-playground messages.
|
||||
1
changelog.d/fixes/9826-command-code-responses-usage.md
Normal file
1
changelog.d/fixes/9826-command-code-responses-usage.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(executors):** preserve Command Code usage in `/v1/responses` streams so Codex clients receive real input, output, cache, and reasoning token counts ([#9826](https://github.com/diegosouzapw/OmniRoute/pull/9826)) — thanks @MrShitFox
|
||||
1
changelog.d/fixes/9828-codex-redundant-oneof-enum.md
Normal file
1
changelog.d/fixes/9828-codex-redundant-oneof-enum.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(executors):** prevent intermittent Codex `upstream_empty_response` errors for tool schemas that combine `oneOf` const branches with a matching sibling `enum` by removing only the semantically redundant `oneOf`; bare, narrowing, non-matching, and type-discriminated `oneOf` schemas remain unchanged. ([#9828](https://github.com/diegosouzapw/OmniRoute/pull/9828))
|
||||
1
changelog.d/fixes/9834-cursor-selected-image-blobid.md
Normal file
1
changelog.d/fixes/9834-cursor-selected-image-blobid.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cursor):** SelectedImage uses `blobIdWithData` + session blobStore, with JPEG soft-cap prep via sharp ([#9834](https://github.com/diegosouzapw/OmniRoute/pull/9834)) — thanks @yansigit
|
||||
@@ -0,0 +1 @@
|
||||
- **test(cli):** OpenCode plugin suite realigned to the bare-key static-catalog contract from #9178/#9175 (21 tests were red on every opencode-plugin CI run; 287/287 after) ([#9614](https://github.com/diegosouzapw/OmniRoute/pull/9614))
|
||||
1
changelog.d/maintenance/9738-deadcode-radar-referrals.md
Normal file
1
changelog.d/maintenance/9738-deadcode-radar-referrals.md
Normal file
@@ -0,0 +1 @@
|
||||
- Removed the unused `RadarReferrals` type export left by the radar referral-links feature (#9697), returning the dead-code ratchet to its 227 baseline.
|
||||
1
changelog.d/maintenance/9839-v3850-quality-ratchets.md
Normal file
1
changelog.d/maintenance/9839-v3850-quality-ratchets.md
Normal file
@@ -0,0 +1 @@
|
||||
- Reconcile the final v3.8.50 bundle-size and file-size ratchets against the measured release tip, preserving exact direction-down ceilings and their source attribution.
|
||||
@@ -96,6 +96,7 @@
|
||||
"node-machine-id",
|
||||
"omniglyph",
|
||||
"open",
|
||||
"opencode-ai",
|
||||
"ora",
|
||||
"parse5",
|
||||
"pino",
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"open-sse/handlers/search.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 34
|
||||
"count": 33
|
||||
}
|
||||
},
|
||||
"open-sse/handlers/sseParser.ts": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
|
||||
"_rebaseline_2026_08_09_9296_adobe_media_capabilities": "PR #9296 (artickc, fix/adobe-firefly-model-capabilities) own growth: src/app/api/v1/models/catalog.ts 1590->1597 (+7). The image and video catalog serializers now expose the already-normalized Adobe Firefly discovery capability data (media_capabilities, plus the existing video modality/size fields) at their only response-emission chokepoints. The discovery parser and capability normalization remain in open-sse/services/adobeFireflyModels.ts; extracting these seven serialization fields would obscure the catalog contract. Covered by tests/unit/adobe-firefly.test.ts and tests/unit/image-upscale.test.ts.",
|
||||
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent’s conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR’s own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
|
||||
"_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).",
|
||||
@@ -14,7 +14,6 @@
|
||||
"_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.",
|
||||
@@ -159,133 +158,8 @@
|
||||
"_rebaseline_2026_06_20_1409_1294_models": "Re-baseline src/lib/db/models.ts 1184->1221: combined growth of sibling fixes #1409 (cascade-delete orphaned model aliases when a provider is removed) + #1294 (persist max_input_tokens/max_output_tokens on custom models), both adding CRUD at the existing models domain module. Cohesive db module; not extractable.",
|
||||
"_rebaseline_2026_06_20_4389_thinking_toolchoice": "Re-baseline base.ts 1387->1399 (#4389): tool_choice-forced thinking guard at the existing Claude wire-image injection chokepoint (effThinking gate avoids the Anthropic 400 when tool_choice forces a tool). Cohesive guard; structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_07_18_6979_codex_test": "PR #6979 own growth: executor-codex.test.ts 1340->1347 (+7 = generalized ensureThinkingBudget assertion added to the existing codex thinking-budget cases). antigravity-test bump 942->977 REVERTED here: #7408's test split dropped that file to 888, so this PR's +35 fits under the original 942 frozen cap.",
|
||||
"_rebaseline_2026_07_24_8354_logs_timeline_sidebar": "PR #8354 (hartmark, feature/scrolling-log) own growth: src/shared/constants/sidebarVisibility/sections.ts 812->820 (+8, the single new logs-timeline SidebarItemDefinition entry added to LOGS_GROUP.items for the new /dashboard/logs/timeline scrolling request-timeline page). Irreducible data-literal wiring at the existing sidebar-sections chokepoint, same shape as every other item in the file; not extractable without an ad-hoc single-item exception to the file's otherwise-uniform multi-line item style.",
|
||||
"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": 1563,
|
||||
"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": 1167,
|
||||
"open-sse/translator/response/openai-responses.ts": 1204,
|
||||
"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": 1084,
|
||||
"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": 1629,
|
||||
"src/shared/components/analytics/charts.tsx": 1035,
|
||||
"src/shared/services/cliRuntime.ts": 1122,
|
||||
"src/sse/handlers/chat.ts": 1877,
|
||||
"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).",
|
||||
@@ -410,7 +284,134 @@
|
||||
"_rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). v1 was cap 800->900 / testCap 800->900 on 2026-07-27; v2 = v1 +20% buffer = cap 900->1000 (+100), testCap 900->1000 (+100). Justification: same as complexity v2 — the v3.8.50 release cut coincides with high-merge activity; owner accepted enlarging the headroom to cover the entire PREPARE phase (5 minor cycles .50-.54) without per-PR rebaseline noise. Targets: decompose-existing-frozen unchanged (frozen still only-shrink — see frozen[] entries and the 105 files >900 that still need structural decomposition regardless of cap); 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 (gives 150 units of post-tighten headroom vs the new 1000 ceiling). Tracked via same roadmap issue as complexity v2. Window: v3.8.50 (release cut) → v3.8.54 close (RE-TIGHTEN at v3.8.51 prep merge per ROADMAP.md). Last entry unless measured regression. v1 entry retained below for audit trail.",
|
||||
"_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": 1640,
|
||||
"open-sse/executors/chatgpt-web.ts": 3241,
|
||||
"open-sse/executors/codex.ts": 1562,
|
||||
"open-sse/executors/cursor.ts": 1563,
|
||||
"open-sse/executors/deepseek-web.ts": 1148,
|
||||
"open-sse/executors/grok-web.ts": 1044,
|
||||
"open-sse/executors/muse-spark-web.ts": 1405,
|
||||
"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": 1553,
|
||||
"open-sse/mcp-server/server.ts": 1448,
|
||||
"open-sse/mcp-server/tools/advancedTools.ts": 1120,
|
||||
"open-sse/services/accountFallback.ts": 1978,
|
||||
"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": 1167,
|
||||
"open-sse/translator/response/openai-responses.ts": 1224,
|
||||
"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": 4703,
|
||||
"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": 1324,
|
||||
"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": 1470,
|
||||
"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": 2361,
|
||||
"src/app/api/v1/models/catalog.ts": 1597,
|
||||
"src/lib/db/apiKeys.ts": 1529,
|
||||
"src/lib/db/core.ts": 1639,
|
||||
"src/lib/db/migrationRunner.ts": 1094,
|
||||
"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": 1629,
|
||||
"src/shared/components/analytics/charts.tsx": 1035,
|
||||
"src/shared/services/cliRuntime.ts": 1122,
|
||||
"src/sse/handlers/chat.ts": 1904,
|
||||
"src/sse/services/auth.ts": 2520,
|
||||
"tests/unit/account-fallback-service.test.ts": 1572,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2985,
|
||||
"open-sse/executors/hyperagent.ts": 1026,
|
||||
"src/lib/tokenHealthCheck.ts": 1053,
|
||||
"open-sse/executors/default.ts": 1042,
|
||||
"open-sse/executors/kiro.ts": 1069,
|
||||
"open-sse/translator/request/openai-to-kiro.ts": 1057,
|
||||
"open-sse/utils/sseHeartbeat.ts": 149
|
||||
},
|
||||
"_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).",
|
||||
@@ -418,15 +419,148 @@
|
||||
"_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.",
|
||||
"_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites.",
|
||||
"_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.",
|
||||
"_rebaseline_2026_07_28_8870_firefly_ref_cap_timeout": "PR #8870 (fix/adobe-firefly-gpt-ref-cap-timeout) own growth: adobeFireflyClient.ts 2322->2385 (+63 = gpt-image subject-ref hard cap at 2 + adaptive poll timeout budget (base 300s + 60s/ref, max 600s) + defensive .slice on referenceBlobs for gpt/nano/generic families). Fixes live 504s on multi-screenshot listing jobs (Featured Promo / Box Art) where 3–4+ subject refs stall colligo until the old 180s poll budget expires. Helpers adobeFireflyMaxImageRefs/adobeFireflyImageTimeoutMs live next to the existing payload/poll chokepoint (not extractable without splitting the wire recipe mid-PR). Covered by tests/unit/adobe-firefly.test.ts (ref-cap + timeout cases). Structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
|
||||
"_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_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.",
|
||||
"_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.",
|
||||
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
|
||||
"_rebaseline_2026_07_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_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.",
|
||||
"_rebaseline_2026_07_28_8870_firefly_ref_cap_timeout": "PR #8870 (fix/adobe-firefly-gpt-ref-cap-timeout) own growth: adobeFireflyClient.ts 2322->2385 (+63 = gpt-image subject-ref hard cap at 2 + adaptive poll timeout budget (base 300s + 60s/ref, max 600s) + defensive .slice on referenceBlobs for gpt/nano/generic families). Fixes live 504s on multi-screenshot listing jobs (Featured Promo / Box Art) where 3–4+ subject refs stall colligo until the old 180s poll budget expires. Helpers adobeFireflyMaxImageRefs/adobeFireflyImageTimeoutMs live next to the existing payload/poll chokepoint (not extractable without splitting the wire recipe mid-PR). Covered by tests/unit/adobe-firefly.test.ts (ref-cap + timeout cases). Structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_08_01_8964_xai_agent_tools": "PR #8964 own growth: chatCore.ts 5020->5034 at the existing native-passthrough chokepoint. Adds xAI Agent Tools passthrough for /v1/responses (xai/xai-oauth/xao): resolve nativeXaiResponsesPassthrough, force openai-responses targetFormat, stamp body marker, and OR into the existing nativeCodexPassthrough sites (web-search bypass + requestEndpointPath). Leaf logic in passthroughHelpers, responsesEndpoint, targetFormat, xai executor, responseSanitizer, usageTracking. Cohesive wiring at the Codex passthrough boundary.",
|
||||
"_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_06_v3850_inherited_drift_reconcile": "Reconciliacao 2026-08-06 do drift ACUMULADO da release/v3.8.50 apos o lote de merges de 08-05/06: 13 arquivos acima do frozen no tip puro 8180b49ce1 (medidos pelo proprio gate). O modo PR base-relative (#8522) deixa PRs inocentes passarem, e os rebaselines individuais dos PRs se perderam nas resolucoes sucessivas de conflito deste hot-file — o drift so aparece no modo absoluto (nightly/local). Crescimentos funcionais dos PRs mergeados: #9024 topology click-nav src/app/(dashboard)/dashboard/HomePageClient.tsx; #9324 OpenRouter enrich src/app/(dashboard)/dashboard/providers/page.tsx; #9329 quota card ordering src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx; #9193 context-window suffixes src/sse/handlers/chat.ts; #9332 nested Claude server tool ids open-sse/executors/base.ts; #9228 strip orphaned tool outputs open-sse/executors/codex.ts; #9236 nvidia tool-name normalize open-sse/executors/default.ts; #9314 nested tool_call validation open-sse/executors/kiro.ts; #9260 caller identity REST hops open-sse/mcp-server/server.ts; #8934 cache breakpoints tests tests/unit/chatcore-translation-paths.test.ts; #9193 suffix tests tests/unit/combo-routing-engine.test.ts; #9196 reasoning-on-tool-finish tests tests/unit/sse-auth.test.ts; #9163 GPT-5.6 Max reasoning tests tests/unit/translator-openai-to-kiro.test.ts. default.ts e kiro.ts entram no frozen (estavam sem entrada, acima do cap 1000). Atualizacao pos-medicao (a base avancou durante o ciclo do PR): src/sse/handlers/chat.ts 1857->1877 (#9184 affinity EOF evict) e open-sse/executors/default.ts 1027->1042 (#9005 Kimi K3 tool-name backfill).",
|
||||
"_rebaseline_2026_08_06b_v3850_sweepreds_drift": "Segunda reconciliacao de 2026-08-06 (/sweep-reds sobre o tip puro 2ddbbc61a6): 3 arquivos voltaram a passar do frozen apos os merges do mesmo dia, com atribuicao 1:1 por commit. (1) src/app/(dashboard)/dashboard/providers/page.tsx 1928->1944 e (2) open-sse/executors/base.ts 1635->1640, ambos do #9515 (feat(radar): flag-gated signed free-model catalog overlay, commit e7f6b1d130) — o overlay do Radar entra por wiring nos chokepoints ja existentes (a resolucao/verificacao do catalogo assinado mora fora destes dois arquivos); +16 e +5 linhas liquidas nao sao extraiveis sem inventar um leaf por callsite. (3) open-sse/services/accountFallback.ts 1966->1972 do #8704 (commit c4527f97bd), +6 linhas de dados em CREDITS_EXHAUSTED_SIGNALS ('has been exhausted', fixes #8631). src/sse/handlers/chat.ts 1880>1877 tambem estava violando e NAO entra aqui de proposito: e drenado por encolhimento na PR #9598, sem rebaseline. Crescimento proprio DESTA PR: src/lib/db/migrationRunner.ts 1077->1084 (+7) — o guard retroativo em isSchemaAlreadyApplied para os arquivos renumerados 137/138, exigido pela propria mensagem de erro de colisao do runner (ambas as migracoes sao ALTER TABLE ADD COLUMN puro, nao idempotente). Dois `case` + dois `return hasColumn(...)` + 3 linhas de comentario dentro do switch existente; nao extraivel.",
|
||||
"_rebaseline_2026_08_06c_v3850_sweepreds_pr2": "Segunda PR do /sweep-reds (fix/release-v3.8.50-basereds-0806b): tests/unit/provider-models-route.test.ts 1784->1787 (medido pelo gate, que conta split(\"\\n\").length) (+2 apos compressao de comentarios) — alinhamento de contrato forcado por dois merges do dia: #9106 tornou gemini-3.1-pro-high user-callable (a entry do alias entra na lista esperada do teste de discovery-retry, +1 linha de dado + 1 de comentario) e ff012ff420 adicionou onboardUser como bootstrap hop (exclusao no mock, ja comprimida a 1 linha). Nao ha o que encolher sem apagar o comentario que explica o porque.",
|
||||
"_rebaseline_2026_08_07_v3850_sweepreds_pr2_toolnamemap": "tests/unit/translator-openai-to-gemini.test.ts 1616->1619 (+3). O frozen estava EXATAMENTE no tamanho da base, entao qualquer linha nova viola. #9568 (c9a3361e5a) fez buildChangedToolNameMap emitir entradas IDENTIDADE (o Gemini minusculiza nomes de tool nas respostas, entao o tradutor de resposta precisa da chave para mapear de volta), o que passou a incluir `_toolNameMap` no envelope Antigravity de qualquer request com tools. As 3 linhas sao: a chave nova na lista esperada de Object.keys, 1 comentario explicando POR QUE ela aparece (sem ele o proximo leitor tenta remove-la de novo) e 1 assert do CONTEUDO do map — presenca de chave sozinha nao provaria a entrada identidade, que e justamente o comportamento novo. Nao ha o que extrair: e alinhamento de contrato dentro de um teste existente."
|
||||
"_rebaseline_2026_08_07_v3850_sweepreds_pr2_toolnamemap": "tests/unit/translator-openai-to-gemini.test.ts 1616->1619 (+3). O frozen estava EXATAMENTE no tamanho da base, entao qualquer linha nova viola. #9568 (c9a3361e5a) fez buildChangedToolNameMap emitir entradas IDENTIDADE (o Gemini minusculiza nomes de tool nas respostas, entao o tradutor de resposta precisa da chave para mapear de volta), o que passou a incluir `_toolNameMap` no envelope Antigravity de qualquer request com tools. As 3 linhas sao: a chave nova na lista esperada de Object.keys, 1 comentario explicando POR QUE ela aparece (sem ele o proximo leitor tenta remove-la de novo) e 1 assert do CONTEUDO do map — presenca de chave sozinha nao provaria a entrada identidade, que e justamente o comportamento novo. Nao ha o que extrair: e alinhamento de contrato dentro de um teste existente.",
|
||||
"_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_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": "1563",
|
||||
"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": "1553",
|
||||
"open-sse/mcp-server/server.ts": "1448",
|
||||
"open-sse/mcp-server/tools/advancedTools.ts": "1120",
|
||||
"open-sse/services/accountFallback.ts": "1978",
|
||||
"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": "1167",
|
||||
"open-sse/translator/response/openai-responses.ts": "1204",
|
||||
"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": "4703",
|
||||
"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": "1324",
|
||||
"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": "1470",
|
||||
"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": "2361",
|
||||
"src/app/api/v1/models/catalog.ts": "1597",
|
||||
"src/lib/tokenHealthCheck.ts": "1053",
|
||||
"src/lib/db/apiKeys.ts": "1529",
|
||||
"src/lib/db/core.ts": "1639",
|
||||
"src/lib/db/migrationRunner.ts": "1094",
|
||||
"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": "1629",
|
||||
"src/shared/components/analytics/charts.tsx": "1035",
|
||||
"src/shared/services/cliRuntime.ts": "1122",
|
||||
"src/sse/handlers/chat.ts": "1904",
|
||||
"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",
|
||||
"open-sse/translator/request/openai-to-kiro.ts": "1057",
|
||||
"open-sse/utils/sseHeartbeat.ts": "142",
|
||||
"_rebaseline_2026_08_04_9305_sse_comments": "#9305 fix: broadened sseCommentsEnabled()",
|
||||
"_rebaseline_2026_08_09_v3850_release_close": "Release v3.8.50 close reconciliation on e0ce95c592: src/sse/handlers/chat.ts 1904->1918 is the irreducible request-pipeline wiring from #9759 that invokes the Modality Bridge guardrail without moving its implementation into the handler; covered by the 17 Vision Bridge canaries plus the PR-1 focused suite. open-sse/translator/response/openai-responses.ts 1204->1215 is #9168's Responses tool-call argument delta buffering/normalization at the existing translator state-machine chokepoint; covered by its dedicated translator regression tests. Both values are measured by check:file-size (split-newline semantics), and the gate remains frozen at the new exact sizes.",
|
||||
"_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario."
|
||||
}
|
||||
|
||||
@@ -177,12 +177,13 @@
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"bundleSize": {
|
||||
"value": 7666,
|
||||
"value": 8045,
|
||||
"direction": "down",
|
||||
"dedicatedGate": true,
|
||||
"_rebaseline_2026_07_07_v3846_release_close": "5601->6534 (+933). v3.8.46 release close: gzip of the 4 bin/*.mjs entrypoints (size-limit + @size-limit/file) grew from this cycle's feature/fix merges pulled transitively into the CLI entrypoints (new providers, combo pipeline strategy #6396, effort/thinking standardization #6241, catalog cache-invalidation #6408). Measured 6534 locally via `check:bundle-size --ratchet` (deterministic gzip, matches CI). Legitimate cycle growth; shrink is separate debt.",
|
||||
"_rebaseline_2026_07_19_7808_codeql_alias_resolver_hook": "6534->6762 (+228). PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix): the ESM loader hook source moved out of the inline `HOOK_SOURCE` template literal in bin/aliasResolver.mjs into a real file bin/aliasResolverHook.mjs, loaded via pathToFileURL() instead of a dynamically-built `data:text/javascript,...` URL. The new file is now counted by size-limit as a 5th bin/*.mjs entrypoint. Net +228 = the hook's gzip size (previously hidden inside aliasResolver.mjs because the template literal was compressed away). Security-driven; no shrink opportunity.",
|
||||
"_rebaseline_2026_07_28_v3849_release_preflight": "6762 -> 7666 (+904). Fechamento do ciclo v3.8.49: gzip dos entrypoints bin/*.mjs (size-limit + @size-limit/file) cresceu com o que os merges do ciclo puxam transitivamente para o CLI (novos provedores — 271->290, seletor de protocolo por conexão #8861, catálogos de busca #8814, resiliência). Crescimento legítimo de ciclo, medido localmente com `npm run check:bundle-size` = 7666 (gzip determinístico, bate com o CI). Encolher é dívida separada."
|
||||
"_rebaseline_2026_07_28_v3849_release_preflight": "6762 -> 7666 (+904). Fechamento do ciclo v3.8.49: gzip dos entrypoints bin/*.mjs (size-limit + @size-limit/file) cresceu com o que os merges do ciclo puxam transitivamente para o CLI (novos provedores — 271->290, seletor de protocolo por conexão #8861, catálogos de busca #8814, resiliência). Crescimento legítimo de ciclo, medido localmente com `npm run check:bundle-size` = 7666 (gzip determinístico, bate com o CI). Encolher é dívida separada.",
|
||||
"_rebaseline_2026_08_09_v3850_release_close": "7666 -> 8045 (+379 gzip bytes, +4.9%). Release v3.8.50 close reconciliation measured twice with the real size-limit + @size-limit/file path on tip e0ce95c592. Per-entry measurements remain below their absolute budgets: omniroute.mjs 4380/15000, mcp-server.mjs 1195/5000, nodeRuntimeSupport.mjs 887/8000, reset-password.mjs 1583/6000. The growth accumulated through legitimate CLI/runtime work in this cycle, including global-install ESM alias resolution, Termux cache preparation, and MCP stdio startup hardening; no entrypoint is near its absolute ceiling. The direction:down ratchet stays blocking from this exact measured tip."
|
||||
},
|
||||
"openapiBreaking": {
|
||||
"value": 0,
|
||||
|
||||
@@ -158,7 +158,7 @@ error lines produced **95.93% token savings / 96.26% character savings** — squ
|
||||
range. But the same pipeline run against normal, non-redundant tool output (a clean `grep` match list,
|
||||
a short file read, ordinary conversational text) correctly produces **near-zero savings**, because
|
||||
there is nothing repetitive to remove and `validateCompression()` (`validation.ts`) refuses to ship a
|
||||
rewrite that would drop or alter code blocks, URLs, headings, versions, or `CONST_CASE` identifiers.
|
||||
rewrite that would drop or alter code blocks, URLs, headings, versions, or ALL-CAPS constant identifiers.
|
||||
|
||||
This is expected, safe behavior, not a bug: a coding session that mostly reads/greps clean files will
|
||||
see modest total savings even with compression fully enabled, while a session that hits a failing
|
||||
|
||||
@@ -107,15 +107,18 @@ client component never reads `process.env` itself.
|
||||
| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). |
|
||||
| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). |
|
||||
|
||||
Once a visitor has a key (`omr_` + 40 hex chars), it is set with `POST
|
||||
/api/radar/settings` (`{ supporterKey }`) — the same endpoint documented under
|
||||
[Data sync](#data-sync-is-a-separate-opt-in--the-privacy-promise) above.
|
||||
|
||||
**Known gap:** the dashboard activation screen does not yet have a dedicated
|
||||
key-paste input — pasting a key today requires calling `POST /api/radar/settings`
|
||||
directly (curl, a script, or a future UI). This release only adds the two claim/plans
|
||||
buttons; the API already accepts and masks the key, but no `<input>` for it exists in
|
||||
`src/app/(dashboard)/dashboard/radar/page.tsx` yet.
|
||||
Once a visitor has a key (`omr_` + 40 hex chars), the activation screen
|
||||
(`src/app/(dashboard)/dashboard/radar/page.tsx`) has a paste-key input as the primary
|
||||
path: pasting a key and submitting sends `POST /api/radar/settings`
|
||||
(`{ optIn: true, supporterKey }`) in one call — pasting a key both sets it and opts in,
|
||||
unlocking the screen. The format (`omr_` + 40 hex chars) is checked client-side first
|
||||
with the shared `isValidSupporterKeyFormat()` helper (`src/lib/radar/supporterKey.ts`)
|
||||
as a UX nicety; the server's Zod schema is the authoritative check either way. Once a
|
||||
key is set, the activation screen shows the masked form (`supporterKeyMasked` from
|
||||
`GET /api/radar/settings`) instead of an empty input, with a "change key" control to
|
||||
paste a new one — the raw key is never redisplayed. The two claim/plans buttons above
|
||||
remain the way to *obtain* a key in the first place; this input is where an operator
|
||||
who already has one activates it.
|
||||
|
||||
---
|
||||
|
||||
@@ -296,36 +299,84 @@ auth state — only the masked form and a `hasSupporterKey` boolean.
|
||||
|
||||
## Referral links (free credits)
|
||||
|
||||
The server-published feed carries a `referrals` section (server-side D28 work, already
|
||||
in production — this section documents the **client** consumption only):
|
||||
Referral links are served from a **standalone, always-current** feed —
|
||||
`GET /v1/referrals/latest` — separate from the catalog feed. This is deliberate: the
|
||||
catalog feed on the community tier is a snapshot that can be up to 30 days old, so a
|
||||
referral link extracted from it used to lag the server's real link list by the same
|
||||
amount (a newly-added referral wouldn't reach a free/community user for up to a month).
|
||||
The referrals feed removes that delay by syncing on its own, much shorter cadence.
|
||||
|
||||
```ts
|
||||
referrals: {
|
||||
fixed: RadarReferral[], // present in EVERY tier, including community
|
||||
campaigns: RadarReferral[], // only populated on the live (supporter) tier;
|
||||
// the community artifact always publishes []
|
||||
// GET /v1/referrals/latest response body (Ed25519-signed, same pinned key as
|
||||
// the catalog feed):
|
||||
{
|
||||
feed: "omniroute-radar-referrals",
|
||||
schemaVersion: 1,
|
||||
generatedAt: string, // ISO — deterministic: max(updatedAt) across referral
|
||||
// links, so two identical requests produce the exact
|
||||
// same signed bytes/signature
|
||||
referrals: {
|
||||
fixed: RadarReferral[], // present in EVERY tier, including no-auth/community
|
||||
campaigns: RadarReferral[], // only populated for a valid live (supporter) Bearer
|
||||
// key; no-auth/expired-key requests get []
|
||||
},
|
||||
}
|
||||
// RadarReferral = { provider, url, kind: "fixo" | "campanha", validUntil,
|
||||
// requiredAction, isDefault }
|
||||
```
|
||||
|
||||
The client never decides which tier it received or which referrals belong in which
|
||||
tier — the server already publishes two artifacts (`live`/`community`) with
|
||||
`campaigns` gated server-side, same principle as the [tiers](#tiers-community-and-live)
|
||||
section above. `RadarFeedSchema` (`src/lib/radar/feedSchema.ts`) validates `referrals`
|
||||
as a whole-object `.default({fixed:[],campaigns:[]})`, and `campaigns` defaults
|
||||
independently inside it — so a feed cached before this section existed on the server
|
||||
still parses cleanly, and `campaigns` alone can also be absent without failing
|
||||
validation. Every `RadarReferral.url` must be `https://` — a `http://` url fails
|
||||
schema validation.
|
||||
Unlike the catalog feed, this body carries no `tier` field at all — the server decides
|
||||
what to include per-request based on the `Authorization` key, so the
|
||||
`x-omniroute-feed-tier` response header is the ONLY source for the served tier
|
||||
(`referralsSync.ts::syncRadarReferrals`); an absent/unrecognized header degrades to
|
||||
`"community"`, the least-privileged assumption. `RadarReferralsFeedSchema`
|
||||
(`src/lib/radar/referralsFeedSchema.ts`) validates the whole body, reusing the same
|
||||
per-referral `RadarReferralSchema` exported from `feedSchema.ts` so both feeds validate
|
||||
individual referrals identically. Every `RadarReferral.url` must be `https://` — a
|
||||
`http://` url fails schema validation.
|
||||
|
||||
The OLD catalog-embedded `referrals` field on `RadarFeedSchema` (`feedSchema.ts`) is
|
||||
kept for backward-compat with already-cached catalog feeds, but `getRadarReferrals()`
|
||||
no longer reads it — see [Accessor](#accessor) below.
|
||||
|
||||
### Sync
|
||||
|
||||
`syncRadarReferrals()` (`src/lib/radar/referralsSync.ts`) is the ONLY module that
|
||||
touches the network for referrals, mirroring `syncRadar()`'s contract exactly: flag off
|
||||
→ `disabled`; opt-in false → `opt_out`; downloads `${RADAR_FEED_URL}/v1/referrals/latest`
|
||||
(same `RADAR_FEED_URL`/`RADAR_FEED_PUBKEY` fork overrides as the catalog), verifies the
|
||||
Ed25519 signature over the exact response bytes (`verifyFeedBytes`), validates against
|
||||
`RadarReferralsFeedSchema`, and caches into the `radar_referrals_cache` table
|
||||
(migration `142_radar_referrals_cache.sql`) — a table entirely separate from the
|
||||
catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor (an
|
||||
incoming feed with a `generatedAt` no newer than the cached one is treated as `stale`
|
||||
and never overwrites the cache — guards against a replay of an older signed artifact)
|
||||
mirror the catalog sync's own `MAX_FEED_BYTES`/version-floor guards. Never throws —
|
||||
always returns a status object; errors never carry a stack trace in `reason`.
|
||||
|
||||
Two triggers keep the referrals cache warm, both independent of the catalog's own
|
||||
24h cadence:
|
||||
|
||||
- **Sync-on-read** — `GET /api/radar/referrals` itself calls `syncRadarReferrals()`
|
||||
inline whenever the cache is missing or older than `REFERRALS_STALE_MS` (1h,
|
||||
`shouldSyncReferralsOnRead()`), before serving the response. This is what makes fixed
|
||||
links "always current" for the very next dashboard load, without waiting on any
|
||||
background timer.
|
||||
- **Scheduler side-sync** — `radarSchedulerTick()` (`scheduler.ts`) independently
|
||||
evaluates referrals staleness on the same hourly tick used for the catalog, calling
|
||||
`syncRadarReferrals()` when due. This runs regardless of whether the catalog itself
|
||||
was due that tick, and never affects `RadarTickResult`'s shape (best-effort side
|
||||
effect only, swallowed on error).
|
||||
|
||||
### Accessor
|
||||
|
||||
`src/lib/radar/index.ts` exports two read-only accessors, both never throwing (same
|
||||
defensive contract as `getRadarCatalog()` — flag off, no cache, or a corrupt/old cached
|
||||
defensive contract as `getRadarCatalog()` — flag off, no cache, or a corrupt cached
|
||||
payload all resolve to the empty shape instead of an error):
|
||||
|
||||
- `getRadarReferrals()` → `{ fixed: RadarReferral[], campaigns: RadarReferral[] }`.
|
||||
- `getRadarReferrals()` → `{ fixed: RadarReferral[], campaigns: RadarReferral[] }`,
|
||||
reading from `radar_referrals_cache` (via `getRadarReferralsCache()`) and validating
|
||||
through `RadarReferralsFeedSchema` — **not** the catalog cache.
|
||||
- `getDefaultReferralFor(provider)` → the `fixed` referral with `isDefault: true` for
|
||||
that provider, or `null`. Only looks at `fixed` — a campaign is never used as a
|
||||
provider's "default" link.
|
||||
@@ -340,11 +391,13 @@ server-only; the providers dashboard imports `referrals.ts` directly instead of
|
||||
### `GET /api/radar/referrals`
|
||||
|
||||
Follows the exact same gate order as every other Radar route: `RADAR_ENABLED` off →
|
||||
`404` (checked first, byte-identical inertia); unauthenticated → `401`; otherwise `200`
|
||||
with `{ fixed, campaigns, tier }` — `tier` comes straight from the cache row and is
|
||||
purely informative (drives the UI's soft upsell copy below), the route does no
|
||||
gating of its own. Never proxies the feed server — same local-cache-only contract as
|
||||
`/api/radar/catalog`.
|
||||
`404` (checked first, byte-identical inertia); unauthenticated → `401`; otherwise
|
||||
triggers a sync-on-read (see above) when stale, then `200` with
|
||||
`{ fixed, campaigns, tier }` — `tier` comes straight from the (possibly just-refreshed)
|
||||
cache row and is purely informative (drives the UI's soft upsell copy below). Never
|
||||
proxies the feed server directly — the route's own source contains no `fetch(` call;
|
||||
the network only ever happens inside `syncRadarReferrals()`, same local-cache-only
|
||||
principle as `/api/radar/catalog`.
|
||||
|
||||
### Dashboard UI — "Free credits" tab on `/dashboard/radar`
|
||||
|
||||
@@ -412,6 +465,16 @@ automatically (`getFeedPublicKeys()` in `src/lib/radar/pinnedKeys.ts`), and vers
|
||||
comparison, schema validation, and the merge rules apply identically to a self-hosted
|
||||
feed.
|
||||
|
||||
Referral links (see [Referral links (free credits)](#referral-links-free-credits)
|
||||
above) are a separate, optional artifact: a fork that only serves `/v1/catalog/latest`
|
||||
still works fully — `syncRadarReferrals()` degrades to `{ status: "error" }` on a `404`
|
||||
from `/v1/referrals/latest` and the cache simply stays empty, so
|
||||
`GET /api/radar/referrals` keeps returning `{ fixed: [], campaigns: [], tier: null }`
|
||||
instead of failing the rest of the page. To also offer referral links, serve
|
||||
`GET /v1/referrals/latest` satisfying `RadarReferralsFeedSchema`
|
||||
(`src/lib/radar/referralsFeedSchema.ts`) and sign it with the same Ed25519 key pair as
|
||||
the catalog feed.
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
@@ -5294,6 +5294,19 @@ paths:
|
||||
"200":
|
||||
description: Caches cleared
|
||||
|
||||
/api/modality-bridge/stats:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get Modality Bridge telemetry
|
||||
description: In-memory per-modality bridge counters (bridged, cacheHits, failures, lastUsedAt). Counters reset on process restart.
|
||||
security:
|
||||
- ManagementSessionAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Per-modality bridge stats (vision, audio)
|
||||
"401":
|
||||
description: Unauthorized
|
||||
|
||||
/api/cache/stats:
|
||||
get:
|
||||
tags: [System]
|
||||
|
||||
@@ -573,6 +573,7 @@ Response example:
|
||||
| `/api/rate-limits` | GET | Per-account rate limits |
|
||||
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
|
||||
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
|
||||
| `/api/modality-bridge/stats` | GET | In-memory Modality Bridge telemetry — per-modality `bridged`/`cacheHits`/`failures`/`lastUsedAt` counters (reset on restart; management auth) |
|
||||
|
||||
### Backup & Export/Import
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Guardrails"
|
||||
version: 3.8.40
|
||||
lastUpdated: 2026-06-28
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-07
|
||||
---
|
||||
|
||||
# Guardrails
|
||||
|
||||
> **Source of truth:** `src/lib/guardrails/`
|
||||
> **Last updated:** 2026-06-28 — v3.8.40 (injection-guard coverage + 16 KB scan bound + red-team)
|
||||
> **Last updated:** 2026-08-07 — v3.8.50 (Modality Bridge PR-1: mode selector, task-aware prompt, describe cache, transparency header + stats)
|
||||
|
||||
Guardrails enforce safety, policy, and content transformations at the boundary
|
||||
between OmniRoute and upstream providers. Each guardrail can inspect (and
|
||||
@@ -32,31 +32,107 @@ The registry auto-loads four guardrails in priority order on import
|
||||
|
||||
Lower priority numbers run **first**.
|
||||
|
||||
### Vision Bridge (`visionBridge.ts`)
|
||||
### Vision Bridge (`visionBridge.ts`) — Modality Bridge PR-1
|
||||
|
||||
Intercepts image-bearing requests aimed at **non-vision models** and replaces
|
||||
the image parts with text descriptions produced by a configurable vision model
|
||||
before the upstream call. This lets text-only providers transparently handle
|
||||
Intercepts image-bearing requests aimed at **non-vision models** and either
|
||||
reroutes the whole request to a vision-capable model or replaces the image
|
||||
parts with text descriptions produced by a configurable vision model before
|
||||
the upstream call. This lets text-only providers transparently handle
|
||||
multimodal payloads.
|
||||
|
||||
Flow:
|
||||
|
||||
1. Skip if the target model already supports vision (unless it appears in the
|
||||
forced-bridge list `isVisionBridgeForcedModel`).
|
||||
2. Extract image parts via `extractImageParts(messages)`. Skip if none.
|
||||
`extractImageParts` recognizes all three image shapes: OpenAI `image_url`,
|
||||
Anthropic base64 `source.type:"base64"`, and Anthropic URL
|
||||
`source.type:"url"` — so Claude-Code-compatible clients (e.g. Zoo Code)
|
||||
sending `{ type: "image", source: { type: "url", url } }` are described
|
||||
instead of silently dropped.
|
||||
3. Load runtime config from `getSettings()` (`visionBridgeEnabled`,
|
||||
`visionBridgeModel`, `visionBridgePrompt`, `visionBridgeTimeout`,
|
||||
`visionBridgeMaxImages`).
|
||||
4. Cap images at `maxImages`, call the vision model **in parallel**
|
||||
(`Promise.allSettled`), and inject `[Image N]: <description>` text parts
|
||||
in their place — failed images become `[Image N]: (unavailable)`.
|
||||
5. Return `modifiedPayload` + meta (`imagesProcessed`, `processingTimeMs`,
|
||||
`visionModel`).
|
||||
2. Extract image parts via `extractImageParts(messages)`
|
||||
(`visionBridgeHelpers.ts`), which delegates to the **unified media
|
||||
detector** `detectMediaParts()` in `open-sse/utils/mediaParts.ts` — the
|
||||
single source of truth shared with the combo compatibility filter.
|
||||
Extraction is allowlisted to top-level parts of the shapes
|
||||
`replaceImageParts` can splice back (the extract↔replace contract): OpenAI
|
||||
`image_url`, Anthropic base64 `source.type:"base64"`, Anthropic URL
|
||||
`source.type:"url"`, and Responses API `input_image`. Nested hits and
|
||||
indicator-only shapes are combo-filter material and are never extracted.
|
||||
Skip if none found.
|
||||
3. Resolve runtime config via `resolveVisionBridgeRuntimeSettings()`
|
||||
(`src/shared/constants/modalityBridgeDefaults.ts`): new `modalityBridge*`
|
||||
settings keys win; legacy `visionBridge*` keys remain a **one-cycle
|
||||
fallback** (rollback window). Skip before any media traversal when the
|
||||
bridge is disabled.
|
||||
4. Mode selector (`modalityBridgeVisionMode`, see table below) decides
|
||||
reroute vs describe. Reroute returns `modifiedPayload` with only `model`
|
||||
swapped, plus meta `{ rerouted, fromModel, toModel, imagesKept }`.
|
||||
5. Describe path: cap images at `maxImages`, compose the task-aware prompt,
|
||||
consult the describe cache, call the vision model **in parallel**
|
||||
(`Promise.allSettled`), and inject `[Image N]: <description>` text parts in
|
||||
their place. A failed describe yields `null` and the original image part is
|
||||
**preserved** (#4012) — except on the combo describe path when every
|
||||
describe failed, where a confirmed non-vision upstream gets an
|
||||
`(unavailable — no vision-capable provider connected)` stub instead (#8430).
|
||||
6. Return `modifiedPayload` + meta (`imagesProcessed`, `descriptions`,
|
||||
`processingTimeMs`, `visionModel`).
|
||||
|
||||
#### Mode selector (`modalityBridgeVisionMode`)
|
||||
|
||||
| Mode | Default | Behavior |
|
||||
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `auto` | ✔ | Legacy heuristic, untouched (#6640/#7204): non-combo/`auto/` models reroute to the best vision model unless the original model already has usable credentials (then describe); combo targets always describe. |
|
||||
| `describe` | | Always describe — the reroute block is skipped entirely; the user's chosen model always answers. |
|
||||
| `reroute` | | Force reroute: the keep-credentialed-model guard is bypassed. The reroute-**target** credential guard still applies — when no usable vision target exists, the request falls through to describe so raw images never reach a text-only backend (#8430). |
|
||||
|
||||
Forced modes short-circuit **before** the auto heuristic runs; `auto` behavior
|
||||
is byte-identical to the pre-PR-1 guardrail.
|
||||
|
||||
#### Task-aware describe prompt (`modalityBridgeVisionTaskAware`)
|
||||
|
||||
Default **true**. `composeVisionPrompt()` (`visionBridgeHelpers.ts`) appends
|
||||
the text of the **last user message** (truncated to 500 chars) to the base
|
||||
describe prompt, steering the description toward what the user actually asked
|
||||
(codex-vision-proxy pattern) and asking the vision model to transcribe visible
|
||||
text. With the flag off — or no user text — the base prompt is used unchanged.
|
||||
|
||||
#### Describe cache (`modalityBridge/bridgeCache.ts`)
|
||||
|
||||
In-memory LRU + TTL cache for describe outputs, shared process-wide.
|
||||
Key = `sha256(imageRef + composedPrompt + configuredBridgeModel)` with
|
||||
length-prefix framing (no field-boundary collisions). The model component is
|
||||
the **configured** bridge model, not the model that actually answered —
|
||||
`callVisionModel` may fall back internally, and keying per attempt would
|
||||
fragment the cache. Failed describes are never cached. Settings:
|
||||
|
||||
| Key | Default | Range |
|
||||
| ------------------------------- | ------- | ------- |
|
||||
| `modalityBridgeCacheEnabled` | `true` | — |
|
||||
| `modalityBridgeCacheTtlMinutes` | `60` | 1–1440 |
|
||||
| `modalityBridgeCacheMaxEntries` | `200` | 10–5000 |
|
||||
|
||||
#### Settings schema + migration
|
||||
|
||||
The new `modalityBridge*` keys are Zod-validated in `updateSettingsSchema`
|
||||
(`src/shared/validation/settingsSchemas.ts`): `modalityBridgeVisionEnabled`,
|
||||
`modalityBridgeVisionMode`, `modalityBridgeVisionModel`,
|
||||
`modalityBridgeVisionTaskAware`, `modalityBridgeVisionPrompt`,
|
||||
`modalityBridgeVisionTimeout`, `modalityBridgeVisionMaxImages`, the
|
||||
`modalityBridgeCache*` trio, and the PR-3-reserved `modalityBridgeAudio*`
|
||||
group. Migration `141_modality_bridge_settings.sql` copies existing legacy
|
||||
`visionBridge*` values to the matching new keys (idempotent, never overwrites
|
||||
an operator-set `modalityBridge*` value); the legacy keys stay accepted as a
|
||||
read fallback for one release cycle.
|
||||
|
||||
#### Transparency header + stats
|
||||
|
||||
Describe-transformed responses carry
|
||||
`x-omniroute-modality-bridge: image->text;model=<visionModel>;parts=<n>`
|
||||
(built by `buildModalityBridgeHeader()` in `modalityBridge/bridgeStats.ts`,
|
||||
stamped by `withModalityBridgeHeader()` in `src/sse/handlers/chatHelpers.ts`).
|
||||
Rerouted requests get **no** header — the payload was untouched and the model
|
||||
swap is already visible in the response body's `model` field.
|
||||
|
||||
`GET /api/modality-bridge/stats` (management auth, same tier as
|
||||
`GET /api/settings`) returns the in-memory per-modality counters
|
||||
`{ bridged, cacheHits, failures, lastUsedAt }` for `vision` (and the
|
||||
PR-3-reserved `audio`). Counters reset on process restart by design
|
||||
(telemetry, not accounting).
|
||||
|
||||
**Self-loop admission bypass:** when the describe call routes through OmniRoute's
|
||||
own `/v1` self-loop (non-standard provider model), the sub-request sends
|
||||
@@ -67,8 +143,10 @@ operator-configured `OMNIROUTE_API_KEY` / `ROUTER_API_KEY` env key (#1350) so
|
||||
is only honored for those exact credentials, so external clients cannot use the
|
||||
header to skip admission.
|
||||
|
||||
Defaults live in `src/shared/constants/visionBridgeDefaults.ts`. The guardrail
|
||||
exposes a `deps` constructor option so tests can inject fake `getSettings` and
|
||||
Legacy defaults live in `src/shared/constants/visionBridgeDefaults.ts`; the
|
||||
new mode/task-aware/cache defaults and the settings resolver live in
|
||||
`src/shared/constants/modalityBridgeDefaults.ts`. The guardrail exposes a
|
||||
`deps` constructor option so tests can inject fake `getSettings` and
|
||||
`callVisionModel` implementations.
|
||||
|
||||
### PII Masker (`piiMasker.ts`)
|
||||
|
||||
@@ -603,7 +603,7 @@ export interface ProviderNodeRow {
|
||||
}
|
||||
|
||||
/** Hosts reachable only from the operator's machine/Docker network. */
|
||||
function isLoopbackNodeHost(baseUrl: string): boolean {
|
||||
export function isLoopbackNodeHost(baseUrl: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(baseUrl).hostname;
|
||||
return (
|
||||
|
||||
@@ -149,6 +149,18 @@ export const ERROR_RULES: ErrorRule[] = [
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{
|
||||
id: "out_of_extra_usage",
|
||||
text: "out of extra usage",
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{
|
||||
id: "extra_usage_required",
|
||||
text: "extra usage required",
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{ id: "capacity", text: "capacity", backoff: true, reason: "model_capacity" },
|
||||
{ id: "overloaded", text: "overloaded", backoff: true, reason: "model_capacity" },
|
||||
{ id: "high_demand", text: "high demand", backoff: true, reason: "model_capacity" },
|
||||
|
||||
@@ -311,7 +311,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "nscale", modelId: "openai/gpt-oss-20b", displayName: "openai/gpt-oss-20b", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" },
|
||||
{ provider: "nscale", modelId: "meta-llama/Llama-4-Scout-17B-16E-Instruct", displayName: "meta-llama/Llama-4-Scout-17B-16E-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" },
|
||||
{ provider: "nscale", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "z-ai/glm-5.1", displayName: "GLM 5.1", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "z-ai/glm-5.2", displayName: "GLM 5.2", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "minimaxai/minimax-m2.7", displayName: "MiniMax M2.7", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "google/gemma-4-31b-it", displayName: "Gemma 4 31B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
@@ -321,7 +320,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "nvidia", modelId: "qwen/qwen3.5-397b-a17b", displayName: "Qwen3.5-397B-A17B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "qwen/qwen3.5-122b-a10b", displayName: "Qwen3.5-122B-A10B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "stepfun-ai/step-3.5-flash", displayName: "Step 3.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "deepseek-ai/deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "openai/gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "openai/gpt-oss-20b", displayName: "GPT OSS 20B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "nvidia/nemotron-3-super-120b-a12b", displayName: "Nemotron 3 Super 120B A12B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
|
||||
@@ -12,6 +12,10 @@ import { FREEPIK_IMAGE_PROVIDER } from "./providers/registry/freepik/index.ts";
|
||||
import { STABILITY_AI_IMAGE_MODELS } from "./providers/registry/stability-ai/imageModels.ts";
|
||||
import { GEMINI_IMAGEN_PROVIDER } from "./providers/registry/gemini/imageModels.ts";
|
||||
import { CHEAPERINFERENCE_IMAGE_PROVIDER } from "./providers/registry/cheaperinference/imageModels.ts";
|
||||
import {
|
||||
ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES,
|
||||
toRegistryImageModels,
|
||||
} from "../services/adobeFireflyModels.ts";
|
||||
|
||||
interface ImageModelEntry {
|
||||
id: string;
|
||||
@@ -22,6 +26,8 @@ interface ImageModelEntry {
|
||||
imageRequired?: boolean;
|
||||
description?: string;
|
||||
isMarket?: boolean;
|
||||
supportedSizes?: string[];
|
||||
mediaCapabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ImageProviderConfig {
|
||||
@@ -35,6 +41,7 @@ interface ImageProviderConfig {
|
||||
authHeader: string;
|
||||
format: string;
|
||||
models: ImageModelEntry[];
|
||||
routingAliases?: readonly string[];
|
||||
supportedSizes: string[];
|
||||
}
|
||||
|
||||
@@ -46,6 +53,7 @@ interface ImageModelAliasEntry {
|
||||
inputModalities?: string[];
|
||||
imageRequired?: boolean;
|
||||
description?: string;
|
||||
mediaCapabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ImageCatalogModelEntry {
|
||||
@@ -55,6 +63,7 @@ interface ImageCatalogModelEntry {
|
||||
supportedSizes: string[];
|
||||
inputModalities: string[];
|
||||
description?: string;
|
||||
mediaCapabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const IMAGE_MODEL_ALIASES: Record<string, ImageModelAliasEntry> = {
|
||||
@@ -678,55 +687,9 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
format: "adobe-firefly-image",
|
||||
models: [
|
||||
{
|
||||
id: "nano-banana-pro",
|
||||
name: "Firefly Gemini 3.0 (Nano Banana Pro)",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "nano-banana",
|
||||
name: "Firefly Gemini 2.5 (Nano Banana)",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "nano-banana-2",
|
||||
name: "Firefly Gemini 3.1 (Nano Banana 2)",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{ id: "gpt-image-2", name: "Firefly GPT Image 2", inputModalities: ["text", "image"] },
|
||||
{ id: "gpt-image", name: "Firefly GPT Image 2", inputModalities: ["text", "image"] },
|
||||
{ id: "gpt-image-1.5", name: "Firefly GPT Image 1.5", inputModalities: ["text", "image"] },
|
||||
{ id: "flux-2", name: "Firefly Flux 2", inputModalities: ["text", "image"] },
|
||||
{ id: "flux-pro", name: "Firefly Flux 1.1 Pro", inputModalities: ["text", "image"] },
|
||||
{ id: "flux-ultra", name: "Firefly Flux 1.1 Ultra", inputModalities: ["text", "image"] },
|
||||
{ id: "seedream-4", name: "Firefly Seedream 4.0", inputModalities: ["text", "image"] },
|
||||
{
|
||||
id: "seedream-5-lite",
|
||||
name: "Firefly Seedream 5.0 Lite",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "runway-gen4-image",
|
||||
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"],
|
||||
models: toRegistryImageModels(),
|
||||
routingAliases: ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES,
|
||||
supportedSizes: [],
|
||||
},
|
||||
|
||||
// Cheaper Inference (OSS-sponsor gateway). Declared AFTER adobe-firefly on
|
||||
@@ -887,7 +850,7 @@ export function parseImageModel(modelStr) {
|
||||
|
||||
// No provider prefix — try to find the model in every provider
|
||||
for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) {
|
||||
if (config.models.some((m) => m.id === modelStr)) {
|
||||
if (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr)) {
|
||||
return { provider: providerId, model: modelStr };
|
||||
}
|
||||
}
|
||||
@@ -906,9 +869,10 @@ function imageProviderCatalogEntries(
|
||||
id: `${providerId}/${model.id}`,
|
||||
name: model.name,
|
||||
provider: providerId,
|
||||
supportedSizes: config.supportedSizes,
|
||||
supportedSizes: model.supportedSizes || config.supportedSizes,
|
||||
inputModalities: model.inputModalities || ["text"],
|
||||
description: model.description || undefined,
|
||||
mediaCapabilities: model.mediaCapabilities,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
[
|
||||
"deepseek-ai/deepseek-v4-pro",
|
||||
"google/gemma-4-31b-it",
|
||||
"minimaxai/minimax-m2.7",
|
||||
"mistralai/devstral-2-123b-instruct-2512",
|
||||
@@ -13,6 +12,5 @@
|
||||
"qwen/qwen3.5-397b-a17b",
|
||||
"stepfun-ai/step-3.5-flash",
|
||||
"thinkingmachines/inkling",
|
||||
"z-ai/glm-5.1",
|
||||
"z-ai/glm-5.2"
|
||||
]
|
||||
|
||||
@@ -8,9 +8,32 @@ export const gemini_webProvider: RegistryEntry = {
|
||||
baseUrl: "https://gemini.google.com/app",
|
||||
authType: "apikey",
|
||||
authHeader: "cookie",
|
||||
// #9356: `supportsReasoning: false` is a live-behavior statement, not a guess
|
||||
// about the underlying Gemini model. The executor drives the gemini.google.com
|
||||
// web UI by typing a prompt, so it has no thinking-budget control to set and
|
||||
// never surfaces `reasoning_content` — agent routers reading /v1/models must
|
||||
// not select these for reasoning work. `toolCalling: false` is the matching
|
||||
// statement for native function calling; the prompt-emulation shim (#7286)
|
||||
// stays available and is advertised separately as `toolCalling: "emulated"`
|
||||
// on the provider constant (src/shared/constants/providers/web-cookie.ts).
|
||||
models: [
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", toolCalling: false },
|
||||
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", toolCalling: false },
|
||||
{ id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash-Lite", toolCalling: false },
|
||||
{
|
||||
id: "gemini-3.1-pro",
|
||||
name: "Gemini 3.1 Pro",
|
||||
toolCalling: false,
|
||||
supportsReasoning: false,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.5-flash",
|
||||
name: "Gemini 3.5 Flash",
|
||||
toolCalling: false,
|
||||
supportsReasoning: false,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.1-flash-lite",
|
||||
name: "Gemini 3.1 Flash-Lite",
|
||||
toolCalling: false,
|
||||
supportsReasoning: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -32,8 +32,6 @@ export const nvidiaProvider: RegistryEntry = {
|
||||
{ id: "qwen/qwen3.5-122b-a10b", name: "Qwen3.5-122B-A10B" },
|
||||
{ id: "stepfun-ai/step-3.5-flash", name: "Step 3.5 Flash" },
|
||||
{ id: "stepfun-ai/step-3.7-flash", name: "Step 3.7 Flash" },
|
||||
{ id: "deepseek-ai/deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
|
||||
{ id: "deepseek-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
|
||||
// Sweep 2026-06-19: verified present in the live NVIDIA NIM /v1/models catalog.
|
||||
{ id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" },
|
||||
{ id: "openai/gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false },
|
||||
|
||||
@@ -5,14 +5,17 @@
|
||||
* Supports local providers plus hosted task-based APIs such as Runway.
|
||||
*/
|
||||
|
||||
import { parseModelFromRegistry, getAllModelsFromRegistry } from "./registryUtils.ts";
|
||||
import { parseModelFromRegistry } from "./registryUtils.ts";
|
||||
import { RUNWAYML_SUPPORTED_VIDEO_MODELS } from "./runway.ts";
|
||||
import { SEGMIND_VIDEO_MODELS } from "./providers/registry/segmind/videoModels.ts";
|
||||
import { toRegistryVideoModels } from "../services/adobeFireflyModels.ts";
|
||||
|
||||
interface VideoModel {
|
||||
id: string;
|
||||
name: string;
|
||||
isMarket?: boolean;
|
||||
supportedSizes?: string[];
|
||||
mediaCapabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface VideoProvider {
|
||||
@@ -326,8 +329,7 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
|
||||
},
|
||||
|
||||
// Adobe Firefly (unofficial) — same IMS/cookie credential as the image entry.
|
||||
// Async 3P video generate + poll (Sora 2, Veo 3.1, Kling …). Fallback list
|
||||
// from models/discovery capture (adobe/get_models.txt).
|
||||
// Exact async video models and capabilities from the verified discovery snapshot.
|
||||
"adobe-firefly": {
|
||||
id: "adobe-firefly",
|
||||
alias: "firefly",
|
||||
@@ -335,18 +337,7 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
format: "adobe-firefly-video",
|
||||
models: [
|
||||
{ id: "sora-2", name: "Firefly Sora 2" },
|
||||
{ id: "sora-2-pro", name: "Firefly Sora 2 Pro" },
|
||||
{ id: "veo-3.1", name: "Firefly Veo 3.1" },
|
||||
{ id: "veo-3.1-fast", name: "Firefly Veo 3.1 Fast" },
|
||||
{ id: "veo-3.1-ref", name: "Firefly Veo 3.1 Reference" },
|
||||
{ id: "kling-3", name: "Firefly Kling v3 Standard I2V" },
|
||||
{ id: "kling-v3-t2v", name: "Firefly Kling v3 Standard T2V" },
|
||||
{ id: "kling-v3-pro-i2v", name: "Firefly Kling v3 Pro I2V" },
|
||||
{ id: "luma-ray3", name: "Firefly Ray3" },
|
||||
{ id: "runway-gen4-turbo", name: "Firefly Runway Gen-4 Video" },
|
||||
],
|
||||
models: toRegistryVideoModels(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -368,5 +359,17 @@ export function parseVideoModel(modelStr: string | null) {
|
||||
* Get all video models as a flat list
|
||||
*/
|
||||
export function getAllVideoModels() {
|
||||
return getAllModelsFromRegistry(VIDEO_PROVIDERS);
|
||||
return Object.entries(VIDEO_PROVIDERS).flatMap(([providerId, config]) =>
|
||||
[providerId, config.alias]
|
||||
.filter((prefix): prefix is string => Boolean(prefix))
|
||||
.flatMap((prefix) =>
|
||||
config.models.map((model) => ({
|
||||
id: `${prefix}/${model.id}`,
|
||||
name: model.name,
|
||||
provider: providerId,
|
||||
supportedSizes: model.supportedSizes || [],
|
||||
mediaCapabilities: model.mediaCapabilities,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -309,6 +309,7 @@ export function stripStoredItemReferences(body: Record<string, unknown>): void {
|
||||
|
||||
function stripOrphanedCodexFunctionCallOutputs(body: Record<string, unknown>): void {
|
||||
if (!Array.isArray(body.input)) return;
|
||||
const input = body.input;
|
||||
// A previous_response_id delegates history resolution to the upstream
|
||||
// Responses service, so a matching function_call may legitimately live in
|
||||
// that remote response rather than in the local input array.
|
||||
@@ -317,7 +318,7 @@ function stripOrphanedCodexFunctionCallOutputs(body: Record<string, unknown>): v
|
||||
const callIds = new Set<string>();
|
||||
let outputCount = 0;
|
||||
|
||||
for (const item of body.input) {
|
||||
for (const item of input) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
|
||||
@@ -341,9 +342,7 @@ function stripOrphanedCodexFunctionCallOutputs(body: Record<string, unknown>): v
|
||||
}
|
||||
|
||||
if (outputCount === 0) return;
|
||||
|
||||
const before = body.input.length;
|
||||
body.input = body.input.filter((item) => {
|
||||
const filteredInput = input.filter((item) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return true;
|
||||
const record = item as Record<string, unknown>;
|
||||
if (record.type === "function_call_output" && typeof record.call_id === "string") {
|
||||
@@ -352,7 +351,8 @@ function stripOrphanedCodexFunctionCallOutputs(body: Record<string, unknown>): v
|
||||
return true;
|
||||
});
|
||||
|
||||
const removedCount = before - body.input.length;
|
||||
const removedCount = input.length - filteredInput.length;
|
||||
body.input = filteredInput;
|
||||
if (removedCount > 0) {
|
||||
console.debug(
|
||||
`[Codex] stripOrphanedCodexFunctionCallOutputs: removed ${removedCount} orphaned function_call_output item(s)`
|
||||
|
||||
@@ -30,6 +30,114 @@ export function isCodexFreePlan(providerSpecificData: unknown): boolean {
|
||||
return typeof plan === "string" && plan.trim().toLowerCase() === "free";
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const REDUNDANT_ONEOF_OBJECT_MAP_FIELDS = [
|
||||
"properties",
|
||||
"patternProperties",
|
||||
"$defs",
|
||||
"definitions",
|
||||
] as const;
|
||||
|
||||
const REDUNDANT_ONEOF_ARRAY_SCHEMA_FIELDS = ["prefixItems", "oneOf", "anyOf", "allOf"] as const;
|
||||
|
||||
const REDUNDANT_ONEOF_SINGLE_SCHEMA_FIELDS = [
|
||||
"items",
|
||||
"additionalProperties",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
] as const;
|
||||
|
||||
const REDUNDANT_ONEOF_ANNOTATION_KEYS = new Set(["const", "description", "title", "$comment"]);
|
||||
|
||||
/**
|
||||
* Remove a redundant `oneOf` when it is fully covered by a sibling `enum`.
|
||||
*
|
||||
* The Codex private Responses endpoint (`chatgpt.com/backend-api/codex/responses`)
|
||||
* intermittently returns a 502 `upstream_empty_response` when a tool parameter
|
||||
* carries the JSON-Schema pattern `oneOf: [{const, ...annotations}]` together
|
||||
* with a sibling `enum` whose value set exactly matches the `const` set. In that
|
||||
* case `oneOf` adds no constraint beyond `enum`, so dropping it is semantically
|
||||
* safe and eliminates the trigger.
|
||||
*
|
||||
* Only the exact-match redundant case is stripped. Bare `oneOf[const]` without
|
||||
* a sibling `enum`, narrowing const sets, non-matching enums, type-discriminated
|
||||
* `oneOf`, and `anyOf`/`allOf` are all preserved.
|
||||
*/
|
||||
export function stripRedundantOneOfConstEnum(schema: unknown): unknown {
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map((entry) => stripRedundantOneOfConstEnum(entry));
|
||||
}
|
||||
if (!isPlainObject(schema)) return schema;
|
||||
|
||||
const result: JsonRecord = { ...schema };
|
||||
|
||||
maybeStripRedundantOneOf(result);
|
||||
|
||||
for (const field of REDUNDANT_ONEOF_OBJECT_MAP_FIELDS) {
|
||||
const map = result[field];
|
||||
if (isPlainObject(map)) {
|
||||
result[field] = Object.fromEntries(
|
||||
Object.entries(map).map(([key, value]) => [key, stripRedundantOneOfConstEnum(value)])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of REDUNDANT_ONEOF_ARRAY_SCHEMA_FIELDS) {
|
||||
if (Array.isArray(result[field])) {
|
||||
result[field] = (result[field] as unknown[]).map((entry) =>
|
||||
stripRedundantOneOfConstEnum(entry)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of REDUNDANT_ONEOF_SINGLE_SCHEMA_FIELDS) {
|
||||
if (result[field] !== undefined) {
|
||||
result[field] = stripRedundantOneOfConstEnum(result[field]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function maybeStripRedundantOneOf(node: JsonRecord): void {
|
||||
const branches = node.oneOf;
|
||||
if (!Array.isArray(branches) || branches.length === 0) return;
|
||||
|
||||
const enumValues = Array.isArray(node.enum) ? node.enum : null;
|
||||
if (!enumValues || enumValues.length === 0) return;
|
||||
|
||||
// Every branch must be {const, ...annotations only}.
|
||||
const constValues: unknown[] = [];
|
||||
for (const branch of branches) {
|
||||
if (!isPlainObject(branch)) return;
|
||||
const branchKeys = Object.keys(branch);
|
||||
if (!branchKeys.includes("const")) return;
|
||||
if (!branchKeys.every((key) => REDUNDANT_ONEOF_ANNOTATION_KEYS.has(key))) return;
|
||||
constValues.push((branch as JsonRecord).const);
|
||||
}
|
||||
|
||||
// Restrict to string consts and string enums (confirmed production shape).
|
||||
if (!constValues.every((value) => typeof value === "string")) return;
|
||||
if (!enumValues.every((value) => typeof value === "string")) return;
|
||||
|
||||
// All const values must be unique.
|
||||
if (new Set(constValues).size !== constValues.length) return;
|
||||
|
||||
// The const set must exactly match the enum set.
|
||||
const enumSet = new Set(enumValues);
|
||||
if (enumSet.size !== constValues.length) return;
|
||||
if (!constValues.every((value) => enumSet.has(value))) return;
|
||||
|
||||
delete node.oneOf;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function normalizeCodexTools(
|
||||
body: Record<string, unknown>,
|
||||
options?: { dropImageGeneration?: boolean; preserveCustomTools?: boolean }
|
||||
@@ -138,7 +246,9 @@ export function normalizeCodexTools(
|
||||
// Codex/OpenAI Responses API rejects `pattern` fields using regex lookaround
|
||||
// (e.g. `^(?=.*@).+$`) with a 400 "regex lookaround is not supported" error.
|
||||
// Strip those before the schema reaches upstream (9router#1556).
|
||||
const sanitizedParameters = stripUnsupportedRegexPatterns(parameters);
|
||||
const sanitizedParameters = stripRedundantOneOfConstEnum(
|
||||
stripUnsupportedRegexPatterns(parameters)
|
||||
);
|
||||
|
||||
// Rewrite in-place to Responses format
|
||||
for (const key of Object.keys(tool)) {
|
||||
|
||||
@@ -420,7 +420,61 @@ type AggregateState = {
|
||||
usage: JsonRecord | null;
|
||||
};
|
||||
|
||||
function firstRecord(record: JsonRecord, keys: readonly string[]): JsonRecord {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (isRecord(value)) return value;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function firstNumber(record: JsonRecord, keys: readonly string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = numberValue(record[key]);
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Keep earlier finish-step usage when the terminal finish event omits it. */
|
||||
function mergeCommandCodeUsage(previous: JsonRecord | null, next: unknown): JsonRecord | null {
|
||||
if (!isRecord(next)) return previous;
|
||||
|
||||
const merged: JsonRecord = { ...(previous || {}), ...next };
|
||||
for (const key of [
|
||||
"inputTokenDetails",
|
||||
"input_token_details",
|
||||
"input_tokens_details",
|
||||
"prompt_tokens_details",
|
||||
"outputTokenDetails",
|
||||
"output_token_details",
|
||||
"output_tokens_details",
|
||||
"completion_tokens_details",
|
||||
"reasoningTokenDetails",
|
||||
"reasoning_token_details",
|
||||
]) {
|
||||
const before = isRecord(previous?.[key]) ? previous[key] : {};
|
||||
const after = isRecord(next[key]) ? next[key] : {};
|
||||
if (Object.keys(before).length > 0 || Object.keys(after).length > 0) {
|
||||
merged[key] = { ...before, ...after };
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function rememberCommandCodeUsage(state: AggregateState, event: JsonRecord): void {
|
||||
const usage =
|
||||
event.type === "finish-step"
|
||||
? (event.usage ?? event.totalUsage)
|
||||
: (event.totalUsage ?? event.usage);
|
||||
state.usage = mergeCommandCodeUsage(state.usage, usage);
|
||||
}
|
||||
|
||||
function applyEventToAggregate(event: JsonRecord, state: AggregateState): void {
|
||||
// Some Command Code protocol revisions attach usage to the terminal payload
|
||||
// without preserving the event type. Capture it before event-specific handling.
|
||||
rememberCommandCodeUsage(state, event);
|
||||
|
||||
switch (event.type) {
|
||||
case "text-delta":
|
||||
state.content += stringValue(event.text) || "";
|
||||
@@ -440,9 +494,10 @@ function applyEventToAggregate(event: JsonRecord, state: AggregateState): void {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "finish-step":
|
||||
break;
|
||||
case "finish":
|
||||
state.finishReason = mapFinishReason(event.finishReason);
|
||||
state.usage = isRecord(event.totalUsage) ? event.totalUsage : null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -460,30 +515,72 @@ function applyEventToAggregateOrThrow(event: JsonRecord, state: AggregateState):
|
||||
|
||||
function usageFromCommandCode(usage: JsonRecord | null) {
|
||||
if (!usage) return undefined;
|
||||
const details = isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : {};
|
||||
const cacheRead = numberValue(details.cacheReadTokens) || 0;
|
||||
const noCache = numberValue(details.noCacheTokens) || 0;
|
||||
const inputDetails = firstRecord(usage, [
|
||||
"inputTokenDetails",
|
||||
"input_token_details",
|
||||
"input_tokens_details",
|
||||
"prompt_tokens_details",
|
||||
]);
|
||||
const outputDetails = firstRecord(usage, [
|
||||
"outputTokenDetails",
|
||||
"output_token_details",
|
||||
"output_tokens_details",
|
||||
"completion_tokens_details",
|
||||
]);
|
||||
const reasoningDetails = firstRecord(usage, [
|
||||
"reasoningTokenDetails",
|
||||
"reasoning_token_details",
|
||||
"reasoning_tokens_details",
|
||||
]);
|
||||
const cacheRead =
|
||||
firstNumber(usage, [
|
||||
"cachedInputTokens",
|
||||
"cached_input_tokens",
|
||||
"cacheReadInputTokens",
|
||||
"cache_read_input_tokens",
|
||||
"cacheReadTokens",
|
||||
"cache_read_tokens",
|
||||
"cached_tokens",
|
||||
]) ??
|
||||
firstNumber(inputDetails, [
|
||||
"cachedTokens",
|
||||
"cached_tokens",
|
||||
"cacheReadTokens",
|
||||
"cache_read_tokens",
|
||||
]);
|
||||
const noCache = firstNumber(inputDetails, ["noCacheTokens", "no_cache_tokens"]);
|
||||
// Command Code's totalUsage.inputTokens is the FULL prompt total and already
|
||||
// includes the cached portion (noCacheTokens + cacheReadTokens = inputTokens),
|
||||
// so we must NOT add cacheRead back — that would double-count. There is no
|
||||
// cache-write field in the upstream payload, so cache creation stays unset.
|
||||
const inputTokens = numberValue(usage.inputTokens) || 0;
|
||||
const prompt = inputTokens;
|
||||
const completion = numberValue(usage.outputTokens) || 0;
|
||||
const prompt =
|
||||
firstNumber(usage, ["inputTokens", "input_tokens", "promptTokens", "prompt_tokens"]) ??
|
||||
(noCache ?? 0) + (cacheRead ?? 0);
|
||||
const reasoning =
|
||||
firstNumber(usage, ["reasoningTokens", "reasoning_tokens"]) ??
|
||||
firstNumber(outputDetails, ["reasoningTokens", "reasoning_tokens"]) ??
|
||||
firstNumber(reasoningDetails, ["reasoningTokens", "reasoning_tokens"]);
|
||||
const textOutput = firstNumber(outputDetails, ["textTokens", "text_tokens"]);
|
||||
const completion =
|
||||
firstNumber(usage, [
|
||||
"outputTokens",
|
||||
"output_tokens",
|
||||
"completionTokens",
|
||||
"completion_tokens",
|
||||
]) ?? (textOutput ?? 0) + (reasoning ?? 0);
|
||||
const total = firstNumber(usage, ["totalTokens", "total_tokens"]) ?? prompt + completion;
|
||||
const result: JsonRecord = {
|
||||
prompt_tokens: prompt,
|
||||
prompt_tokens_details: { cached_tokens: cacheRead ?? 0 },
|
||||
completion_tokens: completion,
|
||||
total_tokens: prompt + completion,
|
||||
completion_tokens_details: { reasoning_tokens: reasoning ?? 0 },
|
||||
total_tokens: total,
|
||||
};
|
||||
// Surface the cache breakdown as informational fields so logUsage prints
|
||||
// `| cache_read=X | no_cache=Y` and appendRequestLog persists them. These are
|
||||
// NOT added to prompt_tokens (already included) — metering stays accurate.
|
||||
if (cacheRead > 0) result.cache_read_input_tokens = cacheRead;
|
||||
if (noCache > 0) result.no_cache_tokens = noCache;
|
||||
// Keep reasoning_token_details (reasoningTokens) when present so stream.ts's
|
||||
// extractUsage can surface it as reasoning_tokens.
|
||||
const reasoningDetails = isRecord(usage.reasoningTokenDetails) ? usage.reasoningTokenDetails : {};
|
||||
const reasoning = numberValue(reasoningDetails.reasoningTokens);
|
||||
if (cacheRead !== undefined && cacheRead > 0) result.cache_read_input_tokens = cacheRead;
|
||||
if (noCache !== undefined && noCache > 0) result.no_cache_tokens = noCache;
|
||||
if (reasoning !== undefined && reasoning > 0) result.reasoning_tokens = reasoning;
|
||||
return result;
|
||||
}
|
||||
@@ -523,6 +620,7 @@ function createStreamResponse(
|
||||
|
||||
const emitEvent = (event: unknown) => {
|
||||
if (!isRecord(event) || closed) return;
|
||||
rememberCommandCodeUsage(state, event);
|
||||
if (!sentRole) {
|
||||
sentRole = true;
|
||||
controller.enqueue(sse(chatCompletionChunk(id, model, { role: "assistant" })));
|
||||
@@ -562,9 +660,10 @@ function createStreamResponse(
|
||||
}
|
||||
case "reasoning-end":
|
||||
break;
|
||||
case "finish-step":
|
||||
break;
|
||||
case "finish": {
|
||||
state.finishReason = mapFinishReason(event.finishReason);
|
||||
state.usage = isRecord(event.totalUsage) ? event.totalUsage : null;
|
||||
controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason)));
|
||||
// Emit a standards-compliant usage-only chunk (choices: []) before
|
||||
// [DONE] when upstream reported usage. stream.ts's extractUsage
|
||||
|
||||
@@ -119,7 +119,8 @@ function serializeMessage(
|
||||
"unsupported_role"
|
||||
);
|
||||
}
|
||||
const label = role === "assistant" ? "Assistant" : role === "system" ? "System" : "User";
|
||||
// role was just narrowed to "user" | "assistant" by the guard above ("system" throws).
|
||||
const label = role === "assistant" ? "Assistant" : "User";
|
||||
const content = record.content;
|
||||
|
||||
if (typeof content === "string") return `[${label}]\n${content}`;
|
||||
|
||||
@@ -14,9 +14,13 @@
|
||||
*/
|
||||
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { prepareToolMessages } from "../translator/webTools.ts";
|
||||
import { buildToolModeResponse } from "./chatgptWebTools.ts";
|
||||
import {
|
||||
checkGeminiWebUnsupportedControls,
|
||||
GEMINI_WEB_UNSUPPORTED_CONTROL_CODE,
|
||||
} from "./gemini-web/capabilities.ts";
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -406,6 +410,33 @@ export class GeminiWebExecutor extends BaseExecutor {
|
||||
const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input;
|
||||
const requestBody = body as GeminiRequestBody;
|
||||
|
||||
// #9356: fail fast on controls this provider cannot honor (reasoning_effort
|
||||
// above "minimal", forced tool_choice). Runs before the credential check and
|
||||
// before Playwright launches — the request is unservable no matter which
|
||||
// cookie is used, and answering 200 with ordinary prose made agents believe
|
||||
// their reasoning/tool requirements had been met. See ./gemini-web/capabilities.ts.
|
||||
const violation = checkGeminiWebUnsupportedControls(body as Record<string, unknown>);
|
||||
if (violation) {
|
||||
log?.warn?.(
|
||||
"GEMINI-WEB",
|
||||
`Rejected request: "${violation.param}" is not supported by this provider`
|
||||
);
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify(
|
||||
buildErrorBody(400, violation.message, null, {
|
||||
type: "invalid_request_error",
|
||||
code: GEMINI_WEB_UNSUPPORTED_CONTROL_CODE,
|
||||
})
|
||||
),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: GEMINI_URL,
|
||||
headers: {},
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
const cookie = resolveGeminiWebCookie(credentials);
|
||||
if (!cookie) {
|
||||
return {
|
||||
|
||||
121
open-sse/executors/gemini-web/capabilities.ts
Normal file
121
open-sse/executors/gemini-web/capabilities.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Request-contract guards for the Gemini Web executor (#9356).
|
||||
*
|
||||
* gemini-web is not an API client. It launches Playwright, types ONE flat
|
||||
* prompt string into the gemini.google.com `.ql-editor` contenteditable,
|
||||
* presses Enter, and captures the first `StreamGenerate` response off the page
|
||||
* (see ../gemini-web.ts). There is no JSON request body on the wire, which
|
||||
* makes two OpenAI controls structurally impossible to honor:
|
||||
*
|
||||
* • `reasoning_effort` — no field exists to carry a thinking budget. Unlike
|
||||
* deepseek-web or perplexity-web, which post a real payload and can flip a
|
||||
* `thinking_enabled` flag or swap the model preference, there is nothing
|
||||
* here to set.
|
||||
* • forced `tool_choice` — the tools support gemini-web does have is the
|
||||
* prompt-emulation shim (`translator/webTools.ts`, #7286): it ASKS the
|
||||
* model, in prose, to answer with `<tool>{...}</tool>` and parses whatever
|
||||
* comes back. That is best-effort by construction. "required" / "any" /
|
||||
* a named function is a GUARANTEE, and a prompt cannot make one.
|
||||
*
|
||||
* Before this module both were accepted and quietly ignored, so an agent got a
|
||||
* 200 with `finish_reason: "stop"`, no `reasoning_content`, and `tool_calls: []`
|
||||
* and concluded its requirements had been met (#9356). Failing the request is
|
||||
* the honest answer: the caller can drop the control, or route to a model that
|
||||
* actually implements it.
|
||||
*
|
||||
* Deliberately NOT rejected — these are already satisfied or already work:
|
||||
* • `reasoning_effort: "none" | "minimal"` — asking for as little reasoning as
|
||||
* possible is something a non-thinking provider trivially complies with.
|
||||
* • `tool_choice: "auto" | "none"` and plain `tools[]` — the #7286 emulation
|
||||
* path, which several shipped combos depend on (#5240, #8488). Untouched.
|
||||
*
|
||||
* Pure and dependency-free so the whole contract is unit-testable without a
|
||||
* browser.
|
||||
*/
|
||||
|
||||
/** `error.code` on every compatibility rejection raised here. */
|
||||
export const GEMINI_WEB_UNSUPPORTED_CONTROL_CODE = "unsupported_control_for_provider";
|
||||
|
||||
/** Effort levels a non-thinking provider already complies with. */
|
||||
const SATISFIED_EFFORT_LEVELS = new Set(["none", "minimal"]);
|
||||
|
||||
/** `tool_choice` strings that demand a tool call rather than merely offering one. */
|
||||
const FORCING_TOOL_CHOICE_STRINGS = new Set(["required", "any"]);
|
||||
|
||||
/** `tool_choice: { type }` values that pin the model to a specific/any tool. */
|
||||
const FORCING_TOOL_CHOICE_TYPES = new Set(["function", "tool", "any"]);
|
||||
|
||||
export interface GeminiWebCapabilityViolation {
|
||||
/** Which request field could not be honored. */
|
||||
param: "reasoning_effort" | "tool_choice";
|
||||
/** Client-facing explanation — already safe to put in a response body. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim().toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `tool_choice` demands a tool call. Covers the OpenAI strings
|
||||
* ("required"), the Anthropic-flavored ones the translators also emit ("any"),
|
||||
* and the object forms that name a function or force any tool. "auto" / "none"
|
||||
* and every unrecognized shape are treated as non-forcing — this guard only
|
||||
* blocks contracts it is certain gemini-web cannot keep.
|
||||
*/
|
||||
export function isForcingToolChoice(toolChoice: unknown): boolean {
|
||||
const asString = normalizeString(toolChoice);
|
||||
if (asString) return FORCING_TOOL_CHOICE_STRINGS.has(asString);
|
||||
|
||||
if (toolChoice && typeof toolChoice === "object" && !Array.isArray(toolChoice)) {
|
||||
const type = normalizeString((toolChoice as Record<string, unknown>).type);
|
||||
return type !== null && FORCING_TOOL_CHOICE_TYPES.has(type);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when `reasoning_effort` asks for MORE thinking than "none at all". */
|
||||
export function requestsThinkingBudget(reasoningEffort: unknown): boolean {
|
||||
const effort = normalizeString(reasoningEffort);
|
||||
if (effort === null) return false;
|
||||
return !SATISFIED_EFFORT_LEVELS.has(effort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect an OpenAI-shaped request body for controls gemini-web cannot honor.
|
||||
* Returns the first violation found, or `null` when the request is servable.
|
||||
*
|
||||
* `reasoning_effort` is checked before `tool_choice` only for determinism; a
|
||||
* request carrying both is rejected either way.
|
||||
*/
|
||||
export function checkGeminiWebUnsupportedControls(
|
||||
body: Record<string, unknown> | null | undefined
|
||||
): GeminiWebCapabilityViolation | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
|
||||
if (requestsThinkingBudget(body.reasoning_effort)) {
|
||||
return {
|
||||
param: "reasoning_effort",
|
||||
message:
|
||||
'Model provider "gemini-web" does not support "reasoning_effort". It drives the ' +
|
||||
"gemini.google.com web UI through a typed prompt and has no thinking-budget control " +
|
||||
'to set, so any effort above "minimal" would be silently ignored. Remove ' +
|
||||
'"reasoning_effort" (or send "none"/"minimal") or route to a reasoning-capable model.',
|
||||
};
|
||||
}
|
||||
|
||||
if (isForcingToolChoice(body.tool_choice)) {
|
||||
return {
|
||||
param: "tool_choice",
|
||||
message:
|
||||
'Model provider "gemini-web" cannot guarantee a forced tool call. Its tool support is ' +
|
||||
"prompt-emulated — the model is asked to emit a tool block and may answer with prose " +
|
||||
'instead — so "tool_choice" values that require one ("required", "any", or a named ' +
|
||||
'function) cannot be honored. Use "auto" to keep best-effort tool calling, or route to ' +
|
||||
"a model with native function calling.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ type KiroStreamState = {
|
||||
contextUsagePercentage?: number;
|
||||
hasContextUsage?: boolean;
|
||||
hasMeteringEvent?: boolean;
|
||||
usage?: UsageSummary;
|
||||
usage?: Partial<UsageSummary>;
|
||||
hasReasoningContent?: boolean;
|
||||
reasoningChunkCount?: number;
|
||||
// Inline-thinking splitter state (populated only when thinkingExpected=true).
|
||||
@@ -185,8 +185,7 @@ function resolveKiroMaxInputTokens(model: string): number {
|
||||
* inflate `total_tokens`.
|
||||
*/
|
||||
function ensureKiroUsage(state: KiroStreamState, model: string) {
|
||||
if (state.usage) return;
|
||||
|
||||
if (state.usage?.total_tokens !== undefined) return;
|
||||
const estimatedOutputTokens =
|
||||
state.totalContentLength && state.totalContentLength > 0
|
||||
? Math.max(1, Math.floor(state.totalContentLength / 4))
|
||||
@@ -198,11 +197,11 @@ function ensureKiroUsage(state: KiroStreamState, model: string) {
|
||||
: 0;
|
||||
|
||||
if (estimatedTotalTokens <= 0 && estimatedOutputTokens <= 0) return;
|
||||
|
||||
// Without a percentage there is no total to split, so the output estimate is
|
||||
// all that is known and stands on its own.
|
||||
if (estimatedTotalTokens <= 0) {
|
||||
state.usage = {
|
||||
...state.usage,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: estimatedOutputTokens,
|
||||
total_tokens: estimatedOutputTokens,
|
||||
@@ -213,6 +212,7 @@ function ensureKiroUsage(state: KiroStreamState, model: string) {
|
||||
const promptTokens = Math.max(0, estimatedTotalTokens - estimatedOutputTokens);
|
||||
|
||||
state.usage = {
|
||||
...state.usage,
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: estimatedOutputTokens,
|
||||
total_tokens: promptTokens + estimatedOutputTokens,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { isCloudflareChallenge } from "../../services/lmarenaTlsClient.ts";
|
||||
import { markLMArenaCatalogModelDead } from "./models.ts";
|
||||
import { parseArenaSSE } from "./stream.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function errorResponse(
|
||||
status: number,
|
||||
message: string,
|
||||
@@ -165,7 +167,7 @@ function baseChunk(model: string) {
|
||||
}
|
||||
|
||||
function enqueueSse(controller: ReadableStreamDefaultController, chunk: Record<string, unknown>) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
}
|
||||
|
||||
function emitStopAndDone(controller: ReadableStreamDefaultController, model: string) {
|
||||
@@ -173,7 +175,8 @@ function emitStopAndDone(controller: ReadableStreamDefaultController, model: str
|
||||
...baseChunk(model),
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
});
|
||||
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
|
||||
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,14 @@ export class RaycastExecutor extends BaseExecutor {
|
||||
return RAYCAST_CHAT_URL;
|
||||
}
|
||||
|
||||
buildHeaders(credentials: ProviderCredentials, payload?: string): Record<string, string> {
|
||||
// Not a BaseExecutor.buildHeaders override: Raycast signs headers over the exact
|
||||
// request payload (2nd param is the body string, not the base's `stream` boolean),
|
||||
// and execute() below is fully custom — keep it as a distinct helper so a
|
||||
// polymorphic buildHeaders(credentials, true) call can never land here.
|
||||
private buildRaycastRequestHeaders(
|
||||
credentials: ProviderCredentials,
|
||||
payload?: string
|
||||
): Record<string, string> {
|
||||
const body = payload || "{}";
|
||||
return buildRaycastHeaders(body, credentials as JsonRecord);
|
||||
}
|
||||
@@ -44,7 +51,11 @@ export class RaycastExecutor extends BaseExecutor {
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
error: { message: sanitizeErrorMessage(message), type: "invalid_request_error", code: "" },
|
||||
error: {
|
||||
message: sanitizeErrorMessage(message),
|
||||
type: "invalid_request_error",
|
||||
code: "",
|
||||
},
|
||||
}),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
@@ -54,7 +65,7 @@ export class RaycastExecutor extends BaseExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(credentials as ProviderCredentials, payload);
|
||||
const headers = this.buildRaycastRequestHeaders(credentials as ProviderCredentials, payload);
|
||||
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders as Record<string, string> | null);
|
||||
|
||||
let raycastResponse: Response;
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateImage,
|
||||
adobeFireflyImageTimeoutMs,
|
||||
adobeFireflyMaxImageRefs,
|
||||
resolveAdobeAccessToken,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeSourceImageReferences,
|
||||
resolveAdobeImageModel,
|
||||
} from "../../../services/adobeFireflyClient.ts";
|
||||
import { getAdobeReferenceUploadLimit } from "../../../services/adobeFireflyModels.ts";
|
||||
import { isAdobeFireflyUpscaleModel } from "../../../services/adobeFireflyUpscale.ts";
|
||||
import { handleAdobeFireflyImageUpscale } from "../../imageUpscale/adobeFirefly.ts";
|
||||
|
||||
@@ -90,7 +90,8 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
|
||||
// Keep the raw credential blob for Cookie + sherlockToken (x-arp-session-id).
|
||||
// JWT may be embedded in the same paste as cookies (HAR / multi-line).
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })?.providerSpecificData;
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })
|
||||
?.providerSpecificData;
|
||||
const sessionCookie =
|
||||
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
|
||||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
|
||||
@@ -98,15 +99,11 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
? credentials.accessToken
|
||||
: undefined);
|
||||
|
||||
// Cap uploads by model family. gpt-image: 2 subject refs max (3–4+ stalls colligo → 504).
|
||||
// nano: 4 general refs for multi-panel composition.
|
||||
const { id: resolvedId } = resolveAdobeImageModel(model);
|
||||
const maxRefs = adobeFireflyMaxImageRefs(resolvedId);
|
||||
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
const { spec } = resolveAdobeImageModel(model);
|
||||
const references = await resolveAdobeSourceImageReferences({
|
||||
accessToken,
|
||||
body,
|
||||
max: maxRefs,
|
||||
max: getAdobeReferenceUploadLimit(spec, "image"),
|
||||
sessionCookie,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
@@ -121,13 +118,13 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
: undefined;
|
||||
const timeoutMs = adobeFireflyImageTimeoutMs({
|
||||
timeoutMs: explicitTimeout,
|
||||
refCount: sourceImageIds.length,
|
||||
refCount: references.length,
|
||||
});
|
||||
|
||||
log?.info?.(
|
||||
"IMAGE",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(sourceImageIds.length ? ` | refs: ${sourceImageIds.length}/${maxRefs}` : "") +
|
||||
(references.length ? ` | refs: ${references.length}` : "") +
|
||||
` | pollTimeoutMs=${timeoutMs}`
|
||||
);
|
||||
|
||||
@@ -139,9 +136,8 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
aspectRatio: body.aspect_ratio ?? body.aspectRatio ?? body.size,
|
||||
quality: body.quality,
|
||||
seed: Number.isFinite(seed as number) ? (seed as number) : undefined,
|
||||
negativePrompt:
|
||||
typeof body.negative_prompt === "string" ? body.negative_prompt : undefined,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined,
|
||||
references: references.length ? references : undefined,
|
||||
sessionCookie,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateVideo,
|
||||
resolveAdobeAccessToken,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeSourceImageReferences,
|
||||
resolveAdobeVideoModel,
|
||||
} from "../../services/adobeFireflyClient.ts";
|
||||
import { getAdobeReferenceUploadLimit } from "../../services/adobeFireflyModels.ts";
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
@@ -55,7 +56,8 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
? Number(body.seed)
|
||||
: undefined;
|
||||
// Keep raw paste for Cookie + sherlockToken (x-arp-session-id).
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })?.providerSpecificData;
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })
|
||||
?.providerSpecificData;
|
||||
const sessionCookie =
|
||||
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
|
||||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
|
||||
@@ -63,13 +65,11 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
? credentials.accessToken
|
||||
: undefined);
|
||||
|
||||
// Kling i2v / Veo ref / Sora frame: upload reference images first.
|
||||
const { id: videoModelId } = resolveAdobeVideoModel(String(model));
|
||||
const maxFrames = videoModelId.includes("kling") || videoModelId.includes("sora") ? 2 : 3;
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
const { spec } = resolveAdobeVideoModel(String(model));
|
||||
const references = await resolveAdobeSourceImageReferences({
|
||||
accessToken,
|
||||
body,
|
||||
max: maxFrames,
|
||||
max: getAdobeReferenceUploadLimit(spec, "image"),
|
||||
sessionCookie,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
@@ -79,7 +79,7 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
log?.info?.(
|
||||
"VIDEO",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "")
|
||||
(references.length ? ` | refs: ${references.length}` : "")
|
||||
);
|
||||
|
||||
const result = await adobeFireflyGenerateVideo({
|
||||
@@ -99,7 +99,7 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
? body.negativePrompt
|
||||
: undefined,
|
||||
generateAudio: body.generate_audio !== false && body.generateAudio !== false,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
references: references.length ? references : undefined,
|
||||
sessionCookie,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
|
||||
@@ -199,10 +199,10 @@ describe("TierResolver", () => {
|
||||
]);
|
||||
// Observable effect of the cache: the duplicate resolves to the same tier and only
|
||||
// ONE entry is memoized (getTierStats counts cache entries, not classify calls).
|
||||
assert.equal(results.length, 2);
|
||||
assert.equal(results[0].tier, results[1].tier);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].tier).toBe(results[1].tier);
|
||||
const stats = getTierStats();
|
||||
assert.equal(stats.free + stats.cheap + stats.premium, 1);
|
||||
expect(stats.free + stats.cheap + stats.premium).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
8
open-sse/services/adobeFireflyModelSnapshot.ts
Normal file
8
open-sse/services/adobeFireflyModelSnapshot.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -1,328 +1,590 @@
|
||||
/**
|
||||
* Adobe Firefly model catalog: live discovery + static fallback from browser capture.
|
||||
* Adobe Firefly model discovery and normalized media capabilities.
|
||||
*
|
||||
* Live: POST firefly-3p.ff.adobe.io/v2/models/discovery (needs valid IMS token).
|
||||
* Fallback: curated rows from adobe/get_models.txt (2026-07 Firefly SPA capture) so
|
||||
* Media/Models still list usable ids when discovery fails or credentials are missing.
|
||||
* The live discovery schema is authoritative. The generated snapshot is used only
|
||||
* when a request cannot perform authenticated discovery (for example /v1/models).
|
||||
*/
|
||||
|
||||
import {
|
||||
type AdobeFireflyDiscoveredModel,
|
||||
discoverAdobeFireflyModels,
|
||||
resolveAdobeAccessToken,
|
||||
} from "./adobeFireflyClient.ts";
|
||||
import { ADOBE_FIREFLY_DISCOVERY_SNAPSHOT } from "./adobeFireflyModelSnapshot.ts";
|
||||
|
||||
export type AdobeFireflyModality = "image" | "video" | "audio" | "unknown";
|
||||
|
||||
export interface AdobeFireflyDiscoveredModel {
|
||||
modelId: string;
|
||||
modelVersion: string;
|
||||
displayName: string;
|
||||
modality: AdobeFireflyModality;
|
||||
enabled: boolean;
|
||||
providerName?: string;
|
||||
releaseReadiness?: string;
|
||||
healthStatus?: string;
|
||||
inputMediaUseCases: string[];
|
||||
requestSchema?: Record<string, unknown>;
|
||||
backingModel?: string;
|
||||
}
|
||||
|
||||
export interface AdobeFireflyReferenceInputCapability {
|
||||
mediaType: string;
|
||||
usageType: string;
|
||||
minItems: number;
|
||||
maxItems: number | null;
|
||||
maxFileSizeBytes: number | null;
|
||||
}
|
||||
|
||||
export interface AdobeFireflyMediaCapabilities {
|
||||
inputMediaUseCases: string[];
|
||||
schemaProperties: string[];
|
||||
requiredProperties: string[];
|
||||
referenceInputs: AdobeFireflyReferenceInputCapability[];
|
||||
maxReferenceItems: number | null;
|
||||
supportedSizes: string[];
|
||||
supportedAspectRatios: string[];
|
||||
supportedResolutions: string[];
|
||||
supportedDurations: number[];
|
||||
durationMin: number | null;
|
||||
durationMax: number | null;
|
||||
durationDefault: number | null;
|
||||
outputCountMin: number | null;
|
||||
outputCountMax: number | null;
|
||||
promptMaxLength: number | null;
|
||||
releaseReadiness: string;
|
||||
healthStatus: string;
|
||||
}
|
||||
|
||||
export interface AdobeFireflyCatalogModel {
|
||||
/** OpenAI-style id without provider prefix, e.g. nano-banana-pro or flux-fluxPro */
|
||||
/** Stable API id without the provider prefix. */
|
||||
id: string;
|
||||
name: string;
|
||||
modality: "image" | "video";
|
||||
/** Upstream wire modelId for generate-async */
|
||||
upstreamModelId: string;
|
||||
/** Upstream wire modelVersion for generate-async */
|
||||
upstreamModelVersion: string;
|
||||
inputModalities?: string[];
|
||||
providerName: string;
|
||||
backingModel: string;
|
||||
inputModalities: string[];
|
||||
capabilities: AdobeFireflyMediaCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static fallback built from adobe/get_models.txt discovery response.
|
||||
* Friendly aliases first (Media page defaults), then popular upstream families.
|
||||
*/
|
||||
export const ADOBE_FIREFLY_FALLBACK_MODELS: AdobeFireflyCatalogModel[] = [
|
||||
// ── Friendly aliases (handler resolveAdobeImageModel / resolveAdobeVideoModel) ──
|
||||
{
|
||||
id: "nano-banana-pro",
|
||||
name: "Gemini 3.0 (Nano Banana Pro)",
|
||||
modality: "image",
|
||||
upstreamModelId: "gemini-flash",
|
||||
upstreamModelVersion: "nano-banana-2",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "nano-banana",
|
||||
name: "Gemini 2.5 (Nano Banana)",
|
||||
modality: "image",
|
||||
upstreamModelId: "gemini-flash",
|
||||
upstreamModelVersion: "nano-banana",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "nano-banana-2",
|
||||
name: "Gemini 3.1 (Nano Banana 2)",
|
||||
modality: "image",
|
||||
upstreamModelId: "gemini-flash",
|
||||
upstreamModelVersion: "nano-banana-3",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "gpt-image-2",
|
||||
name: "GPT Image 2",
|
||||
modality: "image",
|
||||
upstreamModelId: "gpt-image",
|
||||
upstreamModelVersion: "2",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "gpt-image",
|
||||
name: "GPT Image 2",
|
||||
modality: "image",
|
||||
upstreamModelId: "gpt-image",
|
||||
upstreamModelVersion: "2",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "gpt-image-1.5",
|
||||
name: "GPT Image 1.5",
|
||||
modality: "image",
|
||||
upstreamModelId: "gpt-image",
|
||||
upstreamModelVersion: "1.5",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "sora-2",
|
||||
name: "Sora 2",
|
||||
modality: "video",
|
||||
upstreamModelId: "sora",
|
||||
upstreamModelVersion: "sora-2",
|
||||
},
|
||||
{
|
||||
id: "sora-2-pro",
|
||||
name: "Sora 2 Pro",
|
||||
modality: "video",
|
||||
upstreamModelId: "sora",
|
||||
upstreamModelVersion: "sora-2-pro",
|
||||
},
|
||||
{
|
||||
id: "veo-3.1",
|
||||
name: "Veo 3.1",
|
||||
modality: "video",
|
||||
upstreamModelId: "veo",
|
||||
upstreamModelVersion: "3.1-generate",
|
||||
},
|
||||
{
|
||||
id: "veo-3.1-fast",
|
||||
name: "Veo 3.1 Fast",
|
||||
modality: "video",
|
||||
upstreamModelId: "veo",
|
||||
upstreamModelVersion: "3.1-fast-generate",
|
||||
},
|
||||
{
|
||||
id: "veo-3.1-ref",
|
||||
name: "Veo 3.1 Reference",
|
||||
modality: "video",
|
||||
upstreamModelId: "veo",
|
||||
upstreamModelVersion: "3.1-generate",
|
||||
},
|
||||
{
|
||||
id: "kling-3",
|
||||
name: "Kling Video v3 Standard Image to Video",
|
||||
modality: "video",
|
||||
upstreamModelId: "kling",
|
||||
upstreamModelVersion: "kling_v3_standard_i2v",
|
||||
},
|
||||
// ── Additional image families from discovery capture ──
|
||||
{
|
||||
id: "flux-2",
|
||||
name: "Flux 2",
|
||||
modality: "image",
|
||||
upstreamModelId: "flux",
|
||||
upstreamModelVersion: "2",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "flux-pro",
|
||||
name: "Flux 1.1 Pro",
|
||||
modality: "image",
|
||||
upstreamModelId: "flux",
|
||||
upstreamModelVersion: "fluxPro",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "flux-ultra",
|
||||
name: "Flux 1.1 Ultra",
|
||||
modality: "image",
|
||||
upstreamModelId: "flux",
|
||||
upstreamModelVersion: "fluxUltra",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "seedream-4",
|
||||
name: "Seedream 4.0",
|
||||
modality: "image",
|
||||
upstreamModelId: "seedream",
|
||||
upstreamModelVersion: "seedream_v4",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "seedream-5-lite",
|
||||
name: "Seedream 5.0 Lite",
|
||||
modality: "image",
|
||||
upstreamModelId: "seedream",
|
||||
upstreamModelVersion: "seedream_v5_lite",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
{
|
||||
id: "runway-gen4-image",
|
||||
name: "Runway Gen-4 Image",
|
||||
modality: "image",
|
||||
upstreamModelId: "runway-gen4-image",
|
||||
upstreamModelVersion: "gen4_image",
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
// ── Additional video families ──
|
||||
{
|
||||
id: "kling-v3-t2v",
|
||||
name: "Kling Video v3 Standard Text to Video",
|
||||
modality: "video",
|
||||
upstreamModelId: "kling",
|
||||
upstreamModelVersion: "kling_v3_standard_t2v",
|
||||
},
|
||||
{
|
||||
id: "kling-v3-pro-i2v",
|
||||
name: "Kling Video v3 Pro Image to Video",
|
||||
modality: "video",
|
||||
upstreamModelId: "kling",
|
||||
upstreamModelVersion: "kling_v3_pro_i2v",
|
||||
},
|
||||
{
|
||||
id: "luma-ray3",
|
||||
name: "Ray3",
|
||||
modality: "video",
|
||||
upstreamModelId: "luma",
|
||||
upstreamModelVersion: "3.0-ray",
|
||||
},
|
||||
{
|
||||
id: "runway-gen4-turbo",
|
||||
name: "Runway Gen-4 Video",
|
||||
modality: "video",
|
||||
upstreamModelId: "runway",
|
||||
upstreamModelVersion: "gen4_turbo",
|
||||
},
|
||||
];
|
||||
export interface AdobeFireflyImageModelSpec extends AdobeFireflyCatalogModel {
|
||||
modality: "image";
|
||||
/** Payload dialect observed for this model family. */
|
||||
family: "gemini" | "gpt-image" | "generic";
|
||||
}
|
||||
|
||||
/** Stable slug for upstream modelId + modelVersion (catalog id when not a friendly alias). */
|
||||
export interface AdobeFireflyVideoModelSpec extends AdobeFireflyCatalogModel {
|
||||
modality: "video";
|
||||
defaultDuration: number;
|
||||
defaultResolution: string;
|
||||
}
|
||||
|
||||
interface MergedObjectSchema {
|
||||
properties: Record<string, Record<string, unknown>>;
|
||||
required: string[];
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => String(item)).filter((item) => item.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
function finiteInteger(value: unknown): number | null {
|
||||
return Number.isInteger(value) ? (value as number) : null;
|
||||
}
|
||||
|
||||
/** Merge object properties/required keys contributed through JSON Schema allOf. */
|
||||
export function mergeAdobeObjectSchema(schema: unknown): MergedObjectSchema {
|
||||
const merged: MergedObjectSchema = { properties: {}, required: [] };
|
||||
const visit = (value: unknown) => {
|
||||
const node = asRecord(value);
|
||||
const properties = asRecord(node.properties);
|
||||
for (const [key, property] of Object.entries(properties)) {
|
||||
merged.properties[key] = asRecord(property);
|
||||
}
|
||||
merged.required.push(...asStringArray(node.required));
|
||||
if (Array.isArray(node.allOf)) node.allOf.forEach(visit);
|
||||
};
|
||||
visit(schema);
|
||||
merged.required = [...new Set(merged.required)];
|
||||
return merged;
|
||||
}
|
||||
|
||||
function schemaBranches(schema: unknown): Record<string, unknown>[] {
|
||||
const root = asRecord(schema);
|
||||
if (Object.keys(root).length === 0) return [];
|
||||
return [
|
||||
root,
|
||||
...(Array.isArray(root.anyOf) ? root.anyOf.map(asRecord) : []),
|
||||
...(Array.isArray(root.oneOf) ? root.oneOf.map(asRecord) : []),
|
||||
];
|
||||
}
|
||||
|
||||
function enumStrings(schema: unknown): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
schemaBranches(schema)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function integerBranch(schema: unknown): Record<string, unknown> {
|
||||
return schemaBranches(schema).find((branch) => branch.type === "integer") || {};
|
||||
}
|
||||
|
||||
/** Stable, collision-resistant public id for an exact upstream model/version pair. */
|
||||
export function slugifyAdobeModel(modelId: string, modelVersion: string): string {
|
||||
const mid = String(modelId || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
const ver = String(modelVersion || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9.]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
if (!ver || ver === "default" || ver === mid) return mid || "model";
|
||||
return `${mid}-${ver}`;
|
||||
const slug = (value: string, allowDot = false) =>
|
||||
String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(allowDot ? /[^a-z0-9.]+/g : /[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
const family = slug(modelId);
|
||||
// Adobe still uses `kling_v3_omni*` internally, while discovery exposes these
|
||||
// products to users as Kling O3. Never leak the obsolete/internal "omni" name
|
||||
// into the public API catalog; the untouched upstream version stays in the spec.
|
||||
const publicVersion =
|
||||
family === "kling" ? modelVersion.replace(/^kling_v3_omni/i, "kling_o3") : modelVersion;
|
||||
const version = slug(publicVersion, true);
|
||||
if (!version || version === "default" || version === family) return family || "model";
|
||||
return `${family}-${version}`;
|
||||
}
|
||||
|
||||
/** Map discovery rows → catalog entries (image/video only). */
|
||||
export function mapDiscoveredToCatalog(
|
||||
rows: AdobeFireflyDiscoveredModel[]
|
||||
): AdobeFireflyCatalogModel[] {
|
||||
const out: AdobeFireflyCatalogModel[] = [];
|
||||
const seen = new Set<string>();
|
||||
/** Parse POST /v2/models/discovery without discarding its resolved request schema. */
|
||||
export function parseAdobeModelsDiscovery(body: unknown): AdobeFireflyDiscoveredModel[] {
|
||||
const root = asRecord(body);
|
||||
const families = Array.isArray(root.models) ? root.models : [];
|
||||
const rows: AdobeFireflyDiscoveredModel[] = [];
|
||||
|
||||
// Prefer friendly aliases when upstream matches known fallback rows.
|
||||
for (const fb of ADOBE_FIREFLY_FALLBACK_MODELS) {
|
||||
const hit = rows.find(
|
||||
(r) =>
|
||||
r.modelId === fb.upstreamModelId &&
|
||||
r.modelVersion === fb.upstreamModelVersion &&
|
||||
(r.modality === fb.modality || r.modality === "unknown")
|
||||
);
|
||||
if (hit && !seen.has(fb.id)) {
|
||||
seen.add(fb.id);
|
||||
out.push({
|
||||
...fb,
|
||||
name: hit.displayName || fb.name,
|
||||
for (const familyValue of families) {
|
||||
const family = asRecord(familyValue);
|
||||
const modelId = String(family.modelId || "").trim();
|
||||
if (!modelId) continue;
|
||||
for (const [modelVersion, versionValue] of Object.entries(asRecord(family.modelVersions))) {
|
||||
const version = asRecord(versionValue);
|
||||
if (version.enabled === false) continue;
|
||||
const outputModalities = asStringArray(version.outputModality).map((item) =>
|
||||
item.toLowerCase()
|
||||
);
|
||||
const modality: AdobeFireflyModality = outputModalities.includes("image")
|
||||
? "image"
|
||||
: outputModalities.includes("video")
|
||||
? "video"
|
||||
: outputModalities.includes("audio")
|
||||
? "audio"
|
||||
: "unknown";
|
||||
rows.push({
|
||||
modelId,
|
||||
modelVersion,
|
||||
displayName: String(
|
||||
version.modelDisplayName || version.modelCaiDisplayName || modelVersion
|
||||
),
|
||||
modality,
|
||||
enabled: version.enabled !== false,
|
||||
providerName:
|
||||
typeof family.acModelFamilyProviderDisplayName === "string"
|
||||
? family.acModelFamilyProviderDisplayName
|
||||
: undefined,
|
||||
releaseReadiness:
|
||||
typeof version.releaseReadiness === "string" ? version.releaseReadiness : undefined,
|
||||
healthStatus: typeof version.healthStatus === "string" ? version.healthStatus : undefined,
|
||||
inputMediaUseCases: asStringArray(version.inputMediaUseCase),
|
||||
requestSchema: asRecord(version.requestSchema),
|
||||
backingModel:
|
||||
typeof version.bksGenerationModel === "string" ? version.bksGenerationModel : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function normalizeCapabilities(row: AdobeFireflyDiscoveredModel): AdobeFireflyMediaCapabilities {
|
||||
const schema = mergeAdobeObjectSchema(row.requestSchema);
|
||||
const referenceSchema = asRecord(schema.properties.referenceBlobs);
|
||||
const referenceInputs: AdobeFireflyReferenceInputCapability[] = [];
|
||||
const mediaCapabilities = Array.isArray(referenceSchema["x-capabilities"])
|
||||
? referenceSchema["x-capabilities"]
|
||||
: [];
|
||||
for (const mediaValue of mediaCapabilities) {
|
||||
const media = asRecord(mediaValue);
|
||||
const maxFileSizeBytes = finiteInteger(media.maxFileSizeBytes);
|
||||
const usageConstraints = Array.isArray(media.usageConstraints) ? media.usageConstraints : [];
|
||||
for (const usageValue of usageConstraints) {
|
||||
const usage = asRecord(usageValue);
|
||||
if (usage.deprecated === true) continue;
|
||||
const usageType = String(usage.usageType || "");
|
||||
const mediaType = String(media.mediaType || "");
|
||||
if (!usageType || !mediaType) continue;
|
||||
referenceInputs.push({
|
||||
mediaType,
|
||||
usageType,
|
||||
minItems: finiteInteger(usage.minItems) ?? 0,
|
||||
maxItems: finiteInteger(usage.maxItems),
|
||||
maxFileSizeBytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of rows) {
|
||||
if (r.modality !== "image" && r.modality !== "video") continue;
|
||||
const id = slugifyAdobeModel(r.modelId, r.modelVersion);
|
||||
if (seen.has(id)) continue;
|
||||
// Skip if already covered by a friendly alias with same upstream
|
||||
if (
|
||||
out.some(
|
||||
(o) =>
|
||||
o.upstreamModelId === r.modelId && o.upstreamModelVersion === r.modelVersion
|
||||
const supportedSizes = [
|
||||
...new Set(
|
||||
schemaBranches(schema.properties.size)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.map(asRecord)
|
||||
.filter((size) => finiteInteger(size.width) !== null && finiteInteger(size.height) !== null)
|
||||
.map((size) => `${size.width}x${size.height}`)
|
||||
),
|
||||
];
|
||||
const supportedAspectRatios = [
|
||||
...new Set(
|
||||
schemaBranches(schema.properties.generationSettings).flatMap((branch) =>
|
||||
enumStrings(asRecord(asRecord(branch.properties).aspectRatio))
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
out.push({
|
||||
id,
|
||||
name: r.displayName || id,
|
||||
modality: r.modality,
|
||||
upstreamModelId: r.modelId,
|
||||
upstreamModelVersion: r.modelVersion,
|
||||
inputModalities: r.modality === "image" ? ["text", "image"] : ["text"],
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getAdobeFireflyFallbackCatalog(modality?: "image" | "video"): AdobeFireflyCatalogModel[] {
|
||||
if (!modality) return [...ADOBE_FIREFLY_FALLBACK_MODELS];
|
||||
return ADOBE_FIREFLY_FALLBACK_MODELS.filter((m) => m.modality === modality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live discovery when credentials resolve; otherwise static fallback from get_models capture.
|
||||
*/
|
||||
export async function resolveAdobeFireflyCatalog(opts: {
|
||||
credentials?: {
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
providerSpecificData?: Record<string, unknown> | null;
|
||||
} | null;
|
||||
modality?: "image" | "video";
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<{ models: AdobeFireflyCatalogModel[]; source: "api" | "fallback" }> {
|
||||
const fetchImpl = opts.fetchImpl || fetch;
|
||||
try {
|
||||
if (opts.credentials) {
|
||||
const token = await resolveAdobeAccessToken(opts.credentials, fetchImpl);
|
||||
const discovered = await discoverAdobeFireflyModels(token, fetchImpl);
|
||||
let catalog = mapDiscoveredToCatalog(discovered);
|
||||
if (opts.modality) catalog = catalog.filter((m) => m.modality === opts.modality);
|
||||
if (catalog.length > 0) return { models: catalog, source: "api" };
|
||||
}
|
||||
} catch {
|
||||
// fall through to static catalog
|
||||
}
|
||||
),
|
||||
];
|
||||
const duration = integerBranch(schema.properties.duration);
|
||||
const outputCount = integerBranch(schema.properties.n);
|
||||
const prompt =
|
||||
schemaBranches(schema.properties.prompt).find((branch) => branch.type === "string") || {};
|
||||
|
||||
return {
|
||||
models: getAdobeFireflyFallbackCatalog(opts.modality),
|
||||
source: "fallback",
|
||||
inputMediaUseCases: [...row.inputMediaUseCases],
|
||||
schemaProperties: Object.keys(schema.properties),
|
||||
requiredProperties: [...schema.required],
|
||||
referenceInputs,
|
||||
maxReferenceItems: finiteInteger(referenceSchema.maxItems),
|
||||
supportedSizes,
|
||||
supportedAspectRatios,
|
||||
supportedResolutions: enumStrings(schema.properties.resolution),
|
||||
supportedDurations: [
|
||||
...new Set(
|
||||
schemaBranches(schema.properties.duration)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.filter((value): value is number => Number.isInteger(value))
|
||||
),
|
||||
],
|
||||
durationMin: finiteInteger(duration.minimum),
|
||||
durationMax: finiteInteger(duration.maximum),
|
||||
durationDefault: finiteInteger(duration.default),
|
||||
outputCountMin: finiteInteger(outputCount.minimum),
|
||||
outputCountMax: finiteInteger(outputCount.maximum),
|
||||
promptMaxLength: finiteInteger(prompt.maxLength),
|
||||
releaseReadiness: row.releaseReadiness || "",
|
||||
healthStatus: row.healthStatus || "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Registry-shaped models for imageRegistry / videoRegistry. */
|
||||
export function toRegistryImageModels(
|
||||
models: AdobeFireflyCatalogModel[] = getAdobeFireflyFallbackCatalog("image")
|
||||
): Array<{ id: string; name: string; inputModalities?: string[] }> {
|
||||
return models
|
||||
.filter((m) => m.modality === "image")
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name.startsWith("Firefly ") ? m.name : `Firefly ${m.name}`,
|
||||
inputModalities: m.inputModalities || ["text", "image"],
|
||||
}));
|
||||
function isCallableGenerationModel(row: AdobeFireflyDiscoveredModel): boolean {
|
||||
if (row.modality !== "image" && row.modality !== "video") return false;
|
||||
if (!mergeAdobeObjectSchema(row.requestSchema).properties.prompt) return false;
|
||||
const excluded = new Set(["upscaling", "sharpening", "denoising"]);
|
||||
return !row.inputMediaUseCases.some((value) => excluded.has(value.toLowerCase()));
|
||||
}
|
||||
|
||||
export function toRegistryVideoModels(
|
||||
models: AdobeFireflyCatalogModel[] = getAdobeFireflyFallbackCatalog("video")
|
||||
): Array<{ id: string; name: string }> {
|
||||
return models
|
||||
.filter((m) => m.modality === "video")
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name.startsWith("Firefly ") ? m.name : `Firefly ${m.name}`,
|
||||
}));
|
||||
function deriveInputModalities(capabilities: AdobeFireflyMediaCapabilities): string[] {
|
||||
return ["text", ...new Set(capabilities.referenceInputs.map((reference) => reference.mediaType))];
|
||||
}
|
||||
|
||||
function semanticCatalogKey(model: AdobeFireflyCatalogModel): string {
|
||||
return JSON.stringify({
|
||||
backingModel: model.backingModel,
|
||||
name: model.name,
|
||||
modality: model.modality,
|
||||
capabilities: model.capabilities,
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize and de-duplicate callable image/video rows from live discovery. */
|
||||
export function mapDiscoveredToCatalog(
|
||||
rows: AdobeFireflyDiscoveredModel[]
|
||||
): AdobeFireflyCatalogModel[] {
|
||||
const output: AdobeFireflyCatalogModel[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!isCallableGenerationModel(row)) continue;
|
||||
const capabilities = normalizeCapabilities(row);
|
||||
const model: AdobeFireflyCatalogModel = {
|
||||
id: slugifyAdobeModel(row.modelId, row.modelVersion),
|
||||
name: row.displayName,
|
||||
modality: row.modality as "image" | "video",
|
||||
upstreamModelId: row.modelId,
|
||||
upstreamModelVersion: row.modelVersion,
|
||||
providerName: row.providerName || "",
|
||||
backingModel: row.backingModel || "",
|
||||
inputModalities: deriveInputModalities(capabilities),
|
||||
capabilities,
|
||||
};
|
||||
const key = semanticCatalogKey(model);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
output.push(model);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function snapshotCatalog(): AdobeFireflyCatalogModel[] {
|
||||
return ADOBE_FIREFLY_DISCOVERY_SNAPSHOT.map((model) => {
|
||||
const capabilities: AdobeFireflyMediaCapabilities = {
|
||||
inputMediaUseCases: [...model.inputMediaUseCases],
|
||||
schemaProperties: [...model.schemaProperties],
|
||||
requiredProperties: [...model.requiredProperties],
|
||||
referenceInputs: model.referenceInputs.map((reference) => ({ ...reference })),
|
||||
maxReferenceItems: model.maxReferenceItems,
|
||||
supportedSizes: [...model.supportedSizes],
|
||||
supportedAspectRatios: [...model.supportedAspectRatios],
|
||||
supportedResolutions: [...model.supportedResolutions],
|
||||
supportedDurations: [...model.supportedDurations],
|
||||
durationMin: model.durationMin,
|
||||
durationMax: model.durationMax,
|
||||
durationDefault: model.durationDefault,
|
||||
outputCountMin: model.outputCountMin,
|
||||
outputCountMax: model.outputCountMax,
|
||||
promptMaxLength: model.promptMaxLength,
|
||||
releaseReadiness: model.releaseReadiness,
|
||||
healthStatus: model.healthStatus,
|
||||
};
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
modality: model.modality,
|
||||
upstreamModelId: model.upstreamModelId,
|
||||
upstreamModelVersion: model.upstreamModelVersion,
|
||||
providerName: model.providerName,
|
||||
backingModel: model.backingModel,
|
||||
inputModalities: deriveInputModalities(capabilities),
|
||||
capabilities,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const ADOBE_FIREFLY_FALLBACK_MODELS: AdobeFireflyCatalogModel[] = snapshotCatalog();
|
||||
|
||||
export function getAdobeFireflyFallbackCatalog(
|
||||
modality?: "image" | "video"
|
||||
): AdobeFireflyCatalogModel[] {
|
||||
return ADOBE_FIREFLY_FALLBACK_MODELS.filter((model) => !modality || model.modality === modality);
|
||||
}
|
||||
|
||||
function imageFamily(model: AdobeFireflyCatalogModel): AdobeFireflyImageModelSpec["family"] {
|
||||
if (model.upstreamModelId === "gemini-flash") return "gemini";
|
||||
if (model.upstreamModelId === "gpt-image" || model.upstreamModelId === "gpt-4o-image") {
|
||||
return "gpt-image";
|
||||
}
|
||||
return "generic";
|
||||
}
|
||||
|
||||
export const ADOBE_FIREFLY_IMAGE_MODELS: Record<string, AdobeFireflyImageModelSpec> =
|
||||
Object.fromEntries(
|
||||
getAdobeFireflyFallbackCatalog("image").map((model) => [
|
||||
model.id,
|
||||
{ ...model, modality: "image" as const, family: imageFamily(model) },
|
||||
])
|
||||
);
|
||||
|
||||
function defaultDuration(model: AdobeFireflyCatalogModel): number {
|
||||
const caps = model.capabilities;
|
||||
return caps.durationDefault ?? caps.supportedDurations[0] ?? caps.durationMin ?? 5;
|
||||
}
|
||||
|
||||
function defaultResolution(model: AdobeFireflyCatalogModel): string {
|
||||
if (model.capabilities.supportedSizes.some((value) => value.includes("1920x1080"))) {
|
||||
return "1080p";
|
||||
}
|
||||
return "720p";
|
||||
}
|
||||
|
||||
export const ADOBE_FIREFLY_VIDEO_MODELS: Record<string, AdobeFireflyVideoModelSpec> =
|
||||
Object.fromEntries(
|
||||
getAdobeFireflyFallbackCatalog("video").map((model) => [
|
||||
model.id,
|
||||
{
|
||||
...model,
|
||||
modality: "video" as const,
|
||||
defaultDuration: defaultDuration(model),
|
||||
defaultResolution: defaultResolution(model),
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const LEGACY_MODEL_ALIASES: Record<string, string> = {
|
||||
"nano-banana": "gemini-flash-nano-banana",
|
||||
"nano-banana-pro": "gemini-flash-nano-banana-2",
|
||||
"nano-banana-2": "gemini-flash-nano-banana-3",
|
||||
"gpt-image": "gpt-image-2",
|
||||
"gpt-image-2": "gpt-image-2",
|
||||
"gpt-image-1.5": "gpt-image-1.5",
|
||||
"flux-2": "flux-2",
|
||||
"flux-pro": "flux-fluxpro",
|
||||
"flux-ultra": "flux-fluxultra",
|
||||
"seedream-4": "seedream-seedream-v4",
|
||||
"seedream-5-lite": "seedream-seedream-v5-lite",
|
||||
"runway-gen4-image": "runway-gen4-image",
|
||||
"veo-3.1": "veo-3.1-generate",
|
||||
"veo-3.1-fast": "veo-3.1-fast-generate",
|
||||
"luma-ray3": "luma-3.0-ray",
|
||||
"runway-gen4-turbo": "runway-gen4-turbo",
|
||||
// Backward compatibility only; the catalog advertises the exact discovered id.
|
||||
"kling-3": "kling-kling-v3-standard-i2v",
|
||||
};
|
||||
|
||||
// Preserve established API aliases when (and only when) they resolve to a model
|
||||
// that is present in the verified discovery snapshot. These keys are not listed.
|
||||
for (const [alias, target] of Object.entries(LEGACY_MODEL_ALIASES)) {
|
||||
const imageTarget = ADOBE_FIREFLY_IMAGE_MODELS[target];
|
||||
if (imageTarget) ADOBE_FIREFLY_IMAGE_MODELS[alias] = imageTarget;
|
||||
const videoTarget = ADOBE_FIREFLY_VIDEO_MODELS[target];
|
||||
if (videoTarget) ADOBE_FIREFLY_VIDEO_MODELS[alias] = videoTarget;
|
||||
}
|
||||
|
||||
/** Backward-compatible request ids. Kept out of every advertised model catalog. */
|
||||
export const ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES = Object.freeze(
|
||||
Object.entries(LEGACY_MODEL_ALIASES)
|
||||
.filter(([, target]) => Boolean(ADOBE_FIREFLY_IMAGE_MODELS[target]))
|
||||
.map(([alias]) => alias)
|
||||
);
|
||||
|
||||
function normalizeRequestedId(model: string): string {
|
||||
return String(model || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^adobe-firefly\//, "")
|
||||
.replace(/^firefly\//, "");
|
||||
}
|
||||
|
||||
function resolveCatalogId(model: string): string {
|
||||
const requested = normalizeRequestedId(model);
|
||||
return LEGACY_MODEL_ALIASES[requested] || requested;
|
||||
}
|
||||
|
||||
export function resolveAdobeImageModel(model: string): {
|
||||
id: string;
|
||||
spec: AdobeFireflyImageModelSpec;
|
||||
} {
|
||||
const id = resolveCatalogId(model);
|
||||
const spec = ADOBE_FIREFLY_IMAGE_MODELS[id];
|
||||
if (!spec) {
|
||||
throw new Error(
|
||||
`Unknown Adobe Firefly image model: ${normalizeRequestedId(model) || "(empty)"}`
|
||||
);
|
||||
}
|
||||
return { id, spec };
|
||||
}
|
||||
|
||||
export function resolveAdobeVideoModel(model: string): {
|
||||
id: string;
|
||||
spec: AdobeFireflyVideoModelSpec;
|
||||
} {
|
||||
const id = resolveCatalogId(model);
|
||||
const spec = ADOBE_FIREFLY_VIDEO_MODELS[id];
|
||||
if (!spec) {
|
||||
throw new Error(
|
||||
`Unknown Adobe Firefly video model: ${normalizeRequestedId(model) || "(empty)"}`
|
||||
);
|
||||
}
|
||||
return { id, spec };
|
||||
}
|
||||
|
||||
export function toRegistryImageModels(): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
inputModalities: string[];
|
||||
imageRequired?: boolean;
|
||||
supportedSizes: string[];
|
||||
mediaCapabilities: Record<string, unknown>;
|
||||
}> {
|
||||
const generated = getAdobeFireflyFallbackCatalog("image").map((model) => ({
|
||||
id: model.id,
|
||||
name: `Firefly ${model.name}`,
|
||||
inputModalities: model.inputModalities,
|
||||
supportedSizes: model.capabilities.supportedSizes,
|
||||
mediaCapabilities: toAdobeMediaCapabilitiesApi(model),
|
||||
}));
|
||||
// Upscaling uses a distinct Firefly endpoint and is not returned by the image
|
||||
// generation discovery schema. Keep its two supported Topaz models visible in
|
||||
// the same provider catalog so image clients can select them deliberately.
|
||||
return [
|
||||
...generated,
|
||||
{
|
||||
id: "topaz-standard",
|
||||
name: "Firefly Topaz Upscale (Standard)",
|
||||
inputModalities: ["image"],
|
||||
imageRequired: true,
|
||||
supportedSizes: [],
|
||||
mediaCapabilities: { input_media_use_cases: ["upscaling"] },
|
||||
},
|
||||
{
|
||||
id: "topaz-bloom",
|
||||
name: "Firefly Topaz Bloom (Creative Upscale)",
|
||||
inputModalities: ["image"],
|
||||
imageRequired: true,
|
||||
supportedSizes: [],
|
||||
mediaCapabilities: { input_media_use_cases: ["upscaling"] },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function toRegistryVideoModels(): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
supportedSizes: string[];
|
||||
mediaCapabilities: Record<string, unknown>;
|
||||
}> {
|
||||
return getAdobeFireflyFallbackCatalog("video").map((model) => ({
|
||||
id: model.id,
|
||||
name: `Firefly ${model.name}`,
|
||||
supportedSizes: model.capabilities.supportedSizes,
|
||||
mediaCapabilities: toAdobeMediaCapabilitiesApi(model),
|
||||
}));
|
||||
}
|
||||
|
||||
/** JSON-safe extension emitted by /v1/models. */
|
||||
export function toAdobeMediaCapabilitiesApi(
|
||||
model: AdobeFireflyCatalogModel
|
||||
): Record<string, unknown> {
|
||||
const caps = model.capabilities;
|
||||
return {
|
||||
upstream_model_id: model.upstreamModelId,
|
||||
upstream_model_version: model.upstreamModelVersion,
|
||||
provider_name: model.providerName,
|
||||
release_readiness: caps.releaseReadiness,
|
||||
health_status: caps.healthStatus,
|
||||
input_media_use_cases: caps.inputMediaUseCases,
|
||||
reference_inputs: caps.referenceInputs.map((reference) => ({
|
||||
media_type: reference.mediaType,
|
||||
usage_type: reference.usageType,
|
||||
min_items: reference.minItems,
|
||||
max_items: reference.maxItems,
|
||||
max_file_size_bytes: reference.maxFileSizeBytes,
|
||||
})),
|
||||
max_reference_items: caps.maxReferenceItems,
|
||||
supported_sizes: caps.supportedSizes,
|
||||
supported_aspect_ratios: caps.supportedAspectRatios,
|
||||
supported_resolutions: caps.supportedResolutions,
|
||||
supported_durations: caps.supportedDurations,
|
||||
duration_min: caps.durationMin,
|
||||
duration_max: caps.durationMax,
|
||||
duration_default: caps.durationDefault,
|
||||
output_count_min: caps.outputCountMin,
|
||||
output_count_max: caps.outputCountMax,
|
||||
prompt_max_length: caps.promptMaxLength,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAdobeReferenceUploadLimit(
|
||||
model: AdobeFireflyCatalogModel,
|
||||
mediaType: string
|
||||
): number {
|
||||
if (model.capabilities.maxReferenceItems !== null) {
|
||||
return Math.max(1, Math.min(32, model.capabilities.maxReferenceItems));
|
||||
}
|
||||
const declaredTotal = model.capabilities.referenceInputs
|
||||
.filter((reference) => reference.mediaType === mediaType)
|
||||
.reduce((total, reference) => total + (reference.maxItems ?? 0), 0);
|
||||
return Math.max(1, Math.min(32, declaredTotal || 1));
|
||||
}
|
||||
|
||||
@@ -190,15 +190,31 @@ export function getBackgroundTaskReason(
|
||||
const messages = toMessageArray(typedBody.messages ?? typedBody.input ?? []);
|
||||
if (!Array.isArray(messages) || messages.length === 0) return null;
|
||||
|
||||
// Find system message
|
||||
// Derive system content from messages array (OpenAI format) or top-level
|
||||
// system field (Anthropic format).
|
||||
const systemMsg = messages.find(
|
||||
(message: BackgroundMessage) => message.role === "system" || message.role === "developer"
|
||||
);
|
||||
if (!systemMsg) return null;
|
||||
|
||||
const systemContent =
|
||||
typeof systemMsg.content === "string" ? systemMsg.content.toLowerCase() : "";
|
||||
|
||||
let systemContent = "";
|
||||
if (systemMsg && typeof systemMsg.content === "string") {
|
||||
systemContent = systemMsg.content.toLowerCase();
|
||||
} else if (!systemMsg) {
|
||||
// Anthropic top-level system field: string or array of text blocks
|
||||
const raw = (typedBody as Record<string, unknown>).system;
|
||||
if (typeof raw === "string") {
|
||||
systemContent = raw.toLowerCase();
|
||||
} else if (Array.isArray(raw)) {
|
||||
systemContent = raw
|
||||
.map((part) =>
|
||||
part && typeof (part as { text?: unknown }).text === "string"
|
||||
? (part as { text: string }).text
|
||||
: ""
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
if (!systemContent) return null;
|
||||
|
||||
// Check against detection patterns
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getHiddenModelsByProvider } from "../../../src/lib/db/models";
|
||||
import { getComboModelString, normalizeComboStep } from "../../../src/lib/combos/steps.ts";
|
||||
import { getProviderByAlias, getProviderById } from "../../../src/shared/constants/providers.ts";
|
||||
import { estimateTokens } from "../contextManager.ts";
|
||||
import { containsMediaKind } from "../../utils/mediaParts.ts";
|
||||
import { getResolvedModelCapabilities } from "../modelCapabilities.ts";
|
||||
import { parseModel, stripContextWindowSuffix } from "../model.ts";
|
||||
import { dedupeTargetsByExecutionKey, isRecord } from "./comboData.ts";
|
||||
@@ -483,21 +484,15 @@ function estimateRequestInputTokens(body: Record<string, unknown>): number {
|
||||
return Object.keys(estimatePayload).length > 0 ? estimateTokens(estimatePayload) : 0;
|
||||
}
|
||||
|
||||
function valueContainsImagePart(value: unknown, depth = 0): boolean {
|
||||
if (depth > 8 || value === null || value === undefined) return false;
|
||||
if (typeof value === "string") return value.startsWith("data:image/");
|
||||
if (Array.isArray(value)) return value.some((entry) => valueContainsImagePart(entry, depth + 1));
|
||||
if (!isRecord(value)) return false;
|
||||
|
||||
const type = typeof value.type === "string" ? value.type.toLowerCase() : null;
|
||||
if (type === "image" || type === "image_url" || type === "input_image") return true;
|
||||
if ("image_url" in value || "input_image" in value) return true;
|
||||
|
||||
const source = isRecord(value.source) ? value.source : null;
|
||||
const mediaType = typeof source?.media_type === "string" ? source.media_type.toLowerCase() : "";
|
||||
if (mediaType.startsWith("image/")) return true;
|
||||
|
||||
return Object.values(value).some((entry) => valueContainsImagePart(entry, depth + 1));
|
||||
function valueContainsImagePart(value: unknown): boolean {
|
||||
// Delegates to the unified media detector (open-sse/utils/mediaParts.ts) —
|
||||
// single source of truth shared with the vision-bridge guardrail. The
|
||||
// detector keeps this filter's legacy permissive matches (image-ish `type`
|
||||
// in any casing, bare `image_url`/`input_image` keys, source.media_type
|
||||
// image/*, bare data:image strings, recursion capped at depth 8) via
|
||||
// "image_indicator" parts. containsMediaKind short-circuits on the first
|
||||
// hit — this runs on every request, so no full-part collection here.
|
||||
return containsMediaKind([{ content: [value] }], "image");
|
||||
}
|
||||
|
||||
export function deriveRequestCompatibilityRequirements(
|
||||
|
||||
@@ -190,10 +190,26 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an `error` field carries a real failure signal. A key-presence check
|
||||
* (`!= null`) false-positives on benign values some backends emit on every
|
||||
* chunk (`{}`, `""`, `false`, `0`) — e.g. tool-call turns where a chunk with
|
||||
* real tool_calls content also carries `"error": {}`. Only substantive values
|
||||
* are treated as upstream failures.
|
||||
*/
|
||||
function isSubstantiveError(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
return Object.keys(value as Record<string, unknown>).length > 0;
|
||||
}
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function isStreamingUpstreamError(parsed: unknown, eventType: string): boolean {
|
||||
if (eventType === "response.failed" || eventType === "error") return true;
|
||||
if (!isRecord(parsed)) return false;
|
||||
if (parsed.error != null) return true;
|
||||
if (isSubstantiveError(parsed.error)) return true;
|
||||
|
||||
const nestedResponse = isRecord(parsed.response) ? parsed.response : null;
|
||||
return nestedResponse?.status === "failed" && nestedResponse.error != null;
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"replacement": " ",
|
||||
"context": "all",
|
||||
"category": "ultra",
|
||||
"minIntensity": "notes"
|
||||
"minIntensity": "ultra"
|
||||
},
|
||||
{
|
||||
"name": "ultra_lowercase",
|
||||
@@ -40,7 +40,7 @@
|
||||
"replacement": " $1",
|
||||
"context": "all",
|
||||
"category": "ultra",
|
||||
"minIntensity": "notes"
|
||||
"minIntensity": "ultra"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -37,6 +37,102 @@ async function getPath() {
|
||||
return _path || null;
|
||||
}
|
||||
|
||||
type UsageRecord = Record<string, unknown>;
|
||||
|
||||
function usageRecord(value: unknown): UsageRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as UsageRecord)
|
||||
: {};
|
||||
}
|
||||
|
||||
function usageNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function usageDetails(record: UsageRecord, ...keys: string[]): UsageRecord {
|
||||
for (const key of keys) {
|
||||
const value = usageRecord(record[key]);
|
||||
if (Object.keys(value).length > 0) return value;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Normalize Chat Completions and Responses usage into the Responses API shape. */
|
||||
function normalizeResponsesUsage(previous: unknown, raw: unknown): UsageRecord | null {
|
||||
const source = usageRecord(raw);
|
||||
if (Object.keys(source).length === 0) return usageRecord(previous);
|
||||
|
||||
const before = usageRecord(previous);
|
||||
const beforeInputDetails = usageDetails(before, "input_tokens_details", "prompt_tokens_details");
|
||||
const beforeOutputDetails = usageDetails(
|
||||
before,
|
||||
"output_tokens_details",
|
||||
"completion_tokens_details"
|
||||
);
|
||||
const inputDetails = usageDetails(
|
||||
source,
|
||||
"input_tokens_details",
|
||||
"prompt_tokens_details",
|
||||
"inputTokenDetails",
|
||||
"input_token_details"
|
||||
);
|
||||
const outputDetails = usageDetails(
|
||||
source,
|
||||
"output_tokens_details",
|
||||
"completion_tokens_details",
|
||||
"outputTokenDetails",
|
||||
"output_token_details",
|
||||
"reasoningTokenDetails",
|
||||
"reasoning_token_details"
|
||||
);
|
||||
|
||||
const inputTokens =
|
||||
usageNumber(source.input_tokens) ??
|
||||
usageNumber(source.prompt_tokens) ??
|
||||
usageNumber(source.inputTokens) ??
|
||||
usageNumber(source.promptTokens) ??
|
||||
usageNumber(before.input_tokens) ??
|
||||
usageNumber(before.prompt_tokens) ??
|
||||
0;
|
||||
const cachedTokens =
|
||||
usageNumber(source.cache_read_input_tokens) ??
|
||||
usageNumber(source.cached_input_tokens) ??
|
||||
usageNumber(source.cachedInputTokens) ??
|
||||
usageNumber(source.cached_tokens) ??
|
||||
usageNumber(inputDetails.cached_tokens) ??
|
||||
usageNumber(inputDetails.cachedTokens) ??
|
||||
usageNumber(inputDetails.cacheReadTokens) ??
|
||||
usageNumber(beforeInputDetails.cached_tokens) ??
|
||||
0;
|
||||
const outputTokens =
|
||||
usageNumber(source.output_tokens) ??
|
||||
usageNumber(source.completion_tokens) ??
|
||||
usageNumber(source.outputTokens) ??
|
||||
usageNumber(source.completionTokens) ??
|
||||
usageNumber(before.output_tokens) ??
|
||||
usageNumber(before.completion_tokens) ??
|
||||
0;
|
||||
const reasoningTokens =
|
||||
usageNumber(source.reasoning_tokens) ??
|
||||
usageNumber(source.reasoningTokens) ??
|
||||
usageNumber(outputDetails.reasoning_tokens) ??
|
||||
usageNumber(outputDetails.reasoningTokens) ??
|
||||
usageNumber(beforeOutputDetails.reasoning_tokens) ??
|
||||
0;
|
||||
const totalTokens =
|
||||
usageNumber(source.total_tokens) ??
|
||||
usageNumber(source.totalTokens) ??
|
||||
inputTokens + outputTokens;
|
||||
|
||||
return {
|
||||
input_tokens: inputTokens,
|
||||
input_tokens_details: { cached_tokens: cachedTokens },
|
||||
output_tokens: outputTokens,
|
||||
output_tokens_details: { reasoning_tokens: reasoningTokens },
|
||||
total_tokens: totalTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Create log directory for responses (Node.js only)
|
||||
export function createResponsesLogger(model, logsDir = null) {
|
||||
// Skip logging in worker environment (no fs)
|
||||
@@ -477,10 +573,11 @@ export function createResponsesApiTransformStream(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.usage) {
|
||||
state.usage = normalizeResponsesUsage(state.usage, parsed.usage);
|
||||
}
|
||||
|
||||
if (!parsed.choices?.length) {
|
||||
if (parsed.usage) {
|
||||
state.usage = parsed.usage;
|
||||
}
|
||||
// #6906: trailing usage-only chunk after finish_reason already deferred
|
||||
// completion — send it now with the usage just captured above.
|
||||
if (state.awaitingTrailingUsage && !state.completedSent) {
|
||||
|
||||
@@ -166,6 +166,29 @@ function isReasoningOnlyReplayTarget(provider: unknown, model: unknown): boolean
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstreams that reject an ABSENT reasoning_content on replay turns, so the
|
||||
* placeholder must survive the cache miss.
|
||||
*
|
||||
* #9573/#9610 removed the placeholder globally because the model echoed it as
|
||||
* its own reasoning and stopped (empty turns). That holds for DeepSeek, where
|
||||
* an absent field was verified to be accepted — but Xiaomi MiMo still 400s
|
||||
* ("Param Incorrect: The reasoning_content in the thinking mode must be passed
|
||||
* back to the API", 9router#1321/#1337), so omitting the field there trades one
|
||||
* live bug for another. Keep the placeholder only for those providers; the echo
|
||||
* that comes back is still stripped on the way in by
|
||||
* isInternalReasoningPlaceholder(), so it never re-poisons cache or history.
|
||||
*/
|
||||
function requiresReasoningContentPresence(provider: unknown, model: unknown): boolean {
|
||||
const normalizedProvider = String(provider ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedModel = String(model ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return normalizedProvider === "xiaomi-mimo" || /(^|\/)mimo/i.test(normalizedModel);
|
||||
}
|
||||
|
||||
/** @param options.normalizeToolCallId - When true, use 9-char tool call ids (e.g. Mistral); when false, leave ids as-is */
|
||||
/** @param options.preserveDeveloperRole - undefined/true: keep developer for OpenAI format (default); false: map to system */
|
||||
/** @param options.preserveCacheControl - When true, preserve client-side cache_control markers (for Claude Code, etc.) */
|
||||
@@ -372,7 +395,9 @@ export function translateRequest(
|
||||
preserveReasoningContent: isReasoner,
|
||||
// Per-provider/model preserveVideoUrl flag from compat overrides.
|
||||
// Falls back to true for moonshot/kimi when unset (legacy behavior).
|
||||
preserveVideoUrl: getModelPreserveVideoUrl(normalizedProvider, options?.model ?? "") ?? (normalizedProvider === "moonshot" || normalizedProvider === "kimi"),
|
||||
preserveVideoUrl:
|
||||
getModelPreserveVideoUrl(normalizedProvider, normalizedModel) ??
|
||||
(normalizedProvider === "moonshot" || normalizedProvider === "kimi"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -573,7 +598,11 @@ export function translateRequest(
|
||||
// the field instead; providers that genuinely enforce the contract
|
||||
// (kimi-coding, moonshot authentic-reasoning) have their own paths above.
|
||||
if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) {
|
||||
delete msg.reasoning_content;
|
||||
if (requiresReasoningContentPresence(normalizedProvider, normalizedModel)) {
|
||||
msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER;
|
||||
} else {
|
||||
delete msg.reasoning_content;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
|
||||
@@ -724,7 +724,7 @@ export function openaiResponsesToOpenAIRequest(
|
||||
const reasoningRec = toRecord(root.reasoning);
|
||||
const effort = toString(reasoningRec.effort);
|
||||
if (effort && result.reasoning_effort === undefined) {
|
||||
result.reasoning_effort = normalizeResponsesReasoningEffort(effort, model);
|
||||
result.reasoning_effort = normalizeResponsesReasoningEffort(effort, model ?? root.model);
|
||||
}
|
||||
if (
|
||||
credentialRecord._copilotClient === true &&
|
||||
|
||||
@@ -112,7 +112,7 @@ export function geminiToClaudeResponse(chunk, state) {
|
||||
// When the toolNameMap provides a match (e.g., lowercase "bash" → "Bash"),
|
||||
// use it directly without passing through normalizeToolName(), which would
|
||||
// reverse TitleCase back to lowercase via REVERSE_MAP (#9568).
|
||||
const restoredToolName = mappedName || normalizeToolName(rawToolName);
|
||||
const restoredToolName = mappedName ?? normalizeToolName(rawToolName);
|
||||
const idx = state.contentBlockIndex++;
|
||||
const toolId = fc.id || `toolu_${Date.now()}_${idx}`;
|
||||
|
||||
|
||||
@@ -451,11 +451,22 @@ function closeMessage(state, emit, idx) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tool calls sit after reasoning (if any) AND after a text message (if one was
|
||||
// actually emitted this turn) — a model commonly emits a short preamble before
|
||||
// calling a tool (e.g. "Kör nu, på riktigt — apply_patch..."), and that message
|
||||
// claims the same reasoningIndex+1 slot the old per-call math (`reasoningIndex
|
||||
// + 1 + tcIdx`) assumed was free for tcIdx=0. Not accounting for the message
|
||||
// item collided the tool call's added/delta/done events onto the same
|
||||
// output_index as the just-closed message, which a client keying per-item
|
||||
// state by output_index can silently drop (live incident 2026-08-08).
|
||||
function toolCallOutputIndexBase(state) {
|
||||
const msgIdx = state.reasoningId ? normalizeOutputIndex(state.reasoningIndex) + 1 : 0;
|
||||
return state.msgItemAdded[msgIdx] ? msgIdx + 1 : msgIdx;
|
||||
}
|
||||
|
||||
function emitToolCall(state, emit, tc) {
|
||||
const tcIdx = tc.index ?? 0;
|
||||
const outputIndex = state.reasoningId
|
||||
? normalizeOutputIndex(state.reasoningIndex) + 1 + normalizeOutputIndex(tcIdx)
|
||||
: normalizeOutputIndex(tcIdx);
|
||||
const outputIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(tcIdx);
|
||||
const newCallId = tc.id;
|
||||
const funcName = tc.function?.name;
|
||||
|
||||
@@ -536,9 +547,7 @@ function emitToolCall(state, emit, tc) {
|
||||
function closeToolCall(state, emit, idx, recordAsCompleted = true) {
|
||||
const callId = state.funcCallIds[idx];
|
||||
if (callId && !state.funcItemDone[idx]) {
|
||||
const normalizedIndex = state.reasoningId
|
||||
? normalizeOutputIndex(state.reasoningIndex) + 1 + normalizeOutputIndex(idx)
|
||||
: normalizeOutputIndex(idx);
|
||||
const normalizedIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(idx);
|
||||
const args = state.funcArgsBuf[idx] || "{}";
|
||||
const toolName = state.funcNames[idx] || "";
|
||||
const isCustomTool =
|
||||
@@ -874,6 +883,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId);
|
||||
|
||||
const toolName = normalizeToolName(item.name);
|
||||
state.currentToolName = toolName; // track for schema lookup at done time
|
||||
if (!toolName) {
|
||||
// Some Responses providers briefly emit placeholder/empty tool names.
|
||||
// Defer emission until output_item.done in case the final name is populated there.
|
||||
@@ -919,26 +929,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta;
|
||||
if (state.currentToolCallDeferred) return null;
|
||||
|
||||
return {
|
||||
id: state.chatId,
|
||||
object: "chat.completion.chunk",
|
||||
created: state.created,
|
||||
model: state.model || "gpt-4",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: state.toolCallIndex,
|
||||
function: { arguments: argsDelta },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
// #9168: buffer arguments until output_item.done for schema-aware null normalization
|
||||
// Previously emitted raw null values for optional enum fields (e.g. isolation: null).
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function call done — emit args chunk from item.arguments when no deltas were received,
|
||||
@@ -1011,6 +1004,35 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
if (item.arguments != null && !buffered) {
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName, toolSchema);
|
||||
|
||||
const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit);
|
||||
if (argsStr) {
|
||||
return {
|
||||
id: state.chatId,
|
||||
object: "chat.completion.chunk",
|
||||
created: state.created,
|
||||
model: state.model || "gpt-4",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: currentIndex,
|
||||
function: { arguments: argsStr },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
} else if (buffered) {
|
||||
// #9168: deltas were buffered — normalize against the original client schema
|
||||
// and emit the cleaned arguments once, stripping optional null values that
|
||||
// would otherwise reach the client raw.
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(buffered, toolName, toolSchema);
|
||||
|
||||
const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit);
|
||||
if (argsStr) {
|
||||
return {
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
import zlib from "node:zlib";
|
||||
import crypto from "node:crypto";
|
||||
import { decodeNativeTodoWriteCompletion } from "./cursorAgentProtobuf/nativeTodoWrite.ts";
|
||||
import {
|
||||
cursorImageAttachmentPath,
|
||||
encodeSelectedImageBody,
|
||||
type EncodedImage,
|
||||
} from "./cursorAgentProtobuf/imageEncoding.ts";
|
||||
import {
|
||||
WT_VARINT,
|
||||
WT_LEN,
|
||||
@@ -63,25 +68,8 @@ const UM_MESSAGE_ID = 2; // UserMessage.message_id
|
||||
const UM_SELECTED_CONTEXT = 3; // UserMessage.selected_context (empty placeholder required)
|
||||
const UM_MODE = 4; // UserMessage.mode (cursor-agent sends 1)
|
||||
|
||||
// ─── Vision input (image) field numbers ────────────────────────────────────
|
||||
// Pinned from cursor-agent's agent.v1 protobuf descriptor (bundle version
|
||||
// 2026.06.02-8c11d9f, cross-checked against composer-api's older-endpoint
|
||||
// encoder for shape). Images attach to the current UserMessage through its
|
||||
// selected_context (field 3): UserMessage.selected_context is a SelectedContext
|
||||
// whose `selected_images` (field 1) is a repeated SelectedImage. Each
|
||||
// SelectedImage carries the raw bytes inline in its `data_or_blob_id` oneof
|
||||
// (the `data` case, field 8) — cursor-agent's CLI instead sends a local file
|
||||
// `path`, which a proxy cannot use, so we inline the bytes like composer-api.
|
||||
const SC_SELECTED_IMAGES = 1; // SelectedContext.selected_images [repeated SelectedImage]
|
||||
|
||||
const SI_UUID = 2; // SelectedImage.uuid
|
||||
const SI_DIMENSION = 4; // SelectedImage.dimension (SelectedImage.Dimension)
|
||||
const SI_MIME_TYPE = 7; // SelectedImage.mime_type
|
||||
const SI_DATA = 8; // SelectedImage.data (oneof data_or_blob_id) — inline image bytes
|
||||
|
||||
const DIM_WIDTH = 1; // SelectedImage.Dimension.width (int32)
|
||||
const DIM_HEIGHT = 2; // SelectedImage.Dimension.height (int32)
|
||||
|
||||
const RM_MODEL_ID = 1; // RequestedModel.model_id
|
||||
const RM_PARAMETERS = 3; // RequestedModel.parameters [repeated]
|
||||
|
||||
@@ -413,58 +401,15 @@ export type AgentRunInput = {
|
||||
// which the executor's processFrame replies to with the stored bytes.
|
||||
systemPrompt?: string;
|
||||
blobStore?: Map<string, Buffer>;
|
||||
// Vision input: images attached to the current user turn. Encoded inline as
|
||||
// SelectedContext.selected_images[] (see encodeSelectedImageBody). Empty /
|
||||
// undefined keeps the request byte-identical to the text-only path.
|
||||
// Vision input: images attached to the current user turn. Encoded as
|
||||
// SelectedContext.selected_images[] via blobIdWithData (see
|
||||
// encodeSelectedImageBody). Empty / undefined keeps the request
|
||||
// byte-identical to the text-only path.
|
||||
images?: EncodedImage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A resolved image ready to embed in a cursor request. `data` is the raw
|
||||
* decoded image bytes (already SSRF-checked / size-capped by the executor's
|
||||
* resolveCursorImages helper). `mimeType` (e.g. "image/png") helps cursor
|
||||
* decode the inline bytes; `width`/`height` populate the optional Dimension
|
||||
* sub-message when cheaply known; `uuid` is a stable per-image id.
|
||||
*/
|
||||
export type EncodedImage = {
|
||||
data: Buffer;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
uuid: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encode the body of a SelectedImage message (no outer field tag — the caller
|
||||
* wraps it via encodeMessage(SC_SELECTED_IMAGES, [body])). Sets the inline
|
||||
* `data` oneof case plus uuid, optional dimension, and mime_type. Fields are
|
||||
* written in ascending field-number order (canonical protobuf layout).
|
||||
*/
|
||||
export function encodeSelectedImageBody(img: EncodedImage): Buffer {
|
||||
const parts: Buffer[] = [encodeString(SI_UUID, img.uuid)];
|
||||
if (
|
||||
typeof img.width === "number" &&
|
||||
typeof img.height === "number" &&
|
||||
Number.isFinite(img.width) &&
|
||||
Number.isFinite(img.height) &&
|
||||
img.width > 0 &&
|
||||
img.height > 0
|
||||
) {
|
||||
parts.push(
|
||||
encodeMessage(SI_DIMENSION, [
|
||||
encodeUInt32Field(DIM_WIDTH, Math.floor(img.width)),
|
||||
encodeUInt32Field(DIM_HEIGHT, Math.floor(img.height)),
|
||||
])
|
||||
);
|
||||
}
|
||||
if (img.mimeType) {
|
||||
parts.push(encodeString(SI_MIME_TYPE, img.mimeType));
|
||||
}
|
||||
// data_or_blob_id oneof = data (inline bytes) — field 8, written last to
|
||||
// keep ascending field order.
|
||||
parts.push(encodeBytes(SI_DATA, img.data));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
export { cursorImageAttachmentPath, encodeSelectedImageBody };
|
||||
export type { EncodedImage };
|
||||
|
||||
/**
|
||||
* Convert OpenAI tool definitions to cursor McpToolDefinition bodies. Used
|
||||
@@ -493,12 +438,15 @@ export function encodeAgentRunRequest(input: AgentRunInput): Buffer {
|
||||
// UserMessage { text, message_id, selected_context, mode=1 }.
|
||||
// selected_context is normally an empty placeholder (required by the server
|
||||
// even when empty — see below), but when the turn carries vision input we
|
||||
// populate its selected_images[] with the inline-encoded images. The
|
||||
// empty-images path produces byte-identical output to the text-only request.
|
||||
// populate its selected_images[] with blobIdWithData-encoded images (and
|
||||
// store the bytes in blobStore for getBlob). The empty-images path produces
|
||||
// byte-identical output to the text-only request.
|
||||
const selectedContextParts: Buffer[] = [];
|
||||
if (input.images && input.images.length > 0) {
|
||||
for (const img of input.images) {
|
||||
selectedContextParts.push(encodeMessage(SC_SELECTED_IMAGES, [encodeSelectedImageBody(img)]));
|
||||
selectedContextParts.push(
|
||||
encodeMessage(SC_SELECTED_IMAGES, [encodeSelectedImageBody(img, input.blobStore)])
|
||||
);
|
||||
}
|
||||
}
|
||||
// The empty selected_context placeholder and mode=1 match cursor-agent's
|
||||
|
||||
80
open-sse/utils/cursorAgentProtobuf/imageEncoding.ts
Normal file
80
open-sse/utils/cursorAgentProtobuf/imageEncoding.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
encodeBytes,
|
||||
encodeMessage,
|
||||
encodeString,
|
||||
encodeUInt32Field,
|
||||
} from "./wire.ts";
|
||||
|
||||
const SI_UUID = 2;
|
||||
const SI_PATH = 3;
|
||||
const SI_DIMENSION = 4;
|
||||
const SI_MIME_TYPE = 7;
|
||||
const SI_BLOB_ID_WITH_DATA = 9;
|
||||
|
||||
const SIBD_BLOB_ID = 1;
|
||||
const SIBD_DATA = 2;
|
||||
|
||||
const DIM_WIDTH = 1;
|
||||
const DIM_HEIGHT = 2;
|
||||
|
||||
export type EncodedImage = {
|
||||
data: Buffer;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
uuid: string;
|
||||
};
|
||||
|
||||
export function cursorImageAttachmentPath(uuid: string, mimeType?: string): string {
|
||||
const normalized = (mimeType || "").toLowerCase();
|
||||
const ext =
|
||||
normalized === "image/jpeg" || normalized === "image/jpg"
|
||||
? "jpg"
|
||||
: normalized === "image/gif"
|
||||
? "gif"
|
||||
: normalized === "image/webp"
|
||||
? "webp"
|
||||
: "png";
|
||||
return `attachment-${uuid}.${ext}`;
|
||||
}
|
||||
|
||||
export function encodeSelectedImageBody(
|
||||
img: EncodedImage,
|
||||
blobStore?: Map<string, Buffer>
|
||||
): Buffer {
|
||||
const blobId = crypto.createHash("sha256").update(img.data).digest();
|
||||
if (blobStore) {
|
||||
blobStore.set(blobId.toString("hex"), img.data);
|
||||
}
|
||||
|
||||
const parts: Buffer[] = [
|
||||
encodeString(SI_UUID, img.uuid),
|
||||
encodeString(SI_PATH, cursorImageAttachmentPath(img.uuid, img.mimeType)),
|
||||
];
|
||||
if (
|
||||
typeof img.width === "number" &&
|
||||
typeof img.height === "number" &&
|
||||
Number.isFinite(img.width) &&
|
||||
Number.isFinite(img.height) &&
|
||||
img.width > 0 &&
|
||||
img.height > 0
|
||||
) {
|
||||
parts.push(
|
||||
encodeMessage(SI_DIMENSION, [
|
||||
encodeUInt32Field(DIM_WIDTH, Math.floor(img.width)),
|
||||
encodeUInt32Field(DIM_HEIGHT, Math.floor(img.height)),
|
||||
])
|
||||
);
|
||||
}
|
||||
if (img.mimeType) {
|
||||
parts.push(encodeString(SI_MIME_TYPE, img.mimeType));
|
||||
}
|
||||
parts.push(
|
||||
encodeMessage(SI_BLOB_ID_WITH_DATA, [
|
||||
encodeBytes(SIBD_BLOB_ID, blobId),
|
||||
encodeBytes(SIBD_DATA, img.data),
|
||||
])
|
||||
);
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
* Image resolution + security for Cursor vision input.
|
||||
*
|
||||
* Turns OpenAI `image_url` parts (base64 `data:` URIs or remote `http(s)`
|
||||
* URLs) into decoded bytes ready to inline into a cursor SelectedImage
|
||||
* (see ../utils/cursorAgentProtobuf.ts::encodeSelectedImageBody).
|
||||
* URLs) into decoded, JPEG-prepped bytes ready for SelectedImage
|
||||
* `blobIdWithData` encoding (see cursorAgentProtobuf.ts).
|
||||
*
|
||||
* Security (OmniRoute hard rules):
|
||||
* - SSRF: remote fetches go through the repo's canonical outbound guard
|
||||
@@ -12,9 +12,9 @@
|
||||
* cloud-metadata hostnames. Client-supplied image URLs are always held to
|
||||
* the strict public-only policy (never gated by the private-URL toggle that
|
||||
* admin-configured provider URLs use).
|
||||
* - Size cap: each image must decode to <= 1 MiB (matches composer-api).
|
||||
* Enforced both before base64 decode (cheap pre-check) and while streaming
|
||||
* a remote body (so a hostile server can't stream gigabytes).
|
||||
* - Size caps: inbound decode/fetch is bounded (16 MiB) so large clipboard
|
||||
* PNGs can shrink via JPEG soft-cap prep; the final wire image must be
|
||||
* <= 1 MiB. Soft target is ~100 KiB JPEG for reliable Cursor hydration.
|
||||
* - Content type: data URIs and URL responses must be `image/*`.
|
||||
* - Errors throw `CursorImageError` with a clean, path-free message; the
|
||||
* executor routes it through the sanitized 400 path (hard rule #12).
|
||||
@@ -23,6 +23,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import dns from "node:dns";
|
||||
import { isIP } from "node:net";
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
parseAndValidatePublicUrl,
|
||||
isPrivateHost,
|
||||
@@ -30,14 +31,47 @@ import {
|
||||
} from "@/shared/network/outboundUrlGuard";
|
||||
import type { EncodedImage } from "./cursorAgentProtobuf.ts";
|
||||
|
||||
// 1 MiB per image — matches composer-api's MAX_CURSOR_IMAGE_BYTES. Large
|
||||
// enough for a typical screenshot, small enough to bound request size and
|
||||
// memory.
|
||||
/** Final per-image byte cap after prep (composer-api / wire bound). */
|
||||
export const MAX_CURSOR_IMAGE_BYTES = 1024 * 1024;
|
||||
|
||||
// Upper bound on the number of images per request. Each image triggers (at
|
||||
// most) one remote fetch, so an unbounded count is a DoS vector; 12 is well
|
||||
// above any realistic vision prompt.
|
||||
/**
|
||||
* Inbound decode/fetch bomb ceiling before JPEG prep. Large clipboard PNGs may
|
||||
* exceed {@link MAX_CURSOR_IMAGE_BYTES} raw but shrink under the wire cap after
|
||||
* re-encode.
|
||||
*/
|
||||
export const MAX_CURSOR_IMAGE_DECODE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Soft target for Cursor vision hydration. Prefer JPEG at or under this size.
|
||||
*/
|
||||
export const CURSOR_VISION_SOFT_MAX_BYTES = 100 * 1024;
|
||||
|
||||
/** Soft target when the client requests `detail: original` or `high`. */
|
||||
export const CURSOR_VISION_SOFT_MAX_BYTES_HIGH = 256 * 1024;
|
||||
|
||||
/** Longest edge after Cursor vision prep. */
|
||||
export const CURSOR_VISION_MAX_EDGE = 2000;
|
||||
|
||||
/** Decode bomb: reject images whose sniffed longest edge exceeds this. */
|
||||
export const MAX_CURSOR_IMAGE_DECODE_EDGE = 8192;
|
||||
|
||||
/** Decode bomb: reject images whose sniffed pixel count exceeds this. */
|
||||
export const MAX_CURSOR_IMAGE_PIXELS = 25_000_000;
|
||||
|
||||
const CURSOR_VISION_JPEG_QUALITIES_DEFAULT = [85, 70, 55, 40] as const;
|
||||
const CURSOR_VISION_JPEG_QUALITIES_HIGH = [90, 80, 65, 50] as const;
|
||||
const CURSOR_VISION_SOFT_MIN_EDGE = 256;
|
||||
const CURSOR_VISION_SOFT_SHRINK = 0.85;
|
||||
|
||||
const CURSOR_VISION_PASSTHROUGH_MIME = new Set([
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
/** Upper bound on images attached to one Cursor turn. */
|
||||
export const MAX_CURSOR_IMAGES = 12;
|
||||
|
||||
// Wall-clock cap for a single remote image fetch. A malformed env value
|
||||
@@ -64,6 +98,25 @@ export class CursorImageError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function estimatedBase64DecodedBytes(payload: string): number {
|
||||
return Math.floor((payload.length * 3) / 4);
|
||||
}
|
||||
|
||||
function isHighDetail(detail: string | undefined): boolean {
|
||||
const normalized = (detail || "").toLowerCase();
|
||||
return normalized === "high" || normalized === "original";
|
||||
}
|
||||
|
||||
function softMaxBytesForDetail(detail: string | undefined): number {
|
||||
return isHighDetail(detail) ? CURSOR_VISION_SOFT_MAX_BYTES_HIGH : CURSOR_VISION_SOFT_MAX_BYTES;
|
||||
}
|
||||
|
||||
function jpegQualitiesForDetail(detail: string | undefined): readonly number[] {
|
||||
return isHighDetail(detail)
|
||||
? CURSOR_VISION_JPEG_QUALITIES_HIGH
|
||||
: CURSOR_VISION_JPEG_QUALITIES_DEFAULT;
|
||||
}
|
||||
|
||||
function decodeDataUrl(url: string): { data: Buffer; mimeType: string } {
|
||||
// data:[<mediatype>][;base64],<data>
|
||||
const comma = url.indexOf(",");
|
||||
@@ -86,16 +139,21 @@ function decodeDataUrl(url: string): { data: Buffer; mimeType: string } {
|
||||
|
||||
// Reject on the raw payload length BEFORE the regex/normalize pass, so an
|
||||
// arbitrarily large data URL can't burn CPU on the whitespace strip. Base64
|
||||
// expands ~4:3, so 2x the byte cap is a safe upper bound on the encoded text.
|
||||
if (payload.length > MAX_CURSOR_IMAGE_BYTES * 2) {
|
||||
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
|
||||
// expands ~4:3, so 2x the decode ceiling is a safe upper bound on the text.
|
||||
if (payload.length > MAX_CURSOR_IMAGE_DECODE_BYTES * 2) {
|
||||
throw new CursorImageError("Image input is too large to process safely.");
|
||||
}
|
||||
|
||||
const normalized = payload.replace(/\s/g, "");
|
||||
// Cheap pre-check: 4 base64 chars -> 3 bytes. Reject obviously oversized
|
||||
// payloads before allocating the decode buffer.
|
||||
if (Math.floor((normalized.length * 3) / 4) > MAX_CURSOR_IMAGE_BYTES) {
|
||||
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
|
||||
if (normalized.length === 0) {
|
||||
throw new CursorImageError("Image data URL contains invalid base64 data.");
|
||||
}
|
||||
// Reject lenient Buffer.from acceptances (wrong alphabet, bad padding).
|
||||
if (normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) {
|
||||
throw new CursorImageError("Image data URL contains invalid base64 data.");
|
||||
}
|
||||
if (estimatedBase64DecodedBytes(normalized) > MAX_CURSOR_IMAGE_DECODE_BYTES) {
|
||||
throw new CursorImageError("Image input is too large to process safely.");
|
||||
}
|
||||
|
||||
let data: Buffer;
|
||||
@@ -104,11 +162,16 @@ function decodeDataUrl(url: string): { data: Buffer; mimeType: string } {
|
||||
} catch {
|
||||
throw new CursorImageError("Image data URL contains invalid base64 data.");
|
||||
}
|
||||
// Buffer.from(base64) silently drops invalid trailing chars; guard against a
|
||||
// payload that decoded to nothing despite being non-empty.
|
||||
if (normalized.length > 0 && data.length === 0) {
|
||||
if (data.length === 0) {
|
||||
throw new CursorImageError("Image data URL contains invalid base64 data.");
|
||||
}
|
||||
// Round-trip guard: Node can silently drop trailing garbage.
|
||||
if (data.toString("base64").replace(/=+$/, "") !== normalized.replace(/=+$/, "")) {
|
||||
throw new CursorImageError("Image data URL contains invalid base64 data.");
|
||||
}
|
||||
if (data.length > MAX_CURSOR_IMAGE_DECODE_BYTES) {
|
||||
throw new CursorImageError("Image input is too large to process safely.");
|
||||
}
|
||||
return { data, mimeType };
|
||||
}
|
||||
|
||||
@@ -216,10 +279,10 @@ async function fetchImageBytes(url: string): Promise<{ data: Buffer; mimeType: s
|
||||
// Reject early on an oversized Content-Length, then still cap during read
|
||||
// (the header is advisory / may be absent).
|
||||
const declaredLen = Number(response.headers.get("content-length") || "0");
|
||||
if (Number.isFinite(declaredLen) && declaredLen > MAX_CURSOR_IMAGE_BYTES) {
|
||||
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
|
||||
if (Number.isFinite(declaredLen) && declaredLen > MAX_CURSOR_IMAGE_DECODE_BYTES) {
|
||||
throw new CursorImageError("Image input is too large to process safely.");
|
||||
}
|
||||
const data = await readCapped(response, MAX_CURSOR_IMAGE_BYTES);
|
||||
const data = await readCapped(response, MAX_CURSOR_IMAGE_DECODE_BYTES);
|
||||
return { data, mimeType };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
@@ -249,7 +312,7 @@ async function readCapped(response: Response, cap: number): Promise<Buffer> {
|
||||
const pushCapped = (chunk: Uint8Array) => {
|
||||
total += chunk.byteLength;
|
||||
if (total > cap) {
|
||||
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
|
||||
throw new CursorImageError("Image input is too large to process safely.");
|
||||
}
|
||||
chunks.push(Buffer.from(chunk));
|
||||
};
|
||||
@@ -284,22 +347,311 @@ async function readCapped(response: Response, cap: number): Promise<Buffer> {
|
||||
// Last resort: buffer then cap-check (only exotic non-stream bodies).
|
||||
const buf = Buffer.from(await response.arrayBuffer());
|
||||
if (buf.length > cap) {
|
||||
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
|
||||
throw new CursorImageError("Image input is too large to process safely.");
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Magic-byte format sniff (independent of declared MIME). */
|
||||
export function sniffCursorImageFormat(
|
||||
data: Uint8Array
|
||||
): "png" | "jpeg" | "gif" | "webp" | undefined {
|
||||
if (
|
||||
data.byteLength >= 8 &&
|
||||
data[0] === 0x89 &&
|
||||
data[1] === 0x50 &&
|
||||
data[2] === 0x4e &&
|
||||
data[3] === 0x47 &&
|
||||
data[4] === 0x0d &&
|
||||
data[5] === 0x0a &&
|
||||
data[6] === 0x1a &&
|
||||
data[7] === 0x0a
|
||||
) {
|
||||
return "png";
|
||||
}
|
||||
if (
|
||||
data.byteLength >= 6 &&
|
||||
data[0] === 0x47 &&
|
||||
data[1] === 0x49 &&
|
||||
data[2] === 0x46 &&
|
||||
data[3] === 0x38
|
||||
) {
|
||||
return "gif";
|
||||
}
|
||||
if (data.byteLength >= 4 && data[0] === 0xff && data[1] === 0xd8) return "jpeg";
|
||||
if (
|
||||
data.byteLength >= 12 &&
|
||||
data[0] === 0x52 &&
|
||||
data[1] === 0x49 &&
|
||||
data[2] === 0x46 &&
|
||||
data[3] === 0x46 &&
|
||||
data[8] === 0x57 &&
|
||||
data[9] === 0x45 &&
|
||||
data[10] === 0x42 &&
|
||||
data[11] === 0x50
|
||||
) {
|
||||
return "webp";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sniff PNG/JPEG/GIF/WebP dimensions from raw bytes when the header is present.
|
||||
* Best-effort only — unknown formats return undefined (dimension is optional).
|
||||
*/
|
||||
export function sniffCursorImageDimensions(
|
||||
data: Uint8Array
|
||||
): { width: number; height: number } | undefined {
|
||||
// PNG: signature + IHDR chunk (width/height at bytes 16..23)
|
||||
if (
|
||||
data.byteLength >= 24 &&
|
||||
data[0] === 0x89 &&
|
||||
data[1] === 0x50 &&
|
||||
data[2] === 0x4e &&
|
||||
data[3] === 0x47 &&
|
||||
data[4] === 0x0d &&
|
||||
data[5] === 0x0a &&
|
||||
data[6] === 0x1a &&
|
||||
data[7] === 0x0a
|
||||
) {
|
||||
const width = ((data[16]! << 24) | (data[17]! << 16) | (data[18]! << 8) | data[19]!) >>> 0;
|
||||
const height = ((data[20]! << 24) | (data[21]! << 16) | (data[22]! << 8) | data[23]!) >>> 0;
|
||||
if (width > 0 && height > 0) return { width, height };
|
||||
}
|
||||
// GIF: "GIF8" + width/height as little-endian u16 at bytes 6..9
|
||||
if (
|
||||
data.byteLength >= 10 &&
|
||||
data[0] === 0x47 &&
|
||||
data[1] === 0x49 &&
|
||||
data[2] === 0x46 &&
|
||||
data[3] === 0x38
|
||||
) {
|
||||
const width = data[6]! | (data[7]! << 8);
|
||||
const height = data[8]! | (data[9]! << 8);
|
||||
if (width > 0 && height > 0) return { width, height };
|
||||
}
|
||||
// WebP: RIFF....WEBP + VP8X / VP8 / VP8L
|
||||
if (
|
||||
data.byteLength >= 30 &&
|
||||
data[0] === 0x52 &&
|
||||
data[1] === 0x49 &&
|
||||
data[2] === 0x46 &&
|
||||
data[3] === 0x46 &&
|
||||
data[8] === 0x57 &&
|
||||
data[9] === 0x45 &&
|
||||
data[10] === 0x42 &&
|
||||
data[11] === 0x50
|
||||
) {
|
||||
const fourcc = String.fromCharCode(data[12]!, data[13]!, data[14]!, data[15]!);
|
||||
if (fourcc === "VP8X") {
|
||||
const width = 1 + (data[24]! | (data[25]! << 8) | (data[26]! << 16));
|
||||
const height = 1 + (data[27]! | (data[28]! << 8) | (data[29]! << 16));
|
||||
if (width > 0 && height > 0) return { width, height };
|
||||
} else if (fourcc === "VP8 ") {
|
||||
if (data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) {
|
||||
const width = (data[26]! | (data[27]! << 8)) & 0x3fff;
|
||||
const height = (data[28]! | (data[29]! << 8)) & 0x3fff;
|
||||
if (width > 0 && height > 0) return { width, height };
|
||||
}
|
||||
} else if (fourcc === "VP8L" && data[20] === 0x2f) {
|
||||
const raw = data[21]! | (data[22]! << 8) | (data[23]! << 16) | (data[24]! << 24);
|
||||
const width = (raw & 0x3fff) + 1;
|
||||
const height = ((raw >> 14) & 0x3fff) + 1;
|
||||
if (width > 0 && height > 0) return { width, height };
|
||||
}
|
||||
}
|
||||
// JPEG: scan for SOF0/SOF2 marker with dimensions
|
||||
if (data.byteLength >= 4 && data[0] === 0xff && data[1] === 0xd8) {
|
||||
let offset = 2;
|
||||
while (offset + 8 < data.byteLength) {
|
||||
if (data[offset] !== 0xff) break;
|
||||
const marker = data[offset + 1]!;
|
||||
// Standalone markers (TEM, RSTn, SOI, EOI) carry no length payload.
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
const length = (data[offset + 2]! << 8) | data[offset + 3]!;
|
||||
if (marker === 0xc0 || marker === 0xc2) {
|
||||
const height = (data[offset + 5]! << 8) | data[offset + 6]!;
|
||||
const width = (data[offset + 7]! << 8) | data[offset + 8]!;
|
||||
if (width > 0 && height > 0) return { width, height };
|
||||
break;
|
||||
}
|
||||
if (length < 2) break;
|
||||
offset += 2 + length;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type PreparedImage = {
|
||||
data: Buffer;
|
||||
mimeType: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-encode toward a JPEG under the soft vision cap when sharp can decode the
|
||||
* payload. Fail-closed with CursorImageError on unsupported MIME, decode bombs,
|
||||
* or undecodable bytes. After the quality ladder, edges shrink iteratively
|
||||
* until the soft byte cap is met (or the min edge floor is hit).
|
||||
*/
|
||||
export async function prepareCursorImageForWire(input: {
|
||||
data: Buffer;
|
||||
mimeType: string;
|
||||
detail?: string;
|
||||
}): Promise<PreparedImage> {
|
||||
const mime = input.mimeType.toLowerCase();
|
||||
const softMax = softMaxBytesForDetail(input.detail);
|
||||
const qualities = jpegQualitiesForDetail(input.detail);
|
||||
const lowestQuality = qualities[qualities.length - 1]!;
|
||||
|
||||
if (!CURSOR_VISION_PASSTHROUGH_MIME.has(mime)) {
|
||||
throw new CursorImageError("Image input type is unsupported.");
|
||||
}
|
||||
|
||||
const format = sniffCursorImageFormat(input.data);
|
||||
const sniffed = sniffCursorImageDimensions(input.data);
|
||||
if (sniffed) {
|
||||
const edge = Math.max(sniffed.width, sniffed.height);
|
||||
const pixels = sniffed.width * sniffed.height;
|
||||
if (edge > MAX_CURSOR_IMAGE_DECODE_EDGE || pixels > MAX_CURSOR_IMAGE_PIXELS) {
|
||||
throw new CursorImageError("Image input dimensions are too large.");
|
||||
}
|
||||
}
|
||||
|
||||
// Soft-cap skip: already soft-capped JPEG that has a real SOF (not SOI-only).
|
||||
const declaredJpeg = mime === "image/jpeg" || mime === "image/jpg";
|
||||
const alreadySmallJpeg =
|
||||
declaredJpeg && format === "jpeg" && sniffed !== undefined && input.data.byteLength <= softMax;
|
||||
if (alreadySmallJpeg) {
|
||||
return {
|
||||
data: input.data,
|
||||
mimeType: "image/jpeg",
|
||||
width: sniffed!.width,
|
||||
height: sniffed!.height,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Force a full decode before accepting passthrough / encode.
|
||||
await sharp(input.data, { failOn: "error" }).resize(1, 1).jpeg({ quality: 1 }).toBuffer();
|
||||
|
||||
// Passthrough only when declared MIME matches actual JPEG magic.
|
||||
if (declaredJpeg && format === "jpeg" && input.data.byteLength <= softMax) {
|
||||
const dims = sniffed ?? (await sharp(input.data).metadata());
|
||||
const width = typeof dims.width === "number" ? dims.width : undefined;
|
||||
const height = typeof dims.height === "number" ? dims.height : undefined;
|
||||
return {
|
||||
data: input.data,
|
||||
mimeType: "image/jpeg",
|
||||
...(width && height && width > 0 && height > 0 ? { width, height } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const meta = await sharp(input.data).metadata();
|
||||
const width = typeof meta.width === "number" ? meta.width : 0;
|
||||
const height = typeof meta.height === "number" ? meta.height : 0;
|
||||
if (width > 0 && height > 0) {
|
||||
const edge = Math.max(width, height);
|
||||
if (edge > MAX_CURSOR_IMAGE_DECODE_EDGE || width * height > MAX_CURSOR_IMAGE_PIXELS) {
|
||||
throw new CursorImageError("Image input dimensions are too large.");
|
||||
}
|
||||
}
|
||||
|
||||
let targetW = width;
|
||||
let targetH = height;
|
||||
if (width > 0 && height > 0 && Math.max(width, height) > CURSOR_VISION_MAX_EDGE) {
|
||||
const scale = CURSOR_VISION_MAX_EDGE / Math.max(width, height);
|
||||
targetW = Math.max(1, Math.round(width * scale));
|
||||
targetH = Math.max(1, Math.round(height * scale));
|
||||
}
|
||||
|
||||
const encodeAt = async (w: number, h: number, quality: number): Promise<Buffer> => {
|
||||
let pipeline = sharp(input.data, { failOn: "error" });
|
||||
if (w > 0 && h > 0 && (w !== width || h !== height)) {
|
||||
pipeline = pipeline.resize(w, h);
|
||||
}
|
||||
return pipeline.jpeg({ quality, mozjpeg: true }).toBuffer();
|
||||
};
|
||||
|
||||
let best: Buffer | undefined;
|
||||
for (const quality of qualities) {
|
||||
const encoded = await encodeAt(targetW, targetH, quality);
|
||||
if (!best || encoded.byteLength < best.byteLength) best = encoded;
|
||||
if (encoded.byteLength <= softMax) {
|
||||
const outDims = sniffCursorImageDimensions(encoded);
|
||||
return {
|
||||
data: encoded,
|
||||
mimeType: "image/jpeg",
|
||||
...(outDims ?? (targetW > 0 && targetH > 0 ? { width: targetW, height: targetH } : {})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
while (
|
||||
best &&
|
||||
best.byteLength > softMax &&
|
||||
targetW > 0 &&
|
||||
targetH > 0 &&
|
||||
Math.max(targetW, targetH) > CURSOR_VISION_SOFT_MIN_EDGE
|
||||
) {
|
||||
const nextW = Math.max(1, Math.round(targetW * CURSOR_VISION_SOFT_SHRINK));
|
||||
const nextH = Math.max(1, Math.round(targetH * CURSOR_VISION_SOFT_SHRINK));
|
||||
if (Math.max(nextW, nextH) < CURSOR_VISION_SOFT_MIN_EDGE) {
|
||||
const scale = CURSOR_VISION_SOFT_MIN_EDGE / Math.max(targetW, targetH);
|
||||
targetW = Math.max(1, Math.round(targetW * scale));
|
||||
targetH = Math.max(1, Math.round(targetH * scale));
|
||||
} else {
|
||||
targetW = nextW;
|
||||
targetH = nextH;
|
||||
}
|
||||
const encoded = await encodeAt(targetW, targetH, lowestQuality);
|
||||
if (!best || encoded.byteLength < best.byteLength) best = encoded;
|
||||
if (encoded.byteLength <= softMax) {
|
||||
const outDims = sniffCursorImageDimensions(encoded);
|
||||
return {
|
||||
data: encoded,
|
||||
mimeType: "image/jpeg",
|
||||
...(outDims ?? { width: targetW, height: targetH }),
|
||||
};
|
||||
}
|
||||
if (Math.max(targetW, targetH) <= CURSOR_VISION_SOFT_MIN_EDGE) break;
|
||||
}
|
||||
|
||||
if (best) {
|
||||
const outDims = sniffCursorImageDimensions(best);
|
||||
return {
|
||||
data: best,
|
||||
mimeType: "image/jpeg",
|
||||
...(outDims ?? (targetW > 0 && targetH > 0 ? { width: targetW, height: targetH } : {})),
|
||||
};
|
||||
}
|
||||
|
||||
if (declaredJpeg && format !== "jpeg") {
|
||||
throw new CursorImageError("Image input is not a valid JPEG.");
|
||||
}
|
||||
throw new CursorImageError("Image input could not be prepared for Cursor vision.");
|
||||
} catch (err) {
|
||||
if (err instanceof CursorImageError) throw err;
|
||||
throw new CursorImageError("Image input is undecodable or unsupported.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve OpenAI `image_url` URLs (data: or http(s):) into EncodedImage[]
|
||||
* ready to inline into a cursor request. Each image gets a stable random uuid.
|
||||
* Throws CursorImageError (clean message, sanitizable) on any invalid /
|
||||
* oversized / blocked input.
|
||||
* ready for SelectedImage blobIdWithData encoding. Each image gets a stable
|
||||
* random uuid. Throws CursorImageError (clean message, sanitizable) on any
|
||||
* invalid / oversized / blocked / undecodable input.
|
||||
*/
|
||||
export async function resolveCursorImages(imageUrls: string[]): Promise<EncodedImage[]> {
|
||||
export async function resolveCursorImages(
|
||||
imageUrls: string[],
|
||||
options?: { detail?: string }
|
||||
): Promise<EncodedImage[]> {
|
||||
if (imageUrls.length > MAX_CURSOR_IMAGES) {
|
||||
throw new CursorImageError(
|
||||
`Too many images in one request (max ${MAX_CURSOR_IMAGES}).`
|
||||
);
|
||||
throw new CursorImageError(`Too many images in one request (max ${MAX_CURSOR_IMAGES}).`);
|
||||
}
|
||||
const out: EncodedImage[] = [];
|
||||
for (const url of imageUrls) {
|
||||
@@ -314,10 +666,27 @@ export async function resolveCursorImages(imageUrls: string[]): Promise<EncodedI
|
||||
if (!data.length) {
|
||||
throw new CursorImageError("Image input is empty.");
|
||||
}
|
||||
if (data.length > MAX_CURSOR_IMAGE_BYTES) {
|
||||
if (data.length > MAX_CURSOR_IMAGE_DECODE_BYTES) {
|
||||
throw new CursorImageError("Image input is too large to process safely.");
|
||||
}
|
||||
|
||||
const prepared = await prepareCursorImageForWire({
|
||||
data,
|
||||
mimeType,
|
||||
detail: options?.detail,
|
||||
});
|
||||
if (prepared.data.length > MAX_CURSOR_IMAGE_BYTES) {
|
||||
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
|
||||
}
|
||||
out.push({ data, mimeType, uuid: crypto.randomUUID() });
|
||||
|
||||
out.push({
|
||||
data: prepared.data,
|
||||
mimeType: prepared.mimeType,
|
||||
uuid: crypto.randomUUID(),
|
||||
...(typeof prepared.width === "number" && typeof prepared.height === "number"
|
||||
? { width: prepared.width, height: prepared.height }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -327,17 +696,11 @@ export async function resolveCursorImages(imageUrls: string[]): Promise<EncodedI
|
||||
* Returns the raw url strings (data: or http(s):) in order. Non-image parts
|
||||
* are ignored. A plain string content has no images.
|
||||
*/
|
||||
export function extractImageUrls(
|
||||
content: unknown
|
||||
): string[] {
|
||||
export function extractImageUrls(content: unknown): string[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
const urls: string[] = [];
|
||||
for (const part of content) {
|
||||
if (
|
||||
part &&
|
||||
typeof part === "object" &&
|
||||
(part as { type?: unknown }).type === "image_url"
|
||||
) {
|
||||
if (part && typeof part === "object" && (part as { type?: unknown }).type === "image_url") {
|
||||
const imageUrl = (part as { image_url?: unknown }).image_url;
|
||||
if (typeof imageUrl === "string") {
|
||||
urls.push(imageUrl);
|
||||
|
||||
250
open-sse/utils/mediaParts.ts
Normal file
250
open-sse/utils/mediaParts.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Unified media-part detection for request messages.
|
||||
* Single source of truth shared by the vision/audio bridge guardrails (src/)
|
||||
* and the combo compatibility filter (open-sse/) — the two previously kept
|
||||
* divergent copies (guardrail missed input_image; combo saw it).
|
||||
*/
|
||||
export type MediaKind = "image" | "audio";
|
||||
|
||||
export interface MediaPart {
|
||||
kind: MediaKind;
|
||||
/** URL, data URI, or base64 payload reference for the media content. */
|
||||
ref: string;
|
||||
/**
|
||||
* Location of the top-level content part this hit belongs to. For nested
|
||||
* hits (`nested: true`) these indexes point at the CONTAINER part — the
|
||||
* entry of `message.content` under which the media was found — not at the
|
||||
* media object itself.
|
||||
*/
|
||||
messageIndex: number;
|
||||
partIndex: number;
|
||||
/**
|
||||
* True when the media was found below the top level of the content part
|
||||
* (inside another object/array, e.g. an image nested in an audio payload
|
||||
* or a data URI inside a text field). Splice-style consumers can only
|
||||
* replace top-level parts, so they must skip nested hits.
|
||||
*/
|
||||
nested: boolean;
|
||||
/** Original wire shape, for callers that need format-specific handling. */
|
||||
shape:
|
||||
| "image_url"
|
||||
| "image_base64"
|
||||
| "image_source_url"
|
||||
| "input_image"
|
||||
| "data_uri_string"
|
||||
| "input_audio"
|
||||
| "audio_url"
|
||||
/** Audio detected via `source.media_type: audio/*` (no explicit type). */
|
||||
| "audio_source"
|
||||
/**
|
||||
* Combo-parity indicator: the value looks like an image part (image-ish
|
||||
* `type` in any casing, a bare `image_url`/`input_image` key, or a
|
||||
* `source.media_type` of image/*) but carries no extractable ref — `ref`
|
||||
* may be "". Boolean callers (combo compatibility filter) count it;
|
||||
* ref-consuming callers (vision bridge) must skip empty refs.
|
||||
*/
|
||||
| "image_indicator";
|
||||
}
|
||||
|
||||
const MAX_DEPTH = 8;
|
||||
|
||||
interface DetectCtx {
|
||||
out: MediaPart[];
|
||||
messageIndex: number;
|
||||
partIndex: number;
|
||||
/** When set, `found` flips true on the first part of this kind (early exit). */
|
||||
stopAtKind?: MediaKind;
|
||||
found?: boolean;
|
||||
}
|
||||
|
||||
/** Extract a URL from either a bare string or a `{ url }` object. */
|
||||
function urlFrom(raw: unknown): string | undefined {
|
||||
if (typeof raw === "string") return raw;
|
||||
const url = (raw as Record<string, unknown> | undefined)?.url;
|
||||
return typeof url === "string" ? url : undefined;
|
||||
}
|
||||
|
||||
function pushPart(
|
||||
ctx: DetectCtx,
|
||||
kind: MediaKind,
|
||||
ref: string,
|
||||
shape: MediaPart["shape"],
|
||||
depth: number
|
||||
): void {
|
||||
ctx.out.push({
|
||||
kind,
|
||||
ref,
|
||||
messageIndex: ctx.messageIndex,
|
||||
partIndex: ctx.partIndex,
|
||||
nested: depth > 0,
|
||||
shape,
|
||||
});
|
||||
if (ctx.stopAtKind === kind) ctx.found = true;
|
||||
}
|
||||
|
||||
/** Strict image shapes with an extractable ref. Returns true when one was pushed. */
|
||||
function inspectImageShapes(
|
||||
obj: Record<string, unknown>,
|
||||
type: string | undefined,
|
||||
ctx: DetectCtx,
|
||||
depth: number
|
||||
): boolean {
|
||||
if (type === "image_url" || type === "input_image") {
|
||||
const url = urlFrom(obj.image_url);
|
||||
if (url) {
|
||||
pushPart(ctx, "image", url, type === "input_image" ? "input_image" : "image_url", depth);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (type === "image") {
|
||||
const source = obj.source as Record<string, unknown> | undefined;
|
||||
if (source?.type === "base64" && typeof source.data === "string") {
|
||||
const media = typeof source.media_type === "string" ? source.media_type : "image/png";
|
||||
pushPart(ctx, "image", `data:${media};base64,${source.data}`, "image_base64", depth);
|
||||
return true;
|
||||
}
|
||||
// Non-empty url required: an empty `source.url` is not an extractable image
|
||||
// (mirrors the guardrail's historical `if (url)` guard).
|
||||
if (source?.type === "url" && typeof source.url === "string" && source.url) {
|
||||
pushPart(ctx, "image", source.url, "image_source_url", depth);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio shapes. Returns true when a part was pushed (at most one per object).
|
||||
* Callers must NOT early-return on audio: the same object can also carry
|
||||
* image indicators or nest image parts inside its payload.
|
||||
*/
|
||||
function inspectAudioShapes(
|
||||
obj: Record<string, unknown>,
|
||||
type: string | undefined,
|
||||
mediaType: unknown,
|
||||
ctx: DetectCtx,
|
||||
depth: number
|
||||
): boolean {
|
||||
if (type === "input_audio") {
|
||||
const audio = obj.input_audio as Record<string, unknown> | undefined;
|
||||
if (typeof audio?.data === "string") {
|
||||
pushPart(ctx, "audio", audio.data, "input_audio", depth);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (type === "audio_url") {
|
||||
const url = urlFrom(obj.audio_url);
|
||||
if (url) {
|
||||
pushPart(ctx, "audio", url, "audio_url", depth);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (typeof mediaType === "string" && mediaType.startsWith("audio/")) {
|
||||
const data = (obj.source as Record<string, unknown>).data;
|
||||
if (typeof data === "string") {
|
||||
pushPart(ctx, "audio", data, "audio_source", depth);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combo-parity image indicators: the legacy valueContainsImagePart
|
||||
* (comboStructure) matched image-ish `type` names case-insensitively, bare
|
||||
* `image_url`/`input_image` keys, and `source.media_type` image/* — all
|
||||
* without needing an extractable ref. Emit an indicator part (ref
|
||||
* best-effort, possibly "") so boolean callers keep seeing those requests as
|
||||
* vision requests. Returns true when one was pushed.
|
||||
*/
|
||||
function inspectImageIndicators(
|
||||
obj: Record<string, unknown>,
|
||||
type: string | undefined,
|
||||
mediaType: unknown,
|
||||
ctx: DetectCtx,
|
||||
depth: number
|
||||
): boolean {
|
||||
const lowerType = type?.toLowerCase();
|
||||
const looksLikeImage =
|
||||
lowerType === "image" ||
|
||||
lowerType === "image_url" ||
|
||||
lowerType === "input_image" ||
|
||||
"image_url" in obj ||
|
||||
"input_image" in obj;
|
||||
const imageMediaType =
|
||||
typeof mediaType === "string" && mediaType.toLowerCase().startsWith("image/");
|
||||
if (!looksLikeImage && !imageMediaType) return false;
|
||||
pushPart(ctx, "image", urlFrom(obj.image_url ?? obj.input_image) ?? "", "image_indicator", depth);
|
||||
return true;
|
||||
}
|
||||
|
||||
function inspect(value: unknown, ctx: DetectCtx, depth: number): void {
|
||||
if (ctx.found || depth > MAX_DEPTH || value == null) return;
|
||||
if (typeof value === "string") {
|
||||
if (value.startsWith("data:image/")) pushPart(ctx, "image", value, "data_uri_string", depth);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
inspect(entry, ctx, depth + 1);
|
||||
if (ctx.found) return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object") return;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const type = typeof obj.type === "string" ? obj.type : undefined;
|
||||
|
||||
if (inspectImageShapes(obj, type, ctx, depth)) return;
|
||||
|
||||
const mediaType = (obj.source as Record<string, unknown> | undefined)?.media_type;
|
||||
// Audio does not early-return: the same object can also carry image
|
||||
// indicators (bare `image_url`/`input_image` keys the legacy combo filter
|
||||
// matched) or nest image parts inside its payload.
|
||||
inspectAudioShapes(obj, type, mediaType, ctx, depth);
|
||||
if (ctx.found) return;
|
||||
if (inspectImageIndicators(obj, type, mediaType, ctx, depth)) return;
|
||||
for (const nested of Object.values(obj)) {
|
||||
inspect(nested, ctx, depth + 1);
|
||||
if (ctx.found) return;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectMediaParts(
|
||||
messages: ReadonlyArray<{ role?: string; content?: unknown }> | undefined | null
|
||||
): MediaPart[] {
|
||||
const out: MediaPart[] = [];
|
||||
if (!Array.isArray(messages)) return out;
|
||||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
||||
const content = messages[messageIndex]?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (let partIndex = 0; partIndex < content.length; partIndex++) {
|
||||
inspect(content[partIndex], { out, messageIndex, partIndex }, 0);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Early-exit presence check: returns true as soon as the FIRST part of the
|
||||
* requested kind is found, without collecting the full part list or finishing
|
||||
* the traversal. Prefer this on hot paths (e.g. the combo compatibility
|
||||
* filter runs on every request) over `detectMediaParts(...).some(...)`.
|
||||
*/
|
||||
export function containsMediaKind(
|
||||
messages: ReadonlyArray<{ role?: string; content?: unknown }> | undefined | null,
|
||||
kind: MediaKind
|
||||
): boolean {
|
||||
if (!Array.isArray(messages)) return false;
|
||||
const out: MediaPart[] = [];
|
||||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
||||
const content = messages[messageIndex]?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (let partIndex = 0; partIndex < content.length; partIndex++) {
|
||||
const ctx: DetectCtx = { out, messageIndex, partIndex, stopAtKind: kind };
|
||||
inspect(content[partIndex], ctx, 0);
|
||||
if (ctx.found) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -79,7 +79,8 @@ export function sseCommentsEnabled(): boolean {
|
||||
if (typeof process === "undefined") return true;
|
||||
const v = process.env.OMNIROUTE_SSE_COMMENTS;
|
||||
if (v === undefined || v === "") return true;
|
||||
return v.trim().toLowerCase() !== "off";
|
||||
const normalized = v.trim().toLowerCase();
|
||||
return normalized !== "off" && normalized !== "false" && normalized !== "0" && normalized !== "no";
|
||||
}
|
||||
|
||||
export function createSseHeartbeatTransform({
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "./streamHelpers.ts";
|
||||
import { calculateCost } from "@/lib/usage/costCalculator";
|
||||
import { buildOmniRouteSseMetadataComment } from "@/domain/omnirouteResponseMeta";
|
||||
import { sseCommentsEnabled } from "./sseHeartbeat.ts";
|
||||
import {
|
||||
createStructuredSSECollector,
|
||||
buildStreamSummaryFromEvents,
|
||||
@@ -1001,6 +1002,11 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
controller: TransformStreamDefaultController,
|
||||
finalUsage: UsageTokenRecord | Record<string, unknown> | null | undefined
|
||||
) => {
|
||||
// Skip SSE metadata comment lines when OMNIROUTE_SSE_COMMENTS is disabled
|
||||
// (e.g., "off", "false", "0", "no"). Strict OpenAI-compatible clients that
|
||||
// JSON.parse every SSE line will crash on `: x-omniroute-*` comment lines.
|
||||
if (!sseCommentsEnabled()) return;
|
||||
|
||||
const costUsd = finalUsage ? await calculateCost(provider, model, finalUsage) : 0;
|
||||
const comment = buildOmniRouteSseMetadataComment({
|
||||
provider,
|
||||
@@ -2463,8 +2469,18 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
status: 200,
|
||||
usage,
|
||||
responseBody,
|
||||
// #9315 switched the summary to the accumulated responseBody to avoid
|
||||
// stale/truncated event data — but responseBody here is synthesized in
|
||||
// chat-completion shape, which loses the Responses API `response` object.
|
||||
// Keep the events-derived summary for OPENAI_RESPONSES only.
|
||||
providerPayload: providerPayloadCollector.build(
|
||||
responseBody,
|
||||
sourceFormat === FORMATS.OPENAI_RESPONSES
|
||||
? buildStreamSummaryFromEvents(
|
||||
providerPayloadCollector.getEvents(),
|
||||
sourceFormat,
|
||||
model
|
||||
)
|
||||
: responseBody,
|
||||
{ includeEvents: false }
|
||||
),
|
||||
clientPayload: clientPayloadCollector.build(responseBody, {
|
||||
@@ -2734,8 +2750,16 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
status: 200,
|
||||
usage: state?.usage,
|
||||
responseBody,
|
||||
// Same OPENAI_RESPONSES carve-out as the passthrough branch above —
|
||||
// the synthesized chat-shaped responseBody drops the `response` object.
|
||||
providerPayload: providerPayloadCollector.build(
|
||||
responseBody,
|
||||
targetFormat === FORMATS.OPENAI_RESPONSES
|
||||
? buildStreamSummaryFromEvents(
|
||||
providerPayloadCollector.getEvents(),
|
||||
targetFormat,
|
||||
model
|
||||
)
|
||||
: responseBody,
|
||||
{ includeEvents: false }
|
||||
),
|
||||
clientPayload: clientPayloadCollector.build(responseBody, {
|
||||
|
||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -27,12 +27,11 @@
|
||||
"@xyflow/react": "^12.11.1",
|
||||
"axios": "^1.16.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^13.0.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
"clsx": "^2.1.1",
|
||||
"commander": "^15.0.0",
|
||||
"csv-stringify": "^6.7.0",
|
||||
"dompurify": "^3.4.12",
|
||||
"dompurify": "^3.4.13",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.3",
|
||||
"fflate": "^0.8.3",
|
||||
@@ -74,6 +73,7 @@
|
||||
"recharts": "^3.8.1",
|
||||
"safe-regex": "^2.1.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
"sharp": "^0.35.3",
|
||||
"smol-toml": "1.7.1",
|
||||
"socks": "^2.8.7",
|
||||
"sql.js": "^1.14.1",
|
||||
@@ -104,7 +104,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "*",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -463,9 +463,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3076,9 +3076,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3561,7 +3561,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -12640,9 +12639,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@yarnpkg/parsers/node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -16980,9 +16979,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -25197,9 +25196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lockfile-lint/node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -26006,9 +26005,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.16.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz",
|
||||
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
||||
"version": "11.16.1",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz",
|
||||
"integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
@@ -27529,9 +27528,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -32779,7 +32778,6 @@
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -32829,7 +32827,6 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -36609,9 +36606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -36951,12 +36948,7 @@
|
||||
},
|
||||
"open-sse": {
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.8.50",
|
||||
"dependencies": {
|
||||
"@toon-format/toon": "^4.1.0",
|
||||
"safe-regex": "^2.1.1",
|
||||
"smol-toml": "1.7.1"
|
||||
}
|
||||
"version": "3.8.50"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
package.json
31
package.json
@@ -34,8 +34,11 @@
|
||||
"scripts/dev/tls-options.mjs",
|
||||
"scripts/check/check-supported-node-runtime.ts",
|
||||
"scripts/dev/sync-env.mjs",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
@@ -266,7 +269,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"commander": "^15.0.0",
|
||||
"csv-stringify": "^6.7.0",
|
||||
"dompurify": "^3.4.12",
|
||||
"dompurify": "^3.4.13",
|
||||
"express": "^5.2.1",
|
||||
"fetch-socks": "^1.3.3",
|
||||
"fflate": "^0.8.3",
|
||||
@@ -308,6 +311,7 @@
|
||||
"recharts": "^3.8.1",
|
||||
"safe-regex": "^2.1.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
"sharp": "^0.35.3",
|
||||
"smol-toml": "1.7.1",
|
||||
"socks": "^2.8.7",
|
||||
"sql.js": "^1.14.1",
|
||||
@@ -415,7 +419,6 @@
|
||||
"unrs-resolver": true
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "^3.4.12",
|
||||
"fast-xml-parser": "^5.10.1",
|
||||
"sharp": "^0.35.0",
|
||||
"postcss": "^8.5.18",
|
||||
@@ -431,7 +434,7 @@
|
||||
"fast-uri": "^3.1.5",
|
||||
"body-parser": "^2.3.0",
|
||||
"@yarnpkg/parsers": {
|
||||
"js-yaml": "^4.2.0"
|
||||
"js-yaml": "^4.3.1"
|
||||
},
|
||||
"jsdom": {
|
||||
"undici": "^7.29.0"
|
||||
@@ -446,11 +449,27 @@
|
||||
"promptfoo": {
|
||||
"js-yaml": "^5.2.2",
|
||||
"@apidevtools/json-schema-ref-parser": {
|
||||
"js-yaml": "^4.2.0"
|
||||
"js-yaml": "^4.3.1"
|
||||
},
|
||||
"undici": "^7.29.0"
|
||||
},
|
||||
"socket.io-parser": "^4.2.7",
|
||||
"tar": "^7.5.21"
|
||||
"tar": "^7.5.21",
|
||||
"nanoid": "^3.3.17",
|
||||
"@eslint/eslintrc": {
|
||||
"js-yaml": "^4.3.1"
|
||||
},
|
||||
"lockfile-lint": {
|
||||
"js-yaml": "^4.3.1"
|
||||
},
|
||||
"xmlbuilder2": {
|
||||
"js-yaml": "^4.3.1"
|
||||
},
|
||||
"monaco-editor": {
|
||||
"dompurify": "^3.4.13"
|
||||
},
|
||||
"@apidevtools/json-schema-ref-parser": {
|
||||
"js-yaml": "^4.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,15 @@ const NATIVE_ASSET_ENTRIES = [
|
||||
src: ["node_modules", "better-sqlite3", "build"],
|
||||
dest: ["node_modules", "better-sqlite3", "build"],
|
||||
},
|
||||
{
|
||||
// #8847: Bun (and npx -g global installs) resolve better-sqlite3's native
|
||||
// binary from prebuilds/ instead of build/Release/, so the compiled build/
|
||||
// copy alone leaves a hollow package that falls back to sql.js (OOM under
|
||||
// Bun). Ship the prebuilds alongside the compiled binary.
|
||||
label: "better-sqlite3 prebuilds (Bun / global installs)",
|
||||
src: ["node_modules", "better-sqlite3", "prebuilds"],
|
||||
dest: ["node_modules", "better-sqlite3", "prebuilds"],
|
||||
},
|
||||
{
|
||||
// TPROXY IP_TRANSPARENT addon (Fase 3 / Epic A). Built by build-tproxy-native
|
||||
// before assembly; Linux-only + opt-in, so the source is absent on non-Linux
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, sep } from "node:path";
|
||||
|
||||
/**
|
||||
* Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is
|
||||
@@ -96,6 +97,33 @@ export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES)
|
||||
return closure;
|
||||
}
|
||||
|
||||
/**
|
||||
* A package in the target tree counts as PRESENT only when its entrypoint
|
||||
* resolves from inside that tree — the same contract the Dockerfile's
|
||||
* post-build guard enforces. Next's file tracing can materialize a package
|
||||
* PARTIALLY (the package.json lands, the files its `main` points at do not),
|
||||
* and a directory-level `existsSync` check then skips the package forever
|
||||
* while the runtime dies with "Cannot find module <pkg>/dist/index.js".
|
||||
*
|
||||
* @param {string} targetNodeModulesDir
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPackageIntact(targetNodeModulesDir, name) {
|
||||
if (!existsSync(join(targetNodeModulesDir, name))) return false;
|
||||
try {
|
||||
const probe = createRequire(
|
||||
join(targetNodeModulesDir, "__colocate_probe__.js")
|
||||
);
|
||||
const resolved = probe.resolve(name);
|
||||
// A resolution that walked past the target into an ancestor tree does not
|
||||
// prove the target copy is usable.
|
||||
return resolved.startsWith(targetNodeModulesDir + sep);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Co-locate the SLM optional dependency closure from `<rootDir>/node_modules`
|
||||
* into a standalone bundle's `node_modules`.
|
||||
@@ -142,11 +170,12 @@ export function colocateLlmlinguaOptionals({
|
||||
|
||||
const closure = computeDependencyClosure(rootNm, seeds);
|
||||
|
||||
// Check the complete closure rather than only the entry package. A partially
|
||||
// populated bundle must still receive any missing transitive dependencies.
|
||||
// Check the complete closure rather than only the entry package, and judge
|
||||
// presence by entrypoint integrity — a partially traced directory (see
|
||||
// isPackageIntact) must still receive its missing files.
|
||||
if (
|
||||
closure.length > 0 &&
|
||||
closure.every((name) => existsSync(join(targetNm, name)))
|
||||
closure.every((name) => isPackageIntact(targetNm, name))
|
||||
) {
|
||||
return { skipped: true, reason: "already co-located" };
|
||||
}
|
||||
@@ -155,11 +184,18 @@ export function colocateLlmlinguaOptionals({
|
||||
|
||||
for (const name of closure) {
|
||||
const dest = join(targetNm, name);
|
||||
if (existsSync(dest)) continue;
|
||||
if (isPackageIntact(targetNm, name)) continue;
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
cpSync(join(rootNm, name), dest, { recursive: true });
|
||||
// force:false merges into a partially traced directory: files the trace
|
||||
// already materialized are kept, missing ones (the package payload) are
|
||||
// filled in from the root tree.
|
||||
cpSync(join(rootNm, name), dest, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: false,
|
||||
});
|
||||
copied++;
|
||||
} catch (err) {
|
||||
log(
|
||||
|
||||
@@ -93,6 +93,10 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
|
||||
// runtime; shipped via package.json "files", so it must be allowed here.
|
||||
"bin/aliasResolverHook.mjs",
|
||||
"bin/mcp-server.mjs",
|
||||
// #9281: stdout/stderr console guard preloaded via `node --import` by
|
||||
// bin/mcp-server.mjs before the MCP entry's module graph evaluates — without it
|
||||
// the published CLI's `omniroute --mcp` crashes on the pathToFileURL() import.
|
||||
"bin/mcpStdioConsoleGuard.mjs",
|
||||
"bin/nodeRuntimeSupport.mjs",
|
||||
"bin/omniroute.mjs",
|
||||
"bin/reset-password.mjs",
|
||||
@@ -117,6 +121,9 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
|
||||
// shipped via package.json "files", so it must be allowed in the tarball.
|
||||
"open-sse/utils/setupPolyfill.ts",
|
||||
"package.json",
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/check/check-supported-node-runtime.ts",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
@@ -183,6 +190,10 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
|
||||
"bin/cli/utils/storageKeyProvision.mjs",
|
||||
"bin/cli/utils/versionFastPath.mjs",
|
||||
"bin/mcp-server.mjs",
|
||||
// #9281: stdout/stderr console guard preloaded via `node --import` by
|
||||
// bin/mcp-server.mjs before the MCP entry's module graph evaluates — without it
|
||||
// the published CLI's `omniroute --mcp` crashes on the pathToFileURL() import.
|
||||
"bin/mcpStdioConsoleGuard.mjs",
|
||||
"bin/nodeRuntimeSupport.mjs",
|
||||
"bin/omniroute.mjs",
|
||||
// #7808: aliasResolver + its hook file. bin/omniroute.mjs imports
|
||||
|
||||
207
scripts/dev/generate-adobe-firefly-snapshot.mjs
Normal file
207
scripts/dev/generate-adobe-firefly-snapshot.mjs
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
"Usage: node scripts/dev/generate-adobe-firefly-snapshot.mjs <discovery.json> <output.ts>"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const [, , inputArg, outputArg] = process.argv;
|
||||
if (!inputArg || !outputArg) usage();
|
||||
|
||||
const inputPath = path.resolve(inputArg);
|
||||
const outputPath = path.resolve(outputArg);
|
||||
const inputBytes = fs.readFileSync(inputPath);
|
||||
const sourceHash = createHash("sha256").update(inputBytes).digest("hex");
|
||||
const root = JSON.parse(inputBytes.toString("utf8"));
|
||||
|
||||
function mergeObjectSchema(schema) {
|
||||
const merged = { properties: {}, required: [] };
|
||||
const visit = (node) => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (node.properties && typeof node.properties === "object") {
|
||||
Object.assign(merged.properties, node.properties);
|
||||
}
|
||||
if (Array.isArray(node.required)) merged.required.push(...node.required);
|
||||
if (Array.isArray(node.allOf)) node.allOf.forEach(visit);
|
||||
};
|
||||
visit(schema);
|
||||
merged.required = [...new Set(merged.required)];
|
||||
return merged;
|
||||
}
|
||||
|
||||
function branches(schema) {
|
||||
if (!schema || typeof schema !== "object") return [];
|
||||
return [schema, ...(schema.anyOf || []), ...(schema.oneOf || [])];
|
||||
}
|
||||
|
||||
function stringEnums(schema) {
|
||||
return [
|
||||
...new Set(
|
||||
branches(schema)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.filter((value) => typeof value === "string")
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function integerSchema(schema) {
|
||||
return branches(schema).find((branch) => branch.type === "integer") || {};
|
||||
}
|
||||
|
||||
function publicModelId(modelId, modelVersion) {
|
||||
const slug = (value, allowDot = false) =>
|
||||
String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(allowDot ? /[^a-z0-9.]+/g : /[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
const family = slug(modelId);
|
||||
const publicVersion =
|
||||
family === "kling" ? String(modelVersion).replace(/^kling_v3_omni/i, "kling_o3") : modelVersion;
|
||||
const version = slug(publicVersion, true);
|
||||
if (!version || version === "default" || version === family) return family || "model";
|
||||
return `${family}-${version}`;
|
||||
}
|
||||
|
||||
function normalizeModel(family, modelVersion, version) {
|
||||
const schema = mergeObjectSchema(version.requestSchema);
|
||||
const properties = schema.properties;
|
||||
const referenceSchema = properties.referenceBlobs || {};
|
||||
const referenceInputs = [];
|
||||
for (const media of referenceSchema["x-capabilities"] || []) {
|
||||
for (const usage of media.usageConstraints || []) {
|
||||
if (usage.deprecated === true) continue;
|
||||
referenceInputs.push({
|
||||
mediaType: String(media.mediaType || ""),
|
||||
usageType: String(usage.usageType || ""),
|
||||
minItems: Number.isInteger(usage.minItems) ? usage.minItems : 0,
|
||||
maxItems: Number.isInteger(usage.maxItems) ? usage.maxItems : null,
|
||||
maxFileSizeBytes: Number.isInteger(media.maxFileSizeBytes) ? media.maxFileSizeBytes : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const supportedSizes = [
|
||||
...new Set(
|
||||
branches(properties.size)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.filter(
|
||||
(size) =>
|
||||
size &&
|
||||
Number.isInteger(size.width) &&
|
||||
size.width > 0 &&
|
||||
Number.isInteger(size.height) &&
|
||||
size.height > 0
|
||||
)
|
||||
.map((size) => `${size.width}x${size.height}`)
|
||||
),
|
||||
];
|
||||
const supportedAspectRatios = [
|
||||
...new Set(
|
||||
branches(properties.generationSettings).flatMap((branch) =>
|
||||
stringEnums(branch?.properties?.aspectRatio)
|
||||
)
|
||||
),
|
||||
];
|
||||
const duration = integerSchema(properties.duration);
|
||||
const supportedDurations = [
|
||||
...new Set(
|
||||
branches(properties.duration)
|
||||
.flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : []))
|
||||
.filter(Number.isInteger)
|
||||
),
|
||||
];
|
||||
const prompt = branches(properties.prompt).find((branch) => branch.type === "string") || {};
|
||||
const outputCount = integerSchema(properties.n);
|
||||
|
||||
return {
|
||||
id: publicModelId(family.modelId, modelVersion),
|
||||
name: String(version.modelDisplayName || version.modelCaiDisplayName || modelVersion),
|
||||
modality: version.outputModality[0],
|
||||
upstreamModelId: family.modelId,
|
||||
upstreamModelVersion: modelVersion,
|
||||
providerName: String(family.acModelFamilyProviderDisplayName || ""),
|
||||
releaseReadiness: String(version.releaseReadiness || ""),
|
||||
healthStatus: String(version.healthStatus || ""),
|
||||
inputMediaUseCases: (version.inputMediaUseCase || []).map(String),
|
||||
schemaProperties: Object.keys(properties),
|
||||
requiredProperties: schema.required,
|
||||
referenceInputs,
|
||||
maxReferenceItems: Number.isInteger(referenceSchema.maxItems) ? referenceSchema.maxItems : null,
|
||||
supportedSizes,
|
||||
supportedAspectRatios,
|
||||
supportedResolutions: stringEnums(properties.resolution),
|
||||
supportedDurations,
|
||||
durationMin: Number.isInteger(duration.minimum) ? duration.minimum : null,
|
||||
durationMax: Number.isInteger(duration.maximum) ? duration.maximum : null,
|
||||
durationDefault: Number.isInteger(duration.default) ? duration.default : null,
|
||||
outputCountMin: Number.isInteger(outputCount.minimum) ? outputCount.minimum : null,
|
||||
outputCountMax: Number.isInteger(outputCount.maximum) ? outputCount.maximum : null,
|
||||
promptMaxLength: Number.isInteger(prompt.maxLength) ? prompt.maxLength : null,
|
||||
backingModel: String(version.bksGenerationModel || ""),
|
||||
};
|
||||
}
|
||||
|
||||
const rawModels = [];
|
||||
for (const family of Array.isArray(root.models) ? root.models : []) {
|
||||
for (const [modelVersion, version] of Object.entries(family.modelVersions || {})) {
|
||||
if (!version || version.enabled === false) continue;
|
||||
const modality = Array.isArray(version.outputModality)
|
||||
? version.outputModality.map((value) => String(value).toLowerCase())[0]
|
||||
: "";
|
||||
if (modality !== "image" && modality !== "video") continue;
|
||||
|
||||
const schema = mergeObjectSchema(version.requestSchema);
|
||||
if (!schema.properties.prompt) continue;
|
||||
const useCases = (version.inputMediaUseCase || []).map((value) => String(value).toLowerCase());
|
||||
if (useCases.some((value) => ["upscaling", "sharpening", "denoising"].includes(value))) {
|
||||
continue;
|
||||
}
|
||||
rawModels.push(normalizeModel(family, modelVersion, version));
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery currently repeats a few exact aliases (for example flux/fluxPro and
|
||||
// fluxPro/1.1). Keep the first canonical wire pair and suppress duplicate cards.
|
||||
const seen = new Set();
|
||||
const models = [];
|
||||
for (const model of rawModels) {
|
||||
const semanticKey = JSON.stringify({
|
||||
backingModel: model.backingModel,
|
||||
name: model.name,
|
||||
modality: model.modality,
|
||||
schemaProperties: model.schemaProperties,
|
||||
requiredProperties: model.requiredProperties,
|
||||
referenceInputs: model.referenceInputs,
|
||||
maxReferenceItems: model.maxReferenceItems,
|
||||
supportedSizes: model.supportedSizes,
|
||||
supportedAspectRatios: model.supportedAspectRatios,
|
||||
supportedResolutions: model.supportedResolutions,
|
||||
supportedDurations: model.supportedDurations,
|
||||
durationMin: model.durationMin,
|
||||
durationMax: model.durationMax,
|
||||
});
|
||||
if (seen.has(semanticKey)) continue;
|
||||
seen.add(semanticKey);
|
||||
models.push(model);
|
||||
}
|
||||
|
||||
const source = `/**
|
||||
* Generated from Adobe Firefly POST /v2/models/discovery with resolveSchema=true.
|
||||
* Source SHA-256: ${sourceHash}
|
||||
* Regenerate with scripts/dev/generate-adobe-firefly-snapshot.mjs; do not edit by hand.
|
||||
* The generated literal stays compact to satisfy the repository's line-count gate.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export const ADOBE_FIREFLY_DISCOVERY_SNAPSHOT = ${JSON.stringify(models)} as const;
|
||||
`;
|
||||
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, source, "utf8");
|
||||
console.log(`Wrote ${models.length} models to ${outputPath}`);
|
||||
@@ -609,14 +609,6 @@ async function main() {
|
||||
args: ["run", "check:pack-artifact"],
|
||||
timeout: 20 * 60 * 1000,
|
||||
});
|
||||
// WS1.2 (#7065 class): boot the REAL packed tarball from a clean install —
|
||||
// the runtime gate structure checks cannot provide. Reuses the same dist/ build.
|
||||
slow.push({
|
||||
id: "pack-boot",
|
||||
label: "Tarball boot-smoke (installed CLI serves /health)",
|
||||
args: ["run", "check:pack-boot"],
|
||||
timeout: 15 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
slow.forEach((g) => announce(`${g.label} [parallel]`));
|
||||
const slowResults = await Promise.all(
|
||||
@@ -633,6 +625,41 @@ async function main() {
|
||||
detail: code === 0 ? "pass" : firstFailureLine(out),
|
||||
});
|
||||
});
|
||||
|
||||
if (WITH_BUILD) {
|
||||
// WS1.2 (#7065 class): boot the REAL packed tarball from a clean install.
|
||||
// check:pack-artifact is the builder for dist/ when staging is absent, so the
|
||||
// boot smoke MUST run after it completes. Running both in the parallel wave
|
||||
// races check:pack-boot against dist/server.js creation on clean worktrees.
|
||||
const packArtifactIndex = slow.findIndex((g) => g.id === "pack-artifact");
|
||||
const packArtifactResult = slowResults[packArtifactIndex];
|
||||
const bootLabel = "Tarball boot-smoke (installed CLI serves /health)";
|
||||
|
||||
if (!packArtifactResult || packArtifactResult.code !== 0) {
|
||||
const out = "skipped because package-artifact did not produce a valid dist/ build";
|
||||
saveGateLog("pack-boot", out);
|
||||
record({
|
||||
id: "pack-boot",
|
||||
label: bootLabel,
|
||||
kind: "hard",
|
||||
ok: false,
|
||||
detail: out,
|
||||
});
|
||||
} else {
|
||||
announce(bootLabel);
|
||||
const { code, out } = await runAsync(npmCmd, ["run", "check:pack-boot"], {
|
||||
timeout: 15 * 60 * 1000,
|
||||
});
|
||||
saveGateLog("pack-boot", out);
|
||||
record({
|
||||
id: "pack-boot",
|
||||
label: bootLabel,
|
||||
kind: "hard",
|
||||
ok: code === 0,
|
||||
detail: code === 0 ? "pass" : firstFailureLine(out),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (WITH_BUILD) {
|
||||
// --with-build without the suites (--quick): still verify the package artifact.
|
||||
const { code, out } = await runAsync(npmCmd, ["run", "check:pack-artifact"], {
|
||||
|
||||
@@ -134,7 +134,7 @@ export function LlmChatCard({
|
||||
}: Props) {
|
||||
const t = useTranslations("miniPlayground");
|
||||
const { keys } = useApiKey();
|
||||
const { models } = useProviderModels(providerId);
|
||||
const { models, loading, error, retry } = useProviderModels(providerId);
|
||||
|
||||
const [internalSelectedKey, setInternalSelectedKey] = useState<string>("");
|
||||
const [internalModel, setInternalModel] = useState<string>(initialModel ?? "");
|
||||
@@ -392,15 +392,31 @@ export function LlmChatCard({
|
||||
<select
|
||||
value={model || firstModel}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
disabled={loading}
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-60"
|
||||
>
|
||||
{modelOptions.length === 0 && <option value="">{initialModel || "—"}</option>}
|
||||
{modelOptions.length === 0 && !loading && <option value="">{initialModel || "—"}</option>}
|
||||
{loading && <option value="">{t("loading") ?? "Loading…"}</option>}
|
||||
{modelOptions.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1" role="alert">
|
||||
<span className="truncate max-w-[180px]" title={String(error)}>
|
||||
{String(error)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
className="shrink-0 text-xs text-primary hover:text-primary-strong underline"
|
||||
>
|
||||
{t("retry") ?? "Retry"}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Key select */}
|
||||
{keys.length > 0 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
@@ -18,6 +18,8 @@ interface UseProviderModelsResult {
|
||||
models: ProviderModel[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** Re-runs the model fetch for the current provider. Useful for a Retry action. */
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,15 +34,14 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
const [models, setModels] = useState<ProviderModel[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Cancels any in-flight load (component unmount or a retry superseding the
|
||||
// previous request) so a stale response never overwrites a newer one.
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const load = useCallback(() => {
|
||||
cleanupRef.current?.();
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -109,11 +110,33 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
void run();
|
||||
const cleanup = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
cleanupRef.current = cleanup;
|
||||
return cleanup;
|
||||
}, [providerId]);
|
||||
|
||||
return { models, loading, error };
|
||||
useEffect(() => {
|
||||
if (!providerId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
return load();
|
||||
}, [providerId, load]);
|
||||
|
||||
// Release the current in-flight cleanup on unmount so no state updates leak.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
if (!providerId) return;
|
||||
load();
|
||||
}, [providerId, load]);
|
||||
|
||||
return { models, loading, error, retry };
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/shared/components";
|
||||
import { shouldAutoSyncOnOpen } from "@/lib/radar/autoSync";
|
||||
import { isValidSupporterKeyFormat } from "@/lib/radar/supporterKey";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -132,6 +133,18 @@ export default function RadarPage() {
|
||||
const [contributorClaimUrl, setContributorClaimUrl] = useState<string | null>(null);
|
||||
const [supporterPlansUrl, setSupporterPlansUrl] = useState<string | null>(null);
|
||||
|
||||
// Paste-key activation — the primary path on the activation screen: paste
|
||||
// an already-obtained supporter key (`omr_` + 40 hex) to activate opt-in
|
||||
// AND the supporter tier in a single POST. `hasSupporterKey`/
|
||||
// `supporterKeyMasked` mirror GET /api/radar/settings so a key set out of
|
||||
// band (e.g. a direct curl call before this UI existed) shows the masked
|
||||
// form instead of an empty input — the raw key is never displayed.
|
||||
const [keyInput, setKeyInput] = useState("");
|
||||
const [keySubmitting, setKeySubmitting] = useState(false);
|
||||
const [hasSupporterKey, setHasSupporterKey] = useState(false);
|
||||
const [supporterKeyMasked, setSupporterKeyMasked] = useState<string | null>(null);
|
||||
const [showKeyForm, setShowKeyForm] = useState(false);
|
||||
|
||||
// Fetch catalog
|
||||
const fetchCatalog = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -189,6 +202,10 @@ export default function RadarPage() {
|
||||
if (!settingsRes.ok) throw new Error(`HTTP ${settingsRes.status}`);
|
||||
const settingsData = await settingsRes.json();
|
||||
setOptIn(settingsData.optIn === true);
|
||||
setHasSupporterKey(settingsData.hasSupporterKey === true);
|
||||
setSupporterKeyMasked(
|
||||
typeof settingsData.supporterKeyMasked === "string" ? settingsData.supporterKeyMasked : null,
|
||||
);
|
||||
// F4/T7 — best-effort: keep whatever we already had if the field is
|
||||
// absent (older cached response shape), never fall back to a literal.
|
||||
if (typeof settingsData.contributorClaimUrl === "string") {
|
||||
@@ -278,6 +295,40 @@ export default function RadarPage() {
|
||||
}
|
||||
}, [t, handleSync]);
|
||||
|
||||
// Activate with a pasted supporter key — the primary path on this screen.
|
||||
// Submitting a key both sets it AND opts in, in a single POST (pasting a
|
||||
// key unlocks the activation screen). Client-side format validation is a
|
||||
// UX nicety only — the server (Zod) always revalidates.
|
||||
const handleSubmitKey = useCallback(async () => {
|
||||
setError("");
|
||||
const trimmed = keyInput.trim();
|
||||
if (!isValidSupporterKeyFormat(trimmed)) {
|
||||
setError(t("keyInvalidFormatError"));
|
||||
return;
|
||||
}
|
||||
setKeySubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/radar/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ optIn: true, supporterKey: trimmed }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setOptIn(true);
|
||||
setHasSupporterKey(true);
|
||||
setSupporterKeyMasked(typeof data.supporterKey === "string" ? data.supporterKey : null);
|
||||
setKeyInput("");
|
||||
setShowKeyForm(false);
|
||||
// After activation, trigger a sync so the live tier catalog loads.
|
||||
await handleSync();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("activationFailed"));
|
||||
} finally {
|
||||
setKeySubmitting(false);
|
||||
}
|
||||
}, [keyInput, t, handleSync]);
|
||||
|
||||
// Determine effective state
|
||||
const flagOn = optIn !== false || entries.length > 0 || meta !== null;
|
||||
const pageState = resolveRadarPageState(
|
||||
@@ -359,6 +410,51 @@ export default function RadarPage() {
|
||||
<span>{t("privacyLocalOnly")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paste-key activation — primary path: pasting an already-obtained
|
||||
supporter key both sets it AND opts in (unlocks this screen).
|
||||
The raw key is NEVER displayed — once set, only the masked
|
||||
form (supporterKeyMasked) is shown, with a "change key" escape
|
||||
hatch to paste a new one. */}
|
||||
<div className="w-full flex flex-col gap-3 text-left">
|
||||
<p className="text-sm font-medium">{t("keySectionTitle")}</p>
|
||||
{hasSupporterKey && !showKeyForm ? (
|
||||
<div className="flex items-center justify-between gap-3 p-3 rounded-lg border border-border">
|
||||
<span className="font-mono text-sm text-text-muted">
|
||||
{supporterKeyMasked}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKeyForm(true)}
|
||||
className="text-sm text-violet-400 hover:underline shrink-0"
|
||||
>
|
||||
{t("changeKeyButton")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
placeholder="omr_..."
|
||||
aria-label={t("keySectionTitle")}
|
||||
className="flex-1 px-3 py-2 text-sm font-mono rounded-lg border border-border bg-transparent focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmitKey}
|
||||
disabled={keySubmitting || keyInput.trim().length === 0}
|
||||
className="px-6 py-2 bg-violet-500 hover:bg-violet-600 text-white font-medium rounded-lg transition-colors disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{keySubmitting ? t("activating") : t("activateWithKeyButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-border" />
|
||||
|
||||
<button
|
||||
onClick={handleActivate}
|
||||
disabled={activating}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { memoryManager } from "@/lib/memory/manager";
|
||||
// Import through the module index, NOT "@/lib/memory/manager" directly: the index's
|
||||
// import-time side effect is what calls memoryManager.register(sqliteBackend). Importing
|
||||
// the bare manager gives an EMPTY registry, so every handler here threw
|
||||
// `Primary backend "sqlite" not registered` and returned 500 (#8752).
|
||||
import { memoryManager } from "@/lib/memory";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import { MemoryUpdatePutSchema } from "@/shared/schemas/memory";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user