diff --git a/.env.example b/.env.example index d3b3f4b364..189d12a11d 100644 --- a/.env.example +++ b/.env.example @@ -1414,7 +1414,7 @@ APP_LOG_TO_FILE=true # Whether call log pipeline capture stores stream chunks when enabled in settings. # Only applies when call_log_pipeline_enabled=true. -# Default: true +# Default: false (opt-in — saves disk: stream chunks are the biggest call-log artifact) # CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS=true # Maximum call log artifact size for pipeline captures, in KB. @@ -1426,7 +1426,7 @@ APP_LOG_TO_FILE=true # bodies is retained in the database. # Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload() # CHAT_LOG_TEXT_LIMIT=65536 # Max string length before truncation (default: 64 KB) -# CHAT_LOG_ARRAY_TAIL_ITEMS=24 # Number of array items retained from tail (default: 24) +# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128) # CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6) # CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit) @@ -1893,7 +1893,7 @@ APP_LOG_TO_FILE=true # Log request shape (content-type + content-length) for large chat payloads. # Used by: src/app/api/v1/chat/completions/route.ts. Set to "0" to silence. -# Default: enabled. +# Default: disabled (opt-in). # OMNIROUTE_LOG_REQUEST_SHAPE=1 # Write raw (untruncated) request/response JSON in call log artifacts. @@ -2514,3 +2514,46 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # URL the dashboard's "Support the project" button opens (payment/plans # page). No pricing/value lives in this repo — only the link. # RADAR_SUPPORTER_PLANS_URL=https://radar.omniroute.online/planos + +# ═══════════════════════════════════════════════════════════════════════════════ +# 27. RELEASE v3.8.50 ADDITIONS +# ═══════════════════════════════════════════════════════════════════════════════ + +# Heavy chat admission queue wait before returning retryable 503. Set 0 for the +# legacy immediate rejection. Used by: src/shared/middleware/chatBodyAdmission.ts. +# Default: 5000 (5 seconds) +# OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=5000 + +# Timeout for /api/jobs/:id/run-now while it waits for an in-flight run. +# Used by: src/app/api/jobs/[id]/run-now/route.ts. Default: 30000 (30 seconds) +# OMNIROUTE_RUNNOW_TIMEOUT_MS=30000 + +# Maximum request/response body size before chat-log summarization, in KiB. +# Used by: src/lib/chatLogTruncation.ts. Default: 1024 +# CHAT_LOG_MAX_BODY_KB=1024 + +# Adobe Firefly browser renewal and durable session cache (enabled by default). +# Used by: open-sse/services/adobeFireflySession.ts. +# ADOBE_FIREFLY_BROWSER_REFRESH=1 +# ADOBE_FIREFLY_SESSION_DISK=1 +# Minimum spacing between submissions and the extra pause after every third success. +# ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS=12000 +# ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS=15000 +# Chrome CDP runtime used by Adobe Firefly renewal. True headless is debug-only: +# Adobe colligo normally rejects risk tokens minted without a headed browser. +# ADOBE_FIREFLY_CHROME_CDP_PORT=9334 +# ADOBE_FIREFLY_CHROME_VISIBLE=0 +# ADOBE_FIREFLY_CHROME_HEADLESS=0 +# ADOBE_FIREFLY_CHROME_FORCE_RESTART=0 +# ADOBE_FIREFLY_CHROME_PING=auto +# ADOBE_FIREFLY_LOGIN_WAIT_MS=0 +# ADOBE_FIREFLY_FORTER_WAIT_MS=45000 +# Optional absolute Chrome executable; auto-detected when unset. +# CHROME_PATH= + +# Telegram Mini App bridge. The update endpoint remains disabled while the bot +# token is unset. Used by: src/lib/telegram/* and src/app/api/telegram/update/route.ts. +# TELEGRAM_BOT_TOKEN= +# TELEGRAM_DEFAULT_MODEL=auto/chat +# TELEGRAM_BOT_API_BASE=https://api.telegram.org +# TELEGRAM_WEBHOOK_TIMEOUT_MS=60000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2439c63a94..feddcc6cd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -811,7 +811,12 @@ jobs: test-bun-sqlite: name: Bun SQLite Compatibility - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + fail-fast: false + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.os == 'windows-latest' }} timeout-minutes: 10 needs: changes if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }} @@ -824,6 +829,15 @@ jobs: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry + - name: Install Bun (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell -c "iwr bun.sh/install.ps1 -useb | iex" + echo "$env:USERPROFILE\.bun\bin" | Out-File -FilePath $env:GITHUB_PATH -Append + - name: Install Bun (non-Windows) + if: runner.os != 'Windows' + run: npm install -g bun - run: npm run test:bun:db test-vitest: diff --git a/.gitignore b/.gitignore index ffccb9d763..ae9832757b 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,6 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.example -!.env.devin-bridge.example !.env.homolog.example # Provider API keys (never commit) *.api-key @@ -172,6 +171,7 @@ config/quality/test-impact-map.json # GitNexus local index .gitnexus .worktrees +bin/omniroute.mjs # Consistent with .dockerignore / .npmignore .omc/ @@ -201,17 +201,12 @@ scripts/i18n/_pending-keys.json .codegraph/ # Fumadocs generated source -/.source/ - -# Temporary local worktrees used to build unpublished npm tarballs -/.deploy-build-*/ +.source/ # AI agent local settings and configs .agents/ .antigravitycli/ .claude/ -!tests/fixtures/devin-bridge/e2e-workspace/.claude/ -!tests/fixtures/devin-bridge/e2e-workspace/.claude/** # PR Reviews and local feedback files pr_reviews*.json @@ -226,6 +221,26 @@ CODEX-SETUP-PROMPT.md # Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não) config/quality/quality-metrics.json +# Electron desktop build output unpacked into the repo root. +# `electron-builder` (squirrel-windows target) unpacks the packaged app — the +# entire Chromium runtime, ~24k files — directly into the repository root. +# Every rule below is ROOT-ANCHORED (leading `/`) on purpose: a bare `locales/` +# or `resources/` would also swallow tracked sources such as the CLI +# translations in `bin/cli/locales/*.json`. +/OmniRoute.exe +/Uninstall OmniRoute.exe +/uninstallerIcon.ico +/locales/ +/resources/ +/*.pak +/*.dll +/icudtl.dat +/snapshot_blob.bin +/v8_context_snapshot.bin +/vk_swiftshader_icd.json +/LICENSE.electron.txt +/LICENSES.chromium.html + # Runtime logs (diretório local, nunca versionado) /logs/ -home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt @@ -238,10 +253,7 @@ omniroute.md # mise configuration mise.toml -# release-green artifacts (.gitignore has no inline comments — a trailing -# `# ...` becomes part of the pattern, so it must sit on its own line). -# Already covered by /_*/ above; kept explicit for discoverability. -_artifacts/ +_artifacts/ # release-green artifacts .claude-flow/ # ESLint file cache (npm run lint --cache / complexity ratchets) @@ -251,8 +263,6 @@ _artifacts/ # CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.) .artifacts/ -# Isolated Devin bridge workspaces, evidence, and test databases -.sandbox/ # Homologation E2E suite (npm run homolog) — real-environment credentials + report output .env.homolog @@ -260,12 +270,11 @@ tests/homolog/.auth/ tests/homolog/ui/.auth/ homolog-report/ docker-compose.yml.bak -.playwright-cli/ -# Playwright screenshot/log output. Today every artifact happens to land inside -# 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 e um repo git SEPARADO (ver AGENTS.md). A linha _tasks/ (com barra) NAO +# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz +# e impede que um git add -A recapture o symlink (incidente 2026-08-08). /_tasks + +# CLI local cache/state +.playwright-cli diff --git a/AGENTS.md b/AGENTS.md index f88c9056a5..9d29c93258 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -627,7 +627,7 @@ procedures are in [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALI complexity) must not regress vs `quality-baseline.json`. Update via `npm run quality:ratchet -- --update` when a metric genuinely improves. - Job `test-vitest` runs `npm run test:vitest` (MCP tools, autoCombo, cache) — blocking. - `test:vitest:ui` is advisory until UI component tests are triaged. + `test:vitest:ui` has been blocking since PR #7127. **Allowlist policy (short form):** Fix the cause; use the allowlist only for pre-existing violations you cannot fix in the same PR. Add a comment with justification + issue number. diff --git a/Dockerfile b/Dockerfile index 905fb294e0..a32305c22d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,7 +93,15 @@ RUN --mount=type=cache,id=npm-cache,target=/root/.npm \ # build from 17min to 9min on the same 32-core box. Webpack stays available as the # escape hatch: `--build-arg`/-e OMNIROUTE_USE_TURBOPACK=0. # See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6. -ENV OMNIROUTE_USE_TURBOPACK=1 +# +# Declared as ARG+ENV, not a bare ENV: a bare ENV shadows any same-named ARG for +# the rest of the stage, so `--build-arg OMNIROUTE_USE_TURBOPACK=0` was silently +# ignored and the escape hatch above only ever worked via `-e` at runtime, never +# at build time. Turbopack compiles in native Rust memory that lives outside the +# V8 heap, so OMNIROUTE_BUILD_MEMORY_MB cannot bound it and a memory-constrained +# build host gets SIGKILLed by the cgroup OOM killer with no error message. +ARG OMNIROUTE_USE_TURBOPACK=1 +ENV OMNIROUTE_USE_TURBOPACK="${OMNIROUTE_USE_TURBOPACK}" # Next.js basePath is fixed at build time; pass OMNIROUTE_BASE_PATH here when the # image should serve under a reverse-proxy subpath without a runtime patch. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..2dec2d6820 --- /dev/null +++ b/Makefile @@ -0,0 +1,69 @@ +.PHONY: help install dev start build build-release lint typecheck typecheck-strict \ + test test-unit test-vitest test-coverage test-all test-integration test-e2e \ + check check-cycles check-docs env-sync clean + +# OmniRoute — convenience wrapper around the npm scripts. +# All targets delegate to the canonical package.json scripts (single source of truth). + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' + +install: ## Install dependencies (auto-generates .env from .env.example) + npm install + +dev: ## Dev server at http://localhost:20128 + npm run dev + +start: ## Production server (requires a prior build) + npm run start + +build: ## Production build (Next.js 16 standalone) + npm run build + +build-release: ## Release build + npm run build:release + +lint: ## ESLint (0 errors expected) + npm run lint + +typecheck: ## TypeScript check (core) + npm run typecheck:core + +typecheck-strict: ## Strict check (no implicit any) + npm run typecheck:noimplicit:core + +test: ## Unit tests (Node native runner) + npm run test:unit + +test-unit: ## Alias for `test` + npm run test:unit + +test-vitest: ## Vitest (MCP server, autoCombo, cache) + npm run test:vitest + +test-coverage: ## Unit tests + coverage gate (60/60/60/60) + npm run test:coverage + +test-all: ## All suites (unit + vitest + ecosystem + e2e) + npm run test:all + +test-integration: ## Integration tests + npm run test:integration + +test-e2e: ## E2E (Playwright) + npm run test:e2e + +check: ## lint + test combined + npm run check + +check-cycles: ## Detect circular dependencies + npm run check:cycles + +check-docs: ## Validate documentation (incl. fabricated-docs) + npm run check:docs-all + +env-sync: ## Sync .env from .env.example + npm run env:sync + +clean: ## Remove build artifacts + rm -rf .build dist coverage .eslintcache diff --git a/changelog.d/features/5696-layer-a-capability-filter.md b/changelog.d/features/5696-layer-a-capability-filter.md new file mode 100644 index 0000000000..37d04132e3 --- /dev/null +++ b/changelog.d/features/5696-layer-a-capability-filter.md @@ -0,0 +1 @@ +- **feat(core):** add Layer A capability filter at router (#5696) diff --git a/changelog.d/features/8468-bun-windows-ci-coverage.md b/changelog.d/features/8468-bun-windows-ci-coverage.md new file mode 100644 index 0000000000..48c4f100cc --- /dev/null +++ b/changelog.d/features/8468-bun-windows-ci-coverage.md @@ -0,0 +1 @@ +- feat(ci): add windows-latest leg to test-bun-sqlite job (#8468) diff --git a/changelog.d/features/9000-encrypted-reasoning-replay.md b/changelog.d/features/9000-encrypted-reasoning-replay.md new file mode 100644 index 0000000000..417256a6f6 --- /dev/null +++ b/changelog.d/features/9000-encrypted-reasoning-replay.md @@ -0,0 +1,2 @@ +- Add a default-off connection setting for Codex, OpenAI, and OpenAI-compatible Responses API providers that preserves client-supplied `reasoning.encrypted_content` items for replay, including per-target combo routing. +- Omit opaque encrypted reasoning values from persisted call logs while retaining compact diagnostic markers. diff --git a/changelog.d/features/9268-gemini-schema-recursive-type-empty-choices.md b/changelog.d/features/9268-gemini-schema-recursive-type-empty-choices.md new file mode 100644 index 0000000000..8b38913bae --- /dev/null +++ b/changelog.d/features/9268-gemini-schema-recursive-type-empty-choices.md @@ -0,0 +1 @@ +- **feat(gemini):** recursive type:object injection in schema normalizer + empty choices interceptor for streaming (#9268) diff --git a/changelog.d/features/9322-nanogpt-endpoint-surface.md b/changelog.d/features/9322-nanogpt-endpoint-surface.md new file mode 100644 index 0000000000..485b3cf6c6 --- /dev/null +++ b/changelog.d/features/9322-nanogpt-endpoint-surface.md @@ -0,0 +1 @@ +- **feat(providers):** expanded the NanoGPT (`nano-gpt.com`) upstream provider from chat-only to the full OpenAI-compatible endpoint surface: audio transcriptions (`/api/v1/audio/transcriptions`), audio speech (`/api/v1/audio/speech`), video generation (`/api/v1/video/generations`), embeddings (`/v1/embeddings`), and the Responses API (`responsesBaseUrl` → `/api/v1/responses`) ([#9322](https://github.com/diegosouzapw/OmniRoute/issues/9322)) diff --git a/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md b/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md index 91357cf987..e2317e3cb8 100644 --- a/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md +++ b/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md @@ -1 +1 @@ -- **sse:** New-API / One-API / Sub2API aggregator balance detection for compatible nodes — with the "Aggregator Gateway" toggle on, OmniRoute queries the aggregator's `/api/user/self` to read the account balance, shows it as a dashboard badge and lets quota-preflight routing skip exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default off), with a `quotaPerUnit` override for aggregators that do not use the default 500000 units/$1 rate ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415)) +- **feat(sse):** New-API/One-API/Sub2API aggregator balance detection for compatible provider nodes — when the "Aggregator Gateway" toggle is enabled, OmniRoute queries the aggregator's `/api/user/self` endpoint to detect the account balance; the dashboard shows a balance badge and quota-preflight routing skips exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default: off), with a custom `quotaPerUnit` override for aggregators that use a different rate than the default 500000 units/$1 ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415)) diff --git a/changelog.d/fixes/9496-kimi-k3-responses-replay.md b/changelog.d/fixes/9496-kimi-k3-responses-replay.md new file mode 100644 index 0000000000..59ee61b0f0 --- /dev/null +++ b/changelog.d/fixes/9496-kimi-k3-responses-replay.md @@ -0,0 +1 @@ +- fix(translator): preserve authentic K3 Responses reasoning by model across providers, keep it on the matching assistant turn, and make Kimi Coding prefer client reasoning then cached replay before its empty-marker fallback (#9496) diff --git a/changelog.d/fixes/9675-anonymous-fallback-disable-toggle.md b/changelog.d/fixes/9675-anonymous-fallback-disable-toggle.md new file mode 100644 index 0000000000..1a1b802eb3 --- /dev/null +++ b/changelog.d/fixes/9675-anonymous-fallback-disable-toggle.md @@ -0,0 +1 @@ +- **fix(providers):** new per-provider `noAuthFallbackDisabledProviders` setting lets operators disable the synthetic anonymous (no-auth) credential fallback for API-key providers whose static definition declares `anonymousFallback: true` (e.g. `opencode-go`, `opencode-zen`) — upstream endpoints now reject anonymous requests with `401 Missing API key`, so the fallback added latency and caused UI health/reconnect churn. Real keyed connections keep working and recover automatically once quota state clears; true no-auth providers (`opencode`, `mimocode`, …) are unaffected, with `blockedProviders` remaining their disable mechanism. Default behavior is unchanged ([#9675](https://github.com/diegosouzapw/OmniRoute/pull/9675)) diff --git a/changelog.d/fixes/9695-docker-bundler-build-arg.md b/changelog.d/fixes/9695-docker-bundler-build-arg.md new file mode 100644 index 0000000000..3bc3f0ee7d --- /dev/null +++ b/changelog.d/fixes/9695-docker-bundler-build-arg.md @@ -0,0 +1 @@ +- **fix(docker):** `--build-arg OMNIROUTE_USE_TURBOPACK=0` now reaches the builder stage — a bare `ENV` was shadowing the `ARG`, so the documented webpack escape hatch was silently ignored and memory-constrained hosts were OOM-killed with no error output ([#9695](https://github.com/diegosouzapw/OmniRoute/pull/9695)) diff --git a/changelog.d/fixes/9719-combo-connection-pins.md b/changelog.d/fixes/9719-combo-connection-pins.md new file mode 100644 index 0000000000..79c3973ee0 --- /dev/null +++ b/changelog.d/fixes/9719-combo-connection-pins.md @@ -0,0 +1 @@ +- fix(db): clear stale combo connection pins when provider connections are deleted (#9719) diff --git a/changelog.d/fixes/9730-persist-rtk-renderers.md b/changelog.d/fixes/9730-persist-rtk-renderers.md new file mode 100644 index 0000000000..ce2c7467b8 --- /dev/null +++ b/changelog.d/fixes/9730-persist-rtk-renderers.md @@ -0,0 +1 @@ +- fix(compression): persist `enableRenderers` through `normalizeRtkConfig` so RTK renderer settings survive a DB round-trip ([#9730](https://github.com/diegosouzapw/OmniRoute/pull/9730)) \ No newline at end of file diff --git a/changelog.d/fixes/9788-model-catalog-gateway-permissions.md b/changelog.d/fixes/9788-model-catalog-gateway-permissions.md new file mode 100644 index 0000000000..f2218d1d2e --- /dev/null +++ b/changelog.d/fixes/9788-model-catalog-gateway-permissions.md @@ -0,0 +1 @@ +- **fix(api):** Model catalogs no longer expose functional gateway mirrors unless the API key permits the mirror's final public model ID ([#9788](https://github.com/diegosouzapw/OmniRoute/pull/9788)) — thanks @xz-dev diff --git a/changelog.d/fixes/9826-command-code-responses-usage.md b/changelog.d/fixes/9826-command-code-responses-usage.md new file mode 100644 index 0000000000..45d8dad254 --- /dev/null +++ b/changelog.d/fixes/9826-command-code-responses-usage.md @@ -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 diff --git a/changelog.d/fixes/9828-codex-redundant-oneof-enum.md b/changelog.d/fixes/9828-codex-redundant-oneof-enum.md new file mode 100644 index 0000000000..b0d7d6b135 --- /dev/null +++ b/changelog.d/fixes/9828-codex-redundant-oneof-enum.md @@ -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)) diff --git a/changelog.d/fixes/9834-cursor-selected-image-blobid.md b/changelog.d/fixes/9834-cursor-selected-image-blobid.md new file mode 100644 index 0000000000..6ad655af98 --- /dev/null +++ b/changelog.d/fixes/9834-cursor-selected-image-blobid.md @@ -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 diff --git a/changelog.d/fixes/i18n-cc-alias-unclosed-tags.md b/changelog.d/fixes/i18n-cc-alias-unclosed-tags.md new file mode 100644 index 0000000000..ed1a1b5de3 --- /dev/null +++ b/changelog.d/fixes/i18n-cc-alias-unclosed-tags.md @@ -0,0 +1 @@ +- fix(i18n): re-escape CC discovery-alias `claude//` to HTML entities so next-intl stops logging INVALID_MESSAGE: UNCLOSED_TAG on provider detail pages (#8747 regression) diff --git a/changelog.d/fixes/release-v3850-base-drifted-test-expectations.md b/changelog.d/fixes/release-v3850-base-drifted-test-expectations.md new file mode 100644 index 0000000000..5fd73a3777 --- /dev/null +++ b/changelog.d/fixes/release-v3850-base-drifted-test-expectations.md @@ -0,0 +1 @@ +- **fix(test):** reconcile test expectations that drifted from the code they guard on `release/v3.8.50` — auth/vision/provider schema snapshots, and three context-aware combo compatibility assertions that contradicted the same file's own stated contract (catalog-too-small targets stay available as runtime fallback rather than being dropped). The combo assertions were masked by an unresolved import that stopped `combo.ts` from loading at all, so they only become reachable once that import is repaired. diff --git a/changelog.d/fixes/release-v3850-post-sweep-locale-lint.md b/changelog.d/fixes/release-v3850-post-sweep-locale-lint.md new file mode 100644 index 0000000000..3377f5c04a --- /dev/null +++ b/changelog.d/fixes/release-v3850-post-sweep-locale-lint.md @@ -0,0 +1 @@ +- Repair release-sweep regressions in locale and environment contracts, package metadata, scripts, dependency, size and dead-code ratchets, OpenAPI coverage, Telegram error sanitization, Openference public-credential handling, DB-module classification, resilience UI test assertions, strict CodeBuddy CN tests, Lite compression typing, and the job-registry migration number. diff --git a/changelog.d/fixes/release-v3850-validator-test-masking-timeout.md b/changelog.d/fixes/release-v3850-validator-test-masking-timeout.md new file mode 100644 index 0000000000..4b7c777092 --- /dev/null +++ b/changelog.d/fixes/release-v3850-validator-test-masking-timeout.md @@ -0,0 +1 @@ +- Let the release-green validator finish the test-masking gate on loaded runners while preserving the existing timeout for every other full-CI gate, and report Node.js `ETIMEDOUT` errors as explicit timeout failures. diff --git a/config/quality/dependency-allowlist.json b/config/quality/dependency-allowlist.json index 0c36dd1155..ce8d808361 100644 --- a/config/quality/dependency-allowlist.json +++ b/config/quality/dependency-allowlist.json @@ -44,6 +44,7 @@ "commander", "concurrently", "cross-env", + "cron-parser", "csv-stringify", "ctrf", "dompurify", @@ -114,6 +115,7 @@ "recharts", "safe-regex", "selfsigned", + "sharp", "size-limit", "smol-toml", "socks", diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 4d1fb3d91c..76f8b0c13e 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1673,7 +1673,7 @@ }, "tests/unit/base-executor-sanitize-effort.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 48 + "count": 6 } }, "tests/unit/batch-deletion.test.ts": { @@ -2036,11 +2036,6 @@ "count": 3 } }, - "tests/unit/codebuddy-cn-provider.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, "tests/unit/codex-banked-reset-credits-5199.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 7 @@ -3339,4 +3334,4 @@ "count": 5 } } -} \ No newline at end of file +} diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 19b2a3e9c4..da6cd4a60d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,9 @@ { "_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_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_08_9183_reasoning_cache_index_sync": "Extracted fix(responses-api): sync reasoning-cache write index with the fixed read side (from the originally-authored #9183) — chatCore.ts's write side cached every response under a hardcoded messageIndex:0, and translator/index.ts's plain-turn (non-tool-call) cache-key lookup ALSO still hardcoded messageIndex 0 at its call site (a second, previously-undiscovered instance of the same hardcoding bug, found while re-verifying this fix against the current upstream tip — the two never agreed once a conversation went past its first assistant turn, so DeepSeek/Xiaomi-mimo plain-turn reasoning replay silently missed the cache). Own growth: open-sse/handlers/chatCore.ts 5034->5042 (+8, computing messageIndex from the incoming request's message count at both the streaming and non-streaming cache-write call sites) — irreducible call-site wiring. Covered by tests/unit/reasoning-cache.test.ts (new end-to-end write/read regression test, rebaselined below) and tests/unit/translator-helper-branches.test.ts fixture updates. Other #9183 sub-fixes (output_index collision prevention, reasoning-content-alias generalization) were originally assumed already superseded by upstream's own independent fix — a live incident 2026-08-08 disproved that for the message-vs-tool-call collision case specifically (fixed separately in #9822); not re-extracted here since this PR's own scope is the narrower messageIndex sync only.", + "_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.", "_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent’s conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR’s own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.", "_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.", "_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).", @@ -159,9 +163,12 @@ "_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.", + "_rebaseline_2026_08_09_v3850_post_sweep_tip": "Release-captain reconciliation of absolute file-size drift on pure tip 382449d593 after the authorized cherry-pick wave. The affected production growth already belongs to merged, tested commits: Adobe Firefly CDP/session recovery (#9881), model capability serialization (#9296), Modality Bridge request wiring (#9759), disconnect-grace/reasoning-cache chatCore wiring (#9653/#9183), stacked Lite precedence, and Responses tool-call index/argument handling (#9843 plus the release translator fixes). This repair adds only the compact migration-146 retroactive guard, covered by db-job-registry-migration-renumber-139.test.ts. Values are the exact check:file-size split-newline measurements and remain shrink-only; structural decomposition remains tracked by the existing #3501 notes.", "cap": 1000, "testCap": 1000, "testFrozen": { + "tests/unit/adobe-firefly.test.ts": 1136, + "tests/unit/reasoning-cache.test.ts": 1035, "_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).", "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", "_rebaseline_2026_07_09_6126_clinepass_dualauth": "#6126 (ClinePass dual-auth) own test growth: oauth-providers-config.test.ts 842->845 (+3: clinepass key/config/required-fields entries reusing the Cline WorkOS flow config, needed after registering clinepass in the oauth.ts PROVIDERS enum).", @@ -350,7 +357,7 @@ "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/chatCore.ts": 5061, "open-sse/handlers/imageGeneration.ts": 3101, "open-sse/handlers/responseSanitizer.ts": 1128, "open-sse/handlers/search.ts": 1536, @@ -359,12 +366,15 @@ "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/adobeFireflyBrowserLogin.ts": 1362, + "open-sse/services/adobeFireflyChromeRuntime.ts": 1201, + "open-sse/services/adobeFireflyClient.ts": 2999, + "open-sse/services/adobeFireflySession.ts": 1003, "open-sse/services/claudeCodeCompatible.ts": 1202, "open-sse/services/combo.ts": 3648, - "open-sse/services/compression/strategySelector.ts": 1060, + "open-sse/services/compression/strategySelector.ts": 1061, "open-sse/services/rateLimitManager.ts": 1167, - "open-sse/translator/response/openai-responses.ts": 1215, + "open-sse/translator/response/openai-responses.ts": 1271, "open-sse/utils/cursorAgentProtobuf.ts": 1505, "open-sse/utils/stream.ts": 2889, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1388, @@ -391,7 +401,7 @@ "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/migrationRunner.ts": 1101, "src/lib/db/models.ts": 1097, "src/lib/db/providers.ts": 1034, "src/lib/memory/retrieval.ts": 1073, @@ -402,7 +412,7 @@ "src/shared/components/analytics/charts.tsx": 1035, "src/shared/services/cliRuntime.ts": 1122, "src/sse/handlers/chat.ts": 1918, - "src/sse/services/auth.ts": 2520, + "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, @@ -421,9 +431,6 @@ "_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_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 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 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.", @@ -435,6 +442,7 @@ "_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_08_9634_migration_139_guard": "PR #9634 (fix/release-v3850-basereds) own growth, re-measured on e0ce95c59 after rebase: src/lib/db/migrationRunner.ts 1094->1096 (+2, the isSchemaAlreadyApplied case-139 retroactive guard for the renumbered ccr migration). Irreducible, matches the per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts.", "_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, 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_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.", + "_rebaseline_2026_08_03_9255_adobe_firefly_durable_sessions": "PR #9255 own cohesive growth: open-sse/services/adobeFireflyClient.ts 2322->2894 adds authenticated-vs-guest IMS classification, browser-risk ARP validation/rebuild, bounded 408 retry/recovery, sticky accepted-session handling, and matching image/video submit recovery at the existing Adobe upstream client chokepoints. This client was already explicitly frozen as a single self-contained upstream integration by #8006/#8510; splitting only the retry/auth helpers now would scatter one request state machine while structural shrink remains tracked in #3501. tests/unit/adobe-firefly.test.ts 871->1136 adds direct regression coverage for guest-token rejection, cookie/ARP rebuilding, 408 retries, sticky accepted ARP reuse, forced auth recovery, and cookie-to-IMS exchange. The obsolete 1179-line managed-Chrome fallback module was deleted rather than rebaselined after the packaged-safe pure-CDP path became authoritative. Focused Adobe suite: 61/61.", + "_rebaseline_2026_08_07_9653_disconnect_grace_period": "Extracted fix(sse): grace period before finalizing a client disconnect as 499 (#9653) — a client that closes its connection right after reading a fully-completed SSE stream can race OmniRoute's own completion bookkeeping, getting persisted as a false 499/0-tokens even though it delivered the full response (live-confirmed: a real disconnect at 18236ms was corrected to 200/82814+1292 tokens). Own growth: open-sse/handlers/chatCore.ts 5030->5039 (+9, wiring createClientDisconnectGraceHandler at the existing onClientDisconnectFinalize call site) — irreducible call-site wiring, the actual grace-period logic lives in the new leaf createClientDisconnectGraceHandler (open-sse/utils/streamFailureFinalization.ts, not frozen). Re-measured to 5042 after rebasing onto a newer release/v3.8.50 tip: the file carries an unrelated +3 base drift from already-merged upstream commits between this PR's original branch point and the rebase target, not covered by this entry. Covered by tests/unit/stream-disconnect-grace-period-9653.test.ts (4/4, fake-timer driven). Other file-size gate violations present on this base tip are pre-existing/unrelated to this change (base-red #9679, re-verify current issue number at merge time).", + "_rebaseline_2026_08_04_9268_gemini_schema_empty_choices": "Feature #9268 own growth: open-sse/utils/stream.ts 2889->2915 (+26 = irreducible call-site wiring for the empty-choices interceptor). The translate-mode flush now rejects a stream that completed without forwarding any valuable chunk (all-empty `choices: []`, no content/tool_calls/finish_reason) as a retryable 502 \"empty content\" instead of a clean empty 200 — the missing streaming counterpart of chatCore.ts's non-streaming isEmptyContentResponse. All rejection logic lives in the NEW leaf module open-sse/utils/streamEmptyChoices.ts (5061 (+11). The Layer A capability gate is irreducible wiring at the existing pre-dispatch chokepoint: feature-flag check, capability derivation, compatibility decision, sanitized 400 response, pending-request cleanup, and warning telemetry. All matching and message logic lives outside the god-file in src/shared/constants/capabilities/capabilityFilter.ts; only orchestration remains here. Covered by tests/unit/capability-filter.test.ts (20 cases, including flag-off and sanitized error behavior). Structural shrink remains tracked separately." } diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 198540871c..c7b29f00fd 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -102,8 +102,9 @@ "_rebaseline_2026_07_28_v3849_release": "75.5 -> 99 (+23.5). Aperto EXIGIDO pelo modo --require-tighten do ratchet: a métrica melhorou de verdade no ciclo v3.8.49. A causa é o workflow assíncrono de tradução, que finalmente alcançou o denominador em EN — as rebaselines anteriores (v3.8.39/.44/.47) foram todas afrouxamentos registrando o atraso das traduções, e agora ele foi pago. O coletor SUBTRAI os placeholders (present - placeholder em scripts/quality/collect-metrics.mjs), então os 317 marcadores __MISSING__ que esta release introduziu para o drift de valor já estão descontados dos 99 — o número é honesto, não inflado por placeholder. Medido pelo collect-metrics do CI no run 30404226939." }, "deadExports": { - "value": 227, + "value": 230, "direction": "down", + "_rebaseline_2026_08_09_v3850_post_sweep": "227 -> 230. Measured by npm run check:dead-code on the unmodified release/v3.8.50 tip 382449d593 during the mandatory --full-ci pre-flight. The +3 is inherited cycle drift from the authorized merge sweep; this repair adds no production exports. Rebaseline records the actual tip so ci.yml quality-gate can run, while structural cleanup remains separate debt.", "_rebaseline_2026_07_01_v3843_release": "225->227 (+2). v3.8.43 cycle drift, surfaced in the Quality Ratchet job after eslintWarnings was rebaselined (check:dead-code runs there). 227 = measured by check:dead-code (knip) on the release tip 4635076eb. The 5 CI fixes add 0 dead exports: safeHttpHref in linkify.ts is module-local AND used (called by linkifyText); no new exports; test files are not scanned. Tighten via --update next cycle.", "dedicatedGate": true, "_rebaseline_2026_06_30_v3842_deadcode_wave": "310 -> 225. Measured by `node scripts/check/check-dead-code.mjs` on the v3.8.42 tip after the JxnLexn dead-code (#5463/#5464/#5466) + duplication (#5471..#5500) wave landed: DEAD_EXPORTS=133 + DEAD_FILES=92 = 225. The stale 310 was the v3.8.38 release snapshot never ratcheted on PR->release fast-gates (check:dead-code runs only on ci.yml PR->main, not quality.yml). Tightening to the true measured value; release-time captain rebaselines up if parallel cycle merges add dead exports.", diff --git a/config/quality/test-discovery-baseline.json b/config/quality/test-discovery-baseline.json index a2e41b902d..c8b96494a0 100644 --- a/config/quality/test-discovery-baseline.json +++ b/config/quality/test-discovery-baseline.json @@ -1,24 +1,10 @@ { "_comment": "Catraca de test-discovery (check-test-discovery.mjs). Cada entrada e um arquivo de teste que NENHUM runner coleta (ele nunca roda) — divida congelada na auditoria 6A.1 (2026-06-09; 195 originais, 135 religados no node runner em 6A.1c). So pode DIMINUIR: religue o teste (ajustando o glob do runner ou movendo o arquivo) e remova a entrada via --update. NAO adicione novos orfaos — corrija o runner.", - "_remaining_60": "Categorias: 33 .test.tsx de tests/unit (religaveis via vitest.config root, MAS o experimento 2026-06-09 mostrou 24 arquivos vermelhos — triagem de drift de UI na janela 2026-06-16, junto com os 14 fails do proprio test:vitest:ui atual); 9 open-sse __tests__ + 8 src __tests__ (includes de vitest.config que NENHUM script executa sem filtro); 4 golden-set + 1 benchmarks + 1 live + 1 stress (deliberadamente manuais — decidir runner/gating); 3 integration/services (gated RUN_SERVICES_INT=1, sem runner CI).", + "_remaining_13": "13 orfaos restantes: 2 testes de API em settings + 1 snapshot de quota do DB; 4 golden-set + 1 benchmark + 1 teste live + 1 stress (deliberadamente manuais — decidir runner/gating); 3 integration/services (gated RUN_SERVICES_INT=1, sem runner CI).", "orphans": [ - "open-sse/services/__tests__/chatgptTlsClient.test.ts", - "open-sse/services/__tests__/claudeTlsClient.test.ts", - "open-sse/services/__tests__/grokTlsClient.test.ts", - "open-sse/services/__tests__/manifestAdapter.test.ts", - "open-sse/services/__tests__/specificityDetector.test.ts", - "open-sse/services/__tests__/tierResolver.test.ts", - "open-sse/services/__tests__/volumeDetector.test.ts", - "open-sse/translator/helpers/__tests__/maxTokensHelper.test.ts", - "open-sse/translator/helpers/__tests__/schemaCoercion.test.ts", "src/app/api/settings/__tests__/memory.test.ts", "src/app/api/settings/__tests__/settings.test.ts", "src/lib/db/__tests__/quotaSnapshots.test.ts", - "src/lib/memory/__tests__/injection.test.ts", - "src/lib/memory/__tests__/qdrant-wiring.test.ts", - "src/lib/memory/__tests__/retrieval.test.ts", - "src/lib/memory/__tests__/schemas.test.ts", - "src/lib/skills/__tests__/integration.test.ts", "tests/benchmarks/pipeline-accuracy.test.ts", "tests/golden-set/compression-caveman-v2.test.ts", "tests/golden-set/compression-quality.test.ts", @@ -28,36 +14,6 @@ "tests/integration/services/full-lifecycle.int.test.ts", "tests/integration/services/route-guard-services.int.test.ts", "tests/live/deepseek-web-live.test.ts", - "tests/theoldllm-stress.test.ts", - "tests/unit/AutoComboCatalog.test.tsx", - "tests/unit/SkillsConceptCard.test.tsx", - "tests/unit/agent-skills-page.test.tsx", - "tests/unit/dashboard/batch/components/BatchDetailModal.test.tsx", - "tests/unit/dashboard/batch/components/ExpirationBadge.test.tsx", - "tests/unit/dashboard/batch/components/NewBatchWizard.test.tsx", - "tests/unit/dashboard/batch/components/ProgressBarBicolor.test.tsx", - "tests/unit/dashboard/batch/components/UploadFileModal.test.tsx", - "tests/unit/dashboard/batch/components/useBatchActions.test.tsx", - "tests/unit/dashboard/batch/concept-cards.test.tsx", - "tests/unit/dashboard/batch/list-regression.test.tsx", - "tests/unit/dashboard/batch/sanitization.test.tsx", - "tests/unit/omni-skills-page.test.tsx", - "tests/unit/shared-clipboard.test.tsx", - "tests/unit/shared/components/AutoRoutingBanner.test.tsx", - "tests/unit/shared/components/KiroAuthModal.test.tsx", - "tests/unit/shared/components/ProxyConfigModal.test.tsx", - "tests/unit/translator-friendly-advanced-section.test.tsx", - "tests/unit/translator-friendly-compression.test.tsx", - "tests/unit/translator-friendly-concept-card.test.tsx", - "tests/unit/translator-friendly-integration.test.tsx", - "tests/unit/translator-friendly-monitor-tab.test.tsx", - "tests/unit/translator-friendly-page-client.test.tsx", - "tests/unit/translator-friendly-pipeline-view.test.tsx", - "tests/unit/translator-friendly-raw-json-panel.test.tsx", - "tests/unit/translator-friendly-result-narrated.test.tsx", - "tests/unit/translator-friendly-simple-controls.test.tsx", - "tests/unit/translator-friendly-stream-transformer.test.tsx", - "tests/unit/translator-friendly-test-bench.test.tsx", - "tests/unit/translator-friendly-translate-tab.test.tsx" + "tests/theoldllm-stress.test.ts" ] } diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index 06c55e99cf..00822ede4d 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -186,10 +186,10 @@ Runs on pull requests only. Runs after `build`. Blocks merge on failure. -| Suite | Validates | Blocking | -| ---------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- | -| `test:vitest` | MCP server (94 tools), autoCombo, cache — vitest runner | Yes | -| `test:vitest:ui` | UI component tests — vitest runner | **Advisory** (`continue-on-error: true`) — failing until Fase 6A UI triage | +| Suite | Validates | Blocking | +| ---------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `test:vitest` | MCP server (94 tools), autoCombo, cache — vitest runner | Yes | +| `test:vitest:ui` | UI component tests — vitest runner | **Blocking** — pre-existing failures are explicitly excluded in `vitest.config.ts`; new failures fail the job | ### Nightly workflows (scheduled, advisory) @@ -401,7 +401,7 @@ several "obvious" merges turned out to hide debt and are **not** clean drop-ins. - `check:openapi-security-tiers` (advisory) — ❌ **NOT cleanly flippable.** It exits 0 but warns that several `traffic-inspector` routes under `LOCAL_ONLY_API_PREFIXES` lack the `x-loopback-only: true` annotation. Enforcing it requires adding those annotations to `openapi.yaml` first. - `typecheck:noimplicit:core` (advisory) — largely subsumed by the blocking `check:type-coverage` ratchet. Flip to a ratchet or drop the redundant second `tsc` pass. -- `test:vitest:ui` (advisory, 14 parked fails) — fix-and-block or delete; don't leave rotting. +- `test:vitest:ui` (now **blocking**) — pre-existing failures are explicitly excluded in `vitest.config.ts` with `// #8618` tracking comments; new failures fail the job. - `check:secrets` (gitleaks, blocking ratchet frozen at 3 documented false-positives) — allowlist the 3 to reach 0, or demote to advisory. Overlaps GitHub native secret-scanning + `check:public-creds`. - `check:pr-evidence` (blocking, greps PR-body prose) — high false-positive risk; weakens Hard Rule #18 enforcement if dropped, so this is a genuine policy call. - `semgrep` (advisory standalone) — overlaps CodeQL for the OWASP families; wire its baseline to a ratchet or drop. diff --git a/docs/getting-started/WEB-COOKIE-GUIDE.md b/docs/getting-started/WEB-COOKIE-GUIDE.md index 9b44cc184e..0a44b5fa6b 100644 --- a/docs/getting-started/WEB-COOKIE-GUIDE.md +++ b/docs/getting-started/WEB-COOKIE-GUIDE.md @@ -18,7 +18,7 @@ Unlike API-key providers, Web Cookie providers authenticate using the credential Many authentication issues are caused by copying cookies from the wrong place. -## Do NOT copy from Cookie Storage +## Do NOT copy from Cookie Storage Most browsers expose stored cookies through: @@ -36,7 +36,7 @@ Although these cookies look correct, they may be: Using these values may cause authentication failures even if they appear valid. -## Copy from a Live Request +## Copy from a Live Request Instead, use the cookies from a successful request: @@ -80,14 +80,14 @@ The exact credentials required depend on the provider. Different websites store authentication differently. Some require only cookies, while others may require additional headers or tokens. -| Provider | Credential Format | Provider Guide | -|----------|-------------------|----------------| -| Claude Web | Full Cookie request header | `docs/providers/CLAUDE_WEB.md` | -| ChatGPT Web | _(verify)_ | | -| Gemini Web | _(verify)_ | | -| Copilot Web | _(verify)_ | | -| Grok Web | _(verify)_ | | -| ... | ... | ... | +| Provider | Credential Format | Provider Guide | +| ----------- | -------------------------------------------------------------- | ------------------------------- | +| Claude Web | Full Cookie request header | `docs/providers/CLAUDE_WEB.md` | +| ChatGPT Web | Full Cookie header or `__Secure-next-auth.session-token` value | `docs/providers/CHATGPT_WEB.md` | +| Gemini Web | _(verify)_ | | +| Copilot Web | _(verify)_ | | +| Grok Web | _(verify)_ | | +| ... | ... | ... | > Update this table as new Web Cookie providers are added or existing providers change their authentication requirements. diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 5233889912..3180c10e3b 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -147,11 +147,11 @@ The prod stack runs in parallel with the dev compose (different container names, The repository ships a multi-stage Dockerfile (`Dockerfile`). Three stages are exposed; pick the right `target` for your use case. -| Stage | Base image | Purpose | -| ------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `builder` | `node:24.15.0-trixie-slim` | Installs deps (`npm ci --legacy-peer-deps`) and runs `npm run build -- --webpack` | -| `runner-base` | `node:24.15.0-trixie-slim` | Production runtime with the Next.js standalone output. **No provider CLIs bundled.** | -| `runner-cli` | `runner-base` | Adds `git`, `docker.io`, `docker-compose` and global CLIs: `@openai/codex`, `@anthropic-ai/claude-code`, `droid`, `openclaw`. **Pick this for agentic workflows.** | +| Stage | Base image | Purpose | +| ------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `builder` | `node:26-trixie-slim` | Installs deps (`npm ci --legacy-peer-deps`) and runs `npm run build` (Turbopack by default — see Build-time resources below) | +| `runner-base` | `node:26-trixie-slim` | Production runtime with the Next.js standalone output. **No provider CLIs bundled.** | +| `runner-cli` | `runner-base` | Adds `git`, `docker.io`, `docker-compose` and global CLIs: `@openai/codex`, `@anthropic-ai/claude-code`, `droid`, `openclaw`. **Pick this for agentic workflows.** | Build a specific target manually: @@ -160,14 +160,50 @@ docker build --target runner-base -t omniroute:base . docker build --target runner-cli -t omniroute:cli . ``` -Defaults exported by `runner-base`: `PORT=20128`, `HOSTNAME=0.0.0.0`, `NODE_OPTIONS=--max-old-space-size=512`, `DATA_DIR=/app/data`, `OMNIROUTE_MIGRATIONS_DIR=/app/migrations`. +### Build-time resources + +Two build args control what the `builder` stage costs. They are build-time only — +`OMNIROUTE_MEMORY_MB` (below) is a separate, runtime knob. + +| Build arg | Default | Effect | +| --------------------------- | ------- | ---------------------------------------------------------------------- | +| `OMNIROUTE_USE_TURBOPACK` | `1` | `0` builds with webpack instead. Lower peak memory, slower. | +| `OMNIROUTE_BUILD_MEMORY_MB` | `4096` | V8 heap ceiling (`--max-old-space-size`) for the spawned `next build`. | + +Turbopack compiles in native Rust memory that lives **outside** the V8 heap, so +`OMNIROUTE_BUILD_MEMORY_MB` does not bound it. On a host with a memory ceiling the +build is then SIGKILLed by the OOM killer with no error text at all — it simply +stops mid-`Creating an optimized production build`, which reads like a hang rather +than an out-of-memory. If the build host is constrained, switch bundlers: + +```bash +docker build --target runner-base \ + --build-arg OMNIROUTE_USE_TURBOPACK=0 \ + -t omniroute:base . +``` + +`webpackBuildWorker` is enabled, so `next build` runs a parent **and** a worker +process and each honours `OMNIROUTE_BUILD_MEMORY_MB` separately. Size the container +ceiling above roughly twice that value, not once. + +Measured on this tree (`--target runner-base`, `OMNIROUTE_BUILD_MEMORY_MB=6144`): + +| Bundler | Container ceiling | Result | +| --------- | ----------------- | ----------------------------- | +| Turbopack | 8 GiB / 16 GiB | OOM-killed at both, silently | +| webpack | 8 GiB | build worker SIGKILLed | +| webpack | 12 GiB | succeeded, peaked at 11.1 GiB | + +### Runtime defaults + +Defaults exported by `runner-base`: `PORT=20128`, `HOSTNAME=0.0.0.0`, `OMNIROUTE_MEMORY_MB=1024`, `NODE_OPTIONS=--max-old-space-size=1024`, `DATA_DIR=/app/data`, `OMNIROUTE_MIGRATIONS_DIR=/app/migrations`. Memory behavior in Docker: -- `NODE_OPTIONS=--max-old-space-size=512` is baked into the image as a fallback. +- The image sets `OMNIROUTE_MEMORY_MB=1024` and derives `NODE_OPTIONS=--max-old-space-size=1024` from it. - The actual server process is started by the standalone launcher, which reads `OMNIROUTE_MEMORY_MB` and appends `--max-old-space-size=`. - Node uses the last repeated `--max-old-space-size` value, so setting `OMNIROUTE_MEMORY_MB` controls the effective Docker heap limit. -- If `OMNIROUTE_MEMORY_MB` is unset, the launcher uses `512`. +- Because the image always sets it, the launcher's own RAM-calibrated fallback never applies under Docker. Raise it explicitly (`-e OMNIROUTE_MEMORY_MB=2048`) on a host with headroom. ## Critical Environment Variables @@ -180,7 +216,7 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md), | `REDIS_PORT` | Host-side port for the bundled Redis container | `6379` | | `REDIS_BIND_HOST` | Host interface the bundled Redis port is published on (loopback unless you add AUTH) | `127.0.0.1` | | `AUTO_UPDATE_HOST_REPO_DIR` | Host path mounted into `cli` profile at `/workspace/omniroute` for self-update workflows | `.` (current directory) | -| `OMNIROUTE_MEMORY_MB` | Runtime Node heap ceiling for the Docker standalone server; overrides the image fallback above | `512` | +| `OMNIROUTE_MEMORY_MB` | Runtime Node heap ceiling for the Docker standalone server; overrides the image default above | `1024` | | `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` | | `OMNIROUTE_BASE_PATH` | URL subpath when the app is published behind a reverse proxy (e.g. `/omniroute`) | _(empty = root)_ | | `NEXT_PUBLIC_BASE_URL` | Public browser origin including the subpath (e.g. `https://host/omniroute`) | unset | diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index 4e928e04fc..8c629422a6 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -523,6 +523,12 @@ exhausts its bounds of `10,000` visited nodes or depth `12`. Each process uses a process-local guard to reserve limited heavyweight capacity before retaining and parsing a large request body. A heavyweight lease remains held for the lifetime of an SSE response. + +When capacity is busy, a heavyweight request first waits up to +`OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` (default `5000`, `0` disables the wait) for a slot to free up +before answering the retryable `503`. The bounded wait exists so agent-style clients +(OpenCode, Claude Code, Cursor) that fan out heavy sub-requests concurrently serialize the burst +instead of burning their whole retry budget on immediate rejections and dying mid-task. Current heavyweight lease occupancy is not surfaced in the dashboard. Settings → Resilience → Request Queue → Concurrent Requests does not control this; that setting governs a separate provider request-queue mechanism. @@ -530,13 +536,18 @@ governs a separate provider request-queue mechanism. **Fix:** 1. Retry first. Clients should honor `Retry-After` and use backoff rather than immediately - repeating the request. + repeating the request. Note that with the default `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=5000` + a heavy request already waited up to 5 seconds before the `503`, so a client retry loop should + back off beyond that instead of hammering. 2. If normal deployment traffic repeatedly exhausts the guard, you can cautiously raise `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` from its default of `1`. Increase it one step at a time, restart OmniRoute after each change, and observe memory headroom under representative load. Every additional heavyweight request can increase concurrent V8 heap use and container or host OOM risk. No value is safe for every deployment; validate the setting against your own traffic and memory limits rather than assuming that `2` is universally safe. +3. Prefer widening the wait (`OMNIROUTE_CHAT_ADMISSION_QUEUE_MS`) over raising the in-flight + limit when bursts are short: waiting costs latency, while an extra concurrent heavyweight + request costs heap residency for the whole request lifetime. See the [environment-variable reference](../reference/ENVIRONMENT.md#4-security--authentication) for the authoritative admission settings. Loosening the heavyweight classification thresholds diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 27e1d37cc8..ae0ca05c56 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -5225,6 +5225,102 @@ paths: "200": description: Sync initialized + # ─── Background Jobs (local-only administration) ─────────────── + + /api/jobs: + get: + tags: [System] + summary: List registered background jobs + description: Local-only runtime administration. Returns each registered job and its latest run. + x-internal: true + responses: + "200": + description: Registered jobs + "500": + description: Failed to list jobs + + /api/jobs/{id}/enable: + post: + tags: [System] + summary: Enable a background job + description: Local-only runtime administration. Enables the job and restarts its timer. + x-internal: true + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Job enabled + "404": + description: Job not found + "500": + description: Failed to enable job + + /api/jobs/{id}/disable: + post: + tags: [System] + summary: Disable a background job + description: Local-only runtime administration. Disables the job and stops its timer. + x-internal: true + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Job disabled + "404": + description: Job not found + "500": + description: Failed to disable job + + /api/jobs/{id}/run-now: + post: + tags: [System] + summary: Trigger a background job + description: >- + Local-only runtime administration. Starts the job, or waits for an in-flight + run before queueing the next one, subject to OMNIROUTE_RUNNOW_TIMEOUT_MS. + x-internal: true + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Job trigger accepted + "404": + description: Job not found + "500": + description: Failed to trigger job + + /api/jobs/{id}/runs: + get: + tags: [System] + summary: Read background-job run history + description: Local-only runtime administration. Returns newest-first run history for one job. + x-internal: true + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Job run history + "404": + description: Job not found + "500": + description: Failed to load job runs + # ─── Resilience & Monitoring ──────────────────────────────────── /api/resilience: @@ -5247,6 +5343,70 @@ paths: "200": description: Updated resilience configuration + /api/resilience/connections: + get: + tags: [System] + summary: Inspect connection resilience state + description: >- + Local-only operational view of per-connection cooldowns, provider circuit + breakers, model lockouts, and recent breaker transitions. Credential columns + are excluded by an explicit database whitelist. + x-internal: true + parameters: + - name: windowMs + in: query + schema: + type: integer + minimum: 0 + maximum: 86400000 + default: 3600000 + - name: provider + in: query + schema: + type: string + minLength: 1 + maxLength: 64 + responses: + "200": + description: Connection, breaker, lockout, window, and degradation metadata + "400": + description: Invalid query parameters + "500": + description: Failed to collect resilience state + + /api/telegram/update: + post: + tags: [System] + summary: Receive Telegram updates or Mini App messages + description: >- + Public Telegram integration endpoint. Bot updates are acknowledged after + reply dispatch is queued. Mini App requests must include Telegram-signed + initData, which is verified with TELEGRAM_BOT_TOKEN before chat proxying. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + properties: + initData: + type: string + message: + type: string + update_id: + type: integer + responses: + "200": + description: Update acknowledged or Mini App reply returned + "400": + description: Invalid JSON, request shape, or missing Mini App message + "401": + description: Invalid Mini App initData signature + "503": + description: Telegram integration is not configured + /api/resilience/reset: post: tags: [System] diff --git a/docs/proposals/TELEGRAM-MINIAPP.md b/docs/proposals/TELEGRAM-MINIAPP.md new file mode 100644 index 0000000000..b7f390ac54 --- /dev/null +++ b/docs/proposals/TELEGRAM-MINIAPP.md @@ -0,0 +1,143 @@ +--- +title: "Feasibility — Telegram Mini App Integration" +version: 3.8.49 +lastUpdated: 2026-08-08 +--- + +# Telegram Mini App Integration — Feasibility Analysis + +**Status: FEASIBLE with moderate effort (estimated 2–4 dev-days for a working slice)** + +## 1. What "Telegram Mini App" means here + +A Telegram Mini App is an iframe-hosted web app opened inside Telegram (via +inline buttons / bot menu buttons) that talks to a bot backend through the +[Telegram WebApp SDK](https://core.telegram.org/bots/webapps). For OmniRoute +the natural shape is: + +- **Bot backend** (new): receives Telegram updates (webhook), validates the + Mini App's `initData` signature, and proxies chat requests to OmniRoute's + existing OpenAI-compatible `/v1/chat/completions` surface. +- **Mini App frontend** (new): a small chat UI served by OmniRoute (Next.js + route or `public/` static bundle), using the Telegram WebApp JS SDK. + +## 2. Current state of the codebase (verified against `main` @ 918fba5e3) + +### Already present — outbound notifications only + +| Piece | Location | What it does | +| ---------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Telegram webhook integration | `src/lib/webhooks/integrations/telegram.ts` | Builds `sendMessage` payloads for **outbound** gateway events (model, provider, latency, error) | +| Webhook dispatcher | `src/lib/webhookDispatcher.ts` | Routes by kind; decrypts `botToken` from DB metadata for telegram | +| Webhook kinds | `src/lib/db/webhooks.ts` | `slack \| telegram \| discord \| custom` | +| Webhook CRUD + test | `src/app/api/webhooks/*` | Create/update/test; telegram kind skips `url` (uses bot token + chat_id) | +| Bot token validation | `telegram.ts:18` | `BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/` | +| Encryption requirement | `webhooks/route.ts:77` | Telegram webhooks require DB encryption enabled (bot tokens stored at rest) | + +### Missing — what a Mini App needs that does not exist yet + +| Gap | Detail | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Inbound Bot API listener** | No `setWebhook` registration, no `/bot/getUpdates` polling, no update handling anywhere. Only the `sendMessage` direction exists. | +| **WebApp `initData` validation** | No HMAC-SHA256 check of `initData` against the bot token (`WebAppData` hash validation from the Bot API docs). | +| **Telegram bot library** | `package.json` has no `telegraf`/`grammy`/`telegram-bot-api` dependency. Would need to add one or hand-roll the (small) HMAC + fetch logic. | +| **Mini App hosting surface** | `public/` exists (static assets) and Next.js routes exist; no `/miniapp` route or static bundle yet. | +| **Session → API key mapping** | Mini App users need to authenticate to `/v1/chat/completions`. Two options: per-user generated OmniRoute API keys (via `src/lib/db/apiKeys`) or a bot-side proxy that injects a shared key. | + +## 3. Constraints + +### 3.1 Architectural + +- **No existing inbound-bot layer.** The webhook system is strictly + event→outbound. A Mini App needs a _new_ Bot API webhook endpoint + (`POST /api/telegram/webhook/` or a dedicated route) plus + update dispatch. This is additive — no conflicts with the existing + `webhooks/` subsystem, but the two must not share the `botToken` storage + semantics blindly (webhooks store bot tokens for _outbound_; the Mini App + needs the same token for _inbound_ signature checks — same token, new use). +- **Public HTTPS required.** Telegram only delivers updates to an HTTPS + endpoint with a valid cert. Self-hosted OmniRoute behind Tailscale/ngrok + needs a public tunnel or Cloudflare Tunnel for the webhook path + (a future webhook-URL setting). The dashboard can render the current + public origin (`OMNIROUTE_PUBLIC_BASE_URL`) but no webhook registration + helper exists. +- **Encryption gate.** `webhooks/route.ts:77` already refuses telegram + kinds without DB encryption. The Mini App bot token has the same + sensitivity (it _is_ the HMAC secret for initData validation) — same gate + applies, which is a _good_ constraint (no plaintext tokens). + +### 3.2 Telegram platform + +- **initData is the only trust anchor.** Mini App auth = verify + `hash` field of `initData` using HMAC-SHA256(key = SHA256(bot_token), + data = sorted `key=value` pairs minus `hash`). Must be implemented + server-side; never trust the client. +- **No inbound push to arbitrary users.** Telegram bots cannot initiate + conversations. The Mini App works for users who _already_ have the bot — + or you add a `/start` command handler + deep-link (`t.me/bot?startapp=`). +- **Rate limits.** Bot API ~30 msg/s per bot, 20 msg/min per chat group. + Chat responses via `sendMessage`/`answerWebAppQuery` are fine at gateway + scale, but streaming must be emulated (send progressive edits or chunked + messages) — no native SSE into Telegram. +- **WebApp SDK quirks.** `Telegram.WebApp.ready()` must be called; theme + params come from the SDK; the mini app is sandboxed iframe (no + `window.open` to external, clipboard limited). For a chat UI this is fine. + +### 3.3 Security / policy + +- **Per-user key issuance is the clean model.** Rather than exposing the + admin's own API keys, mint a scoped OmniRoute API key per Telegram user + (`apiKeys` table + `isModelAllowedForKey` policy), or proxy with a single + gateway key and map `user_id` → account. Recommendation: per-user keys so + existing rate-limit / model-allowlist / policy code applies unchanged. +- **initData expiry.** `auth_date` in initData must be checked (Telegram + recommends < 24h; short TTLs for chat flows). +- **Secret handling.** Bot token must stay in the encrypted DB / env — + mirror the existing `isEncryptionEnabled()` gate. + +## 4. Required next steps (implementation plan) + +### Phase 0 — Spike (½–1 dev-day) + +1. Add `grammy` or `telegraf` (or ~60 lines of hand-rolled HMAC + fetch). +2. Implement `src/lib/telegram/initData.ts` — `verifyInitData(initData, botToken)`. +3. Stand up a throwaway `POST /api/telegram/miniapp/webhook` route behind + a dedicated webhook secret; register via `setWebhook` once, locally. + +### Phase 1 — Minimal chat slice (1–2 dev-days) + +1. **Webhook endpoint** `POST /api/telegram/bot/update` (or + `/api/telegram/miniapp/update`): parse Update, verify initData, dispatch. +2. **Command handler**: `/start` → reply with deep link + `https://t.me/?startapp=`; `startapp` param carries a + one-time token that maps to a generated OmniRoute API key. +3. **Chat proxy**: map `initData.user.id` → API key → call + `handleChat` (same path as `/v1/chat/completions`) → reply via + `sendMessage` (non-stream) or chunked edits (fake streaming). +4. **Mini App page**: `src/app/(dashboard)/miniapp/page.tsx` (or static + bundle in `public/miniapp/`) — Telegram WebApp SDK init + minimal chat + UI posting to the bot webhook. +5. **Config**: `TELEGRAM_BOT_TOKEN` env (or reuse webhook metadata), + `OMNIROUTE_PUBLIC_BASE_URL` for webhook URL display; doc in + `.env.example` + `ENVIRONMENT.md` (env-doc-sync check). + +### Phase 2 — Production hardening (1 dev-day) + +- Streaming emulation (message edits), error/backpressure mapping to Bot API + limits, per-user key revocation (`/logout` command → revoke API key), + usage/rate-limit surfacing (reuse `enforceApiKeyPolicy`), webhook + registration helper in dashboard settings, i18n for the mini app UI. + +## 5. Verdict + +**Feasible.** The gateway already exposes the exact API a Mini App chat +needs (`/v1/chat/completions` with per-key policy), and the outbound +Telegram webhook shows the team already handles bot tokens safely +(encryption gate + token format validation). The genuinely new surface is +small: an inbound update webhook + initData HMAC verification + a thin +chat proxy + a static Mini App page. No changes to the core SSE/relay +pipeline are required. + +**Primary risks:** (1) public HTTPS requirement for the webhook (tunnel +needed on self-hosted installs), (2) no native streaming to Telegram +(UX tradeoff), (3) initData trust must be strictly server-side. diff --git a/docs/providers/CHATGPT_WEB.md b/docs/providers/CHATGPT_WEB.md new file mode 100644 index 0000000000..188f626712 --- /dev/null +++ b/docs/providers/CHATGPT_WEB.md @@ -0,0 +1,125 @@ +--- +title: "Providers — ChatGPT Web (session credentials via Cookie Editor)" +version: 3.8.50 +lastUpdated: 2026-08-08 +--- + +# Providers — ChatGPT Web (Plus/Pro session credentials) + +`chatgpt-web` (alias `cgpt-web`, display name **ChatGPT Web (Plus/Pro)**) sends OpenAI-format chat requests through an authenticated `chatgpt.com` browser session. It authenticates with the `__Secure-next-auth.session-token` cookie — **no API key required**. + +> **New to Web Cookie providers?** +> +> Read **`docs/getting-started/WEB-COOKIE-GUIDE.md`** for the general setup process, limitations, and troubleshooting before following this provider-specific guide. + +--- + +## 1. What credential does OmniRoute need? + +Defined in `src/shared/constants/providers/web-cookie.ts` + `src/shared/providers/webSessionCredentials.ts`: + +| Field | Value | +| -------------------------- | ----------------------------------------------------------------------------- | +| Provider id | `chatgpt-web` | +| Credential name | `__Secure-next-auth.session-token` | +| Accepts full Cookie header | ✅ yes | +| Accepted storage keys | `cookie`, `sessionToken`, `session-token`, `__Secure-next-auth.session-token` | + +Two paste formats both work: + +- **Bare value** — just the token contents: `eyJhbGciOi...` +- **Full Cookie header** — `__Secure-next-auth.session-token=eyJhbGciOi...; cf_clearance=...` (preferred — carries rotation/anti-bot cookies the executor needs) + +--- + +## 2. Copy the cookie header with Cookie Editor + +Cookie Editor can copy the cookies for the active `chatgpt.com` tab as an HTTP header string. +Always compare the exported value with a live authenticated request as described in section 3. + +### 2.1 Install and pin + +1. Install **[Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm)** (Moustachauve) in Chrome/Edge, or the Firefox equivalent. +2. Pin it to the toolbar if you use it regularly. + +### 2.2 Copy the credential + +1. Go to **https://chatgpt.com** and make sure you're **signed in with the Plus/Pro account** you want OmniRoute to use. +2. Open a conversation and send at least one message (forces the session token to be live/refreshed). +3. Click the **Cookie Editor** icon to open its side panel for the active tab. +4. Find `__Secure-next-auth.session-token`. If it's split into chunks (`__Secure-next-auth.session-token.0`, `.1`, …), select **all** of them — OmniRoute's `nextAuthCookie.ts` merges rotated chunk families. +5. Click **Copy**, choose **Header string**, and copy the resulting `name=value; name=value` text. + +> **If the token is missing:** confirm that you are signed in, send a message to refresh the session, and inspect the live request in section 3. + +--- + +## 3. Verify the required data (before pasting) + +The repo's `WEB-COOKIE-GUIDE.md` mandates a live-request check. Do it once per session: + +1. With chatgpt.com open, press **F12** → **Network** tab. +2. Refresh the page, then send a chat message. +3. Click the conversation request (e.g. `/backend-api/conversation` or the SSE stream) → **Headers** → **Request Headers** → **Cookie**. +4. Confirm it contains `__Secure-next-auth.session-token=...` — **not** just `cf_clearance` or `__cf_bm`. + +The value you copied in step 2.3 must match what the live request sends. If they differ, re-copy from Cookie Editor. + +--- + +## 4. Add / update the credential in OmniRoute + +### Dashboard (typical user path) + +1. Open the OmniRoute dashboard → **Providers** → **Add Provider**. +2. Search **ChatGPT Web (Plus/Pro)** (id `chatgpt-web`). +3. Paste the copied cookie header into the credential field. +4. Click **Test Connection**. +5. Save. + +If requests later return 401 or 403, re-copy the header from a fresh live session. The executor merges `Set-Cookie` rotations while the connection is active, but it cannot recover a credential that is no longer accepted upstream. + +### Bulk / session pools (many accounts) + +For multiple ChatGPT sessions, use the bulk web-session import or session-pool endpoints: + +- `POST /api/providers/bulk-web-session` — import many cookie credentials at once +- `GET /api/session-pools` + `/api/session-pools/[provider]` — pool rotation across accounts + +Each credential blob must carry the `__Secure-next-auth.session-token` value under one of the accepted storage keys (`cookie`, `sessionToken`, `session-token`, or the cookie's exact name). + +### Renewing when the session expires + +Web sessions can stop working after sign-out or server-side rotation. Re-run steps 2.2 through 4 whenever requests start failing with 401/403. + +--- + +## 5. Contributing updates + +If you changed the credential contract (new storage key, new cookie name, changed hint) or are filling the docs gap, contribute it: + +1. Update `src/shared/providers/webSessionCredentials.ts` (credential name / placeholder / storage keys) or `src/shared/constants/providers/web-cookie.ts` (`authHint`). +2. Update this guide (`docs/providers/CHATGPT_WEB.md`) and the provider table in `docs/getting-started/WEB-COOKIE-GUIDE.md`. +3. Update `.env.example` + `docs/reference/ENVIRONMENT.md` if you touched env vars, then run: + ```bash + node scripts/check/check-env-doc-sync.mjs # must pass + ``` +4. Run the provider/unit tests: + ```bash + npm run test:unit + # targeted: tests/unit/chatgpt-web.test.ts (stealth path) + ``` +5. Follow `CONTRIBUTING.md`, branch from the current active release tip, use a Conventional Commit message, and open the PR against that active release branch. + +> ⚠️ **Never commit a real cookie value.** All examples above are placeholders. If a test fixture needs a token, use a fake `eyJhbGciOi...` string. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| -------------------------------- | -------------------------------------------- | --------------------------------------------------------- | +| Cookie not in Cookie Editor | Signed out / not HttpOnly-visible | Sign in; enable HttpOnly display in options | +| Token missing from live request | Request is not authenticated | Sign in and send a chat message first | +| 401 after Test Connection passed | Expired or rotated session | Re-copy from a fresh live request | +| Chunked token fails | Only one chunk pasted | Select all `__Secure-next-auth.session-token.*` chunks | diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 161b9f1252..3f319dde03 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -736,12 +736,12 @@ The logging system writes to both stdout and rotated log files. All configuratio | `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. | | `ENABLE_REQUEST_LOGS` | _(unset)_ | Force detailed request logging on or off, overriding the dashboard setting. | | `MAX_PENDING_REQUEST_AGE_MS` | `3600000` (1 hour) | Max age for orphaned active request log entries before in-memory cleanup. | -| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. | +| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `false` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. Opt-in (`true`) — off by default to save disk. | | `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | Max pipeline call log artifact size in KB when `call_log_pipeline_enabled=true`. | | `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `proxy_logs` SQLite table before pruning. | | `APP_LOG_ROTATION_CHECK_INTERVAL_MS` | `60000` (1 min) | How often `src/lib/logRotation.ts` re-checks the active log file size. | | `CHAT_LOG_TEXT_LIMIT` | `65536` | Max string length retained in chat log artifacts (default 64 KB). | -| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `24` | Number of array items retained from the tail when truncating chat log payloads. | +| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `128` | Number of array items retained from the tail when truncating chat log payloads. | | `CHAT_LOG_MAX_DEPTH` | `6` | Max nesting depth before chat log payloads are truncated. | | `CHAT_LOG_MAX_OBJECT_KEYS` | `80` | Max object keys retained in chat log payloads (0 = unlimited). | | `CHAT_DEBUG_FILE` | `false` | When true, `serializeArtifactForStorage` skips size-based truncation. Debug only. | @@ -976,7 +976,7 @@ changing them requires a code edit, not an env var: | `CURSOR_AGENT_CLI_VERSION` | _(detect / pin)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Agent CLI build id (`YYYY.MM.DD-`) for `x-cursor-client-version: cli-…` on Agent Run. | | `CURSOR_DATA_DIR` | _(probed)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Override Cursor Agent CLI data dir (`…/versions/`); same var the official agent uses. | | `CURSOR_TOKEN` | _(unset)_ | `scripts/ad-hoc/cursor-tap.cjs` | Direct Cursor bearer token used by developer tooling. | -| `OMNIROUTE_LOG_REQUEST_SHAPE` | enabled (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads. Set `"0"` to silence. | +| `OMNIROUTE_LOG_REQUEST_SHAPE` | disabled (opt-in via `"1"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads when `"1"` is set. Off by default to reduce log noise. | | `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Set `true` to log Responses API SSE→JSON translation details. | | `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(unset)_ | E2E test harness | Set `true` to enable E2E test mode (relaxed auth, test hooks). | @@ -1389,3 +1389,31 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro | `REDIS_BIND_HOST` | `127.0.0.1` | Bind address for the embedded Redis service. | | `REDIS_PORT` | `6379` | Port for the embedded Redis service. | | `OMNIROUTE_REDIS_BIND_HOST` | – | OmniRoute-scoped override for the embedded Redis bind address. | + +--- + +## 24. Release v3.8.50 additions + +These settings were introduced after the previous environment-contract snapshot. + +| Variable | Default | Source File | Description | +| --- | --- | --- | --- | +| `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` | `5000` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum wait for a heavyweight chat admission slot before a retryable `503`; `0` restores immediate rejection. | +| `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | `src/app/api/jobs/[id]/run-now/route.ts` | Bounds how long a run-now call waits for an in-flight job before starting the queued run. | +| `CHAT_LOG_MAX_BODY_KB` | `1024` | `src/lib/logEnv.ts` | Maximum request or response body size before log summarization, in KiB. | +| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh through account-scoped Chrome CDP sessions; set `0` to disable. | +| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR`; set `0` for memory-only state. | +| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing between Adobe Firefly generate submissions. | +| `ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS` | `15000` | `open-sse/services/adobeFireflySession.ts` | Extra quiet period after every third successful Adobe submission. | +| `ADOBE_FIREFLY_CHROME_CDP_PORT` | `9334` | `open-sse/services/adobeFireflyChromeRuntime.ts` | CDP port for the account-scoped Chrome runtime. | +| `ADOBE_FIREFLY_CHROME_VISIBLE` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set `1` to keep the Adobe renewal browser visible; the default parks a headed window off-screen. | +| `ADOBE_FIREFLY_CHROME_HEADLESS` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Debug-only true-headless mode; Adobe colligo normally rejects the resulting risk session. | +| `ADOBE_FIREFLY_CHROME_FORCE_RESTART` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set `1` to restart the account-scoped Chrome runtime before renewal. | +| `ADOBE_FIREFLY_CHROME_PING` | automatic | `open-sse/services/adobeFireflyChromeRuntime.ts` | `1` forces, and `0` disables, the in-page generate probe used to prove the renewed ARP session. | +| `ADOBE_FIREFLY_LOGIN_WAIT_MS` | context-dependent | `open-sse/services/adobeFireflyChromeRuntime.ts` | Interactive-login wait budget: `0` on background renewal and `300000` on the explicit login flow unless overridden. | +| `ADOBE_FIREFLY_FORTER_WAIT_MS` | `45000` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Maximum wait for a fresh Forter token during session renewal. | +| `CHROME_PATH` | auto-detect | `open-sse/services/adobeFireflyChromeRuntime.ts` | Optional absolute Chrome executable used when platform auto-detection is insufficient. | +| `TELEGRAM_BOT_TOKEN` | _(unset)_ | `src/lib/telegram/config.ts` | BotFather token that enables the inbound webhook and signs Mini App `initData`. | +| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. | +| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override for proxies or self-hosted Bot API servers. | +| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout in milliseconds for outbound Bot API calls. | diff --git a/docs/video-preset-generation.md b/docs/video-preset-generation.md new file mode 100644 index 0000000000..163fa8856e --- /dev/null +++ b/docs/video-preset-generation.md @@ -0,0 +1,113 @@ +# Video Generation Through Preset Jobs + +Custom provider nodes whose `/videos` surface is an **async submit → poll → fetch-result API** (instead of a synchronous generation endpoint) can be wired into the `/api/v1/videos/generations` route without any new provider code. The model row carries a `generationConfig.preset`, and the dispatcher routes the request through a single job executor that is configured entirely by declarative preset data. + +## How dispatch works + +1. The route parses `model` as `provider/model` and resolves the provider node's credentials (`POST /api/v1/videos/generations`). +2. `handleVideoGeneration` (in `open-sse/handlers/videoGeneration.ts`) checks whether the provider is a **custom provider node** (no entry in the static video registry). +3. For custom nodes it reads the custom model row via `getCustomModelVideoPreset(provider, model)`: + - The model row has `generationConfig.preset` set (e.g. `"agnes-video-job"`) → dispatch through the **job executor** (`open-sse/handlers/videoGeneration/job.ts`). + - The preset name does not match any known preset → **502** `Unknown video job preset: ` (server-side misconfiguration). + - No preset configured → fall back to the generic OpenAI-compatible sync handler, mirroring the images route. +4. The job executor runs the preset pipeline: **submit** the job, **poll** for terminal status, **read** the finished video URL, and return the standard OpenAI-compatible response shape. + +The executor is one handler family; every provider-specific detail (paths, auth, body shape, status/result fields, poll cadence) is data in the preset definition. + +## Response contract + +Both the sync and job paths return the same shape: + +```json +{ + "created": 1234567890, + "data": [{ "url": "https://…", "format": "mp4" }] +} +``` + +This is the shape the media-generation consumer reads (`data.data[0].url`), so preset-job providers are drop-in replacements for sync providers. + +## Presets + +Presets live in `open-sse/handlers/videoGeneration/job.ts` (`VIDEO_JOB_PRESETS`). Each preset declares: + +| Field | Meaning | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `authHeaderName` / `authScheme` | `x-api-key` with `raw` value (Agnes, muapi) or `Authorization` with `Bearer` prefix (Sora). Missing credentials → request goes out without an auth header. | +| `baseUrlFallback` | Default base URL. Overridden by the provider connection's `providerSpecificData.baseUrl` (or top-level `baseUrl`), which wins when set. | +| `submit.path` / `submit.buildBody` | Where and how the job is submitted. `{model}` in the path is substituted with the encoded model id; the body is built from `model`/`prompt`/`duration` plus pass-through of every other request field. | +| `taskIdPath` | Dot path into the submit response identifying the job (e.g. `task_id`, `request_id`, `id`). Missing job id → **502**. | +| `poll.pathTemplate` | Poll URL template; `{taskId}` is substituted. | +| `statusPath` / `statusDone` / `statusFailed` | Where the job status lives and which values are terminal. | +| `resultPath` | Dot path into the poll response holding the finished video URL: a string, a string array, or an array of `{ url }` objects are all accepted. Completed job with no readable URL → **502**. | +| `maxPolls` / `pollIntervalMs` | Poll budget (default 60 polls × 2000 ms). Exhausted → **504** `Video job timed out`. | + +### `agnes-video-job` — Agnes Video V2.0 + +- Auth: `x-api-key: ` (raw). +- Base URL fallback: `https://apihub.agnes-ai.com`. +- Submit: `POST /v1/videos` with `{ model, prompt, ...extras }` — image, mode, `num_frames`, `frame_rate` and other provider knobs pass through untouched. +- Job id: `task_id` from the submit response. +- Poll: `GET /v1/videos/{taskId}`; status at `status` (`completed` / `failed`). +- Result: `metadata.url` — the completed video URL is returned as JSON metadata, not a binary body. + +### `muapi-video-job` — muapi.ai + +- Auth: `x-api-key: ` (raw). +- Base URL fallback: `https://api.muapi.ai`. +- Submit: `POST /api/v1/{model}` with `{ prompt, duration?, ...extras }`. +- Job id: `request_id` from the submit response. +- Poll: `GET /api/v1/predictions/{taskId}/result`; status at `status` (`completed` / `failed`). +- Result: `outputs` — an array of video URLs. + +### `sora-job` — OpenAI Sora + +- Auth: `Authorization: Bearer `. +- Base URL fallback: `https://api.openai.com`. +- Submit: `POST /v1/videos` with `{ model, prompt, seconds?, ...extras }`. `seconds` is a **string** enum (`"4" | "8" | "12"`) in the Sora API, so a numeric `duration` is stringified; size mapping is intentionally not forced. +- Job id: `id` from the submit response. +- Poll: `GET /v1/videos/{taskId}`; status at `status` (`completed` / `failed`). +- Result: `data` — an array whose entries are either a URL string or `{ url: "…" }`. + +## Setup + +1. **Register the provider node** as an OpenAI-compatible custom provider (`providerSpecificData.baseUrl` optional — the preset's `baseUrlFallback` is used when absent). +2. **Register a custom model** tagged with the `videos` endpoint and a `generationConfig`: + + ```json + { + "id": "super-video-v1", + "name": "Super Video v1", + "source": "manual", + "apiFormat": "chat-completions", + "supportedEndpoints": ["videos"], + "generationConfig": { "preset": "agnes-video-job" } + } + ``` + + `addCustomModel` (in `src/lib/db/models.ts`) accepts `generationConfig?: { preset: string }` as its final parameter and persists it on the model row; `updateCustomModel` forwards it the same way. The provider-models API accepts `generationConfig` on create and update. + +3. **Call the route** as usual: + + ```bash + curl -X POST http://localhost:8787/api/v1/videos/generations \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $API_KEY" \ + -d '{ + "model": "my-custom-provider/super-video-v1", + "prompt": "a cat playing piano", + "duration": 5 + }' + ``` + +## Troubleshooting + +| Symptom | Cause | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| `400 Unknown video provider: …` | Non-custom provider not in the static registry; preset jobs only apply to custom provider nodes. | +| `502 Unknown video job preset: …` | `generationConfig.preset` does not match any preset in `VIDEO_JOB_PRESETS`. Fix the model row. | +| `502 Video provider did not return a job id (…)` | Submit succeeded but the response had no readable value at `taskIdPath`. | +| `502 Video job failed (…)` / `Video job completed but no result URL found (…)` | Poll reached a terminal `statusFailed` state, or `resultPath` held no readable URL. | +| `504 Video job timed out after 60 polls (…)` | Job never reached a terminal status within the poll budget. | +| Upstream 4xx/5xx passthrough | `fetchJson` returns the upstream status when the submit/poll request itself is not OK. | +| Requests go out without auth | No `apiKey`/`accessToken` on the provider connection; the executor sends `Content-Type` only. | diff --git a/electron/package-lock.json b/electron/package-lock.json index 7909fcd7ec..4fdb5b2374 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -55,9 +55,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -257,9 +257,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -297,6 +297,45 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -835,16 +874,16 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/buffer-from": { @@ -1091,6 +1130,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1260,9 +1308,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1411,6 +1459,19 @@ "node": ">=14.0.0" } }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, "node_modules/electron-publish": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", @@ -1445,6 +1506,66 @@ "tiny-typed-emitter": "^2.1.0" } }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1627,9 +1748,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -1792,9 +1913,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2113,9 +2234,9 @@ } }, "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==", "funding": [ { "type": "github", @@ -2359,6 +2480,20 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2622,6 +2757,36 @@ "node": ">=18" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -2816,6 +2981,21 @@ "node": ">= 4" } }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -3045,9 +3225,9 @@ } }, "node_modules/tar": { - "version": "7.5.20", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", - "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -3071,6 +3251,21 @@ "node": ">=18" } }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/temp-file": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 8877ca0257..06d32d93af 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -247,6 +247,17 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = { format: "speechmatics", models: [{ id: "enhanced", name: "Enhanced" }], }, + + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1/audio/transcriptions", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "whisper-1", name: "Whisper 1" }, + { id: "gpt-4o-transcription", name: "GPT-4o Transcription" }, + ], + }, }; /** @@ -570,6 +581,17 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { { id: "mimo-v2.5-tts-voiceclone", name: "MiMo V2.5 Voice Clone" }, ], }, + + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1/audio/speech", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "tts-1-hd", name: "TTS 1 HD" }, + { id: "tts-1", name: "TTS 1" }, + ], + }, }; /** diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index 81e3c29ee1..24210c56a6 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -1,4 +1,5 @@ import { getUpstreamTimeoutConfig } from "@/shared/utils/runtimeTimeouts"; +import { resolvePublicCred } from "../utils/publicCreds.ts"; import type { LegacyProvider } from "./providerRegistry.ts"; import { loadProviderCredentials } from "./credentialLoader.ts"; import { generateLegacyProviders } from "./providerRegistry.ts"; @@ -18,6 +19,15 @@ export const FETCH_TIMEOUT_MS = upstreamTimeouts.fetchTimeoutMs; // idle for this duration. Override with STREAM_IDLE_TIMEOUT_MS env var. export const STREAM_IDLE_TIMEOUT_MS = upstreamTimeouts.streamIdleTimeoutMs; +// Grace period (ms) a client-disconnect finalization waits for the stream's own +// completion bookkeeping to land before persisting a 499. See #9653 — a client +// that closes right after reading a fully-completed SSE stream can otherwise +// race OmniRoute's own completion callback, resulting in a false 499 with zero +// token usage for a request that actually delivered its full response. Set +// STREAM_DISCONNECT_GRACE_PERIOD_MS=0 to disable and restore the old +// immediate-fail behavior. +export const STREAM_DISCONNECT_GRACE_PERIOD_MS = upstreamTimeouts.streamDisconnectGracePeriodMs; + // Timeout for the first non-ping SSE event. Inherits REQUEST_TIMEOUT_MS when // set, unless STREAM_READINESS_TIMEOUT_MS is specified directly. This must stay // conservative for large prompts and slow first-byte reasoning providers. @@ -65,27 +75,27 @@ export const PROVIDERS: Record = new Proxy( {} as Record, { get(_, prop) { - if (typeof prop === 'symbol') return undefined; + if (typeof prop === "symbol") return undefined; return Reflect.get(initProviders(), prop, _providers); }, has(_, prop) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; return Reflect.has(initProviders(), prop); }, ownKeys() { return Reflect.ownKeys(initProviders()); }, getOwnPropertyDescriptor(_, prop) { - if (typeof prop === 'symbol') return undefined; + if (typeof prop === "symbol") return undefined; return Object.getOwnPropertyDescriptor(initProviders(), prop); }, set(_, prop, value) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; (initProviders() as Record)[prop] = value; return true; }, deleteProperty(_, prop) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; return Reflect.deleteProperty(initProviders(), prop); }, } @@ -124,6 +134,11 @@ export const OAUTH_ENDPOINTS = { auth: "https://github.com/login/oauth/authorize", deviceCode: "https://github.com/login/device/code", }, + openference: { + token: "https://openference.com/oauth/token", + auth: "https://openference.com/app/oauth/authorize", + clientId: resolvePublicCred("openference_id"), + }, }; // Cache TTLs (seconds) diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index f8de0e18e9..c0c84f94fa 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -409,6 +409,25 @@ export const EMBEDDING_PROVIDERS: Record = { }, ], }, + + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + models: [ + { + id: "text-embedding-3-small", + name: "Text Embedding 3 Small", + dimensions: 1536, + }, + { + id: "text-embedding-3-large", + name: "Text Embedding 3 Large", + dimensions: 3072, + }, + ], + }, }; const EMBEDDING_PROVIDER_ALIASES: Record = { diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 42ec705f8c..aba8cc030b 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -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" }, diff --git a/open-sse/config/nvidiaHostedModels.snapshot.json b/open-sse/config/nvidiaHostedModels.snapshot.json index 29bb66e0ad..60d2f2e76d 100644 --- a/open-sse/config/nvidiaHostedModels.snapshot.json +++ b/open-sse/config/nvidiaHostedModels.snapshot.json @@ -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" ] diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index 75099bad83..d20e05acfb 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -90,10 +90,65 @@ export function getDefaultModel(aliasOrId: string): string | null { return models?.[0]?.id || null; } +/** Score a registry entry by how many capability flags it defines. */ +function modelRichness(m: RegistryModel): number { + let score = 0; + if (m.supportsXHighEffort !== undefined) score += 10; // critical for effort routing + if (m.supportsReasoning !== undefined) score += 5; + if (m.contextLength !== undefined) score += 3; + if (m.maxOutputTokens !== undefined) score += 2; + if (m.supportsVision !== undefined) score += 2; + if (m.toolCalling !== undefined) score += 2; + if (m.interleavedField !== undefined) score += 1; + if (m.unsupportedParams !== undefined) score += 1; + return score; +} + +function getGlobalModel(modelId: string): RegistryModel | undefined { + // 1. Exact match — collect all, pick the richest + let candidates: RegistryModel[] = []; + for (const models of Object.values(PROVIDER_MODELS)) { + const found = models.find((m) => m.id === modelId); + if (found) candidates.push(found); + } + if (candidates.length > 0) { + return candidates.sort((a, b) => modelRichness(b) - modelRichness(a))[0]; + } + + // 2. Strip provider prefix (e.g. moonshotai/kimi-k3-free -> kimi-k3-free) + const basename = modelId.split("/").pop() || modelId; + candidates = []; + for (const models of Object.values(PROVIDER_MODELS)) { + const found = models.find((m) => m.id === basename); + if (found) candidates.push(found); + } + if (candidates.length > 0) { + return candidates.sort((a, b) => modelRichness(b) - modelRichness(a))[0]; + } + + // 3. Substring match for base model name (e.g. kimi-k3-free -> kimi-k3) + // Finds the longest matching base model ID; on ties, prefers the richer entry. + let bestMatch: RegistryModel | undefined; + for (const models of Object.values(PROVIDER_MODELS)) { + for (const m of models) { + if (basename.startsWith(m.id)) { + if ( + !bestMatch || + m.id.length > bestMatch.id.length || + (m.id.length === bestMatch.id.length && modelRichness(m) > modelRichness(bestMatch)) + ) { + bestMatch = m; + } + } + } + } + return bestMatch; +} + export function getProviderModel(aliasOrId: string, modelId: string): RegistryModel | undefined { const models = PROVIDER_MODELS[aliasOrId]; - if (!models) return undefined; - return models.find((model) => model.id === modelId); + if (!models) return getGlobalModel(modelId); + return models.find((model) => model.id === modelId) || getGlobalModel(modelId); } export function isValidModel( @@ -103,26 +158,20 @@ export function isValidModel( ): boolean { if (passthroughProviders.has(aliasOrId)) return true; const models = PROVIDER_MODELS[aliasOrId]; - if (!models) return false; - return models.some((m) => m.id === modelId); + if (!models) return !!getGlobalModel(modelId); + return models.some((m) => m.id === modelId) || !!getGlobalModel(modelId); } export function findModelName(aliasOrId: string, modelId: string): string { const models = PROVIDER_MODELS[aliasOrId]; - if (!models) return modelId; - const found = models.find((m) => m.id === modelId); + if (!models) return getGlobalModel(modelId)?.name || modelId; + const found = models.find((m) => m.id === modelId) || getGlobalModel(modelId); return found?.name || modelId; } export function getModelTargetFormat(aliasOrId: string, modelId: string): string | null { const models = PROVIDER_MODELS[aliasOrId]; - // Strip provider prefix if present: "openai/gpt-5.6-luna" → "gpt-5.6-luna" - const prefix = aliasOrId + "/"; - const bareModelId = - typeof modelId === "string" && modelId.startsWith(prefix) - ? modelId.slice(prefix.length) - : modelId; - const found = models?.find((m) => m.id === bareModelId); + const found = models?.find((m) => m.id === modelId) || getGlobalModel(modelId); if (found?.targetFormat) return found.targetFormat; // #5842: OpenAI "*-pro" reasoning models (o1-pro, gpt-5.x-pro) are only served by // the native /v1/responses endpoint — /v1/chat/completions 404s ("only supported @@ -130,14 +179,17 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string // covers dynamically-synced ids that post-date the catalog (same spirit as the gh // executor's /codex/i routing, 9router#102). Scoped to the openai alias so other // providers shipping *-pro ids keep their own endpoint semantics. - if (aliasOrId === "openai" && /-pro$/i.test(bareModelId)) return "openai-responses"; + if (aliasOrId === "openai" && /-pro$/i.test(modelId)) return "openai-responses"; return null; } export function getModelStripTypes(aliasOrId: string, modelId: string): string[] { const models = PROVIDER_MODELS[aliasOrId]; - if (!models) return []; - const found = models.find((m) => m.id === modelId); + if (!models) + return Array.isArray(getGlobalModel(modelId)?.strip) + ? [...getGlobalModel(modelId)!.strip!] + : []; + const found = models.find((m) => m.id === modelId) || getGlobalModel(modelId); return Array.isArray(found?.strip) ? [...found.strip] : []; } @@ -262,7 +314,7 @@ function resolveProviderModelList(aliasOrId: string): { export function supportsXHighEffort(aliasOrId: string, modelId: string): boolean { const { models: providerModels } = resolveProviderModelList(aliasOrId); - const model = providerModels?.find((entry) => entry.id === modelId); + const model = providerModels?.find((entry) => entry.id === modelId) || getGlobalModel(modelId); if (model?.supportsXHighEffort !== undefined) { return model.supportsXHighEffort !== false; } diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 47a5896784..a64d9b3c04 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -121,6 +121,8 @@ import { chatgpt_webProvider } from "./registry/chatgpt-web/index.ts"; import { openrouterProvider } from "./registry/openrouter/index.ts"; import { cheaperinferenceProvider } from "./registry/cheaperinference/index.ts"; import { openvectaProvider } from "./registry/openvecta/index.ts"; +import { openferenceProvider } from "./registry/openference/index.ts"; +import { openference_apiProvider } from "./registry/openference-api/index.ts"; import { orcarouterProvider } from "./registry/orcarouter/index.ts"; import { copilot_webProvider } from "./registry/copilot-web/index.ts"; import { copilot_m365_webProvider } from "./registry/copilot-m365-web/index.ts"; @@ -345,6 +347,8 @@ export const REGISTRY: Record = { openrouter: openrouterProvider, cheaperinference: cheaperinferenceProvider, openvecta: openvectaProvider, + openference: openferenceProvider, + "openference-api": openference_apiProvider, orcarouter: orcarouterProvider, "copilot-web": copilot_webProvider, "copilot-m365-web": copilot_m365_webProvider, diff --git a/open-sse/config/providers/registry/nanogpt/index.ts b/open-sse/config/providers/registry/nanogpt/index.ts index 9947938ffb..1c95c8bb52 100644 --- a/open-sse/config/providers/registry/nanogpt/index.ts +++ b/open-sse/config/providers/registry/nanogpt/index.ts @@ -8,6 +8,7 @@ export const nanogptProvider: RegistryEntry = { executor: "default", baseUrl: "https://nano-gpt.com/api/v1/chat/completions", modelsUrl: "https://nano-gpt.com/api/v1/models", + responsesBaseUrl: "https://nano-gpt.com/api/v1/responses", authType: "apikey", authHeader: "bearer", models: CHAT_OPENAI_COMPAT_MODELS.nanogpt, diff --git a/open-sse/config/providers/registry/nvidia/index.ts b/open-sse/config/providers/registry/nvidia/index.ts index c6e349fce1..3603700d30 100644 --- a/open-sse/config/providers/registry/nvidia/index.ts +++ b/open-sse/config/providers/registry/nvidia/index.ts @@ -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 }, diff --git a/open-sse/config/providers/registry/openference-api/index.ts b/open-sse/config/providers/registry/openference-api/index.ts new file mode 100644 index 0000000000..34a20de303 --- /dev/null +++ b/open-sse/config/providers/registry/openference-api/index.ts @@ -0,0 +1,18 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +/** + * Openference API key — OpenAI-compatible gateway (https://openference.com/). + * + * Bearer API keys (`sk-…`) hit the same api.openference.com/v1/* surface as OAuth + * JWTs. Live model discovery uses NAMED_OPENAI_STYLE_PROVIDERS; the seed below is + * the offline fallback when the live fetch fails. + */ +export const openference_apiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "openference-api", + alias: "ofa", + baseUrl: "https://api.openference.com/v1/chat/completions", + responsesBaseUrl: "https://api.openference.com/v1/responses", + passthroughModels: true, + models: [{ id: "GLM-5.2", name: "GLM 5.2", contextLength: 850000 }], +}); diff --git a/open-sse/config/providers/registry/openference/index.ts b/open-sse/config/providers/registry/openference/index.ts new file mode 100644 index 0000000000..fe5f50025e --- /dev/null +++ b/open-sse/config/providers/registry/openference/index.ts @@ -0,0 +1,25 @@ +import { resolvePublicCred, type RegistryEntry } from "../../shared.ts"; + +/** + * Openference — OpenAI-compatible AI inference gateway (https://openference.com/). + * + * OAuth access tokens are ES256 JWTs accepted as Bearer credentials on + * api.openference.com/v1/*. Live model discovery uses NAMED_OPENAI_STYLE_PROVIDERS; + * seed models below are the offline fallback when the live fetch fails. + */ +export const openferenceProvider: RegistryEntry = { + id: "openference", + alias: "of", + format: "openai", + executor: "default", + baseUrl: "https://api.openference.com/v1/chat/completions", + responsesBaseUrl: "https://api.openference.com/v1/responses", + authType: "oauth", + authHeader: "bearer", + passthroughModels: true, + oauth: { + clientIdDefault: resolvePublicCred("openference_id"), + tokenUrl: "https://openference.com/oauth/token", + }, + models: [{ id: "GLM-5.2", name: "GLM 5.2", contextLength: 850000 }], +}; diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index ee8dfffa57..6121fffe45 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -339,6 +339,15 @@ export const VIDEO_PROVIDERS: Record = { format: "adobe-firefly-video", models: toRegistryVideoModels(), }, + + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1/video/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [{ id: "default", name: "NanoGPT Video" }], + }, }; /** diff --git a/open-sse/executors/azure-ai.ts b/open-sse/executors/azure-ai.ts new file mode 100644 index 0000000000..438a4d1bc5 --- /dev/null +++ b/open-sse/executors/azure-ai.ts @@ -0,0 +1,35 @@ +import { DefaultExecutor } from "./default.ts"; +import type { ProviderCredentials } from "./base.ts"; +import { applyAzureParamRules } from "./azureParamRules.ts"; + +/** + * Azure AI Foundry (`azure-ai`). + * + * URL building, auth headers and the `responses` vs `chat` apiType switch all + * live in `DefaultExecutor`, keyed on the `azure-ai` provider id — this subclass + * inherits them unchanged and adds only the Azure request-param rules. + * + * Before this existed, `azure-ai` fell through to the bare `DefaultExecutor` + * while `azure-openai` had the rules inline, so the same Azure deployment + * behaved differently depending on which connection served it: `azure-openai` + * succeeded and `azure-ai` returned HTTP 400 for `max_tokens` / + * `reasoning_effort`. + */ +export class AzureAiExecutor extends DefaultExecutor { + constructor() { + super("azure-ai"); + } + + override transformRequest( + model: string, + body: unknown, + stream: boolean, + credentials: ProviderCredentials + ): unknown { + return applyAzureParamRules( + model, + body, + super.transformRequest(model, body, stream, credentials) + ); + } +} diff --git a/open-sse/executors/azure-openai.ts b/open-sse/executors/azure-openai.ts index 9b910d5c95..3872757a56 100644 --- a/open-sse/executors/azure-openai.ts +++ b/open-sse/executors/azure-openai.ts @@ -1,9 +1,9 @@ import { DefaultExecutor } from "./default.ts"; import type { ProviderCredentials } from "./base.ts"; import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; +import { applyAzureParamRules } from "./azureParamRules.ts"; const DEFAULT_API_VERSION = "2024-12-01-preview"; -const GPT5_OR_REASONING_DEPLOYMENT = /(?:^|[/_-])(?:gpt-5|o(?:1|3|4))(?:[._-]|$)/i; function normalizeAzureBaseUrl(rawBaseUrl?: string | null): string { const normalized = stripTrailingSlashes((rawBaseUrl || "").trim()); @@ -57,37 +57,10 @@ export class AzureOpenAIExecutor extends DefaultExecutor { stream: boolean, credentials: ProviderCredentials ): unknown { - const transformed = super.transformRequest(model, body, stream, credentials); - if (!GPT5_OR_REASONING_DEPLOYMENT.test(model)) return transformed; - if (!transformed || typeof transformed !== "object" || Array.isArray(transformed)) { - return transformed; - } - - const original = - body && typeof body === "object" && !Array.isArray(body) - ? (body as Record) - : null; - const normalized = { ...(transformed as Record) }; - - if (original?.max_completion_tokens !== undefined) { - normalized.max_completion_tokens = original.max_completion_tokens; - } else if ( - normalized.max_completion_tokens === undefined && - original?.max_tokens !== undefined - ) { - normalized.max_completion_tokens = original.max_tokens; - } - delete normalized.max_tokens; - - if (normalized.temperature !== undefined && normalized.temperature !== 1) { - delete normalized.temperature; - } - - const hasTools = Array.isArray(normalized.tools) && normalized.tools.length > 0; - if (hasTools || normalized.reasoning_effort === "none") { - delete normalized.reasoning_effort; - } - - return normalized; + return applyAzureParamRules( + model, + body, + super.transformRequest(model, body, stream, credentials) + ); } } diff --git a/open-sse/executors/azureParamRules.ts b/open-sse/executors/azureParamRules.ts new file mode 100644 index 0000000000..4bd8eab22a --- /dev/null +++ b/open-sse/executors/azureParamRules.ts @@ -0,0 +1,76 @@ +/** + * Azure Chat Completions param rules, shared by every Azure wire path. + * + * Azure's newer deployments reject a handful of stock OpenAI Chat Completions + * params and return HTTP 400 rather than ignoring them: + * + * - `max_tokens` -> "Unsupported parameter: 'max_tokens' is not supported + * with this model. Use 'max_completion_tokens' instead." + * - `temperature` -> only the default (1) is accepted. + * - `reasoning_effort` -> "Function tools with reasoning_effort are not + * supported ... Please use /v1/responses instead." + * + * This logic previously lived inline in `AzureOpenAIExecutor`, so it only + * covered the `azure-openai` provider. `azure-ai` (Azure AI Foundry) routes + * through `DefaultExecutor` and inherited none of it, which meant an identical + * deployment 400'd on one connection and succeeded on the other. Extracted here + * so both executors apply exactly the same rules. + */ + +/** + * Deployments that require `max_completion_tokens` instead of `max_tokens`. + * + * Matches the GPT-5 family and the o1/o3/o4 reasoning series at a token + * boundary, so a deployment named `my-gpt-5-prod` matches while an unrelated + * `piston-o4-legacy`-style name does not match by accident. `gpt-chat-latest` + * is listed explicitly: it is a moving alias that currently resolves to a + * GPT-5-era model and rejects `max_tokens`, but carries no version number for + * the boundary pattern to key on. + */ +export const AZURE_COMPLETION_TOKEN_DEPLOYMENT = + /(?:^|[/_-])(?:gpt-5|o(?:1|3|4))(?:[._-]|$)|^gpt-chat-latest$/i; + +/** + * Apply the Azure param rules to an already-translated Chat Completions body. + * + * `originalBody` is the pre-translation request, consulted only to recover a + * caller-supplied token budget that translation may have moved or dropped. + * Returns `transformed` untouched when the deployment is unaffected or the body + * is not a plain object, and never mutates either input. + */ +export function applyAzureParamRules( + model: string, + originalBody: unknown, + transformed: unknown +): unknown { + if (!AZURE_COMPLETION_TOKEN_DEPLOYMENT.test(model)) return transformed; + if (!transformed || typeof transformed !== "object" || Array.isArray(transformed)) { + return transformed; + } + + const original = + originalBody && typeof originalBody === "object" && !Array.isArray(originalBody) + ? (originalBody as Record) + : null; + const normalized = { ...(transformed as Record) }; + + if (original?.max_completion_tokens !== undefined) { + normalized.max_completion_tokens = original.max_completion_tokens; + } else if (normalized.max_completion_tokens === undefined && original?.max_tokens !== undefined) { + normalized.max_completion_tokens = original.max_tokens; + } + delete normalized.max_tokens; + + if (normalized.temperature !== undefined && normalized.temperature !== 1) { + delete normalized.temperature; + } + + // Azure 400s on reasoning_effort as soon as tools are present, which is every + // agentic client (Claude Code, Cursor agent) on every turn. + const hasTools = Array.isArray(normalized.tools) && normalized.tools.length > 0; + if (hasTools || normalized.reasoning_effort === "none") { + delete normalized.reasoning_effort; + } + + return normalized; +} diff --git a/open-sse/executors/base/reasoningEffort.ts b/open-sse/executors/base/reasoningEffort.ts index 6e1528caff..3f35d89221 100644 --- a/open-sse/executors/base/reasoningEffort.ts +++ b/open-sse/executors/base/reasoningEffort.ts @@ -2,16 +2,19 @@ // Extracted verbatim from base.ts. Deps are config/services only (no host import → no cycle). import { PROVIDER_CLAUDE } from "../../services/systemTransforms.ts"; import { isClaudeCodeCompatible } from "../../services/provider.ts"; -import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/providerModels.ts"; +import { + supportsClaudeMaxEffort, + supportsXHighEffort, + getProviderModel, +} from "../../config/providerModels.ts"; /** * Sanitize reasoning_effort for providers that don't accept all values. * - * The claude→openai translator passes output_config.effort through verbatim - * (including max) and only performs form conversion; provider-aware effort - * policy is owned here. Combined with runtime alias remapping (e.g. - * claude-opus-4-6 → mimo/mimo-v2.5-pro), this routes a client's effort value - * to OpenAI-shape providers that don't accept it: + * The claude→openai translator may emit reasoning_effort=max/xhigh when the + * client sends output_config.effort=max on a Claude-shape request. Combined with + * runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this + * routes xhigh to OpenAI-shape providers that don't accept the value: * * xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh * mistral : devstral models reject reasoning_effort entirely @@ -140,9 +143,11 @@ export function mapNvidiaGlm52ReasoningParams( } export function supportsMaxEffortForProvider(provider: string, model: string): boolean { + const resolvedModelId = getProviderModel(provider, model)?.id || model; + const isClaude = (provider === PROVIDER_CLAUDE || isClaudeCodeCompatible(provider)) && - supportsClaudeMaxEffort(model); + supportsClaudeMaxEffort(resolvedModelId); // opencode-go proxies DeepSeek with the native DeepSeek API contract, which // accepts {high, max} literally. Without this opt-in, max would be // normalized to xhigh (the OmniRoute-internal top tier) and rejected by the @@ -151,11 +156,12 @@ export function supportsMaxEffortForProvider(provider: string, model: string): b // Ollama Cloud also accepts literal max (for example GLM 5.2 supports // low|medium|high|max|none) and rejects xhigh. const isOpencodeGoDeepSeek = - (provider === "opencode-go" || provider === "opencode-zen") && - model.toLowerCase().includes("deepseek"); + provider === "opencode-go" && resolvedModelId.toLowerCase().includes("deepseek"); const isOllamaCloud = provider === "ollama-cloud"; - const isMoonshotK3 = - (provider === "moonshot" || provider === "kimi") && /^kimi-k3(?:$|-)/i.test(model); + // Kimi K3 only accepts literal max and rejects xhigh natively. Apply this mapping + // regardless of provider so that OpenAI-compatible proxies (e.g. TokenRouter) + // correctly pass max instead of the internal xhigh top tier. + const isMoonshotK3 = /^kimi-k3(?:$|-)/i.test(resolvedModelId); return isClaude || isOpencodeGoDeepSeek || isOllamaCloud || isMoonshotK3; } @@ -253,16 +259,6 @@ export function sanitizeReasoningEffortForProvider( const effortStr = typeof c.effort === "string" ? c.effort.toLowerCase() : ""; const modelStr = model || ""; - // Oh My Pi exposes `minimal`, while Codex's Responses API starts at `low`. - // Normalize every carrier before the Codex executor sends the upstream request. - if (provider === "codex" && effortStr === "minimal") { - log?.info?.( - "REASONING_SANITIZE", - `${provider}/${modelStr}: normalized reasoning_effort minimal → low` - ); - return writeEffortValue(b, "low", c); - } - const githubOptIn = provider === "github" && GITHUB_REASONING_EFFORT_OPT_IN_PATTERN.test(modelStr); const rejecting = @@ -298,27 +294,48 @@ export function sanitizeReasoningEffortForProvider( } const supportsXHigh = supportsXHighEffort(provider, modelStr); - const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHigh; - const supportsXHighForMax = supportsXHigh; const supportsMax = supportsMaxEffortForProvider(provider, modelStr); - const shouldNormalizeMaxToXHigh = effortStr === "max" && !supportsMax && supportsXHighForMax; - const shouldDowngradeMax = effortStr === "max" && !supportsMax && !supportsXHighForMax; - if (shouldNormalizeMaxToXHigh) { + // ── xhigh handling ────────────────────────────────────────────────────── + // xhigh is OmniRoute-internal. Map it to the best effort the model accepts. + if (effortStr === "xhigh") { + if (supportsXHigh) return body; // model accepts xhigh natively + if (supportsMax) { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: mapped reasoning_effort xhigh → max` + ); + return writeEffortValue(b, "max", c); + } + // Model explicitly rejects xhigh — gracefully degrade to high (its highest standard tier) log?.info?.( "REASONING_SANITIZE", - `${provider}/${modelStr}: normalized reasoning_effort max → xhigh` - ); - return writeEffortValue(b, "xhigh", c); - } - - if (shouldDowngradeXHigh || shouldDowngradeMax) { - log?.info?.( - "REASONING_SANITIZE", - `${provider}/${modelStr}: downgraded reasoning_effort ${effortStr} → high` + `${provider}/${modelStr}: downgraded reasoning_effort xhigh → high` ); return writeEffortValue(b, "high", c); } + // ── max handling ──────────────────────────────────────────────────────── + // NEW DEFAULT: pass max through unchanged. Most reasoning-capable APIs + // accept max natively. Only degrade when we KNOW the model rejects it + // (registry has supportsXHighEffort explicitly set to false AND it's not + // in the supportsMax whitelist). Unknown models pass through — trust the + // upstream, and if it 400s the user gets a clear signal. This prevents + // new models from being unusable for weeks until they're whitelisted (#8057). + if (effortStr === "max") { + if (supportsMax) return body; // explicitly known to accept max + if (!supportsXHigh) { + // Model is explicitly flagged as rejecting xhigh (and not in supportsMax) — + // it likely only accepts standard tiers. Degrade to its highest: high. + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: downgraded reasoning_effort max → high (model rejects max/xhigh)` + ); + return writeEffortValue(b, "high", c); + } + // Default: pass max through unchanged — trust the upstream + return body; + } + return body; } diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index c96db39586..9930b84ec6 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -2822,8 +2822,7 @@ export class ChatGptWebExecutor extends BaseExecutor { const modelSlug = resolvedModel.slug; const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( (body || {}) as Record, - messages as Array<{ role: string; content: unknown }>, - { hardened: isThinkingCapableModel(model, modelSlug) } + messages as Array<{ role: string; content: unknown }> ); if (!credentials.apiKey) { diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 83095f4d20..f6490b835f 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -408,12 +408,13 @@ export class CliproxyapiExecutor extends BaseExecutor { input.log?.info?.("CPA", `CLIProxyAPI → ${url} (model: ${input.model}, shape: ${shape})`); - // _toolNameMap is an in-memory channel to chatCore for response-side - // tool name restoration; never send it over the wire. + // _toolNameMap and _namespaceToolIdentityMap are in-memory channels to + // chatCore for response-side tool name restoration; never send them over + // the wire. const wireBody = transformedBody && typeof transformedBody === "object" ? JSON.stringify(transformedBody, (key, value) => - key === "_toolNameMap" ? undefined : value + key === "_toolNameMap" || key === "_namespaceToolIdentityMap" ? undefined : value ) : JSON.stringify(transformedBody); diff --git a/open-sse/executors/codebuddy-cn.ts b/open-sse/executors/codebuddy-cn.ts index 359eaa016c..4b95b51e7d 100644 --- a/open-sse/executors/codebuddy-cn.ts +++ b/open-sse/executors/codebuddy-cn.ts @@ -1,5 +1,82 @@ import { DefaultExecutor } from "./default.ts"; -import type { ProviderCredentials } from "./base.ts"; +import type { ExecuteInput, ExecutorExecuteResult, ProviderCredentials } from "./base.ts"; + +const SENSITIVE_CONTENT_REJECTION = + "抱歉,系统检测到您当前输入的信息存在敏感内容,我无法响应您的请求,请检查后重新输入"; +const LARGE_TOOL_METADATA_BYTES = 64 * 1024; + +function responseFromResult(result: ExecutorExecuteResult): Response { + return result instanceof Response ? result : result.response; +} + +function credentialsFromResult( + result: ExecutorExecuteResult, + fallback: ProviderCredentials +): ProviderCredentials { + if (result instanceof Response || !result.headers) return fallback; + + const authorization = Object.entries(result.headers).find( + ([name]) => name.toLowerCase() === "authorization" + )?.[1]; + if (!authorization?.startsWith("Bearer ")) return fallback; + + return { + ...fallback, + accessToken: authorization.slice("Bearer ".length), + expiresAt: undefined, + }; +} + +function compactToolDescriptions(body: unknown): unknown | null { + if (!body || typeof body !== "object" || Array.isArray(body)) return null; + + const request = body as Record; + if (!Array.isArray(request.tools) || request.tools.length === 0) return null; + + const originalTools = request.tools; + try { + const serializedTools = JSON.stringify(originalTools); + if (new TextEncoder().encode(serializedTools).byteLength < LARGE_TOOL_METADATA_BYTES) { + return null; + } + } catch { + return null; + } + + let tools: unknown[] | null = null; + originalTools.forEach((tool, index) => { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return; + + const declaration = tool as Record; + if ( + declaration.type !== "function" || + !declaration.function || + typeof declaration.function !== "object" || + Array.isArray(declaration.function) + ) { + return; + } + + const toolFunction = declaration.function as Record; + if (!Object.prototype.hasOwnProperty.call(toolFunction, "description")) return; + + const compactFunction = { ...toolFunction }; + delete compactFunction.description; + tools ??= originalTools.slice(); + tools[index] = { ...declaration, function: compactFunction }; + }); + + return tools ? { ...request, tools } : null; +} + +async function isSensitiveContentRejection(response: Response): Promise { + if (response.status !== 400) return false; + const responseText = await response + .clone() + .text() + .catch(() => ""); + return responseText.includes(SENSITIVE_CONTENT_REJECTION); +} /** * CodeBuddyCnExecutor — talks to https://copilot.tencent.com/v2/chat/completions @@ -21,6 +98,26 @@ export class CodeBuddyCnExecutor extends DefaultExecutor { super("codebuddy-cn"); } + async execute(input: ExecuteInput): Promise { + const result = await super.execute(input); + if (!(await isSensitiveContentRejection(responseFromResult(result)))) { + return result; + } + + const compactBody = compactToolDescriptions(input.body); + if (!compactBody) return result; + + input.log?.debug?.( + "CODEBUDDY_CN", + "Upstream rejected an oversized tool request as sensitive content; retrying with compact tool descriptions" + ); + return super.execute({ + ...input, + body: compactBody, + credentials: credentialsFromResult(result, input.credentials), + }); + } + transformRequest( model: string, body: unknown, diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index f595b7f1b8..2ff814889e 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -32,6 +32,7 @@ import { } from "../config/codexIdentity.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; +import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts"; import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { CORS_HEADERS } from "../utils/cors.ts"; @@ -222,90 +223,6 @@ function convertSystemToDeveloperRole(body: Record): void { } } -/** - * Strip server-generated item IDs from the input array. - * - * The Codex /codex/responses endpoint does not persist response items even when - * store=true is sent. When proxy clients (e.g. OpenClaw) include response items - * from previous turns in the input array, those items carry server-assigned IDs - * (prefixed with "rs_", "fc_", "resp_", "msg_"). The Codex backend tries to - * validate these IDs against its persistence store and returns 404 when the items - * are not found (because store was effectively false). - * - * This function: - * 1. Removes bare string references ("rs_abc123") from the input array - * 2. Removes object items with type "item_reference" (explicit stored-item refs) - * 3. Strips the "id" field from any object in input whose id matches a - * server-generated prefix (rs_, fc_, resp_, msg_) — so the content is - * preserved but the backend won't try to look it up - */ -export function stripStoredItemReferences(body: Record): void { - if (Array.isArray(body.input) && body.input.length === 0) { - body.input = [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "continue" }], - }, - ]; - } - - if (!Array.isArray(body.input)) return; - - const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; - let strippedCount = 0; - - body.input = body.input.filter((item) => { - // Bare string references: "rs_abc123", "resp_abc123" - if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) { - strippedCount++; - return false; - } - - // Object references: { type: "item_reference", id: "rs_..." } - if ( - item && - typeof item === "object" && - !Array.isArray(item) && - (item as Record).type === "item_reference" - ) { - strippedCount++; - return false; - } - - // Reasoning blobs (encrypted_content) are unusable with store=false since - // previous_response_id is deleted — strip them to avoid wasting context - // tokens (O(n^2) growth across agentic turns). - if ( - item && - typeof item === "object" && - !Array.isArray(item) && - (item as Record).type === "reasoning" - ) { - strippedCount++; - return false; - } - - // Object items with server-generated IDs: strip the id field but keep the item. - // e.g. { id: "rs_...", type: "reasoning", summary: [...] } → keep content, remove id - // e.g. { id: "fc_...", type: "function_call", ... } → keep content, remove id - if (item && typeof item === "object" && !Array.isArray(item)) { - const record = item as Record; - if (typeof record.id === "string" && SERVER_ID_PATTERN.test(record.id)) { - delete record.id; - strippedCount++; - } - } - - return true; - }); - - if (strippedCount > 0) { - console.debug( - `[Codex] stripStoredItemReferences: sanitized ${strippedCount} server-generated ID(s) from input` - ); - } -} function stripOrphanedCodexFunctionCallOutputs(body: Record): void { if (!Array.isArray(body.input)) return; @@ -1296,7 +1213,7 @@ export class CodexExecutor extends BaseExecutor { } // Issue #1832 & #1853: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input. - // This MUST run before convertSystemToDeveloperRole and stripStoredItemReferences. + // This MUST run before convertSystemToDeveloperRole. if (!body.input && Array.isArray(body.messages)) { body.input = body.messages.map((msg: ResponsesMessageInput) => ({ type: "message", @@ -1419,11 +1336,6 @@ export class CodexExecutor extends BaseExecutor { preserveCustomTools: nativeCodexPassthrough, }); - // Strip stored response item references (rs_, resp_, msg_ IDs) from input. - // The /codex/responses endpoint does not persist responses even with store=true, - // so any references to previous response items would cause 404 errors. - stripStoredItemReferences(body); - // Issue #806: Even for native passthrough, some clients (purist completions) might indiscriminately inject // a `messages` or `prompt` array which the strict Codex Responses schema rejects. delete body.messages; @@ -1515,6 +1427,11 @@ export class CodexExecutor extends BaseExecutor { delete body.session_id; delete body.conversation_id; + applyResponsesInputPolicy( + body, + credentials?.providerSpecificData?.preserveEncryptedReasoning === true + ); + if (nativeCodexPassthrough) { return body; } diff --git a/open-sse/executors/codex/tools.ts b/open-sse/executors/codex/tools.ts index 52d01e9d87..0547432f2b 100644 --- a/open-sse/executors/codex/tools.ts +++ b/open-sse/executors/codex/tools.ts @@ -30,6 +30,114 @@ export function isCodexFreePlan(providerSpecificData: unknown): boolean { return typeof plan === "string" && plan.trim().toLowerCase() === "free"; } +type JsonRecord = Record; + +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, 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)) { diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index cc2642de5c..251981fa78 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -48,6 +48,21 @@ function recordOrEmpty(value: unknown): JsonRecord { return {}; } +/** + * Build the `arguments` field for an assistant tool-call part that Command + * Code's /alpha/generate schema REQUIRES (rejects a missing field with + * `missing required field 'arguments'`). Valid source values round-trip: + * - object arguments -> JSON string of the object + * - string arguments -> the string as-is (already valid JSON) + * - missing / empty / invalid JSON -> "{}" (a valid empty-object string) + */ +function toolCallArgumentsString(value: unknown): string { + const parsed = recordOrEmpty(value); + if (isRecord(value)) return JSON.stringify(parsed); + if (typeof value === "string" && value.trim()) return value; + return JSON.stringify(parsed); +} + function normalizeContentText(content: unknown): string { if (typeof content === "string") return content; return asRecordArray(content) @@ -244,11 +259,15 @@ function convertMessages( const id = stringValue(call.id) || ""; if (!id || !pairedToolCallIds.has(id)) continue; const fn = isRecord(call.function) ? call.function : {}; + const parsedInput = recordOrEmpty(fn.arguments); parts.push({ type: "tool-call", toolCallId: id, toolName: stringValue(fn.name) || "", - input: recordOrEmpty(fn.arguments), + input: parsedInput, + // /alpha/generate requires this field on assistant tool-call parts; + // a missing one is rejected with `missing required field 'arguments'`. + arguments: toolCallArgumentsString(fn.arguments), }); } @@ -420,7 +439,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 +513,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 +534,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 +639,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 +679,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 diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 99fda068af..4f3314ca8d 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -515,7 +515,6 @@ export function messagesToPrompt( historyWindow = 0 ): string { if (messages.length === 0) return ""; - const systemParts: string[] = []; const conversation: Array<{ role: string; text: string }> = []; const callNameById = new Map(); @@ -527,8 +526,9 @@ export function messagesToPrompt( } else if (m.role === "user" || m.role === "assistant") { if (text) conversation.push({ role: m.role, text }); if (m.role === "user") lastUserContent = text; - const calls = Array.isArray((m as { tool_calls?: unknown }).tool_calls) - ? (m as { tool_calls: Array<{ id?: string; function?: { name?: string } }> }).tool_calls + const toolCalls = (m as { tool_calls?: unknown }).tool_calls; + const calls = Array.isArray(toolCalls) + ? (toolCalls as Array<{ id?: string; function?: { name?: string } }>) : []; for (const c of calls) { if (c?.id && typeof c.function?.name === "string") callNameById.set(c.id, c.function.name); diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 8836b79dfb..8814c4262a 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -61,12 +61,11 @@ import { } from "@/lib/providers/validation/urlHelpers"; import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; import { resolveZaiUrl } from "./default/zaiFormatOverride.ts"; +import { normalizePoolConfig } from "./default/poolConfig.ts"; import { acquireNvidiaConcurrencySlot } from "./default/nvidiaConcurrencyGate.ts"; import { resolveAlibabaProviderBaseUrl } from "@/shared/constants/alibabaProviderRegions"; import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts"; -import type { PoolConfig } from "../services/sessionPool/types.ts"; - const NVIDIA_TOOL_CALL_ID_PATTERN = /^[A-Za-z0-9]{9}$/; function normalizeNvidiaToolCallId(id: unknown): unknown { @@ -146,7 +145,7 @@ export class DefaultExecutor extends BaseExecutor { super(provider, PROVIDERS[provider] || PROVIDERS.openai); const registryEntry = getRegistryEntry(provider); if (registryEntry?.poolConfig) { - this.poolConfig = registryEntry.poolConfig as PoolConfig; + this.poolConfig = normalizePoolConfig(registryEntry.poolConfig) ?? undefined; } } diff --git a/open-sse/executors/default/poolConfig.ts b/open-sse/executors/default/poolConfig.ts new file mode 100644 index 0000000000..5cb781a3d6 --- /dev/null +++ b/open-sse/executors/default/poolConfig.ts @@ -0,0 +1,33 @@ +import type { PoolConfig } from "../../services/sessionPool/types.ts"; + +export function normalizePoolConfig(value: Record): PoolConfig | null { + const { + minSessions, + maxSessions, + cooldownBase, + cooldownMax, + cooldownJitter, + requestTimeout, + requestJitter, + } = value; + if ( + typeof minSessions !== "number" || + typeof maxSessions !== "number" || + typeof cooldownBase !== "number" || + typeof cooldownMax !== "number" || + typeof cooldownJitter !== "number" || + typeof requestTimeout !== "number" || + typeof requestJitter !== "number" + ) { + return null; + } + return { + minSessions, + maxSessions, + cooldownBase, + cooldownMax, + cooldownJitter, + requestTimeout, + requestJitter, + }; +} diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index 72abad4f84..3b066d3c0f 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -137,8 +137,23 @@ interface DuckDuckGoModelCapabilities { reasoningEffort: string | null; } +type DuckDuckGoRequestMessage = Record & { + role: string; + content: unknown; +}; + let durablePublicKey: JsonWebKey | null = null; +export function normalizeDuckDuckGoMessages(value: unknown): DuckDuckGoRequestMessage[] { + if (!Array.isArray(value)) return []; + return value.flatMap((message) => { + if (!message || typeof message !== "object" || Array.isArray(message)) return []; + const record = message as Record; + if (typeof record.role !== "string") return []; + return [{ ...record, role: record.role, content: record.content }]; + }); +} + function extractDuckDuckGoContent(data: unknown): string { if (!data || typeof data !== "object") return ""; const record = data as Record; @@ -251,11 +266,14 @@ export function normalizeDuckDuckGoModel(model: string | undefined): string { } function getDuckDuckGoModelCapabilities(model: string): DuckDuckGoModelCapabilities { - // Per duckchat/v1/models (2026-07-22): claude-haiku-4-5 and gpt-oss-120b take a "low" - // reasoningEffort on the free tier; the others omit it (duck.ai applies its own default). + // `reasoningEffort` is REQUIRED on every duckchat/v1/chat request. Omitting it + // returns 400 ERR_BAD_REQUEST — A/B verified live against duck.ai with an + // otherwise byte-identical payload (200 with the field, 400 without, repeated). + // The live duck.ai bundle always sends one, so there is no "let the server + // pick a default" path any more. if (model === "claude-haiku-4-5") return { reasoningEffort: "low" }; if (model === "tinfoil/gpt-oss-120b") return { reasoningEffort: "low" }; - return { reasoningEffort: null }; + return { reasoningEffort: "none" }; } function extractDuckDuckGoFeVersion(html: string): string | null { @@ -353,7 +371,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { } private warmed = false; - private seeded = false; private feVersion = DEFAULT_FE_VERSION; private pendingVqdHash1: string | null = null; private readonly cookieJar = new Map(); @@ -440,14 +457,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { const { model, body, stream, signal, upstreamExtraHeaders } = input; const upstreamModel = normalizeDuckDuckGoModel(model); const bodyObj = (body || {}) as Record; - const rawMessages = Array.isArray((body as { messages?: unknown[] } | null)?.messages) - ? ((body as { messages: unknown[] }).messages as Array>) - : []; + const rawMessages = normalizeDuckDuckGoMessages(bodyObj.messages); const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( bodyObj, rawMessages ); - const messages = effectiveMessages as Array>; + const messages = effectiveMessages; const isStreaming = stream !== false; const upstreamHeaders = upstreamExtraHeaders || {}; @@ -561,7 +576,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { } await this.warmSession(mergedSignal); - await this.seedChallengeChain(upstreamModel, mergedSignal); + // NOTE: the throwaway "seed" chat POST that used to run here has been removed. + // It existed to coax a usable challenge out of the upstream while the solver + // was broken; now that the solver reproduces a real browser's probe vectors + // exactly, the first real request succeeds on its own. Keeping it only doubled + // the chat calls per user request against an IP-rate-limited endpoint, which + // showed up as spurious 429 ERR_RATE_LIMIT. const vqdHeaders = await this.acquireAuthHeaders(mergedSignal); if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) { clearTimeout(timeout); @@ -770,41 +790,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { ); } - private async seedChallengeChain(model: string, signal: AbortSignal): Promise { - if (this.seeded || signal.aborted) return; - this.seeded = true; - const seedMessages = [{ role: "user", content: "hi" }]; - const previousPending = this.pendingVqdHash1; - try { - const vqdHeaders = await this.acquireAuthHeaders(signal); - if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) { - this.pendingVqdHash1 = previousPending; - return; - } - const response = await fetch(CHAT_URL, { - method: "POST", - headers: mergeHeadersCaseInsensitive(this.buildRequestHeaders(), { - Accept: "text/event-stream", - "Content-Type": "application/json", - "x-ddg-journey-id": randomUUID().replaceAll("-", ""), - "x-fe-signals": makeDuckDuckGoFeSignals(), - "x-fe-version": this.feVersion, - ...(vqdHeaders.vqd4 ? { "x-vqd-4": vqdHeaders.vqd4 } : {}), - ...(vqdHeaders.vqdHash1 ? { "x-vqd-hash-1": vqdHeaders.vqdHash1 } : {}), - }), - body: JSON.stringify(buildDuckDuckGoPayload(model, seedMessages, false)), - signal, - }); - this.rememberResponseCookies(response); - if (response.ok) this.rememberChallengeHeader(response); - else this.pendingVqdHash1 = previousPending; - await response.body?.cancel().catch(() => {}); - } catch (error) { - void error; - this.pendingVqdHash1 = previousPending; - } - } - private async processResponse( response: Response, streaming: boolean, diff --git a/open-sse/executors/duckduckgo-web/challenge.ts b/open-sse/executors/duckduckgo-web/challenge.ts index 3c0159ba3a..8b4ea22feb 100644 --- a/open-sse/executors/duckduckgo-web/challenge.ts +++ b/open-sse/executors/duckduckgo-web/challenge.ts @@ -5,12 +5,38 @@ import { createHash } from "node:crypto"; import vm from "node:vm"; import { parseFragment, serialize } from "parse5"; +// WARNING: the contents of this template literal are NOT TypeScript — they are plain +// script-mode JavaScript executed via `vm.runInContext`. `vm.runInContext` compiles in +// script (non-module) mode, so an `export` keyword anywhere in here is a hard +// SyntaxError that kills the whole solver. A refactor that mass-added `export` to the +// five `function` declarations below silently broke every DuckDuckGo chat request +// (solve threw -> unsolved challenge sent -> HTTP 418 ERR_CHALLENGE). Do not add +// `export`/`import` to this string; `duckduckgo-challenge-split.test.ts` guards this. export const CHALLENGE_STUBS = String.raw` var __ua = __DDG_REAL_UA__; var __HTML_LOOKUP = __DDG_HTML_LOOKUP__; -export function __makeHtmlElement(tag) { +// Browser-fidelity shims for the DDG "am I a real browser" probes. +// In a browser every built-in stringifies as native code; under a plain vm +// context the user-land re-declarations below would otherwise leak their source. +function __nativeFn(fn, name){ + Object.defineProperty(fn, 'name', { value: name, configurable: true }); + fn.toString = function(){ return 'function ' + name + '() { [native code] }'; }; + return fn; +} +__nativeFn(parseInt, 'parseInt'); +__nativeFn(parseFloat, 'parseFloat'); +__nativeFn(isNaN, 'isNaN'); +__nativeFn(encodeURIComponent, 'encodeURIComponent'); +__nativeFn(decodeURIComponent, 'decodeURIComponent'); +// NOTE: do NOT seal Math. Real Chromium reports Object.isSealed(Math) === false, +// and at least one challenge variant probes exactly that; sealing it here made +// the vector differ from the browser by one and failed the challenge. +function __makeHtmlElement(tag) { var state = { _innerHTML: '', _qsaCount: 0, _cssText: '' }; - var el = { + // Instantiate against the real per-tag constructor so + // document.createElement('div') instanceof HTMLDivElement holds. + var el = Object.create(__ctorForTag(tag).prototype); + Object.assign(el, { tagName: String(tag).toUpperCase(), nodeName: String(tag).toUpperCase(), nodeType: 1, children: [], childNodes: [], classList: [], dataset: {}, offsetWidth: 1, offsetHeight: 1, clientWidth: 1, clientHeight: 1, scrollHeight: 1, scrollWidth: 1, @@ -19,9 +45,9 @@ export function __makeHtmlElement(tag) { getAttribute: function(a){ if(a==='srcdoc') return state._srcdoc||''; return null; }, hasAttribute: function(){ return false; }, appendChild: function(c){ return c; }, removeChild: function(c){ return c; }, addEventListener: function(){}, removeEventListener: function(){}, querySelector: function(){ return null; }, - querySelectorAll: function(s){ if (s === '*') { var arr = []; arr.length = state._qsaCount; return arr; } return []; }, + querySelectorAll: function(s){ if (s === '*') { return __makeNodeList(state._qsaCount); } return __makeNodeList(0); }, cloneNode: function(){ return __makeHtmlElement(tag); } - }; + }); Object.defineProperty(el, 'style', { value: new Proxy({}, { set: function(t, k, v){ t[k] = v; if (k === 'cssText') state._cssText = String(v); return true; }, get: function(t, k){ if (k === 'cssText') return state._cssText; return t[k] || ''; } }), enumerable: true, configurable: true }); Object.defineProperty(el, 'innerHTML', { get: function(){ return state._innerHTML; }, set: function(v){ var key = String(v); var entry = __HTML_LOOKUP && __HTML_LOOKUP[key]; if (entry) { state._innerHTML = String(entry.html); state._qsaCount = entry.count|0; } else { state._innerHTML = key; state._qsaCount = 0; } }, enumerable: true, configurable: true }); Object.defineProperty(el, 'outerHTML', { get: function(){ return '<' + tag + '>' + state._innerHTML + ''; }, enumerable: true }); @@ -30,7 +56,7 @@ export function __makeHtmlElement(tag) { Object.defineProperty(el, 'contentDocument', { get: function(){ return __ifDoc; }, enumerable: true }); return el; } -export function __mkObj(name, base) { +function __mkObj(name, base) { base = base || {}; return new Proxy(base, { get: function(t, k) { @@ -54,18 +80,105 @@ export function __mkObj(name, base) { has: function(t, k){ return k in t; }, set: function(t, k, v){ t[k] = v; return true; } }); } -export function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\\s*display\\s*:\\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; } -export function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; } +function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\s*display\s*:\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; } +function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; } var __ifMeta = __mkObj('meta', { getAttribute: function(a){ return a==='content' ? "default-src 'none'; script-src 'unsafe-inline';" : null; }, hasAttribute: function(a){ return a==='content'; }, tagName: 'META', nodeName: 'META' }); var __ifDoc = __mkObj('iframeDoc', { querySelector: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; if (s === 'meta') return __ifMeta; return null; }, querySelectorAll: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; if (s === 'meta') return [__ifMeta]; return []; }, getElementsByTagName: function(t){ return t && t.toLowerCase()==='meta' ? [__ifMeta] : []; }, body: __mkObj('iframeBody'), head: __mkObj('iframeHead'), documentElement: __mkObj('iframeRoot'), createElement: function(){ return __mkObj('elem', {setAttribute:function(){}, appendChild:function(){}, removeChild:function(){}, getAttribute:function(){return null;}, hasAttribute:function(){return false;}}); }, cookie: '', readyState: 'complete' }); var __iframeEl = __mkObj('iframe', { contentDocument: __ifDoc, contentWindow: __mkObj('iframeWin', { document: __ifDoc, top: undefined, parent: undefined }), document: __ifDoc, getAttribute: function(a){ if (a==='sandbox') return 'allow-scripts allow-same-origin'; if (a==='srcdoc') return ''; if (a==='id') return 'jsa'; return null; }, hasAttribute: function(a){ return a==='sandbox'||a==='id'; }, tagName: 'IFRAME', nodeName: 'IFRAME', id: 'jsa' }); -var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return []; }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __mkObj('body', {appendChild:function(){}, removeChild:function(){}, querySelector:function(s){return s==='#jsa'?__iframeEl:null;}, querySelectorAll:function(s){return s==='#jsa'?[__iframeEl]:[];}}), head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} }); +// document.body keeps a LIVE children collection: challenges append a node and +// assert body.children.length grew by exactly 1, then remove it again. +var __bodyKids = []; +Object.defineProperty(__bodyKids, 'constructor', { value: HTMLCollection, enumerable: false, configurable: true }); +var __body = __mkObj('body', { + appendChild: function(c){ __bodyKids.push(c); return c; }, + removeChild: function(c){ var i = __bodyKids.indexOf(c); if (i !== -1) __bodyKids.splice(i, 1); return c; }, + contains: function(c){ return __bodyKids.indexOf(c) !== -1; }, + querySelector: function(s){ return s === '#jsa' ? __iframeEl : null; }, + querySelectorAll: function(s){ return s === '#jsa' ? [__iframeEl] : __makeNodeList(0); }, + children: __bodyKids, childNodes: __bodyKids, + tagName: 'BODY', nodeName: 'BODY', nodeType: 1 +}); +var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return __makeNodeList(__bodyKids.length + 3); }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __body, head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} }); var window = __mkObj('window', { document: document, __DDG_BE_VERSION__: 1, __DDG_FE_CHAT_HASH__: 1, navigator: __mkObj('navigator', { userAgent: __ua, webdriver: false, language: 'en-US', languages: ['en-US','en'], platform: 'Linux x86_64', vendor: 'Google Inc.', appVersion: '5.0 (X11)', cookieEnabled: true, onLine: true, hardwareConcurrency: 8, deviceMemory: 8 }), innerWidth: 1280, innerHeight: 800, outerWidth: 1280, outerHeight: 800, devicePixelRatio: 1, screen: __mkObj('screen', { width:1920, height:1080, availWidth:1920, availHeight:1080, colorDepth:24, pixelDepth:24 }), location: __mkObj('location', { href:'https://duck.ai/', origin:'https://duck.ai', host:'duck.ai', hostname:'duck.ai', protocol:'https:', pathname:'/' }), performance: __mkObj('perf', { now: function(){ return 0; }, timeOrigin: 0 }), history: __mkObj('history', { length: 1, state: null }), addEventListener: function(){}, removeEventListener: function(){}, dispatchEvent: function(){return true;}, setTimeout: function(fn){ try{fn();}catch(e){} return 0; }, clearTimeout: function(){}, hasOwnProperty: function(k){ if (k==='__DDG_BE_VERSION__'||k==='__DDG_FE_CHAT_HASH__') return true; return Object.prototype.hasOwnProperty.call(this,k); } }); window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window; +// Object.prototype.toString.call(window) must be "[object Window]". +try { window[Symbol.toStringTag] = 'Window'; } catch (e) {} +// In a browser a sloppy-mode function called with no receiver gets the global +// object, and challenges assert (function(){return this;})() === window. +// In a vm context that is the context's own global, so alias it to window. +try { + var __g = (function(){ return this; })(); + if (__g && __g !== window) { + Object.defineProperty(__g, Symbol.toStringTag, { value: 'Window', configurable: true }); + // Copy by VALUE, not via accessors. Two reasons: + // 1) the var top/self/navigator/... declarations further down are hoisted, + // so those names already exist on the vm global and an "in" guard would + // skip them, leaving window.navigator undefined; + // 2) accessors closing over the window binding would recurse once it is + // rebound to __g below. + // The stub window is static, so a value copy is equivalent. + var __winStub = window; + for (var __k in __winStub) { + try { __g[__k] = __winStub[__k]; } catch (e) {} + } + // hasOwnProperty is probed for the __DDG_* markers; keep the stub's version. + try { __g.hasOwnProperty = function(k){ return __winStub.hasOwnProperty(k); }; } catch (e) {} + window = __g; + window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window; + } +} catch (e) {} var top = window, self = window, parent = window, navigator = window.navigator, location = window.location, screen = window.screen, performance = window.performance, history = window.history; var __R = null, __E = null; -export function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; } -var HTMLElement = __HTMLClass('HTMLElement'), HTMLDivElement = __HTMLClass('HTMLDivElement'), HTMLIFrameElement = __HTMLClass('HTMLIFrameElement'), HTMLDocument = __HTMLClass('HTMLDocument'), Document = __HTMLClass('Document'), Element = __HTMLClass('Element'), Node = __HTMLClass('Node'), Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response'); +// Real DOM constructor chain. Some DDG challenge variants assert +// HTMLDivElement.prototype instanceof HTMLElement and +// HTMLElement.prototype instanceof Element, so these cannot be flat +// unrelated stubs — the prototype links have to be real. +function __DomClass(name, parent){ + var c = function(){}; + if (parent) c.prototype = Object.create(parent.prototype); + c.prototype.constructor = c; + Object.defineProperty(c, 'name', { value: name, configurable: true }); + c.toString = function(){ return 'function ' + name + '() { [native code] }'; }; + return c; +} +var EventTarget = __DomClass('EventTarget', null); +var Node = __DomClass('Node', EventTarget); +var Element = __DomClass('Element', Node); +var HTMLElement = __DomClass('HTMLElement', Element); +var HTMLDivElement = __DomClass('HTMLDivElement', HTMLElement); +var HTMLIFrameElement = __DomClass('HTMLIFrameElement', HTMLElement); +var HTMLLIElement = __DomClass('HTMLLIElement', HTMLElement); +var HTMLUnknownElement = __DomClass('HTMLUnknownElement', HTMLElement); +var Document = __DomClass('Document', Node); +var HTMLDocument = __DomClass('HTMLDocument', Document); +var NodeList = __DomClass('NodeList', null); +var HTMLCollection = __DomClass('HTMLCollection', null); +// Map a tag name to the constructor a browser would use, so +// document.createElement('div') instanceof HTMLDivElement holds. +function __ctorForTag(tag){ + var t = String(tag||'div').toLowerCase(); + if (t === 'div') return HTMLDivElement; + if (t === 'iframe') return HTMLIFrameElement; + if (t === 'li') return HTMLLIElement; + return HTMLElement; +} +// A NodeList-like: array-shaped but NOT a real Array, with .constructor.name +// === 'NodeList' — challenges check both !Array.isArray(x) and the ctor name. +function __makeNodeList(length){ + var nl = Object.create(NodeList.prototype); + var n = length|0; + for (var i = 0; i < n; i++) nl[i] = __makeHtmlElement('div'); + Object.defineProperty(nl, 'length', { value: n, enumerable: false, configurable: true }); + nl.item = function(i){ return this[i] || null; }; + nl.forEach = function(fn, thisArg){ for (var i = 0; i < n; i++) fn.call(thisArg, this[i], i, this); }; + nl[Symbol.iterator] = function(){ var i = 0, self = this; return { next: function(){ return i < n ? { value: self[i++], done: false } : { value: undefined, done: true }; } }; }; + return nl; +} +function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; } +// NOTE: HTMLElement / HTMLDivElement / HTMLIFrameElement / Element / Node / +// Document / HTMLDocument / NodeList are defined above via __DomClass with a +// REAL prototype chain — do not redeclare them here or the instanceof probes break. +var Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response'); var fetch = function(){ return Promise.resolve(__mkObj('resp', {ok:true, status:200, json:function(){return Promise.resolve({});}, text:function(){return Promise.resolve('');}})); }; var getComputedStyle = __getComputedStyle; `; @@ -90,9 +203,16 @@ export function buildHtmlLookup(js: string): Record
  • { // SECURITY NOTE: This function executes base64-decoded JavaScript from duck.ai via vm.runInContext. // The challenge code is upstream-supplied (supply-chain surface). It is sandboxed with a 5s timeout @@ -121,14 +260,31 @@ export async function solveDuckDuckGoChallenge( ); const context = vm.createContext({}); vm.runInContext(stubs, context, { timeout: 5000 }); + const startedAt = Date.now(); const result = (await vm.runInContext(js, context, { timeout: 5000, })) as DuckDuckGoChallengeResult; + const elapsedMs = Date.now() - startedAt; const clientHashes = Array.isArray(result.client_hashes) ? result.client_hashes : []; if (clientHashes.length === 0) throw new Error("DuckDuckGo challenge returned empty client_hashes"); clientHashes[0] = userAgent; result.client_hashes = clientHashes.map((hash) => sha256Base64(String(hash))); + + // The real frontend augments the challenge's own `meta` with origin / stack / + // duration before sending it back. Omitting them yields 418 ERR_CHALLENGE even + // when every client_hash is correct (confirmed by capturing a real browser's + // x-vqd-hash-1 header, which always carries all three). + const origin = options.origin ?? DUCKDUCKGO_CHALLENGE_ORIGIN; + const bundlePath = options.bundlePath ?? "/dist/duckai-dist/entry.duckai.js"; + const meta = (result.meta ?? {}) as Record; + result.meta = { + ...meta, + origin, + stack: buildChallengeStack(origin, bundlePath), + duration: String(elapsedMs), + }; + return Buffer.from(JSON.stringify(result), "utf8").toString("base64"); } diff --git a/open-sse/executors/gemini-business.ts b/open-sse/executors/gemini-business.ts index ea68969582..efa014357b 100644 --- a/open-sse/executors/gemini-business.ts +++ b/open-sse/executors/gemini-business.ts @@ -80,16 +80,7 @@ export class GeminiBusinessExecutor extends BaseExecutor { // Extract cookies from credentials — check apiKey/cookie first, then // try each __Secure-1PSID* key in providerSpecificData individually. // A user with only __Secure-1PSID (no PSIDTS) is still valid. - const directCookie = - readCredentialString(credentials?.apiKey) || readCredentialString(credentials?.cookie); - const psid = readProviderSpecificString(credentials?.providerSpecificData, [ - "__Secure-1PSID", - "cookie", - ]); - const psidts = readProviderSpecificString(credentials?.providerSpecificData, [ - "__Secure-1PSIDTS", - ]); - const cookie = directCookie || [psid, psidts].filter(Boolean).join("; "); + const cookie = resolveGeminiBusinessCookie(credentials); if (!cookie) { return makeErrorResult( @@ -380,6 +371,15 @@ function readProviderSpecificString(providerSpecificData: unknown, keys: string[ return ""; } +export function resolveGeminiBusinessCookie(credentials: unknown): string { + if (!credentials || typeof credentials !== "object") return ""; + const data = credentials as Record; + const directCookie = readCredentialString(data.apiKey) || readCredentialString(data.cookie); + const psid = readProviderSpecificString(data.providerSpecificData, ["__Secure-1PSID", "cookie"]); + const psidts = readProviderSpecificString(data.providerSpecificData, ["__Secure-1PSIDTS"]); + return directCookie || [psid, psidts].filter(Boolean).join("; "); +} + function extractTextContent(content: unknown): string { if (typeof content === "string") return content.trim(); if (Array.isArray(content)) { diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index b25f4d9555..6b9477338b 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -25,6 +25,7 @@ import { ChatGptWebExecutor } from "./chatgpt-web.ts"; import { BlackboxWebExecutor } from "./blackbox-web.ts"; import { MuseSparkWebExecutor } from "./muse-spark-web.ts"; import { AzureOpenAIExecutor } from "./azure-openai.ts"; +import { AzureAiExecutor } from "./azure-ai.ts"; import { CommandCodeExecutor } from "./commandCode.ts"; import { GitlabExecutor } from "./gitlab.ts"; import { NlpCloudExecutor } from "./nlpcloud.ts"; @@ -89,6 +90,7 @@ const executors = { glmt: new GlmExecutor("glmt"), cu: new CursorExecutor(), // Alias for cursor "azure-openai": new AzureOpenAIExecutor(), + "azure-ai": new AzureAiExecutor(), "command-code": new CommandCodeExecutor(), cmd: new CommandCodeExecutor(), // Alias gitlab: new GitlabExecutor(), @@ -263,6 +265,7 @@ export { ChatGptWebExecutor } from "./chatgpt-web.ts"; export { BlackboxWebExecutor } from "./blackbox-web.ts"; export { MuseSparkWebExecutor } from "./muse-spark-web.ts"; export { AzureOpenAIExecutor } from "./azure-openai.ts"; +export { AzureAiExecutor } from "./azure-ai.ts"; export { CommandCodeExecutor } from "./commandCode.ts"; export { GitlabExecutor } from "./gitlab.ts"; export { NlpCloudExecutor } from "./nlpcloud.ts"; diff --git a/open-sse/executors/theoldllm.ts b/open-sse/executors/theoldllm.ts index 688e79eb2c..422452e7f2 100644 --- a/open-sse/executors/theoldllm.ts +++ b/open-sse/executors/theoldllm.ts @@ -108,13 +108,9 @@ export function mapModel(model: string): string { const TOKEN_SEED = "oldllm-client-2026"; const UA_PREFIX = CHROME_UA.slice(0, 20); // "Mozilla/5.0 (Windows" -type TheOldLlmProxy = { - type?: string; - host: string; - port: number; - username?: string | null; - password?: string | null; -} | null; +type TheOldLlmProxy = Awaited< + ReturnType +>; interface TheOldLlmFetchDependencies { resolveProxy: () => Promise; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2b110b47f4..01c1cd027c 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -21,6 +21,7 @@ import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHe import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts"; import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; +import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts"; import { getHeaderValueCaseInsensitive, isNoMemoryRequested, @@ -141,6 +142,8 @@ import { getExplicitModelOutputCap, resolveInputTokenCapForGate, } from "@/lib/modelCapabilities.ts"; +import { checkRequestCapabilityFit, deriveRequestCapabilityRequirements, buildCapabilityMismatchMessage } from "@/shared/constants/capabilities/capabilityFilter.ts"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts"; import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts"; import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts"; import { @@ -170,6 +173,7 @@ import { ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE, STREAM_RECOVERY, DEFAULT_MAX_TOKENS, + STREAM_DISCONNECT_GRACE_PERIOD_MS, } from "../config/constants.ts"; import { createRecoverableStream, makeContinuationBody } from "../services/streamRecovery.ts"; import { @@ -207,7 +211,6 @@ import { stageTrace } from "./chatCore/stageTrace.ts"; import { attachCompressionUsageReceiptAfterAnalytics as attachCompressionUsageReceiptAfterAnalyticsFor } from "./chatCore/compressionUsageReceipt.ts"; import { prepareUpstreamBody } from "./chatCore/upstreamBody.ts"; import { getQuotaScopeLabelForProvider } from "../services/antigravityQuotaFamily.ts"; - import { getCallLogPipelineCaptureStreamChunks, getCallLogPipelineMaxSizeBytes, @@ -367,9 +370,7 @@ import { isTpmExhausted, isRpmExhausted, } from "../services/geminiRateLimitTracker.ts"; - import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; - /** * Core chat handler - shared between SSE and Worker * Returns { success, response, status, error } for caller to handle fallback @@ -389,10 +390,8 @@ import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; * @param {boolean} options.isCombo - Whether this request is from a combo * @param {string} options.connectionId - Connection ID for settings lookup */ - // extractSystemRoleMessages extracted to chatCore/claudeSystemRole.ts (#3501); re-exported above so // existing importers (e.g. tests/unit/system-role-extraction.test.ts) keep resolving it from here. - export async function handleChatCore({ body, modelInfo, @@ -428,7 +427,6 @@ export async function handleChatCore({ /* fail open */ } } - // Per-request model-routing metadata (first extracted slice of the request-setup phase). const { apiFormat, customModelTargetFormat, requestedModel } = resolveChatCoreRequestSetup( modelInfo, @@ -442,7 +440,6 @@ export async function handleChatCore({ // (not Math.random) purely to satisfy CodeQL js/insecure-randomness — this id // is a log-correlation token, not a security secret. const traceId = globalThis.crypto.randomUUID().slice(0, 6); - // Emit request.started event for real-time dashboard setImmediate(() => { emit("request.started", { @@ -526,7 +523,6 @@ export async function handleChatCore({ `long-running goal mode enabled: readinessMax=${agentGoalPolicy.readinessMaxTimeoutMs}ms streamRecovery=${agentGoalPolicy.streamRecoveryEnabled}` ); } - let effectiveServiceTier: EffectiveServiceTier = "standard"; // Codex service-tier resolvers extracted to chatCore/serviceTier.ts (#3501); bind the per-request // provider/credentials once and delegate so the existing call sites stay byte-identical. @@ -555,7 +551,6 @@ export async function handleChatCore({ }) ).catch(() => {}); }; - // Key-health updater extracted to chatCore/keyHealth.ts (#3501); bind the per-request log once // and delegate so the existing call sites stay byte-identical. const recordKeyHealthStatus = ( @@ -563,11 +558,9 @@ export async function handleChatCore({ creds: Record | null | undefined, transport?: string ): void => recordKeyHealthStatusFor(status, creds, log, transport); - const persistCodexQuotaState = async (headers: Record | null, status = 0) => { const currentConnectionId = getCurrentConnectionId(); if (provider !== "codex" || !currentConnectionId || !headers) return; - try { const existingProviderData = credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" @@ -582,28 +575,23 @@ export async function handleChatCore({ status, }); if (!built) return; - if (built.exhaustionLog) { log?.debug?.("CODEX", built.exhaustionLog); } - // Invalidate the preflight cache for this connection so the next // isModelAvailable check fetches fresh quota data. if (status === 429) { invalidateCodexQuotaCache(currentConnectionId); } - await updateProviderConnection(currentConnectionId, { providerSpecificData: built.nextProviderData, }); - credentials.providerSpecificData = built.nextProviderData; } catch (err) { const errMessage = err instanceof Error ? err.message : String(err); log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`); } }; - // ── Phase 9.2: Idempotency check ── // Resolve the idempotency key once here and reuse it at the Phase 9.2 save site below, // rather than re-deriving it. (#3821-review LEDGER-6) @@ -622,13 +610,11 @@ export async function handleChatCore({ if (idempotencyHit) { return idempotencyHit; } - // T07: Inject connectionId into credentials so executors can rotate API keys // using providerSpecificData.extraApiKeys (API Key Round-Robin feature) if (connectionId && credentials && !credentials.connectionId) { credentials.connectionId = connectionId; } - // Endpoint/format resolution extracted to chatCore/requestFormat.ts (#3501); pure derivation // from the inbound request, destructured so every downstream use stays byte-identical. const { @@ -1071,6 +1057,13 @@ export async function handleChatCore({ return cacheHit; } + if (targetFormat === FORMATS.OPENAI_RESPONSES && body && typeof body === "object") { + applyResponsesInputPolicy( + body as Record, + credentials?.providerSpecificData?.preserveEncryptedReasoning === true + ); + } + body = sanitizeChatRequestBody(body, sourceFormat, targetFormat); // Per-request opt-out: clients that manage their own context send // `x-omniroute-no-memory: true` to skip memory+skills injection (a null owner @@ -2264,8 +2257,19 @@ export async function handleChatCore({ // the latter is a Kiro/Claude passthrough alias channel with string values, // while namespace identities carry `{namespace, name}` for the #7936 response // seam. Extract first because Kiro merge may reuse `_toolNameMap` below. + // + // #9780 — prefer the dedicated channel: on a pivot the openai->claude/gemini + // step publishes its own alias map on `_toolNameMap`, so that property alone + // yields aliases here. The `_toolNameMap` read stays as the fallback for the + // non-pivot producers (executors/base.ts, cliproxyapi.ts, antigravity). + const namespaceIdentityMap = translatedBody._namespaceToolIdentityMap; const requestToolIdentityMap = - translatedBody._toolNameMap instanceof Map ? translatedBody._toolNameMap : null; + namespaceIdentityMap instanceof Map + ? namespaceIdentityMap + : translatedBody._toolNameMap instanceof Map + ? translatedBody._toolNameMap + : null; + delete translatedBody._namespaceToolIdentityMap; delete translatedBody._toolNameMap; // Kiro: sanitize tool schemas before dispatch. Kiro returns 400 "Improperly @@ -2636,7 +2640,16 @@ export async function handleChatCore({ } } // === /Quota Share enforcement PRE-hook === - + if (isFeatureFlagEnabled("CAPABILITY_FILTER_ENABLED")) { + const fit = checkRequestCapabilityFit(getResolvedModelCapabilities({ provider, model: effectiveModel }), + deriveRequestCapabilityRequirements(body as Record), provider); + if (!fit.compatible) { + const msg = buildCapabilityMismatchMessage(fit.terminalReason!, provider, effectiveModel); + log?.warn?.("CAPABILITY", msg); + trackPendingRequest(model, provider, connectionId, false); + return createErrorResult(400, msg, null, fit.terminalReason, "invalid_request_error"); + } + } // Get executor for this provider (with optional upstream proxy routing) const executor = await resolveExecutorWithProxy(provider); const getExecutionCredentials = () => @@ -4333,9 +4346,14 @@ export async function handleChatCore({ try { const firstChoice = translatedResponse?.choices?.[0]; const msg = firstChoice?.message; + // The response being cached now will be replayed as history on the *next* + // turn, where the read side (translator/index.ts) keys the lookup by the + // message's real position in that future `messages` array — i.e. right + // after everything the client sent this turn. + const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages; cacheReasoningFromAssistantMessage(msg, provider, model, { requestId: skillRequestId, - messageIndex: 0, + messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0, }); } catch { // Cache capture is non-critical — never block the response @@ -4760,12 +4778,15 @@ export async function handleChatCore({ // with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.) if (normalizedStreamStatus === 200 && streamResponseBody) { try { - const body = streamResponseBody as Record; - const choices = body.choices as { message?: Record }[] | undefined; + const streamBody = streamResponseBody as Record; + const choices = streamBody.choices as { message?: Record }[] | undefined; const msg = choices?.[0]?.message; + // See the non-streaming capture above: messageIndex must match the + // position this message will occupy in the *next* turn's history. + const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages; cacheReasoningFromAssistantMessage(msg, provider, model, { requestId: skillRequestId, - messageIndex: 0, + messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0, }); } catch { // Cache capture is non-critical — never block the stream @@ -4908,13 +4929,20 @@ export async function handleChatCore({ }); const handleStreamFailure = streamFailureFinalizers.handleStreamFailure; onPipelineStreamError = streamFailureFinalizers.onPipelineStreamError; - onClientDisconnectFinalize = (event) => - handleStreamFailure({ - status: 499, - message: `Client disconnected: ${event.reason}`, - code: "client_disconnected", - type: "client_disconnected", - }); + // #9653: gives a genuine, race-delayed completion a chance to land (see + // createClientDisconnectGraceHandler's doc comment) before persisting a false + // 499/0-tokens for a request that actually delivered its full response. + onClientDisconnectFinalize = streamFailure.createClientDisconnectGraceHandler({ + isStreamCompletionRecorded: () => streamCompletionRecorded, + gracePeriodMs: STREAM_DISCONNECT_GRACE_PERIOD_MS, + finalize: (event) => + handleStreamFailure({ + status: 499, + message: `Client disconnected: ${event.reason}`, + code: "client_disconnected", + type: "client_disconnected", + }), + }); // For providers using Responses API format, translate stream back to openai (Chat Completions) format // UNLESS client is Droid CLI which expects openai-responses format back @@ -5025,7 +5053,6 @@ export async function handleChatCore({ }), }; } - export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { if (!expiresAt) return false; const expiresAtMs = new Date(expiresAt).getTime(); diff --git a/open-sse/handlers/chatCore/logTruncation.ts b/open-sse/handlers/chatCore/logTruncation.ts index e2a4b51c96..03a854ae57 100644 --- a/open-sse/handlers/chatCore/logTruncation.ts +++ b/open-sse/handlers/chatCore/logTruncation.ts @@ -3,11 +3,11 @@ import { getChatLogMaxDepth, getChatLogArrayTailItems, getChatLogMaxObjectKeys, + getChatLogMaxBodyBytes, } from "@/lib/logEnv"; import { estimateSizeFast } from "../../utils/estimateSize.ts"; export const MEMORY_EXTRACTION_TEXT_LIMIT = 64 * 1024; -const MAX_LOG_BODY_CHARS = 8 * 1024; // 8KB cap for logged request/response bodies export function capMemoryExtractionText(value: string): string { if (value.length <= MEMORY_EXTRACTION_TEXT_LIMIT) return value; @@ -60,9 +60,10 @@ export function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown { /** * Truncate a large object for logging. If its JSON representation exceeds - * MAX_LOG_BODY_CHARS, return a lightweight summary instead of the full clone. - * This prevents persistAttemptLogs from holding multi-MB references to - * translatedBody across 17 call sites per request. + * the configured max body size (getChatLogMaxBodyBytes()), return a + * lightweight summary instead of the full clone. This prevents + * persistAttemptLogs from holding multi-MB references to translatedBody + * across 17 call sites per request. * * When the summarized object carries a `tools` definition, re-attach it * (bounded via `cloneBoundedChatLogPayload`) so the request-details view can @@ -75,8 +76,9 @@ export function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown { export function truncateForLog(value: unknown): Record | null | undefined { if (value === null || value === undefined) return value as null | undefined; if (typeof value !== "object") return value as unknown as Record; - const estimatedSize = estimateSizeFast(value); - if (estimatedSize <= MAX_LOG_BODY_CHARS) return value as Record; + const maxBodyBytes = getChatLogMaxBodyBytes(); + const estimatedSize = estimateSizeFast(value, maxBodyBytes); + if (estimatedSize <= maxBodyBytes) return value as Record; // Object is too large — return a summary instead of a deep clone const obj = value as Record; const summary: Record = { diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 97941262ed..9f77478d8c 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -1524,7 +1524,7 @@ async function handleFalAIImageGeneration({ } const payload = await response.json(); - const images = await normalizeProviderImagePayload(payload, body, log); + const images = await normalizeProviderImagePayload(payload, body, log, "b64_json"); return saveImageSuccessResult({ provider, model, @@ -1714,7 +1714,7 @@ async function handleStabilityAIImageGeneration({ payload = { image: buffer.toString("base64") }; } - const images = await normalizeProviderImagePayload(payload, body, log); + const images = await normalizeProviderImagePayload(payload, body, log, "b64_json"); return saveImageSuccessResult({ provider, model, @@ -1833,7 +1833,7 @@ async function handleBlackForestLabsImageGeneration({ }) : initialPayload; - const images = await normalizeProviderImagePayload(finalPayload, body, log); + const images = await normalizeProviderImagePayload(finalPayload, body, log, "url"); return saveImageSuccessResult({ provider, model, @@ -1908,7 +1908,7 @@ async function handleRecraftImageGeneration({ } const payload = await response.json(); - const images = await normalizeProviderImagePayload(payload, body, log); + const images = await normalizeProviderImagePayload(payload, body, log, "url"); return saveImageSuccessResult({ provider, model, @@ -2200,7 +2200,7 @@ function shouldIncludeStabilityMask(model) { ]).has(model); } -async function normalizeProviderImagePayload(payload, body, log) { +async function normalizeProviderImagePayload(payload, body, log, defaultFormat) { const candidates = []; const pushCandidate = (value) => { @@ -2226,7 +2226,7 @@ async function normalizeProviderImagePayload(payload, body, log) { const normalized = []; for (const candidate of candidates) { - const item = await normalizeProviderImageCandidate(candidate, body); + const item = await normalizeProviderImageCandidate(candidate, body, defaultFormat); if (item) normalized.push(item); } @@ -2240,8 +2240,8 @@ async function normalizeProviderImagePayload(payload, body, log) { return normalized; } -async function normalizeProviderImageCandidate(candidate, body) { - const wantsBase64 = body?.response_format === "b64_json"; +async function normalizeProviderImageCandidate(candidate, body, defaultFormat) { + const wantsBase64 = body?.response_format === "b64_json" || defaultFormat === "b64_json"; let url = null; let b64 = null; diff --git a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts index d4f6eb6da4..7d320a68ef 100644 --- a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts +++ b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts @@ -15,14 +15,15 @@ import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGenerat import { AdobeFireflyError, adobeFireflyGenerateImage, - adobeFireflyImageTimeoutMs, - resolveAdobeAccessToken, - resolveAdobeSourceImageReferences, + resolveAdobeSourceImageIds, resolveAdobeImageModel, } from "../../../services/adobeFireflyClient.ts"; -import { getAdobeReferenceUploadLimit } from "../../../services/adobeFireflyModels.ts"; -import { isAdobeFireflyUpscaleModel } from "../../../services/adobeFireflyUpscale.ts"; -import { handleAdobeFireflyImageUpscale } from "../../imageUpscale/adobeFirefly.ts"; +import { ensureAdobeFireflySession } from "../../../services/adobeFireflySession.ts"; + +function normalizePositiveNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} export async function handleAdobeFireflyImageGeneration({ model, @@ -50,25 +51,22 @@ export async function handleAdobeFireflyImageGeneration({ images?: unknown; [key: string]: unknown; }; - credentials: { apiKey?: string; accessToken?: string }; + credentials: { + apiKey?: string; + accessToken?: string; + connectionId?: string; + providerSpecificData?: { + cookie?: unknown; + access_token?: unknown; + accessToken?: unknown; + browserSessionKey?: unknown; + } | null; + }; log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; fetchImpl?: typeof fetch; }) { const startTime = Date.now(); const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; - - // Topaz upscalers share adobe-firefly but use /v2/3p-images/upsample (no prompt). - if (isAdobeFireflyUpscaleModel(model)) { - return handleAdobeFireflyImageUpscale({ - model, - provider, - body: body as Record, - credentials, - log, - fetchImpl, - }); - } - if (!prompt) { return saveImageErrorResult({ provider, @@ -80,7 +78,17 @@ export async function handleAdobeFireflyImageGeneration({ } try { - const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl); + // Durable session: JWT + Cookie once → auto-rebuild ARP from forter/arkose, + // cache, optional Playwright warm-up. Submit path rotates ARP on 408. + const session = await ensureAdobeFireflySession({ + credentials, + fetchImpl, + log, + }); + const accessToken = session.accessToken; + const sessionCookie = session.cookie || undefined; + const arpSessionId = session.arpSessionId; + const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000); const seed = typeof body.seed === "number" ? body.seed @@ -88,44 +96,26 @@ export async function handleAdobeFireflyImageGeneration({ ? Number(body.seed) : undefined; - // 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 sessionCookie = - (typeof psd?.cookie === "string" && psd.cookie.trim()) || - (typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) || - (typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";") - ? credentials.accessToken - : undefined); + // Cap uploads by model family (matches MediaViewModel GetSourceImageLimit). + const { id: resolvedId } = resolveAdobeImageModel(model); + const maxRefs = resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") ? 4 : 2; - const { spec } = resolveAdobeImageModel(model); - const references = await resolveAdobeSourceImageReferences({ + const sourceImageIds = await resolveAdobeSourceImageIds({ accessToken, body, - max: getAdobeReferenceUploadLimit(spec, "image"), + max: maxRefs, sessionCookie, + arpSessionId, prompt, fetchImpl, log, }); - const explicitTimeout = - typeof body.timeout_ms === "number" - ? body.timeout_ms - : typeof body.timeout_ms === "string" && body.timeout_ms.trim() - ? Number(body.timeout_ms) - : undefined; - const timeoutMs = adobeFireflyImageTimeoutMs({ - timeoutMs: explicitTimeout, - refCount: references.length, - }); - log?.info?.( "IMAGE", `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + - (references.length ? ` | refs: ${references.length}` : "") + - ` | pollTimeoutMs=${timeoutMs}` + (sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") + + ` | session=${session.source}` ); const result = await adobeFireflyGenerateImage({ @@ -137,8 +127,11 @@ export async function handleAdobeFireflyImageGeneration({ quality: body.quality, seed: Number.isFinite(seed as number) ? (seed as number) : undefined, negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, - references: references.length ? references : undefined, + sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, sessionCookie, + arpSessionId, + sessionFingerprint: session.fingerprint, + sessionBrowserKey: session.browserSessionKey, timeoutMs, fetchImpl, log, diff --git a/open-sse/handlers/responsesHandler.ts b/open-sse/handlers/responsesHandler.ts index 37c4169d64..d0a55241c1 100644 --- a/open-sse/handlers/responsesHandler.ts +++ b/open-sse/handlers/responsesHandler.ts @@ -40,7 +40,12 @@ export async function handleResponsesCore({ const customToolNames = collectResponsesCustomToolNames(body?.tools, inputItems); // Convert Responses API format to Chat Completions format - const convertedBody = convertResponsesApiFormat(body, credentials, modelInfo?.provider); + const convertedBody = convertResponsesApiFormat( + body, + credentials, + modelInfo?.provider, + modelInfo?.model + ); // Ensure stream is enabled convertedBody.stream = true; diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 0b26f7d673..1d2fe21e1c 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -4,7 +4,7 @@ * Handles POST /v1/videos/generations requests. Proxies to upstream video * generation providers (ComfyUI AnimateDiff/SVD, SD WebUI AnimateDiff, and * more — see the per-format handlers below). Response format (OpenAI-like): - * { "created": 1234567890, "data": [{ "b64_json": "...", "format": "mp4" }] } + * { "created": 1234567890, "data": [{ "url": "https://…", "format": "mp4" }] } */ import { getVideoProvider, parseVideoModel } from "../config/videoRegistry.ts"; @@ -18,6 +18,16 @@ import { handleNovitaVideoGeneration } from "./videoGeneration/novitaHandler.ts" import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts"; import { handleSegmindVideoGeneration } from "./videoGeneration/providers/segmind.ts"; import { handleAdobeFireflyVideoGeneration } from "./videoGeneration/adobeFireflyHandler.ts"; +import { handleOpenAIVideoGeneration } from "./videoGeneration/openai.ts"; +import { getVideoJobPreset, handleVideoJobGeneration } from "./videoGeneration/job.ts"; +import { + extractRunwayFailureMessage, + normalizeRunwayVideoResult, + resolvePositiveInteger, + resolveRunwayDuration, + resolveRunwayPromptImage, + resolveRunwayRatio, +} from "./videoGeneration/runwayHelpers.ts"; import { getExecutor } from "../executors/index.ts"; import { getKieTaskId, isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { @@ -33,13 +43,94 @@ import { resolveComfyUiBaseUrl, } from "../utils/comfyuiClient.ts"; import { saveCallLog } from "@/lib/usageDb"; +import { getAllCustomModels } from "@/lib/db/models"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { + FetchTimeoutError, + fetchWithTimeout, + getConfiguredTimeout, +} from "@/shared/utils/fetchTimeout"; + +/** + * Resolve the base URL for OpenAI-compatible video generation endpoints. + * Prefers providerSpecificData.baseUrl (from custom node config), falls back to + * top-level credentials.baseUrl, then to the provided fallback. + */ +export function resolveVideoBaseUrl( + credentials: + { baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined, + fallback: string +): string { + const psd = credentials?.providerSpecificData; + const psdBaseUrl = + psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim() + ? psd.baseUrl.trim() + : null; + const topLevelBaseUrl = + typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim() + ? credentials.baseUrl.trim() + : null; + const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; + + if (!nodeBaseUrl) return fallback; + + // Trim trailing slashes + let normalized = nodeBaseUrl; + while (normalized.endsWith("/")) normalized = normalized.slice(0, -1); + if (normalized.endsWith("/videos/generations")) return normalized; + const stripped = normalized.replace(/\/videos\/generations$/, ""); + return `${stripped}/videos/generations`; +} + +/** + * Read generationConfig.preset from the custom model row for the given + * provider/model id. Returns null when the model has no preset configured (or + * the registry is unreadable), so callers can fall back to the sync path. + */ +async function getCustomModelVideoPreset( + providerId: string, + modelId: string +): Promise { + try { + const customModelsMap = (await getAllCustomModels()) as Record< + string, + Array> + >; + const models = customModelsMap[providerId]; + if (!Array.isArray(models)) return null; + for (const model of models) { + if (!model || typeof model !== "object" || model.id !== modelId) continue; + const generationConfig = model.generationConfig; + if ( + generationConfig && + typeof generationConfig === "object" && + typeof (generationConfig as Record).preset === "string" + ) { + return (generationConfig as Record).preset as string; + } + return null; + } + return null; + } catch { + return null; + } +} /** * Handle video generation request */ -export async function handleVideoGeneration({ body, credentials, log }) { - const { provider, model } = parseVideoModel(body.model); + +/** + * Handle video generation request + */ +export async function handleVideoGeneration({ body, credentials, log, resolvedProvider = null }) { + let { provider, model } = parseVideoModel(body.model); + if (resolvedProvider) { + provider = resolvedProvider; + model = body.model.startsWith(provider + "/") + ? body.model.slice(provider.length + 1) + : body.model; + } if (!provider) { return { @@ -51,11 +142,59 @@ export async function handleVideoGeneration({ body, credentials, log }) { const providerConfig = getVideoProvider(provider); if (!providerConfig) { - return { - success: false, - status: 400, - error: `Unknown video provider: ${provider}`, + if (!resolvedProvider) { + return { + success: false, + status: 400, + error: `Unknown video provider: ${provider}`, + }; + } + // Custom provider node. When the custom model row carries a + // generationConfig.preset (e.g. "agnes-video-job"), dispatch through the + // submit → poll job pipeline; otherwise mirror the images route and use the + // generic OpenAI-compatible handler with a synthetic config. + const presetName = await getCustomModelVideoPreset(provider, model); + if (presetName !== null) { + if (!getVideoJobPreset(presetName)) { + return { + success: false, + status: 502, + error: `Unknown video job preset: ${presetName}`, + }; + } + if (log) + log.info("VIDEO", `Custom model ${provider}/${model} — using job preset ${presetName}`); + return handleVideoJobGeneration({ + model, + presetName, + body, + credentials, + log, + }); + } + if (log) + log.info("VIDEO", `Custom model ${provider}/${model} — using OpenAI-compatible handler`); + const syntheticConfig = { + id: provider, + baseUrl: resolveVideoBaseUrl( + credentials, + "http://generative.language.googleapis.com/v1beta/openai/videos/generations" + ), + authType: "apikey", + authHeader: "bearer", + format: "openai-video", }; + return handleOpenAIVideoGeneration({ + model, + body, + credentials, + provider, + providerConfig: syntheticConfig, + log, + }); + } + if (providerConfig.format === "openai-video") { + return handleOpenAIVideoGeneration({ model, provider, providerConfig, body, credentials, log }); } if (providerConfig.format === "vertex-veo") { @@ -158,7 +297,10 @@ export async function handleVideoGeneration({ body, credentials, log }) { log, }); } - + if (resolvedProvider) { + // Custom provider with no matching built-in format — use OpenAI-compatible fallback + return handleOpenAIVideoGeneration({ model, provider, providerConfig, body, credentials, log }); + } return { success: false, status: 400, @@ -832,148 +974,6 @@ const RUNWAY_TERMINAL_FAILURE_STATUSES = new Set([ "DELETED", ]); -function resolveRunwayPromptImage(body) { - const directCandidates = [ - body.promptImage, - body.prompt_image, - body.image, - body.image_url, - body.imageUrl, - body.provider_options?.promptImage, - body.provider_options?.prompt_image, - ]; - - for (const candidate of directCandidates) { - if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); - if (candidate && typeof candidate === "object") return candidate; - if (Array.isArray(candidate) && candidate.length > 0) return candidate; - } - - const arrayCandidates = [ - body.imageUrls, - body.image_urls, - body.provider_options?.imageUrls, - body.provider_options?.image_urls, - ]; - for (const candidate of arrayCandidates) { - if (Array.isArray(candidate) && candidate.length > 0) return candidate; - } - - return null; -} - -function resolveRunwayRatio(body) { - const aspectRatio = typeof body.aspect_ratio === "string" ? body.aspect_ratio : body.aspectRatio; - if (aspectRatio === "1280:720" || aspectRatio === "720:1280") return aspectRatio; - if (aspectRatio === "16:9") return "1280:720"; - if (aspectRatio === "9:16") return "720:1280"; - - const size = typeof body.size === "string" ? body.size : ""; - const [widthRaw, heightRaw] = size.split("x"); - const width = Number(widthRaw); - const height = Number(heightRaw); - if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { - return width >= height ? "1280:720" : "720:1280"; - } - - return "1280:720"; -} - -function resolveRunwayDuration(body) { - if (Number.isFinite(body.duration)) { - return clampRunwayDuration(body.duration); - } - - if (Number.isFinite(body.frames) && Number.isFinite(body.fps) && Number(body.fps) > 0) { - return clampRunwayDuration(Number(body.frames) / Number(body.fps)); - } - - return 5; -} - -function clampRunwayDuration(value) { - const duration = Math.round(Number(value)); - if (!Number.isFinite(duration)) return 5; - return Math.min(10, Math.max(2, duration)); -} - -function resolvePositiveInteger(value, fallback) { - const numeric = Number(value); - if (!Number.isFinite(numeric) || numeric <= 0) return fallback; - return Math.floor(numeric); -} - -function extractRunwayOutputUrls(task) { - const rawOutput = Array.isArray(task?.output) - ? task.output - : Array.isArray(task?.result) - ? task.result - : []; - - return rawOutput - .map((entry) => { - if (typeof entry === "string") return entry; - if (!entry || typeof entry !== "object") return null; - return entry.url || entry.uri || entry.videoUrl || entry.video_url || null; - }) - .filter((value) => typeof value === "string" && value.length > 0); -} - -function extractRunwayFailureMessage(task) { - const directCandidates = [ - task?.failure, - task?.failureReason, - task?.error, - task?.errorMessage, - task?.message, - ]; - for (const candidate of directCandidates) { - if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); - } - - if (task?.failure && typeof task.failure === "object") { - const nestedCandidates = [ - task.failure.message, - task.failure.reason, - task.failure.error, - task.failure.code, - ]; - for (const candidate of nestedCandidates) { - if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); - } - } - - return null; -} - -async function normalizeRunwayVideoResult(task, body) { - const urls = extractRunwayOutputUrls(task); - if (urls.length === 0) { - throw new Error( - `Runway task completed without output URLs: ${JSON.stringify(task).slice(0, 400)}` - ); - } - - if (body.response_format === "url") { - return urls.map((url) => ({ url, format: "mp4" })); - } - - const videos = []; - for (const url of urls) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Runway output fetch failed (${response.status})`); - } - const arrayBuffer = await response.arrayBuffer(); - videos.push({ - b64_json: Buffer.from(arrayBuffer).toString("base64"), - format: "mp4", - }); - } - - return videos; -} - async function handleHaiperVideoGeneration({ model, provider, diff --git a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts index b6f267e0bc..b812bb7b74 100644 --- a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts +++ b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts @@ -9,11 +9,10 @@ import { sanitizeErrorMessage } from "../../utils/error.ts"; import { AdobeFireflyError, adobeFireflyGenerateVideo, - resolveAdobeAccessToken, - resolveAdobeSourceImageReferences, + resolveAdobeSourceImageIds, resolveAdobeVideoModel, } from "../../services/adobeFireflyClient.ts"; -import { getAdobeReferenceUploadLimit } from "../../services/adobeFireflyModels.ts"; +import { ensureAdobeFireflySession } from "../../services/adobeFireflySession.ts"; function normalizePositiveNumber(value: unknown, fallback: number): number { const n = Number(value); @@ -32,7 +31,17 @@ export async function handleAdobeFireflyVideoGeneration({ provider: string; providerConfig?: { baseUrl?: string }; body: Record; - credentials?: { apiKey?: string; accessToken?: string } | null; + credentials?: { + apiKey?: string; + accessToken?: string; + connectionId?: string; + providerSpecificData?: { + cookie?: unknown; + access_token?: unknown; + accessToken?: unknown; + browserSessionKey?: unknown; + } | null; + } | null; log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; fetchImpl?: typeof fetch; }) { @@ -47,7 +56,14 @@ export async function handleAdobeFireflyVideoGeneration({ } try { - const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl); + const session = await ensureAdobeFireflySession({ + credentials, + fetchImpl, + log, + }); + const accessToken = session.accessToken; + const sessionCookie = session.cookie || undefined; + const arpSessionId = session.arpSessionId; const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300_000); const seed = typeof body.seed === "number" @@ -55,22 +71,16 @@ export async function handleAdobeFireflyVideoGeneration({ : typeof body.seed === "string" && String(body.seed).trim() ? Number(body.seed) : undefined; - // Keep raw paste for Cookie + sherlockToken (x-arp-session-id). - const psd = (credentials as { providerSpecificData?: { cookie?: string } }) - ?.providerSpecificData; - const sessionCookie = - (typeof psd?.cookie === "string" && psd.cookie.trim()) || - (typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) || - (typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";") - ? credentials.accessToken - : undefined); - const { spec } = resolveAdobeVideoModel(String(model)); - const references = await resolveAdobeSourceImageReferences({ + // 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({ accessToken, body, - max: getAdobeReferenceUploadLimit(spec, "image"), + max: maxFrames, sessionCookie, + arpSessionId, prompt, fetchImpl, log, @@ -79,7 +89,8 @@ export async function handleAdobeFireflyVideoGeneration({ log?.info?.( "VIDEO", `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + - (references.length ? ` | refs: ${references.length}` : "") + (sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") + + ` | session=${session.source}` ); const result = await adobeFireflyGenerateVideo({ @@ -99,8 +110,11 @@ export async function handleAdobeFireflyVideoGeneration({ ? body.negativePrompt : undefined, generateAudio: body.generate_audio !== false && body.generateAudio !== false, - references: references.length ? references : undefined, + sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, sessionCookie, + arpSessionId, + sessionFingerprint: session.fingerprint, + sessionBrowserKey: session.browserSessionKey, timeoutMs, fetchImpl, log, diff --git a/open-sse/handlers/videoGeneration/job.ts b/open-sse/handlers/videoGeneration/job.ts new file mode 100644 index 0000000000..aea6a8b9cc --- /dev/null +++ b/open-sse/handlers/videoGeneration/job.ts @@ -0,0 +1,418 @@ +/** + * Async job/poll video generation for custom OpenAI-compatible provider nodes + * whose /videos surface is a submit → poll → fetch-result API (e.g. Agnes + * Video V2.0, muapi.ai, OpenAI Sora). Presets are declarative data — the + * handler here is one family; everything else is per-preset config. + * + * Response shape stays OpenAI-like: { created, data: [{ url, format: "mp4" }] } so the + * /v1/videos/generations route returns the same contract as the synchronous + * path. + */ + +import { + fetchWithTimeout, + FetchTimeoutError, + getConfiguredTimeout, +} from "@/shared/utils/fetchTimeout"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { sleep } from "../../utils/sleep.ts"; + +interface LogLike { + info?: (tag: string, msg: string, meta?: unknown) => void; + warn?: (tag: string, msg: string, meta?: unknown) => void; + error?: (tag: string, msg: string, meta?: unknown) => void; +} + +interface CredentialsLike { + providerSpecificData?: { baseUrl?: unknown } | null; + baseUrl?: unknown; + apiKey?: unknown; + accessToken?: unknown; +} + +/** Dot-path reader restricted to plain objects/arrays (no prototypes). */ +function readPath(value: unknown, path: string): unknown { + if (!path) return value; + let current: unknown = value; + for (const segment of path.split(".")) { + if (current === null || current === undefined) return undefined; + if (typeof current !== "object") return undefined; + if (Array.isArray(current)) { + const index = Number(segment); + if (!Number.isInteger(index) || index < 0 || index >= current.length) return undefined; + current = current[index]; + continue; + } + if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined; + current = (current as Record)[segment]; + } + return current; +} + +/** Non-empty string from a dot path, or null. */ +function readStringPath(value: unknown, path: string): string | null { + const found = readPath(value, path); + return typeof found === "string" && found.trim() ? found : null; +} + +function isDoneStatus( + status: unknown, + done: string[], + failed: string[] +): "done" | "failed" | "pending" { + if (typeof status !== "string") return "pending"; + if (failed.includes(status)) return "failed"; + if (done.includes(status)) return "done"; + return "pending"; +} + +export type VideoJobPreset = { + id: string; + displayName: string; + /** auth header name plus value scheme */ + authHeaderName: "x-api-key" | "Authorization"; + authScheme: "bearer" | "raw"; + baseUrlFallback: string; + submit: { + method: "POST"; + /** may contain {model} — substituted before POST */ + path: string; + buildBody: (params: { + model?: string; + prompt?: string; + duration?: number; + extras: Record; + }) => Record; + }; + /** dot path into the submit response identifying the job */ + taskIdPath: string; + poll: { + /** contains {taskId} */ + pathTemplate: string; + }; + statusPath: string; + statusDone: string[]; + statusFailed: string[]; + /** dot path into the poll response holding the finished video URL/array */ + resultPath: string; + maxPolls: number; + pollIntervalMs: number; +}; + +// #9820: declarative presets for the shipping async job/poll video providers. +const VIDEO_JOB_PRESETS: Record = { + "agnes-video-job": { + id: "agnes-video-job", + displayName: "Agnes Video V2.0", + authHeaderName: "x-api-key", + authScheme: "raw", + // Real default, matching the Agnes Video V2.0 reference: POST /v1/videos with + // x-api-key auth; GET /v1/videos/{task_id} returns status/progress/metadata. + baseUrlFallback: "https://apihub.agnes-ai.com", + submit: { + method: "POST", + path: "/v1/videos", + buildBody: ({ model, prompt, extras }) => ({ + model, + prompt, + // passthrough of image/mode/num_frames/frame_rate/… — the generic + // route body uses .catchall, so provider-specific knobs survive. + ...extras, + }), + }, + taskIdPath: "task_id", + poll: { pathTemplate: "/v1/videos/{taskId}" }, + statusPath: "status", + statusDone: ["completed"], + statusFailed: ["failed"], + resultPath: "metadata.url", + maxPolls: 60, + pollIntervalMs: 2000, + }, + "muapi-video-job": { + id: "muapi-video-job", + displayName: "muapi.ai", + authHeaderName: "x-api-key", + authScheme: "raw", + // muapi.ai video/audio surface is Replicate-style: POST /api/v1/{model} + // returns { request_id }; poll GET /api/v1/predictions/{id}/result. + baseUrlFallback: "https://api.muapi.ai", + submit: { + method: "POST", + path: "/api/v1/{model}", + buildBody: (params) => { + const { prompt, duration, extras } = params; + return { + prompt, + ...(typeof duration === "number" ? { duration } : {}), + ...extras, + }; + }, + }, + taskIdPath: "request_id", + poll: { pathTemplate: "/api/v1/predictions/{taskId}/result" }, + statusPath: "status", + statusDone: ["completed"], + statusFailed: ["failed"], + resultPath: "outputs", + maxPolls: 60, + pollIntervalMs: 2000, + }, + "sora-job": { + id: "sora-job", + displayName: "OpenAI Sora", + authHeaderName: "Authorization", + authScheme: "bearer", + baseUrlFallback: "https://api.openai.com", + submit: { + method: "POST", + path: "/v1/videos", + buildBody: (params) => { + const { model, prompt, duration, extras } = params; + // seconds is a STRING enum ("4"|"8"|"12") in the Sora API; absolute + // size mapping is intentionally not forced here. + return { + model, + prompt, + ...(typeof duration === "number" ? { seconds: String(duration) } : {}), + ...extras, + }; + }, + }, + taskIdPath: "id", + poll: { pathTemplate: "/v1/videos/{taskId}" }, + statusPath: "status", + statusDone: ["completed"], + statusFailed: ["failed"], + resultPath: "data", + maxPolls: 60, + pollIntervalMs: 2000, + }, +}; + +/** Resolve a configured job preset; null when the preset is unknown/none. */ +export function getVideoJobPreset(presetName: unknown): VideoJobPreset | null { + if (typeof presetName !== "string") return null; + const preset = VIDEO_JOB_PRESETS[presetName]; + return preset ?? null; +} + +/** + * Handle a video-generation job via the submit→poll preset pipeline. + * Returns the same shape as the sync handlers: { success, data?: …, status?, error? }. + */ +export async function handleVideoJobGeneration({ + model, + presetName, + body, + credentials, + log, + maxPolls: maxPollsOverride, + pollIntervalMs: pollIntervalOverride, +}: { + model: string; + presetName: string; + body: Record; + credentials?: unknown; + log?: { + info?: (tag: string, msg: string, meta?: unknown) => void; + error?: (tag: string, msg: string) => void; + }; + maxPolls?: number; + pollIntervalMs?: number; +}) { + const preset = getVideoJobPreset(presetName); + if (!preset) { + return { + success: false, + status: 400, + error: `Unknown video job preset: ${presetName}`, + }; + } + + const baseUrl = resolveJobBaseUrl(credentials, preset.baseUrlFallback); + log?.info?.("VIDEO", `Job preset ${presetName} submitting ${model}`); + log?.info?.("VIDEO", JSON.stringify({ baseUrl })); + + const bodyForPreset = preset.submit.buildBody({ + model: model, + prompt: typeof body.prompt === "string" ? body.prompt : undefined, + duration: typeof body.duration === "number" ? body.duration : undefined, + // passthrough of the remainder — the API keeps catchall extras + extras: Object.fromEntries( + Object.entries(body ?? {}).filter( + ([key]) => key !== "model" && key !== "prompt" && key !== "duration" + ) + ), + }); + + const submitPath = preset.submit.path.replace("{model}", encodeURIComponent(model)); + const submitUrl = `${baseUrl}${submitPath}`; // baseUrl never ends with "/" + const submitResult = await fetchJson(submitUrl, { + method: preset.submit.method, + headers: buildJobHeaders(preset, credentials), + body: JSON.stringify(bodyForPreset), + log, + }); + if (submitResult.ok === false) { + return { success: false, status: submitResult.status, error: submitResult.error }; + } + + const taskId = readStringPath(submitResult.data, preset.taskIdPath); + if (!taskId) { + return { + success: false, + status: 502, + error: `Video provider did not return a job id (${presetName})`, + }; + } + + // Poll loop. + const maxPolls = maxPollsOverride ?? preset.maxPolls; + const pollInterval = pollIntervalOverride ?? preset.pollIntervalMs; + + for (let attempt = 1; attempt <= maxPolls; attempt += 1) { + await sleep(pollInterval); + const pollUrl = `${baseUrl}${preset.poll.pathTemplate.replace("{taskId}", encodeURIComponent(taskId))}`; + const pollResult = await fetchJson(pollUrl, { + method: "GET", + headers: buildJobHeaders(preset, credentials), + log, + }); + if (pollResult.ok === false) { + return { success: false, status: pollResult.status, error: pollResult.error }; + } + + const status = readPath(pollResult.data, preset.statusPath); + const jobState = isDoneStatus(status, preset.statusDone, preset.statusFailed); + if (jobState === "done") { + const url = readResultUrl(pollResult.data, preset.resultPath); + if (!url) { + return { + success: false, + status: 502, + error: `Video job completed but no result URL found (${presetName})`, + }; + } + log?.info?.("VIDEO", `Job completed after ${attempt} poll(s)`); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ url, format: "mp4" }], + }, + }; + } + if (jobState === "failed") { + return { + success: false, + status: 502, + error: `Video job failed (${presetName})`, + }; + } + } + + return { + success: false, + status: 504, + error: `Video job timed out after ${maxPolls} polls (${presetName})`, + }; +} + +function buildJobHeaders(preset: VideoJobPreset, credentials?: unknown): Record { + const creds = credentials as CredentialsLike | null | undefined; + const apiKey = + typeof creds?.apiKey === "string" && creds.apiKey + ? creds.apiKey + : typeof creds?.accessToken === "string" && creds.accessToken + ? creds.accessToken + : ""; + const headers: Record = { "Content-Type": "application/json" }; + if (!apiKey) return headers; + if (preset.authScheme === "raw") { + headers[preset.authHeaderName] = apiKey; + } else { + headers[preset.authHeaderName] = `Bearer ${apiKey}`; + } + return headers; +} + +function resolveJobBaseUrl(credentials: unknown, fallback: string): string { + const creds = credentials as CredentialsLike | null | undefined; + const psdBaseUrl = + creds?.providerSpecificData?.baseUrl != null && + typeof creds.providerSpecificData.baseUrl === "string" && + creds.providerSpecificData.baseUrl.trim() + ? (creds.providerSpecificData.baseUrl as string).trim() + : null; + const topLevelBaseUrl = + creds?.baseUrl != null && typeof creds.baseUrl === "string" && creds.baseUrl.trim() + ? (creds.baseUrl as string).trim() + : null; + const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; + if (!nodeBaseUrl) return fallback.replace(/\/+$/, ""); + let normalized = nodeBaseUrl; + while (normalized.endsWith("/")) normalized = normalized.slice(0, -1); + return normalized; +} + +async function fetchJson( + url: string, + { + method, + headers, + body, + log, + }: { + method: string; + headers: Record; + body?: string; + log?: LogLike; + } +): Promise<{ ok: true; data: unknown } | { ok: false; status: number; error: string }> { + try { + const response = await fetchWithTimeout(url, { + method, + headers, + ...(body !== undefined ? { body } : {}), + timeoutMs: getConfiguredTimeout(), + }); + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("VIDEO", `Upstream ${response.status} for ${url}: ${errorText.slice(0, 200)}`); + return { ok: false, status: response.status, error: errorText }; + } + const data = await response.json(); + return { ok: true, data }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + const isTimeout = + err instanceof FetchTimeoutError || (err instanceof Error && err.name === "AbortError"); + log?.error?.( + "VIDEO", + `${isTimeout ? "Timeout" : "Request error"} for ${url}: ${sanitizeErrorMessage(message)}` + ); + return { + ok: false, + status: isTimeout ? 504 : 502, + error: `Video provider error: ${sanitizeErrorMessage(message)}`, + }; + } +} + +function readResultUrl(data: unknown, resultPath: string): string | null { + const found = readPath(data, resultPath); + if (typeof found === "string" && found.trim()) return found.trim(); + if (Array.isArray(found)) { + const first = found[0]; + // muapi-style: resultPath "outputs" resolves to ["https://…"]. + if (typeof first === "string" && first.trim()) return first.trim(); + // sora-style: resultPath "data" resolves to [{ url: "https://…" }]. + if (first && typeof first === "object" && !Array.isArray(first)) { + const urlEntry = (first as Record).url; + if (typeof urlEntry === "string" && urlEntry.trim()) return urlEntry.trim(); + } + return null; + } + return null; +} diff --git a/open-sse/handlers/videoGeneration/openai.ts b/open-sse/handlers/videoGeneration/openai.ts new file mode 100644 index 0000000000..b53ae51fea --- /dev/null +++ b/open-sse/handlers/videoGeneration/openai.ts @@ -0,0 +1,156 @@ +import { + fetchWithTimeout, + FetchTimeoutError, + getConfiguredTimeout, +} from "@/shared/utils/fetchTimeout"; +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +interface LogLike { + info?: (tag: string, msg: string, meta?: unknown) => void; + error?: (tag: string, msg: string) => void; +} + +interface CredentialsLike { + providerSpecificData?: { baseUrl?: unknown } | null; + baseUrl?: unknown; + apiKey?: unknown; + accessToken?: unknown; +} + +/** + * Resolve the video generation endpoint URL from credentials and fallback. + * Handles baseUrl from providerSpecificData or top-level credentials. + */ +function resolveVideoEndpoint(credentials: unknown, fallback: string): string { + const creds = credentials as CredentialsLike | null | undefined; + const psdBaseUrl = + creds?.providerSpecificData?.baseUrl != null && + typeof creds.providerSpecificData.baseUrl === "string" && + creds.providerSpecificData.baseUrl.trim() + ? creds.providerSpecificData.baseUrl.trim() + : null; + const topLevelBaseUrl = + creds?.baseUrl != null && typeof creds.baseUrl === "string" && creds.baseUrl.trim() + ? creds.baseUrl.trim() + : null; + const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; + let n = nodeBaseUrl; + while (n.endsWith("/")) n = n.slice(0, -1); + if (n.endsWith("/videos/generations")) return n; + return `${n}/videos/generations`; +} + +/** + * Fetch the video generation endpoint with timeout and error handling. + */ +async function fetchVideoEndpoint( + url: string, + { headers, body, log }: { headers: Record; body: string; log?: LogLike } +) { + try { + const response = await fetchWithTimeout(url, { + method: "POST", + headers, + body, + timeoutMs: getConfiguredTimeout(), + }); + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("VIDEO", `Upstream ${response.status} for ${url}: ${errorText}`); + return { success: false, status: response.status, error: errorText }; + } + const data = await response.json(); + return { + success: true, + data: { created: data.created || Math.floor(Date.now() / 1000), data: data.data || [] }, + }; + } catch (err) { + const message = err?.message; + const isTimeout = err instanceof FetchTimeoutError || err?.name === "AbortError"; + log?.error?.( + "VIDEO", + `${isTimeout ? "Timeout" : "Request error"} for ${url}: ${sanitizeErrorMessage(message || err)}` + ); + return { + success: false, + status: isTimeout ? 504 : 502, + error: `Video provider error: ${sanitizeErrorMessage(message || err)}`, + }; + } +} + +/** + * Handle OpenAI-compatible video generation. + * This handler is dispatched for custom providers with format "openai-video". + */ +export async function handleOpenAIVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string; authHeader: string }; + body: unknown; + credentials: unknown; + log?: LogLike; +}) { + const startTime = Date.now(); + const creds = credentials as CredentialsLike | null | undefined; + const apiToken = creds?.apiKey || creds?.accessToken; + const endpoint = resolveVideoEndpoint(credentials, providerConfig.baseUrl); + const headers = { + "Content-Type": "application/json", + ...(providerConfig.authHeader === "x-api-key" + ? { "x-api-key": String(apiToken) } + : { Authorization: `Bearer ${apiToken}` }), + }; + const bodyObj = body as Record; + const upstreamBody = { + model, + prompt: (bodyObj.prompt ?? "") as string, + ...(typeof bodyObj.duration === "number" && { duration: bodyObj.duration }), + }; + const logRequestBody = { + model: bodyObj.model, + prompt: + typeof bodyObj.prompt === "string" + ? bodyObj.prompt.slice(0, 200) + : String(bodyObj.prompt ?? ""), + duration: bodyObj.duration, + }; + log?.info?.("VIDEO", `OpenAI-compatible video generation: ${provider}/${model} -> ${endpoint}`, { + body: logRequestBody, + }); + + const fetchResult = await fetchVideoEndpoint(endpoint, { + headers, + body: JSON.stringify(upstreamBody), + log, + }); + + if (!fetchResult.success) { + return { success: false, status: fetchResult.status, error: fetchResult.error }; + } + + // Save call log for billing/tracking + await saveCallLog({ + provider, + model: String(bodyObj.model), + endpoint: "video", + status: fetchResult.status, + durationMs: Date.now() - startTime, + tokensIn: 0, + tokensOut: 0, + requestId: null, + }); + + return { + success: true, + data: fetchResult.data, + }; +} diff --git a/open-sse/handlers/videoGeneration/runwayHelpers.ts b/open-sse/handlers/videoGeneration/runwayHelpers.ts new file mode 100644 index 0000000000..94917a55ad --- /dev/null +++ b/open-sse/handlers/videoGeneration/runwayHelpers.ts @@ -0,0 +1,125 @@ +export function resolveRunwayPromptImage(body) { + const directCandidates = [ + body.promptImage, + body.prompt_image, + body.image, + body.image_url, + body.imageUrl, + body.provider_options?.promptImage, + body.provider_options?.prompt_image, + ]; + + for (const candidate of directCandidates) { + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + if (candidate && typeof candidate === "object") return candidate; + if (Array.isArray(candidate) && candidate.length > 0) return candidate; + } + + const arrayCandidates = [ + body.imageUrls, + body.image_urls, + body.provider_options?.imageUrls, + body.provider_options?.image_urls, + ]; + for (const candidate of arrayCandidates) { + if (Array.isArray(candidate) && candidate.length > 0) return candidate; + } + + return null; +} + +export function resolveRunwayRatio(body) { + const aspectRatio = typeof body.aspect_ratio === "string" ? body.aspect_ratio : body.aspectRatio; + if (aspectRatio === "1280:720" || aspectRatio === "720:1280") return aspectRatio; + if (aspectRatio === "16:9") return "1280:720"; + if (aspectRatio === "9:16") return "720:1280"; + + const size = typeof body.size === "string" ? body.size : ""; + const [widthRaw, heightRaw] = size.split("x"); + const width = Number(widthRaw); + const height = Number(heightRaw); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + return width >= height ? "1280:720" : "720:1280"; + } + + return "1280:720"; +} + +export function resolveRunwayDuration(body) { + if (Number.isFinite(body.duration)) return clampRunwayDuration(body.duration); + if (Number.isFinite(body.frames) && Number.isFinite(body.fps) && Number(body.fps) > 0) { + return clampRunwayDuration(Number(body.frames) / Number(body.fps)); + } + return 5; +} + +function clampRunwayDuration(value) { + const duration = Math.round(Number(value)); + if (!Number.isFinite(duration)) return 5; + return Math.min(10, Math.max(2, duration)); +} + +export function resolvePositiveInteger(value, fallback) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) return fallback; + return Math.floor(numeric); +} + +function extractRunwayOutputUrls(task) { + const rawOutput = Array.isArray(task?.output) + ? task.output + : Array.isArray(task?.result) + ? task.result + : []; + return rawOutput + .map((entry) => { + if (typeof entry === "string") return entry; + if (!entry || typeof entry !== "object") return null; + return entry.url || entry.uri || entry.videoUrl || entry.video_url || null; + }) + .filter((value) => typeof value === "string" && value.length > 0); +} + +export function extractRunwayFailureMessage(task) { + const directCandidates = [ + task?.failure, + task?.failureReason, + task?.error, + task?.errorMessage, + task?.message, + ]; + for (const candidate of directCandidates) { + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + } + if (task?.failure && typeof task.failure === "object") { + const nestedCandidates = [ + task.failure.message, + task.failure.reason, + task.failure.error, + task.failure.code, + ]; + for (const candidate of nestedCandidates) { + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + } + } + return null; +} + +export async function normalizeRunwayVideoResult(task, body) { + const urls = extractRunwayOutputUrls(task); + if (urls.length === 0) { + throw new Error( + `Runway task completed without output URLs: ${JSON.stringify(task).slice(0, 400)}` + ); + } + if (body.response_format === "url") return urls.map((url) => ({ url, format: "mp4" })); + + const videos = []; + for (const url of urls) { + const response = await fetch(url); + if (!response.ok) throw new Error(`Runway output fetch failed (${response.status})`); + const arrayBuffer = await response.arrayBuffer(); + videos.push({ b64_json: Buffer.from(arrayBuffer).toString("base64"), format: "mp4" }); + } + return videos; +} diff --git a/open-sse/services/accountSemaphore.ts b/open-sse/services/accountSemaphore.ts index 2d3a1f50b8..ec0f06f090 100644 --- a/open-sse/services/accountSemaphore.ts +++ b/open-sse/services/accountSemaphore.ts @@ -54,21 +54,8 @@ export function buildAccountSemaphoreKey({ return `${String(provider)}:${String(accountKey)}`; } -/** - * Effective positive cap, or null when the semaphore is bypassed (unset/<=0). - * - * Narrowing companion of {@link isBypassed}: that one returns a plain boolean, so - * TypeScript cannot narrow `number | null` to `number` in its else-branch (a - * `x is null | undefined` predicate would be unsound — 0 bypasses too). Callers - * that need the VALUE after the guard go through here instead of casting. - */ -function resolveActiveCap(maxConcurrency?: number | null): number | null { - if (maxConcurrency == null || maxConcurrency <= 0) return null; - return maxConcurrency; -} - function isBypassed(maxConcurrency?: number | null): boolean { - return resolveActiveCap(maxConcurrency) === null; + return maxConcurrency == null || maxConcurrency <= 0; } function createNoopReleaseFn(): () => void { @@ -205,8 +192,7 @@ export function acquire( maxQueueSize = DEFAULT_MAX_QUEUE_SIZE, }: AcquireAccountSemaphoreOptions = {} ): Promise<() => void> { - const activeCap = resolveActiveCap(maxConcurrency); - if (activeCap === null) { + if (isBypassed(maxConcurrency)) { return Promise.resolve(createNoopReleaseFn()); } @@ -214,7 +200,9 @@ export function acquire( return Promise.reject(makeAbortError(signal)); } - const gate = ensureGate(semaphoreKey, activeCap); + // isBypassed() above already excluded null/<=0 — ensureGate requires a plain + // number, but a boolean-returning helper isn't a type predicate TS can narrow on. + const gate = ensureGate(semaphoreKey, maxConcurrency as number); clearCleanupTimer(gate); if (gate.running < gate.maxConcurrency && !isBlocked(gate)) { diff --git a/open-sse/services/adobeFireflyBrowserLogin.ts b/open-sse/services/adobeFireflyBrowserLogin.ts index 1482971c3e..a3ab0443b1 100644 --- a/open-sse/services/adobeFireflyBrowserLogin.ts +++ b/open-sse/services/adobeFireflyBrowserLogin.ts @@ -3,42 +3,202 @@ * * Firefly needs an Adobe IMS access_token JWT (Bearer) issued for * client_id `clio-playground-web`. That JWT is NEVER present in - * cookies/localStorage тАФ the SPA only holds it in memory and attaches it + * cookies/localStorage — the SPA only holds it in memory and attaches it * as `Authorization: Bearer ` on XHRs to firefly-3p.ff.adobe.io. * - * IMPORTANT: The VibeProxyServices.exe is a pkg-packaged Node binary. + * IMPORTANT: The standalone executable is a pkg-packaged Node binary. * Dynamic `import("playwright")` fails there (native bindings / browsers * are not in the package). This module launches the **system** Chrome or * Edge with `--remote-debugging-port` and talks pure Chrome DevTools - * Protocol over WebSocket тАФ zero Playwright dependency. + * Protocol over WebSocket — zero Playwright dependency. */ import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import http from "node:http"; import { createServer } from "node:net"; -import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + decodeAdobeJwtPayload, + isAdobeUserAccessToken, + looksLikeAdobeJwt, +} from "./adobeFireflyClient.ts"; +import { isAdobeFireflyApiUrl, isAdobeLoginCookieDomain } from "./adobeFireflySecurity.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +/** + * Loopback HTTP GET that MUST NOT use globalThis.fetch. + * OmniRoute patches fetch with a proxy dispatcher (proxyFetch.ts); routing + * 127.0.0.1 Chrome DevTools through that proxy yields PROXY_UNREACHABLE / + * "Chrome DevTools did not become ready: fetch failed" while Chrome is fine. + */ +function loopbackHttpGetJson( + port: number, + path: string, + timeoutMs = 2000 +): Promise { + return new Promise((resolve, reject) => { + const req = http.get( + { + host: "127.0.0.1", + port, + path, + timeout: Math.max(500, timeoutMs), + headers: { Accept: "application/json" }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + res.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) { + reject(new Error(`HTTP ${res.statusCode || 0} ${path}`)); + return; + } + try { + resolve(JSON.parse(body || "null") as T); + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + } + ); + req.on("timeout", () => { + req.destroy(new Error(`timeout ${timeoutMs}ms ${path}`)); + }); + req.on("error", reject); + }); +} + const FIREFLY_HOME_URL = "https://firefly.adobe.com/"; -const FIREFLY_3P_HOST_SUFFIX = "firefly-3p.ff.adobe.io"; // Bounded quantifiers (Hard Rule: avoid ReDoS on adversarial Authorization headers). const ADOBE_BEARER_REGEX = /^Bearer\s+(eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096})/i; +const ADOBE_JWT_IN_TEXT_REGEX = + /eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g; const DEFAULT_LOGIN_TIMEOUT_MS = 300_000; const MIN_LOGIN_TIMEOUT_MS = 15_000; const MAX_LOGIN_TIMEOUT_MS = 600_000; const POLL_INTERVAL_MS = 400; -const CDP_READY_TIMEOUT_MS = 30_000; +/** Interactive sign-in must surface Chrome quickly; 12s is enough if spawn works. */ +const CDP_READY_TIMEOUT_MS = 12_000; +const CDP_READY_TIMEOUT_RETRY_MS = 20_000; +/** Risk cookies that go stale and must be re-minted by the SPA (never seed on force warm). */ +const ADOBE_RISK_COOKIE_NAMES = new Set([ + "fortertoken", + "forter", + "arkose", + "sherlocktoken", + "x-arp-session-id", +]); export interface AdobeFireflyBrowserLoginResult { success: boolean; credentials?: { accessToken?: string; cookie?: string }; - /** Best-effort Adobe account label (email or user id) decoded from the JWT. */ + arpSessionId?: string; + /** Human-readable Adobe account label resolved from IMS userinfo. */ account?: string; error?: string; } +export interface AdobeFireflyCdpRefreshResult { + accessToken: string; + cookie: string; + arpSessionId: string; +} + +type AdobeFireflyBrowserLog = { + info?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; +}; + +/** + * Separate queues so a multi-minute background Forter warm cannot block + * interactive "Sign in with browser" (and vice versa uses different profile dirs). + */ +let interactiveCdpChain: Promise = Promise.resolve(); +let backgroundCdpChain: Promise = Promise.resolve(); + +/** @deprecated test alias — both chains reset together. */ +export function __resetAdobeFireflyCdpChainsForTests(): void { + interactiveCdpChain = Promise.resolve(); + backgroundCdpChain = Promise.resolve(); +} + +/** True when cookie name is a colligo/Forter risk token (must re-mint, never re-seed stale). */ +export function isAdobeRiskCookieName(name: string): boolean { + return ADOBE_RISK_COOKIE_NAMES.has( + String(name || "") + .trim() + .toLowerCase() + ); +} + +/** Epoch ms embedded in forterToken (`…_{ms}__UDF43…`), or 0. */ +export function extractAdobeForterTimestampFromValue(value: string): number { + const f = String(value || "").trim(); + if (!f) return 0; + let decoded = f; + try { + if (/%[0-9A-Fa-f]{2}/.test(decoded)) decoded = decodeURIComponent(decoded); + } catch { + /* keep */ + } + const m = decoded.match(/_(\d{13})__/); + return m ? Number(m[1]) : 0; +} + +/** Drop stale risk cookies from a seed set so force-warm cannot re-inject a dead Forter. */ +export function filterSeedCookiesForWarm( + cookies: Array<{ name: string; value: string; domain?: string; path?: string }>, + opts?: { dropRiskCookies?: boolean } +): Array<{ name: string; value: string; domain?: string; path?: string }> { + const dropRisk = opts?.dropRiskCookies !== false; + return cookies.filter((c) => { + if (!c?.name || !c?.value) return false; + if (dropRisk && isAdobeRiskCookieName(c.name)) return false; + return true; + }); +} + +/** Pull a user IMS JWT from sessionStorage-ish JSON / raw blobs. */ +export function extractUserJwtFromStorageRaw(raw: string): string { + const matches = String(raw || "").match(ADOBE_JWT_IN_TEXT_REGEX) || []; + // Prefer longest user tokens (guest tokens are shorter / rejected by isAdobeUserAccessToken). + const sorted = [...matches].sort((a, b) => b.length - a.length); + for (const tok of sorted) { + if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) return tok; + } + return ""; +} + +function resolveAdobeFireflyDataRoot(): string { + const dataRoot = + String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || + (process.env.LOCALAPPDATA + ? join(process.env.LOCALAPPDATA, "OmniRoute") + : join(process.cwd(), ".data")); + mkdirSync(dataRoot, { recursive: true }); + return dataRoot; +} + +export function adobeFireflyBrowserSessionKey(value: unknown): string { + const raw = String(value || "legacy-default").trim() || "legacy-default"; + return createHash("sha256").update(raw).digest("hex").slice(0, 32); +} + +/** Chrome 136+ requires a non-default user-data-dir for remote debugging. */ +export function resolveAdobeFireflyBrowserProfileDir(sessionKey?: string): string { + const profile = join( + resolveAdobeFireflyDataRoot(), + "adobe-chrome-profiles", + adobeFireflyBrowserSessionKey(sessionKey) + ); + mkdirSync(profile, { recursive: true }); + return profile; +} + export function clampAdobeFireflyLoginTimeout(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_LOGIN_TIMEOUT_MS; return Math.max(MIN_LOGIN_TIMEOUT_MS, Math.min(MAX_LOGIN_TIMEOUT_MS, Math.trunc(value))); @@ -54,7 +214,15 @@ export function extractAdobeBearerTokenFromAuthorization(authHeader: string): st export function buildAdobeFireflyCookieHeader( cookies: Array<{ name: string; value: string; domain?: string }> ): string { - const wanted = ["sherlockToken", "forterToken", "aux_sid", "ff_session_guid"]; + const wanted = [ + "sherlockToken", + "forterToken", + "arkose", + "ff_session_guid", + "aux_sid", + "bfp", + "fpjs", + ]; const parts: string[] = []; for (const wantedName of wanted) { const c = cookies.find( @@ -69,23 +237,56 @@ export function buildAdobeFireflyCookieHeader( return parts.join("; "); } -/** Best-effort account label from an IMS JWT payload. Exported for unit tests. */ +function humanAdobeLabel(value: unknown): string { + const label = typeof value === "string" ? value.trim() : ""; + if (!label || /@(Adobe|Guest)ID$/i.test(label)) return ""; + return label; +} + +/** Human-readable label claims only; opaque Adobe IDs are intentionally excluded. */ export function accountLabelFromAdobeJwt(token: string): string { - try { - const part = String(token || "").split(".")[1]; - if (!part) return ""; - const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); - const obj = JSON.parse(json) as Record; - for (const key of ["email", "preferred_username", "user_id", "sub"]) { - const v = obj[key]; - if (typeof v === "string" && v.trim()) return v.trim(); - } - } catch { - // ignore + const obj = decodeAdobeJwtPayload(token); + if (!obj) return ""; + for (const key of ["email", "preferred_username", "name", "display_name"]) { + const label = humanAdobeLabel(obj[key]); + if (label) return label; } return ""; } +/** Resolve email/display name from Adobe IMS; never expose the opaque user_id as a label. */ +export async function resolveAdobeAccountLabel( + token: string, + fetchImpl: typeof fetch = fetch +): Promise { + const claimLabel = accountLabelFromAdobeJwt(token); + const payload = decodeAdobeJwtPayload(token); + const clientId = humanAdobeLabel(payload?.client_id) || "clio-playground-web"; + try { + const response = await fetchImpl( + `https://ims-na1.adobelogin.com/ims/userinfo/v2?client_id=${encodeURIComponent(clientId)}`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10_000), + } + ); + if (response.ok) { + const user = (await response.json()) as Record; + for (const key of ["email", "preferred_username", "name", "display_name"]) { + const label = humanAdobeLabel(user[key]); + if (label) return label; + } + const given = humanAdobeLabel(user.given_name); + const family = humanAdobeLabel(user.family_name); + const full = [given, family].filter(Boolean).join(" ").trim(); + if (full) return full; + } + } catch { + // JWT label or generic fallback below keeps login successful if userinfo is unavailable. + } + return claimLabel || "Adobe account"; +} + /** Resolve system Chrome/Edge executable. Exported for unit tests. */ export function resolveSystemBrowserExecutable(): string | null { const configured = process.env.OMNIROUTE_LOGIN_BROWSER_PATH?.trim(); @@ -141,16 +342,16 @@ async function waitForCdpReady( let lastError = "CDP endpoint not ready"; while (Date.now() < deadline) { try { - const res = await fetch(`http://127.0.0.1:${port}/json/version`, { - signal: AbortSignal.timeout(2000), - }); - if (res.ok) { - const body = (await res.json()) as { webSocketDebuggerUrl?: string }; - if (body.webSocketDebuggerUrl) { - return { webSocketDebuggerUrl: body.webSocketDebuggerUrl }; - } + // Use node:http — never proxy-patched fetch (see loopbackHttpGetJson). + const body = await loopbackHttpGetJson<{ webSocketDebuggerUrl?: string }>( + port, + "/json/version", + 2000 + ); + if (body?.webSocketDebuggerUrl) { + return { webSocketDebuggerUrl: body.webSocketDebuggerUrl }; } - lastError = `CDP /json/version HTTP ${res.status}`; + lastError = "CDP /json/version missing webSocketDebuggerUrl"; } catch (err) { lastError = err instanceof Error ? err.message : String(err); } @@ -159,7 +360,99 @@ async function waitForCdpReady( throw new Error(`Chrome DevTools did not become ready: ${lastError}`); } -type CdpCookie = { name: string; value: string; domain?: string }; +export type AdobeBrowserCookie = { + name: string; + value: string; + domain?: string; + path?: string; + expires?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Strict" | "Lax" | "None"; +}; + +type CdpCookie = AdobeBrowserCookie; + +function isAdobeCookieDomain(domain: string | undefined): boolean { + const value = String(domain || "") + .trim() + .replace(/^\./, "") + .toLowerCase(); + return ( + value === "adobe.com" || + value.endsWith(".adobe.com") || + value === "adobelogin.com" || + value.endsWith(".adobelogin.com") || + value === "adobe.io" || + value.endsWith(".adobe.io") + ); +} + +export function filterAdobeBrowserCookies(cookies: CdpCookie[]): AdobeBrowserCookie[] { + return cookies + .filter( + (cookie) => + isAdobeCookieDomain(cookie.domain) && + Boolean(cookie.name && cookie.value) && + !/[\r\n\0]/.test(cookie.name + cookie.value) + ) + .map((cookie) => ({ + name: cookie.name, + value: cookie.value, + ...(cookie.domain ? { domain: cookie.domain } : {}), + path: cookie.path || "/", + ...(typeof cookie.expires === "number" ? { expires: cookie.expires } : {}), + ...(typeof cookie.httpOnly === "boolean" ? { httpOnly: cookie.httpOnly } : {}), + ...(typeof cookie.secure === "boolean" ? { secure: cookie.secure } : {}), + ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), + })); +} + +function adobeBrowserCookieJarPath(sessionKey: string): string { + const dir = join(resolveAdobeFireflyDataRoot(), "adobe-browser-sessions"); + mkdirSync(dir, { recursive: true }); + return join(dir, `${adobeFireflyBrowserSessionKey(sessionKey)}.json`); +} + +function loadAdobeBrowserCookies(sessionKey: string): AdobeBrowserCookie[] { + try { + const path = adobeBrowserCookieJarPath(sessionKey); + if (!existsSync(path)) return []; + const parsed = JSON.parse(readFileSync(path, "utf8")); + return Array.isArray(parsed) ? filterAdobeBrowserCookies(parsed as CdpCookie[]) : []; + } catch { + return []; + } +} + +function saveAdobeBrowserCookies(sessionKey: string, cookies: CdpCookie[]): void { + try { + writeFileSync( + adobeBrowserCookieJarPath(sessionKey), + JSON.stringify(filterAdobeBrowserCookies(cookies)), + "utf8" + ); + } catch { + // Best-effort: login still returns the portable JWT + Firefly risk cookies. + } +} + +function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> { + const cookies: Array<{ name: string; value: string }> = []; + for (const part of String(cookieHeader || "").split(";")) { + const idx = part.indexOf("="); + if (idx <= 0) continue; + const name = part.slice(0, idx).trim(); + const value = part.slice(idx + 1).trim(); + if (!name || !value || /[\r\n\0]/.test(name + value)) continue; + cookies.push({ name, value }); + } + return cookies; +} + +function cookieValue(cookies: CdpCookie[], name: string): string { + return cookies.find((cookie) => cookie.name.toLowerCase() === name.toLowerCase())?.value || ""; +} class CdpSocket { private ws: WebSocket; @@ -197,16 +490,39 @@ class CdpSocket { }); } - send(method: string, params?: Record, sessionId?: string): Promise { + send( + method: string, + params?: Record, + sessionId?: string, + timeoutMs = 8_000 + ): Promise { const id = this.nextId++; const msg: Record = { id, method }; if (params) msg.params = params; if (sessionId) msg.sessionId = sessionId; return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); + const timer = setTimeout( + () => { + if (!this.pending.has(id)) return; + this.pending.delete(id); + reject(new Error(`CDP timeout after ${timeoutMs}ms: ${method}`)); + }, + Math.max(500, timeoutMs) + ); + this.pending.set(id, { + resolve: (v) => { + clearTimeout(timer); + resolve(v); + }, + reject: (e) => { + clearTimeout(timer); + reject(e); + }, + }); try { this.ws.send(JSON.stringify(msg)); } catch (err) { + clearTimeout(timer); this.pending.delete(id); reject(err instanceof Error ? err : new Error(String(err))); } @@ -214,6 +530,10 @@ class CdpSocket { } close(): void { + for (const [id, p] of this.pending) { + this.pending.delete(id); + p.reject(new Error("CDP socket closed")); + } try { this.ws.close(); } catch { @@ -244,126 +564,466 @@ async function openCdp(url: string): Promise { /** * Capture Firefly IMS JWT by watching Network.requestWillBeSent on all page targets. + * Background warm (`waitForRiskRefresh`) REQUIRES a fresher forterToken — never returns + * the same stale risk cookies as "success" (that caused false 408 recovery loops). */ async function captureViaCdp(opts: { port: number; browserWsUrl: string; timeoutMs: number; -}): Promise<{ accessToken: string; cookies: CdpCookie[] }> { + fallbackAccessToken?: string; + seedCookie?: string; + seedBrowserCookies?: AdobeBrowserCookie[]; + waitForRiskRefresh?: boolean; +}): Promise<{ + accessToken: string; + cookies: CdpCookie[]; + arpSessionId: string; +}> { let capturedAccessToken = ""; - const pageSockets = new Map(); + let storageAccessToken = ""; + let capturedArpSessionId = ""; + let latestCookies: CdpCookie[] = []; + /** Flatten auto-attach page sessions only — do NOT also open page WebSockets (double-attach freezes Chrome: "Debugger paused in another tab"). */ + const pageSessionIds = new Set(); let browserCdp: CdpSocket | null = null; + let humanizeDone = false; + let riskReloadDone = false; + const requireFreshRisk = Boolean(opts.waitForRiskRefresh); + // Force warm: after wiping Firefly cookies, any fresh forter (ts within last 10 min) counts. + // Baseline from seed is only used for interactive partial-wait comparisons. + const seedForterTs = extractAdobeForterTimestampFromValue( + [...(opts.seedBrowserCookies || []), ...parseCookieHeader(opts.seedCookie || "")].find( + (cookie) => cookie.name.toLowerCase() === "fortertoken" + )?.value || "" + ); + const baselineForterTs = requireFreshRisk ? 0 : Math.max(seedForterTs, 0); + const startedAt = Date.now(); + + const SPA_JWT_EXPR = `(() => { + const out = []; + try { + for (const key of Object.keys(sessionStorage)) { + if (!/adobeid_ims_access_token|clio-playground/i.test(key)) continue; + out.push(sessionStorage.getItem(key) || ""); + } + if (out.length === 0) { + for (const key of Object.keys(sessionStorage)) { + out.push(sessionStorage.getItem(key) || ""); + } + } + } catch (e) {} + return out.join("\\n"); + })()`; + + /** MUST be awaited before other session commands or Google OAuth freezes yellow. */ + const resumeTargetIfNeeded = async (sessionId: string): Promise => { + if (!browserCdp || !sessionId) return; + try { + await browserCdp.send("Runtime.runIfWaitingForDebugger", {}, sessionId); + } catch { + /* ignore */ + } + }; + + const setupPageSession = async (sessionId: string): Promise => { + if (!browserCdp || !sessionId || pageSessionIds.has(sessionId)) { + // Still resume if re-attached / re-entered waiting state. + await resumeTargetIfNeeded(sessionId); + return; + } + pageSessionIds.add(sessionId); + // Order is critical: resume FIRST, then enable domains (never leave waitingForDebugger). + await resumeTargetIfNeeded(sessionId); + await browserCdp.send("Network.enable", {}, sessionId).catch(() => undefined); + await resumeTargetIfNeeded(sessionId); + // Runtime.enable only for force-warm (sessionStorage/JWT evaluate). Interactive login + // primarily uses Network Authorization capture; Runtime is enabled on-demand when reading JWT. + if (requireFreshRisk) { + await browserCdp.send("Runtime.enable", {}, sessionId).catch(() => undefined); + await resumeTargetIfNeeded(sessionId); + } + }; const onEvent = (method: string, params: Record) => { if (method === "Network.requestWillBeSent") { - if (capturedAccessToken) return; const request = params.request as { url?: string; headers?: Record } | undefined; - if (!request?.url) return; - let host: string; - try { - host = new URL(request.url).hostname.toLowerCase(); - } catch { - return; - } - if (host !== FIREFLY_3P_HOST_SUFFIX && !host.endsWith(`.${FIREFLY_3P_HOST_SUFFIX}`)) return; + if (!request?.url || !isAdobeFireflyApiUrl(request.url)) return; const headers = request.headers || {}; const auth = headers.Authorization || headers.authorization || headers.AUTHORIZATION || ""; const token = extractAdobeBearerTokenFromAuthorization(auth); - if (token) capturedAccessToken = token; + if (token && isAdobeUserAccessToken(token)) capturedAccessToken = token; + const arp = + headers["x-arp-session-id"] || + headers["X-Arp-Session-Id"] || + headers["X-ARP-SESSION-ID"] || + ""; + if (typeof arp === "string" && arp.trim()) capturedArpSessionId = arp.trim(); } else if (method === "Target.attachedToTarget") { const sessionId = String(params.sessionId || ""); const targetInfo = params.targetInfo as { type?: string; targetId?: string } | undefined; - if (sessionId && targetInfo?.type === "page" && browserCdp) { - void browserCdp.send("Network.enable", {}, sessionId).catch(() => undefined); + if (!sessionId || !browserCdp) return; + if (targetInfo?.type === "page" || targetInfo?.type === "iframe") { + // Fire-and-forget async setup but resume is first awaited inside setupPageSession. + void setupPageSession(sessionId).catch(() => undefined); + } else { + void resumeTargetIfNeeded(sessionId).catch(() => undefined); } + } else if (method === "Target.detachedFromTarget") { + const sessionId = String(params.sessionId || ""); + if (sessionId) pageSessionIds.delete(sessionId); + } + }; + + const readSpaJwtFromSession = async (sessionId: string): Promise => { + if (!browserCdp || !sessionId) return ""; + try { + await resumeTargetIfNeeded(sessionId); + await browserCdp.send("Runtime.enable", {}, sessionId).catch(() => undefined); + await resumeTargetIfNeeded(sessionId); + const result = (await browserCdp.send( + "Runtime.evaluate", + { + expression: SPA_JWT_EXPR, + returnByValue: true, + awaitPromise: false, + }, + sessionId + )) as { result?: { value?: string } }; + return extractUserJwtFromStorageRaw(String(result?.result?.value || "")); + } catch { + return ""; + } + }; + + const nudgeForterSession = async (sessionId: string): Promise => { + if (!browserCdp || !sessionId) return; + try { + await resumeTargetIfNeeded(sessionId); + for (const [x, y] of [ + [140, 180], + [420, 260], + [700, 340], + [520, 420], + ] as const) { + await browserCdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y }, sessionId); + } + await browserCdp.send( + "Input.dispatchMouseEvent", + { type: "mousePressed", x: 640, y: 360, button: "left", clickCount: 1 }, + sessionId + ); + await browserCdp.send( + "Input.dispatchMouseEvent", + { type: "mouseReleased", x: 640, y: 360, button: "left", clickCount: 1 }, + sessionId + ); + await browserCdp.send( + "Input.dispatchMouseEvent", + { type: "mouseWheel", x: 400, y: 300, deltaX: 0, deltaY: 240 }, + sessionId + ); + } catch { + /* ignore */ } }; try { const browserWs = await openCdp(opts.browserWsUrl); browserCdp = new CdpSocket(browserWs, onEvent); + // Force warm: never re-seed stale forter/arkose/sherlock — SSO cookies only. + // Interactive sign-in: seed nothing when freshSession emptied the jar; otherwise full seed ok. + const rawSeed: AdobeBrowserCookie[] = [ + ...(opts.seedBrowserCookies || []), + ...parseCookieHeader(opts.seedCookie || "").map((cookie) => ({ + ...cookie, + domain: "firefly.adobe.com", + path: "/", + secure: true as const, + })), + ]; + const seed: AdobeBrowserCookie[] = ( + requireFreshRisk ? filterSeedCookiesForWarm(rawSeed, { dropRiskCookies: true }) : rawSeed + ) as AdobeBrowserCookie[]; + if (seed.length > 0) { + await browserCdp + .send("Storage.setCookies", { + cookies: seed.map((cookie) => ({ + name: cookie.name, + value: cookie.value, + ...(cookie.domain ? { domain: cookie.domain } : { url: FIREFLY_HOME_URL }), + path: cookie.path || "/", + ...(typeof cookie.expires === "number" && cookie.expires > 0 + ? { expires: cookie.expires } + : {}), + ...(typeof cookie.httpOnly === "boolean" ? { httpOnly: cookie.httpOnly } : {}), + ...(typeof cookie.sameSite === "string" ? { sameSite: cookie.sameSite } : {}), + secure: cookie.secure !== false, + })), + }) + .catch(() => undefined); + } + // Force warm: wipe Firefly origin storage so Forter cannot re-hydrate a hours-old token + // from cookies/localStorage/IndexedDB. Keep adobelogin.com SSO (AdobeID) intact. + if (requireFreshRisk) { + try { + for (const origin of [ + "https://firefly.adobe.com", + "https://www.firefly.adobe.com", + "https://firefly-3p.ff.adobe.io", + ]) { + await browserCdp + .send("Storage.clearDataForOrigin", { + origin, + storageTypes: + "cookies,local_storage,indexeddb,cache_storage,service_workers,shader_cache", + }) + .catch(() => undefined); + } + const existing = (await browserCdp.send("Storage.getCookies")) as { + cookies?: CdpCookie[]; + }; + for (const cookie of existing?.cookies || []) { + const domain = String(cookie.domain || "") + .replace(/^\./, "") + .toLowerCase(); + const isFireflySite = + domain === "firefly.adobe.com" || + domain.endsWith(".firefly.adobe.com") || + domain === "ff.adobe.io" || + domain.endsWith(".ff.adobe.io"); + if (!isFireflySite && !isAdobeRiskCookieName(cookie.name)) continue; + if (isAdobeLoginCookieDomain(domain) && !isAdobeRiskCookieName(cookie.name)) continue; + await browserCdp + .send("Storage.deleteCookies", { + name: cookie.name, + ...(cookie.domain ? { domain: cookie.domain } : { url: FIREFLY_HOME_URL }), + path: cookie.path || "/", + }) + .catch(() => undefined); + } + } catch { + /* best-effort */ + } + } + // Single browser-level CDP + flatten auto-attach only. + // NEVER open /json/list page WebSockets (second debugger → yellow "Debugger paused"). await browserCdp.send("Target.setDiscoverTargets", { discover: true }).catch(() => undefined); await browserCdp .send("Target.setAutoAttach", { autoAttach: true, + // false = do not start targets paused; still resume defensively on attach. waitForDebuggerOnStart: false, flatten: true, }) .catch(() => undefined); + // Existing pages (Chrome already opened firefly URL) are NOT auto-attached as "new" + // targets — attach once via Target.attachToTarget (still one session, no page WS). + try { + const { targetInfos } = (await browserCdp.send("Target.getTargets")) as { + targetInfos?: Array<{ targetId?: string; type?: string; url?: string }>; + }; + for (const t of targetInfos || []) { + if ((t.type !== "page" && t.type !== "iframe") || !t.targetId) continue; + try { + const attached = (await browserCdp.send("Target.attachToTarget", { + targetId: t.targetId, + flatten: true, + })) as { sessionId?: string }; + const sid = String(attached?.sessionId || ""); + if (sid) await setupPageSession(sid); + } catch { + /* target may vanish */ + } + } + } catch { + /* getTargets may fail briefly */ + } + const deadline = Date.now() + opts.timeoutMs; + let lastJwtProbeAt = 0; + let lastResumeSweepAt = 0; while (Date.now() < deadline) { - // Attach to every page target listed by the DevTools HTTP API. - try { - const list = (await fetch(`http://127.0.0.1:${opts.port}/json/list`, { - signal: AbortSignal.timeout(2000), - }).then((r) => r.json())) as Array<{ - id?: string; - type?: string; - url?: string; - webSocketDebuggerUrl?: string; - }>; - for (const t of list) { - if (t.type !== "page" || !t.webSocketDebuggerUrl || !t.id) continue; - if (pageSockets.has(t.id)) continue; - try { - const ws = await openCdp(t.webSocketDebuggerUrl); - const cdp = new CdpSocket(ws, onEvent); - pageSockets.set(t.id, cdp); - await cdp.send("Network.enable"); - if (!t.url || t.url === "about:blank" || t.url.startsWith("chrome://")) { - await cdp.send("Page.enable").catch(() => undefined); - await cdp.send("Page.navigate", { url: FIREFLY_HOME_URL }).catch(() => undefined); - } - } catch { - // page may navigate away mid-connect - } + const now = Date.now(); + // Resume periodically (not every 400ms spam) — enough to clear accidental waits. + if (now - lastResumeSweepAt >= 1_500) { + lastResumeSweepAt = now; + for (const sid of [...pageSessionIds]) { + await resumeTargetIfNeeded(sid); } - } catch { - // list may fail briefly while Chrome starts } - if (capturedAccessToken) { - // Prefer cookies from any live page socket; fall back to empty. - for (const cdp of pageSockets.values()) { - if (!cdp.open) continue; - try { - const result = (await cdp.send("Network.getAllCookies")) as { - cookies?: CdpCookie[]; + try { + const result = (await browserCdp.send("Storage.getCookies")) as { + cookies?: CdpCookie[]; + }; + if (Array.isArray(result?.cookies)) latestCookies = result.cookies; + } catch { + /* retry while Chrome is settling */ + } + + // Pull SPA sessionStorage JWT. Throttle evaluate so interactive Google login stays smooth + // (network Authorization capture is preferred and does not touch the page). + if (now - lastJwtProbeAt >= (requireFreshRisk ? 800 : 2_000)) { + lastJwtProbeAt = now; + for (const sid of pageSessionIds) { + const fromStorage = await readSpaJwtFromSession(sid); + if (fromStorage) { + storageAccessToken = fromStorage; + break; + } + } + } + + if (requireFreshRisk && pageSessionIds.size > 0) { + const elapsedWarm = Date.now() - startedAt; + // Nudge Forter early, then hard-reload once so SDKs re-mint risk tokens. + if (!humanizeDone && elapsedWarm >= 2_000) { + humanizeDone = true; + for (const sid of pageSessionIds) { + await nudgeForterSession(sid); + break; + } + } else if (!riskReloadDone && humanizeDone && elapsedWarm >= 12_000) { + riskReloadDone = true; + for (const sid of pageSessionIds) { + await browserCdp.send("Page.reload", { ignoreCache: true }, sid).catch(() => undefined); + await new Promise((r) => setTimeout(r, 1_500)); + await resumeTargetIfNeeded(sid); + await nudgeForterSession(sid); + break; + } + } + } + + const fallbackToken = String(opts.fallbackAccessToken || "").trim(); + const accessToken = + capturedAccessToken || + storageAccessToken || + (isAdobeUserAccessToken(fallbackToken) ? fallbackToken : ""); + if (accessToken) { + const elapsed = Date.now() - startedAt; + const forter = cookieValue(latestCookies, "forterToken"); + const forterTs = extractAdobeForterTimestampFromValue(forter); + const forterAgeMs = + forterTs > 0 ? Math.max(0, Date.now() - forterTs) : Number.POSITIVE_INFINITY; + const hasRiskCookies = Boolean( + forter && + cookieValue(latestCookies, "ff_session_guid") && + (cookieValue(latestCookies, "arkose") || cookieValue(latestCookies, "sherlockToken")) + ); + // Fresh forter: either newer than baseline, or mint age under 10 minutes (force wipe path). + const riskAdvanced = + forterTs > 0 && + (baselineForterTs <= 0 + ? forterAgeMs < 10 * 60_000 + : forterTs > baselineForterTs || forterAgeMs < 10 * 60_000); + + if (!requireFreshRisk) { + // Interactive Sign in: colligo 408s if we store JWT without forter/arkose/sherlock. + // Prefer a full risk cookie jar (browser works when these are present). Soft-wait + // up to 45s after JWT — WinUI login already allows minutes for OAuth. + if (hasRiskCookies && riskAdvanced) { + return { + accessToken, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, + }; + } + // Last resort: JWT only after 45s (generate will likely 408 until risk cookies exist). + if (elapsed >= 45_000) { + return { + accessToken, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, }; + } + } else { + const minWaitMs = 8_000; + if (hasRiskCookies && elapsed >= minWaitMs && riskAdvanced) { return { - accessToken: capturedAccessToken, - cookies: Array.isArray(result?.cookies) ? result.cookies : [], + accessToken, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, }; - } catch { - /* try next */ } } - return { accessToken: capturedAccessToken, cookies: [] }; } await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } + // Force warm timed out without a fresher forter → hard fail (caller retries / surfaces error). + // IMPORTANT: baselineForterTs===0 must NOT accept any timestamped forter — require age < 10 min + // (or strictly newer than baseline). Old bug accepted 20h-old forter and colligo 408'd. + if (requireFreshRisk) { + const forter = cookieValue(latestCookies, "forterToken"); + const forterTs = extractAdobeForterTimestampFromValue(forter); + const forterAgeMs = + forterTs > 0 ? Math.max(0, Date.now() - forterTs) : Number.POSITIVE_INFINITY; + const riskAdvanced = + forterTs > 0 && + (baselineForterTs <= 0 + ? forterAgeMs < 10 * 60_000 + : forterTs > baselineForterTs || forterAgeMs < 10 * 60_000); + if (!riskAdvanced) { + throw new Error( + "Adobe Firefly risk session did not refresh (forterToken stale). " + + "Re-open Sign in with browser once, or wait and retry generate." + ); + } + const token = + capturedAccessToken || + storageAccessToken || + (isAdobeUserAccessToken(String(opts.fallbackAccessToken || "").trim()) + ? String(opts.fallbackAccessToken).trim() + : ""); + if (token) { + return { + accessToken: token, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, + }; + } + } + + const fallbackRaw = String(opts.fallbackAccessToken || "").trim(); + const fallback = isAdobeUserAccessToken(fallbackRaw) ? fallbackRaw : ""; + if (fallback && latestCookies.length > 0 && !requireFreshRisk) { + return { + accessToken: capturedAccessToken || storageAccessToken || fallback, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, + }; + } throw new Error( "Adobe Firefly sign-in timed out. Complete sign-in at firefly.adobe.com and trigger an action " + "(open Generate) so the browser sends the Firefly request, then try again." ); } finally { - for (const cdp of pageSockets.values()) cdp.close(); + pageSessionIds.clear(); browserCdp?.close(); } } function killProcessTree(child: ChildProcess | null): void { if (!child?.pid) return; + const pid = child.pid; + // Never taskkill our own Node/pkg process or its parent (would kill the backend mid-login). + if (pid === process.pid || (typeof process.ppid === "number" && pid === process.ppid)) { + return; + } try { if (process.platform === "win32") { - spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + // /T kills only this PID's descendants — not system Chrome profiles we did not spawn. + const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, + detached: true, }); + killer.unref?.(); } else { child.kill("SIGTERM"); setTimeout(() => { @@ -383,14 +1043,83 @@ function killProcessTree(child: ChildProcess | null): void { } } +/** + * Background cookie/JWT refresh visibility. + * + * Default = **offscreen headed** (window parked off-display + minimized + windowsHide). + * True `--headless=new` mints Forter/ARP risk sessions colligo rejects → HTTP 408 on + * generate while a normal browser still works. Only opt into true headless with + * ADOBE_FIREFLY_CHROME_HEADLESS=1 (known-broken for media; debug only). + */ +export function adobeFireflyBackgroundUsesHeadlessChrome(): boolean { + return process.env.ADOBE_FIREFLY_CHROME_HEADLESS === "1"; +} + +export function buildAdobeFireflyBrowserArgs(opts: { + port: number; + userDataDir: string; + interactive: boolean; + freshSession?: boolean; +}): string[] { + const interactive = opts.interactive === true; + // Interactive "Sign in with browser" = real headed UI. Everything else = headless + // (or rare opt-in offscreen headed) so image gen / 408 recovery never pops a window. + const backgroundHeadless = !interactive && adobeFireflyBackgroundUsesHeadlessChrome(); + + return [ + `--remote-debugging-port=${opts.port}`, + // Force loopback bind so waitForCdpReady (node:http → 127.0.0.1) can connect. + "--remote-debugging-address=127.0.0.1", + // Chrome 111+ may refuse CDP HTTP (/json/version) without an allow-list. + "--remote-allow-origins=*", + `--user-data-dir=${opts.userDataDir}`, + "--no-first-run", + "--no-default-browser-check", + // NOTE: do NOT use --incognito here. Unique user-data-dir already isolates the + // session; incognito + remote-debugging is flaky on recent Chrome (CDP port + // never binds → ECONNREFUSED while a chrome.exe process still exists). + ...(interactive + ? [ + // Prevent attaching to an existing Chrome instance (would drop remote-debugging). + "--new-window", + "--window-size=1280,800", + ] + : backgroundHeadless + ? [ + // Silent cookie/JWT warm — ZERO visible window (user requirement). + "--headless=new", + "--disable-gpu", + "--window-size=1280,800", + ] + : [ + // Rare Forter debug: headed but parked far off-screen + minimized. + "--window-position=-32000,-32000", + "--window-size=1280,800", + "--start-minimized", + ]), + // Start on Firefly so risk SDKs load (especially important for background warm). + FIREFLY_HOME_URL, + ]; +} + /** * Launch system Chrome/Edge at firefly.adobe.com, intercept firefly-3p * Authorization Bearer via CDP, return JWT + useful cookies. + * + * IMPORTANT: never mass-kill system Chrome via WMI/PowerShell from this path — + * that wedged the packaged backend event loop and made login show + * "VibeProxy backend is not ready for Adobe Firefly sign-in." + * Only kill the child we spawn (killProcessTree in finally / retry). */ -export async function startAdobeFireflyBrowserLogin( - requestedTimeout?: unknown -): Promise { - const timeout = clampAdobeFireflyLoginTimeout(requestedTimeout); +async function runAdobeFireflyCdpBrowser(opts: { + timeoutMs: number; + interactive: boolean; + sessionKey: string; + freshSession?: boolean; + seedCookie?: string; + accessToken?: string; + log?: AdobeFireflyBrowserLog; +}): Promise { const browserPath = resolveSystemBrowserExecutable(); if (!browserPath) { return { @@ -402,64 +1131,152 @@ export async function startAdobeFireflyBrowserLogin( }; } - let userDataDir: string | null = null; let child: ChildProcess | null = null; try { - userDataDir = mkdtempSync(join(tmpdir(), "omniroute-firefly-login-")); - const port = await getFreeLoopbackPort(); + const userDataDir = resolveAdobeFireflyBrowserProfileDir(opts.sessionKey); + // Isolate interactive sign-in profiles so a prior hung CDP instance cannot lock the dir. + // freshSession uses a per-attempt suffix; background warm keeps the stable key for SSO reuse. + const launchUserDataDir = + opts.interactive && opts.freshSession !== false + ? `${userDataDir}-login-${Date.now().toString(36)}` + : userDataDir; + try { + mkdirSync(launchUserDataDir, { recursive: true }); + } catch { + /* parent resolve already mkdir'd base */ + } - const args = [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${userDataDir}`, - "--no-first-run", - "--no-default-browser-check", - "--disable-sync", - "--disable-background-networking", - "--window-size=1280,800", - FIREFLY_HOME_URL, - ]; - - child = spawn(browserPath, args, { - stdio: "ignore", - windowsHide: false, - detached: false, - }); - - // If Chrome exits immediately, fail fast with a clear message. - const earlyExit = new Promise((_, reject) => { - child?.once("exit", (code) => { - reject(new Error(`Browser exited early (code ${code}). Is the executable runnable?`)); - }); - child?.once("error", (err) => { - reject(new Error(`Failed to launch browser: ${err.message}`)); - }); - }); - - const ready = waitForCdpReady(port, CDP_READY_TIMEOUT_MS); - const { webSocketDebuggerUrl } = await Promise.race([ready, earlyExit]); - - // Detach exit handler so normal user close after capture is fine - child.removeAllListeners("exit"); - child.removeAllListeners("error"); - - const captured = await Promise.race([ - captureViaCdp({ + let lastError = "Browser failed to start"; + for (let launchAttempt = 1; launchAttempt <= 2; launchAttempt++) { + if (child) { + killProcessTree(child); + child = null; + await new Promise((r) => setTimeout(r, 300)); + } + const port = await getFreeLoopbackPort(); + // Unique profile per launch attempt so a half-dead previous Chrome cannot lock the dir. + const attemptUserDataDir = + opts.interactive && opts.freshSession !== false + ? `${launchUserDataDir}-a${launchAttempt}` + : launchUserDataDir; + try { + mkdirSync(attemptUserDataDir, { recursive: true }); + } catch { + /* best-effort */ + } + const args = buildAdobeFireflyBrowserArgs({ port, - browserWsUrl: webSocketDebuggerUrl, - timeoutMs: timeout, - }), - earlyExit, - ]); + userDataDir: attemptUserDataDir, + interactive: opts.interactive, + freshSession: opts.freshSession, + }); - const cookie = buildAdobeFireflyCookieHeader(captured.cookies); - const account = accountLabelFromAdobeJwt(captured.accessToken); + // Interactive: keep attached (reliable CDP bind on Windows). Background warm may + // detach so a long Forter wait does not pin the Node process refcount. + // Host job SILENT_BREAKAWAY_OK still prevents Chrome from joining the backend job + // (that was killing/wedging VibeProxyServices on Sign in with browser). + child = spawn(browserPath, args, { + stdio: "ignore", + // Interactive sign-in: show Chrome. Background warm: hide spawn console/window + // host; headless flags already suppress the browser UI. + windowsHide: !opts.interactive, + detached: !opts.interactive, + }); + if (!opts.interactive) { + try { + child.unref?.(); + } catch { + /* ignore */ + } + } + + let exitedEarly = false; + let exitCode: number | null = null; + const onExit = (code: number | null) => { + exitedEarly = true; + exitCode = code; + }; + const onErr = (err: Error) => { + exitedEarly = true; + lastError = `Failed to launch browser: ${err.message}`; + }; + // Attach listeners BEFORE any delay so we never miss a fast exit. + child.once("exit", onExit); + child.once("error", onErr); + // Give Chrome a beat to bind --remote-debugging-port before the first CDP probe. + await new Promise((r) => setTimeout(r, 600)); + if (exitedEarly) { + lastError = `Browser exited early (code ${exitCode}). Retrying…`; + opts.log?.warn?.("ADOBE-FIREFLY", lastError); + continue; + } + + try { + const cdpWaitMs = launchAttempt === 1 ? CDP_READY_TIMEOUT_MS : CDP_READY_TIMEOUT_RETRY_MS; + const { webSocketDebuggerUrl } = await waitForCdpReady(port, cdpWaitMs); + if (exitedEarly) { + lastError = `Browser exited early (code ${exitCode}). Retrying…`; + continue; + } + child.removeListener("exit", onExit); + child.removeListener("error", onErr); + + opts.log?.info?.( + "ADOBE-FIREFLY", + opts.interactive + ? "Chrome ready — complete Adobe/Google sign-in in the window (do not close it)" + : "headless CDP warm attached" + ); + + // Interactive: capture JWT as soon as firefly-3p auth is seen. Soft-wait for risk + // cookies is handled inside captureViaCdp; do NOT force risk refresh for interactive + // (that blocked login when Forter did not advance). + const captured = await captureViaCdp({ + port, + browserWsUrl: webSocketDebuggerUrl, + timeoutMs: opts.timeoutMs, + fallbackAccessToken: opts.accessToken, + seedCookie: opts.seedCookie, + seedBrowserCookies: + opts.interactive && opts.freshSession !== false + ? [] + : loadAdobeBrowserCookies(opts.sessionKey), + waitForRiskRefresh: !opts.interactive, + }); + + const cookie = buildAdobeFireflyCookieHeader(captured.cookies); + // Persist risk cookies under the stable session key (not the -login- temp dir). + saveAdobeBrowserCookies(opts.sessionKey, captured.cookies); + const account = await resolveAdobeAccountLabel(captured.accessToken); + opts.log?.info?.( + "ADOBE-FIREFLY", + `CDP ${opts.interactive ? "sign-in" : "refresh"} captured durable session ` + + `(cookieCount=${captured.cookies.length}, arpLen=${captured.arpSessionId.length})` + ); + return { + success: true, + credentials: { + accessToken: captured.accessToken, + ...(cookie ? { cookie } : {}), + }, + ...(captured.arpSessionId ? { arpSessionId: captured.arpSessionId } : {}), + ...(account ? { account } : {}), + }; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + if (launchAttempt < 2) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `Chrome CDP launch attempt ${launchAttempt} failed: ${lastError}; retrying…` + ); + continue; + } + break; + } + } return { - success: true, - credentials: { - accessToken: captured.accessToken, - ...(cookie ? { cookie } : {}), - }, - ...(account ? { account } : {}), + success: false, + error: sanitizeErrorMessage(lastError), }; } catch (error) { return { @@ -467,16 +1284,78 @@ export async function startAdobeFireflyBrowserLogin( error: sanitizeErrorMessage(error instanceof Error ? error.message : error), }; } finally { + // Interactive sign-in: leave the window open briefly is not possible after return — + // we must kill the CDP-debug Chrome we spawned (it is a dedicated profile instance). + // Only our child PID tree is killed — never a system-wide Chrome sweep. killProcessTree(child); child = null; - if (userDataDir) { - // Give Chrome a moment to release the profile directory. - await new Promise((r) => setTimeout(r, 300)); - try { - rmSync(userDataDir, { recursive: true, force: true }); - } catch { - // Profile may still be locked; temp cleaner will reclaim later. - } + } +} + +export async function startAdobeFireflyBrowserLogin( + requestedTimeout?: unknown, + opts?: { sessionKey?: string; freshSession?: boolean } +): Promise { + // Interactive queue is independent of background warm — long 408 recovery must not + // prevent "Sign in with browser" from launching Chrome. + const run = interactiveCdpChain.then(() => + runAdobeFireflyCdpBrowser({ + timeoutMs: clampAdobeFireflyLoginTimeout(requestedTimeout), + interactive: true, + sessionKey: String(opts?.sessionKey || "legacy-default"), + freshSession: opts?.freshSession !== false, + }) + ); + interactiveCdpChain = run.then( + () => undefined, + () => undefined + ); + return run; +} + +/** Packaged-safe background renewal. Reuses the durable sign-in profile; never imports Playwright. */ +export async function refreshAdobeFireflyViaCdp(opts: { + cookie?: string; + accessToken?: string; + timeoutMs?: number; + log?: AdobeFireflyBrowserLog; + sessionKey?: string; +}): Promise { + const run = backgroundCdpChain.then(async () => { + const result = await runAdobeFireflyCdpBrowser({ + timeoutMs: Math.max(15_000, Math.min(120_000, Number(opts.timeoutMs) || 75_000)), + interactive: false, + sessionKey: String(opts.sessionKey || "legacy-default"), + seedCookie: opts.cookie, + accessToken: opts.accessToken, + log: opts.log, + }); + const accessToken = String(result.credentials?.accessToken || "").trim(); + const cookie = String(result.credentials?.cookie || "").trim(); + if (!result.success || !accessToken || !cookie) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `CDP background refresh incomplete: ${result.error || "missing token/cookie"}` + ); + return null; } + return { + accessToken, + cookie, + arpSessionId: String(result.arpSessionId || "").trim(), + }; + }); + backgroundCdpChain = run.then( + () => undefined, + () => undefined + ); + try { + return await run; + } catch (error) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `CDP background refresh failed: ${error instanceof Error ? error.message : String(error)}` + ); + return null; } } diff --git a/open-sse/services/adobeFireflyChromeRuntime.ts b/open-sse/services/adobeFireflyChromeRuntime.ts new file mode 100644 index 0000000000..c9af5ecfcf --- /dev/null +++ b/open-sse/services/adobeFireflyChromeRuntime.ts @@ -0,0 +1,1200 @@ +/** + * Adobe Firefly optional Chrome (CDP) session runtime. + * + * Default product path is the same as other OmniRoute web-cookie providers + * (notion-web, perplexity-web, …): pure HTTP with the pasted Cookie/JWT — NO browser. + * + * Browser warm is OPT-IN for proactive use (`ADOBE_FIREFLY_BROWSER_REFRESH=1`) and may + * also run mid-batch 408 recovery via `allowWithoutEnvOptIn`. + * + * **Mode (UI + colligo):** background warm defaults to **offscreen headed** (parked off + * display + minimized) so Forter tokens work. True `--headless=new` is opt-in only + * (`ADOBE_FIREFLY_CHROME_HEADLESS=1`) and typically yields generate HTTP 408 while a real + * browser still works. Interactive sign-in uses modeOverride=visible. + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + buildAdobeArpSessionIdFromCookies, + extractAdobeForterTimestampMs, + mergeAdobeCookieHeaders, + type AdobeFireflySession, +} from "./adobeFireflySession.ts"; +import { + extractAdobeCookieHeader, + isAdobeUserAccessToken, + looksLikeAdobeJwt, + decodeAdobeJwtPayload, +} from "./adobeFireflyClient.ts"; + +const DEFAULT_CDP_PORT = Number(process.env.ADOBE_FIREFLY_CHROME_CDP_PORT || 9334); +const PROFILE_DIR_NAME = "adobe-chrome-profile"; + +type Log = { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; + +type RuntimeState = { + port: number; + profileDir: string; + chromeProc: ChildProcess | null; + browser: import("playwright").Browser | null; + context: import("playwright").BrowserContext | null; + page: import("playwright").Page | null; + lastWarmAt: number; + lastCookieSeed: string; + /** "offscreen" | "visible" | "headless" */ + mode: string; +}; + +let runtime: RuntimeState | null = null; +let warmChain: Promise = Promise.resolve(); +let startingChrome: Promise | null = null; +/** Temporary mode override (e.g. force a visible window for interactive sign-in). */ +let modeOverride: "offscreen" | "visible" | "headless" | null = null; + +/** + * Background cookie/JWT work should not flash a normal desktop window. + * - default / HEADED / OFFSCREEN → offscreen headed (Forter-safe; colligo accepts) + * - HEADLESS=1 → true headless (often 408 on generate — debug only) + * - VISIBLE=1 → on-screen (debug only; interactive sign-in uses modeOverride) + */ +function resolveChromeMode(): "offscreen" | "visible" | "headless" { + if (modeOverride) return modeOverride; + if (process.env.ADOBE_FIREFLY_CHROME_VISIBLE === "1") return "visible"; + // True headless is opt-in only — colligo rejects its Forter tokens (API 408, browser OK). + if (process.env.ADOBE_FIREFLY_CHROME_HEADLESS === "1") return "headless"; + return "offscreen"; +} + +async function safePageWait(page: import("playwright").Page, ms: number): Promise { + try { + if (page.isClosed()) return; + await page.waitForTimeout(ms); + } catch { + /* page closed / target destroyed — caller will re-acquire */ + } +} + +async function ensureLivePage( + context: import("playwright").BrowserContext, + preferred: import("playwright").Page | null +): Promise { + if (preferred && !preferred.isClosed()) { + try { + // Touch the page; if target is dead this throws + void preferred.url(); + return preferred; + } catch { + /* fall through */ + } + } + const existing = + context.pages().find((p) => !p.isClosed() && /firefly\.adobe\.com/i.test(p.url())) || + context.pages().find((p) => !p.isClosed()); + if (existing) return existing; + return context.newPage(); +} + +function dataDir(): string { + return ( + String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || + join(process.cwd(), ".data") + ); +} + +function profileDir(): string { + // Prefer LOCALAPPDATA when present so the managed Chrome profile survives restarts. + const local = process.env.LOCALAPPDATA || process.env.HOME || process.env.USERPROFILE || ""; + if (local) { + const p = join(local, "OmniRoute", PROFILE_DIR_NAME); + try { + mkdirSync(p, { recursive: true }); + } catch { + /* ignore */ + } + return p; + } + const p = join(dataDir(), PROFILE_DIR_NAME); + try { + mkdirSync(p, { recursive: true }); + } catch { + /* ignore */ + } + return p; +} + +function findChromeExecutable(): string | null { + if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) { + return process.env.CHROME_PATH; + } + const candidates = [ + "C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe", + "C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe", + join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"), + "/usr/bin/google-chrome", + "/usr/bin/chromium-browser", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ]; + for (const c of candidates) { + if (c && existsSync(c)) return c; + } + return null; +} + +async function waitForCdp(port: number, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const r = await fetch(`http://127.0.0.1:${port}/json/version`); + if (r.ok) return; + } catch { + /* retry */ + } + await new Promise((r) => setTimeout(r, 350)); + } + throw new Error(`Chrome CDP not ready on port ${port}`); +} + +async function killPortOwner(port: number): Promise { + if (process.platform !== "win32") return; + try { + const { execSync } = await import("node:child_process"); + execSync( + `powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }"`, + { stdio: "ignore", timeout: 8000 } + ); + } catch { + /* ignore */ + } +} + +function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> { + const out: Array<{ name: string; value: string }> = []; + for (const part of String(cookieHeader || "").split(";")) { + const idx = part.indexOf("="); + if (idx <= 0) continue; + let name = part.slice(0, idx).trim(); + let value = part.slice(idx + 1).trim(); + try { + name = decodeURIComponent(name); + } catch { + /* keep */ + } + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (!name || /[\r\n\0]/.test(value)) continue; + out.push({ name, value }); + } + return out; +} + +/** Detect whether the process listening on `port` was started with --headless. */ +async function isPortChromeHeadless(port: number): Promise { + if (process.platform !== "win32") return null; + try { + const { execSync } = await import("node:child_process"); + const out = execSync( + `powershell -NoProfile -Command "$c=Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if(-not $c){exit 2}; $p=Get-CimInstance Win32_Process -Filter (\\"ProcessId=$($c.OwningProcess)\\"); if($p.CommandLine -match 'headless'){Write-Output 'headless'}else{Write-Output 'headed'}"`, + { encoding: "utf8", timeout: 8000, stdio: ["ignore", "pipe", "ignore"] } + ).trim(); + if (out === "headless") return true; + if (out === "headed") return false; + return null; + } catch { + return null; + } +} + +async function tryConnectExistingCdp( + chromium: typeof import("playwright").chromium, + port: number, + dir: string, + desiredMode: string, + log?: Log +): Promise { + try { + const r = await fetch(`http://127.0.0.1:${port}/json/version`); + if (!r.ok) return null; + + // Match process headless-ness to desiredMode: + // - headless desired: never reuse a headed process (would flash a real window). + // - offscreen/visible desired: never reuse headless (wrong Forter/profile mode). + const headless = await isPortChromeHeadless(port); + if (desiredMode === "headless" && headless === false) { + log?.warn?.( + "ADOBE-FIREFLY", + `existing CDP on ${port} is headed — killing and restarting as headless (no UI)` + ); + await killPortOwner(port); + return null; + } + if (desiredMode !== "headless" && headless === true) { + log?.warn?.( + "ADOBE-FIREFLY", + `existing CDP on ${port} is headless — killing and restarting as ${desiredMode}` + ); + await killPortOwner(port); + return null; + } + + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + const context = browser.contexts()[0] || (await browser.newContext()); + const page = await ensureLivePage(context, null); + log?.info?.( + "ADOBE-FIREFLY", + `reused existing Chrome CDP port=${port} desiredMode=${desiredMode} pages=${context.pages().length}` + ); + return { + port, + profileDir: dir, + chromeProc: null, + browser, + context, + page, + lastWarmAt: 0, + lastCookieSeed: "", + mode: desiredMode, + }; + } catch { + return null; + } +} + +/** + * Chrome remembers last window bounds in the profile. Off-screen warms park the window at + * ~(-32000,-32000) / secondary-monitor coords — a later "visible" sign-in then opens Firefly + * off-screen and the user sees nothing. Reset placement on disk before a visible spawn. + */ +function resetChromeWindowPlacementOnDisk(dir: string, log?: Log): void { + const candidates = [join(dir, "Default", "Preferences"), join(dir, "Preferences")]; + const onScreen = { + bottom: 960, + left: 80, + maximized: false, + right: 1360, + top: 60, + work_area_bottom: 1080, + work_area_left: 0, + work_area_right: 1920, + work_area_top: 0, + }; + for (const path of candidates) { + if (!existsSync(path)) continue; + try { + const raw = readFileSync(path, "utf8"); + const obj = JSON.parse(raw) as Record; + const browser = ( + obj.browser && typeof obj.browser === "object" + ? (obj.browser as Record) + : {} + ) as Record; + browser.window_placement = onScreen; + browser.window_placement_popup = onScreen; + obj.browser = browser; + // Avoid session restore putting us back off-screen. + if (obj.profile && typeof obj.profile === "object") { + (obj.profile as Record).exit_type = "Normal"; + (obj.profile as Record).exited_cleanly = true; + } + writeFileSync(path, JSON.stringify(obj), "utf8"); + log?.info?.("ADOBE-FIREFLY", `reset Chrome window_placement on disk (${path})`); + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `could not reset window_placement: ${err instanceof Error ? err.message : String(err)}` + ); + } + } +} + +/** After CDP connect, force the browser window onto the primary work area (visible sign-in). */ +async function forceChromeWindowOnScreen( + browser: import("playwright").Browser, + page: import("playwright").Page, + log?: Log +): Promise { + try { + const cdp = await page.context().newCDPSession(page); + const { windowId } = (await cdp.send( + "Browser.getWindowForTarget" as "Browser.getWindowForTarget" + )) as { + windowId: number; + }; + await cdp.send("Browser.setWindowBounds" as "Browser.setWindowBounds", { + windowId, + bounds: { + left: 80, + top: 60, + width: 1280, + height: 900, + windowState: "normal", + }, + }); + await page.bringToFront().catch(() => {}); + // Best-effort Windows focus (Chrome can open behind the host app). + if (process.platform === "win32") { + try { + const { execSync } = await import("node:child_process"); + execSync( + `powershell -NoProfile -Command "$p=Get-Process chrome -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -match 'Firefly|Adobe|Chrome' } | Select-Object -First 1; if($p){ Add-Type -Name W -Namespace N -MemberDefinition '[DllImport(\\\"user32.dll\\\")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport(\\\"user32.dll\\\")] public static extern bool ShowWindow(IntPtr h,int n);'; [N.W]::ShowWindow($p.MainWindowHandle,9) | Out-Null; [N.W]::SetForegroundWindow($p.MainWindowHandle) | Out-Null }"`, + { stdio: "ignore", timeout: 5000 } + ); + } catch { + /* ignore */ + } + } + log?.info?.("ADOBE-FIREFLY", "forced Chrome window on-screen (80,60 1280x900)"); + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `forceChromeWindowOnScreen failed: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +async function ensureChromeStarted( + log?: Log, + opts?: { forceRestart?: boolean } +): Promise { + const mode = resolveChromeMode(); + + // Always kill the CDP port on forceRestart (even if in-memory runtime is null — leftover + // off-screen Chrome from a prior warm is the usual "browser didn't appear" case). + if (opts?.forceRestart) { + try { + await runtime?.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + await killPortOwner(DEFAULT_CDP_PORT); + } + + if (runtime?.browser && runtime.context) { + // Mode mismatch: always restart so we never keep a headed UI when silent headless + // is required, and never keep headless when offscreen/visible is required. + if (runtime.mode !== mode) { + log?.warn?.( + "ADOBE-FIREFLY", + `cached Chrome mode=${runtime.mode} desired=${mode} — restarting` + ); + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + await killPortOwner(DEFAULT_CDP_PORT); + } else { + try { + await fetch(`http://127.0.0.1:${runtime.port}/json/version`); + // Live process must still match headless/headed expectation. + const hl = await isPortChromeHeadless(runtime.port); + const mismatch = + (mode === "headless" && hl === false) || (mode !== "headless" && hl === true); + if (mismatch) { + log?.warn?.("ADOBE-FIREFLY", `live CDP headless=${hl} desired=${mode} — restarting`); + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + await killPortOwner(DEFAULT_CDP_PORT); + } else { + runtime.page = await ensureLivePage(runtime.context, runtime.page); + return runtime; + } + } catch { + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + } + } + } + + if (startingChrome) return startingChrome; + + if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") { + throw new Error("ADOBE_FIREFLY_BROWSER_REFRESH=0"); + } + + startingChrome = (async () => { + const chromePath = findChromeExecutable(); + if (!chromePath) throw new Error("Google Chrome not found (set CHROME_PATH)"); + + let chromium: typeof import("playwright").chromium; + try { + chromium = (await import("playwright")).chromium; + } catch { + throw new Error("playwright package not available for CDP connect"); + } + + const port = DEFAULT_CDP_PORT; + const dir = profileDir(); + + // Prefer reusing a healthy CDP only when mode matches (headless vs headed). + // Mismatched reuse is rejected inside tryConnectExistingCdp. + if (!opts?.forceRestart) { + const existing = await tryConnectExistingCdp(chromium, port, dir, mode, log); + if (existing) { + runtime = existing; + return existing; + } + } + + // Kill stale listener before spawn (headless leftover / force restart). + await killPortOwner(port); + + // Visible sign-in: wipe off-screen bounds left by prior off-screen warms. + if (mode === "visible") { + resetChromeWindowPlacementOnDisk(dir, log); + } + + // Default headless: zero UI for cookie/JWT warm. Offscreen/visible are opt-in only. + const args = [ + `--remote-debugging-port=${port}`, + "--remote-debugging-address=127.0.0.1", + "--remote-allow-origins=*", + `--user-data-dir=${dir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-features=TranslateUI", + "--disable-session-crashed-bubble", + "--hide-crash-restore-bubble", + ...(mode === "headless" + ? ["--headless=new", "--disable-gpu", "--window-size=1280,900"] + : mode === "offscreen" + ? [ + "--window-position=-32000,-32000", + "--window-size=1280,900", + // Start minimized as extra belt-and-suspenders (Windows may still create a taskbar entry). + "--start-minimized", + ] + : [ + // Explicit on-screen position — profile restore alone is not enough. + "--window-position=80,60", + "--window-size=1280,900", + "--start-maximized", + ]), + mode === "visible" + ? "https://firefly.adobe.com/" + : "https://firefly.adobe.com/generate/image", + ]; + + log?.info?.( + "ADOBE-FIREFLY", + `starting Chrome CDP profile=${dir} port=${port} mode=${mode} (headless=silent; offscreen=headed parked; visible=on-screen sign-in)` + ); + const chromeProc = spawn(chromePath, args, { + stdio: "ignore", + detached: true, + // Only interactive sign-in may show a window host; silent refresh stays hidden. + windowsHide: mode !== "visible", + }); + chromeProc.unref(); + + await waitForCdp(port, 45_000); + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + const context = browser.contexts()[0] || (await browser.newContext()); + const page = await ensureLivePage(context, null); + + if (mode === "visible") { + await forceChromeWindowOnScreen(browser, page, log); + } + + runtime = { + port, + profileDir: dir, + chromeProc, + browser, + context, + page, + lastWarmAt: 0, + lastCookieSeed: "", + mode, + }; + return runtime; + })(); + + try { + return await startingChrome; + } finally { + startingChrome = null; + } +} + +async function seedCookies( + context: import("playwright").BrowserContext, + cookieHeader: string +): Promise { + const pairs = parseCookieHeader(cookieHeader); + let n = 0; + for (const { name, value } of pairs) { + for (const domain of [".adobe.com", "firefly.adobe.com", ".firefly.adobe.com"]) { + try { + await context.addCookies([ + { name, value, domain, path: "/", secure: true, sameSite: "Lax" }, + ]); + n++; + break; + } catch { + /* try next domain */ + } + } + } + return n; +} + +function extractUserJwtFromStorageRaw(raw: string): string { + const matches = + String(raw || "").match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g) || []; + for (const tok of matches) { + if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) return tok; + } + return ""; +} + +async function readSpaUserJwt(page: import("playwright").Page): Promise { + const tokens = await page.evaluate(() => { + const out: string[] = []; + for (const key of Object.keys(sessionStorage)) { + if (!/adobeid_ims_access_token|clio-playground/i.test(key)) continue; + out.push(sessionStorage.getItem(key) || ""); + } + return out; + }); + for (const raw of tokens) { + const tok = extractUserJwtFromStorageRaw(raw); + if (tok) return tok; + } + // broader scan + const all = await page.evaluate(() => { + const out: string[] = []; + for (const key of Object.keys(sessionStorage)) out.push(sessionStorage.getItem(key) || ""); + return out; + }); + for (const raw of all) { + const tok = extractUserJwtFromStorageRaw(raw); + if (tok) return tok; + } + return ""; +} + +async function injectUserJwt(page: import("playwright").Page, token: string): Promise { + if (!token) return; + await page + .evaluate((t) => { + for (const key of Object.keys(sessionStorage)) { + if (!key.includes("adobeid_ims_access_token")) continue; + try { + const obj = JSON.parse(sessionStorage.getItem(key) || "{}") as Record; + obj.tokenValue = t; + obj.access_token = t; + obj.valid = true; + obj.expire = Date.now() + 20 * 3600 * 1000; + obj.expires_in = 86400000; + obj.client_id = "clio-playground-web"; + sessionStorage.setItem(key, JSON.stringify(obj)); + } catch { + /* skip */ + } + } + }, token) + .catch(() => {}); +} + +async function humanize(page: import("playwright").Page): Promise { + try { + if (page.isClosed()) return; + for (let i = 0; i < 16; i++) { + if (page.isClosed()) return; + await page.mouse.move(100 + i * 45, 160 + (i % 5) * 35, { steps: 4 }); + await safePageWait(page, 80); + } + // Light scroll nudges Forter / passive listeners on real headed Chrome. + await page.mouse.wheel(0, 240).catch(() => {}); + await safePageWait(page, 200); + await page.mouse.wheel(0, -120).catch(() => {}); + } catch { + /* ignore */ + } +} + +/** Poll jar until forterToken timestamp advances past `minTs`, or timeout. */ +async function waitForFresherForter( + context: import("playwright").BrowserContext, + minTs: number, + timeoutMs: number, + log?: Log +): Promise { + const start = Date.now(); + let best = 0; + while (Date.now() - start < timeoutMs) { + const cookie = await jarCookieHeader(context); + const ts = extractAdobeForterTimestampMs(cookie); + if (ts > best) best = ts; + if (ts > minTs) { + log?.info?.("ADOBE-FIREFLY", `Chrome forter refreshed (ts=${ts}, deltaMs=${ts - minTs})`); + return ts; + } + await new Promise((r) => setTimeout(r, 1500)); + } + log?.warn?.( + "ADOBE-FIREFLY", + `Chrome forter did not advance past ${minTs} within ${timeoutMs}ms (best=${best})` + ); + return best; +} + +async function jarCookieHeader(context: import("playwright").BrowserContext): Promise { + const jar = await context.cookies(); + // Prefer firefly-relevant cookies; keep full jar for rebuild pieces + return jar.map((c) => `${c.name}=${c.value}`).join("; "); +} + +async function buildArpFromContext( + context: import("playwright").BrowserContext, + page: import("playwright").Page +): Promise<{ arp: string; cookie: string }> { + const cookie = await jarCookieHeader(context); + const ls = await page + .evaluate(() => ({ + bfp: localStorage.getItem("bfp") || "", + fpjs: localStorage.getItem("fpjs") || "", + })) + .catch(() => ({ bfp: "", fpjs: "" })); + let blob = cookie; + if (ls.bfp && !/(?:^|;\s*)bfp=/.test(blob)) blob = mergeAdobeCookieHeaders(blob, `bfp=${ls.bfp}`); + if (ls.fpjs && !/(?:^|;\s*)fpjs=/.test(blob)) { + blob = mergeAdobeCookieHeaders(blob, `fpjs=${encodeURIComponent(ls.fpjs)}`); + } + const arp = + buildAdobeArpSessionIdFromCookies(blob, { + bfp: ls.bfp || undefined, + fpjs: ls.fpjs || undefined, + }) || ""; + return { arp, cookie: extractAdobeCookieHeader(blob) || blob }; +} + +/** + * Warm (or create) the durable Chrome Firefly session. + * Returns accessToken + cookie + arpSessionId ready for generate-async. + */ +export async function warmAdobeFireflyViaChrome(opts: { + cookie: string; + accessToken?: string; + log?: Log; + /** Wait for interactive login if only guest JWT is present (ms, 0 = don't wait). */ + waitForLoginMs?: number; + /** + * Mid-batch 408 recovery: allow warm without ADOBE_FIREFLY_BROWSER_REFRESH=1. + * Uses headless Chrome by default (no UI). Opt into headed offscreen with + * ADOBE_FIREFLY_CHROME_HEADED=1 if diagnosing colligo. + */ + allowWithoutEnvOptIn?: boolean; + /** When true (or ADOBE_FIREFLY_CHROME_PING=1), prove ARP with in-page generate-async. */ + proveWithPing?: boolean; +}): Promise { + // Kill switch + if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") return null; + // Default OFF for proactive use; recovery may pass allowWithoutEnvOptIn. + if (!opts.allowWithoutEnvOptIn && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "1") return null; + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) { + return null; + } + + const run = warmChain.then(async () => { + const log = opts.log; + const cookieIn = extractAdobeCookieHeader(opts.cookie) || opts.cookie; + if (!cookieIn?.trim() && !opts.accessToken) return null; + + const forterBefore = extractAdobeForterTimestampMs(cookieIn); + // Force restart on recovery so we never reuse a half-dead CDP; mode is still headless + // by default (no popup). ADOBE_FIREFLY_CHROME_HEADED=1 opts into offscreen headed. + const rt = await ensureChromeStarted(log, { + forceRestart: + Boolean(opts.allowWithoutEnvOptIn) || + process.env.ADOBE_FIREFLY_CHROME_FORCE_RESTART === "1", + }); + const context = rt.context!; + let page = await ensureLivePage(context, rt.page); + + if (cookieIn && cookieIn !== rt.lastCookieSeed) { + const n = await seedCookies(context, cookieIn); + rt.lastCookieSeed = cookieIn; + log?.info?.("ADOBE-FIREFLY", `Chrome seeded ${n} cookie entries`); + } + + // Navigate / reload with page-closed recovery (prior flaky "Target page closed"). + const gotoFirefly = async () => { + page = await ensureLivePage(context, page); + if (!/firefly\.adobe\.com/i.test(page.url())) { + await page.goto("https://firefly.adobe.com/generate/image", { + waitUntil: "domcontentloaded", + timeout: 90_000, + }); + } else { + await page.reload({ waitUntil: "domcontentloaded", timeout: 90_000 }).catch(async () => { + page = await ensureLivePage(context, null); + await page.goto("https://firefly.adobe.com/generate/image", { + waitUntil: "domcontentloaded", + timeout: 90_000, + }); + }); + } + }; + + await gotoFirefly(); + await safePageWait(page, 8_000); + await humanize(page); + + let jwt = await readSpaUserJwt(page).catch(() => ""); + if (!jwt && opts.accessToken && isAdobeUserAccessToken(opts.accessToken)) { + page = await ensureLivePage(context, page); + await injectUserJwt(page, opts.accessToken); + await page.reload({ waitUntil: "domcontentloaded", timeout: 90_000 }).catch(() => {}); + await safePageWait(page, 6_000); + await humanize(page); + jwt = (await readSpaUserJwt(page).catch(() => "")) || opts.accessToken; + log?.info?.("ADOBE-FIREFLY", "Chrome injected cached user JWT into SPA sessionStorage"); + } + + // Wait for interactive login if still no user JWT (one-time profile SSO) + const waitMs = opts.waitForLoginMs ?? Number(process.env.ADOBE_FIREFLY_LOGIN_WAIT_MS || 0); + if (!jwt && waitMs > 0) { + log?.warn?.( + "ADOBE-FIREFLY", + `No user JWT yet — sign in to Firefly in the Chrome window (wait ${Math.round(waitMs / 1000)}s)` + ); + const start = Date.now(); + while (Date.now() - start < waitMs) { + await safePageWait(page, 2000); + page = await ensureLivePage(context, page); + jwt = await readSpaUserJwt(page).catch(() => ""); + if (jwt) break; + } + } + + if (!jwt && opts.accessToken && isAdobeUserAccessToken(opts.accessToken)) { + jwt = opts.accessToken; + } + if (!jwt || !isAdobeUserAccessToken(jwt)) { + log?.warn?.("ADOBE-FIREFLY", "Chrome warm: still no AdobeID user JWT (cookie-only guest)"); + // Still return ARP if possible — caller may already have JWT + if (!opts.accessToken) return null; + jwt = opts.accessToken; + } + + // Give Forter SDK time to mint a NEW forterToken (stale paste is the usual 408 root cause). + const forterWaitMs = Number(process.env.ADOBE_FIREFLY_FORTER_WAIT_MS || 45_000); + await waitForFresherForter(context, forterBefore, forterWaitMs, log); + + // Second humanize + short settle after token land + page = await ensureLivePage(context, page); + await humanize(page); + await safePageWait(page, 2_000); + + let { arp, cookie } = await buildArpFromContext(context, page); + if (!arp) { + log?.warn?.("ADOBE-FIREFLY", "Chrome warm: could not rebuild ARP from jar — one more reload"); + await gotoFirefly(); + await safePageWait(page, 8_000); + await humanize(page); + await waitForFresherForter(context, forterBefore, 20_000, log); + ({ arp, cookie } = await buildArpFromContext(context, page)); + } + if (!arp) { + log?.warn?.("ADOBE-FIREFLY", "Chrome warm: could not rebuild ARP from jar"); + return null; + } + + // Prove colligo accepts this ARP. Default ON for recovery path; env can force either way. + const shouldPing = + opts.proveWithPing === true || + process.env.ADOBE_FIREFLY_CHROME_PING === "1" || + (opts.allowWithoutEnvOptIn && process.env.ADOBE_FIREFLY_CHROME_PING !== "0"); + if (shouldPing) { + page = await ensureLivePage(context, page); + const ok = await pingGenerateInPage(page, jwt, arp, log); + if (!ok) { + log?.warn?.( + "ADOBE-FIREFLY", + "Chrome ping generate failed — waiting for forter once more and rebuilding ARP" + ); + await waitForFresherForter(context, extractAdobeForterTimestampMs(cookie), 20_000, log); + ({ arp, cookie } = await buildArpFromContext(context, page)); + if (arp) { + page = await ensureLivePage(context, page); + const ok2 = await pingGenerateInPage(page, jwt, arp, log); + if (!ok2) { + log?.warn?.("ADOBE-FIREFLY", "Chrome ping still failed — returning ARP for node retry"); + } + } + } + } + + rt.page = page; + rt.lastWarmAt = Date.now(); + const ftrTs = extractAdobeForterTimestampMs(cookie); + log?.info?.( + "ADOBE-FIREFLY", + `Chrome warm OK (mode=${rt.mode}, arpLen=${arp.length}, forterTs=${ftrTs || 0}, forterDeltaMs=${ftrTs && forterBefore ? ftrTs - forterBefore : "n/a"}, user=${String(decodeAdobeJwtPayload(jwt)?.user_id || "").slice(0, 20)})` + ); + + return { + accessToken: jwt, + cookie, + arpSessionId: arp, + tokenExpiresAt: (() => { + const p = decodeAdobeJwtPayload(jwt); + const created = Number(p?.created_at || 0); + const exp = Number(p?.expires_in || 0); + return created && exp ? created + exp : Date.now() + 20 * 3600_000; + })(), + updatedAt: Date.now(), + fingerprint: "chrome", + source: "browser" as const, + }; + }); + + // Serialize warms + warmChain = run.then( + () => undefined, + () => undefined + ); + try { + return await run; + } catch (err) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `Chrome warm failed: ${err instanceof Error ? err.message : String(err)}` + ); + // Soft-reset page/browser handle but do not kill Chrome process — reuse next warm. + if (runtime) { + runtime.page = null; + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime.browser = null; + runtime.context = null; + } + runtime = null; + return null; + } +} + +/** + * Wipe Adobe SSO from the managed profile so "Add Account" can log into a *new* identity + * instead of silently reusing the previous Adobe session. + */ +async function clearAdobeBrowserSession( + context: import("playwright").BrowserContext, + page: import("playwright").Page, + log?: Log +): Promise { + try { + await context.clearCookies(); + } catch { + /* ignore */ + } + try { + await page.goto("https://firefly.adobe.com/", { + waitUntil: "domcontentloaded", + timeout: 60_000, + }); + await page + .evaluate(() => { + try { + sessionStorage.clear(); + } catch { + /* ignore */ + } + try { + localStorage.clear(); + } catch { + /* ignore */ + } + }) + .catch(() => {}); + } catch { + /* ignore */ + } + // Best-effort IMS logout so the next load shows the sign-in UI. + try { + await page.goto( + "https://auth.services.adobe.com/en_US/index.html?callback=https%3A%2F%2Ffirefly.adobe.com%2F", + { + waitUntil: "domcontentloaded", + timeout: 45_000, + } + ); + await safePageWait(page, 1500); + } catch { + /* ignore */ + } + log?.info?.("ADOBE-FIREFLY", "sign-in: cleared prior Adobe session for a fresh login"); +} + +/** + * Interactive one-time sign-in for the "browser session" credential model. + * Opens a VISIBLE managed Chrome (persistent profile), navigates to Firefly, and waits for the + * user to log in. Returns the IMS JWT + cookie jar so generate works immediately without + * depending on sessionStorage surviving a browser close. + * Never throws — returns { success:false } on timeout / unavailable. + */ +export async function loginAdobeFireflyViaChrome(opts: { + cookie?: string; + /** Max time to wait for the user to complete login (ms). Default 5 min. */ + waitForLoginMs?: number; + /** + * When true (default for "Add Account"), wipe the prior Adobe SSO so a *new* account can be + * signed in instead of reopening the previous logged-in profile. + */ + freshSession?: boolean; + log?: Log; +}): Promise<{ + success: boolean; + account?: string; + accessToken?: string; + cookie?: string; + arpSessionId?: string; +}> { + if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") { + return { success: false }; + } + const log = opts.log; + const prev = modeOverride; + modeOverride = "visible"; + const fresh = opts.freshSession !== false; // default true for multi-account Add Account + try { + // Fresh visible window (a cached off-screen CDP would be parked off-display for login). + // forceRestart ALWAYS kills port 9334 + restarts with on-screen bounds. + const rt = await ensureChromeStarted(log, { forceRestart: true }); + const context = rt.context!; + let page = await ensureLivePage(context, rt.page); + + // Re-assert on-screen + foreground (profile may re-apply bad bounds after first paint). + await forceChromeWindowOnScreen(rt.browser!, page, log); + + if (fresh) { + await clearAdobeBrowserSession(context, page, log); + page = await ensureLivePage(context, null); + rt.lastCookieSeed = ""; + } else { + const cookieIn = opts.cookie ? extractAdobeCookieHeader(opts.cookie) || opts.cookie : ""; + if (cookieIn) { + const n = await seedCookies(context, cookieIn); + rt.lastCookieSeed = cookieIn; + log?.info?.("ADOBE-FIREFLY", `sign-in: seeded ${n} cookie entries as a hint`); + } + } + + await page + .goto("https://firefly.adobe.com/", { waitUntil: "domcontentloaded", timeout: 90_000 }) + .catch(() => {}); + page = await ensureLivePage(context, page); + await forceChromeWindowOnScreen(rt.browser!, page, log); + log?.info?.( + "ADOBE-FIREFLY", + `sign-in: Chrome window open ON-SCREEN (fresh=${fresh}) — waiting for Adobe login…` + ); + + const waitMs = + opts.waitForLoginMs ?? Number(process.env.ADOBE_FIREFLY_LOGIN_WAIT_MS || 300_000); + const start = Date.now(); + let jwt = ""; + while (Date.now() - start < waitMs) { + await safePageWait(page, 2500); + page = await ensureLivePage(context, page); + jwt = await readSpaUserJwt(page).catch(() => ""); + if (jwt && isAdobeUserAccessToken(jwt)) break; + } + const ok = Boolean(jwt && isAdobeUserAccessToken(jwt)); + const account = ok ? String(decodeAdobeJwtPayload(jwt)?.user_id || "") : undefined; + + // Capture durable credentials BEFORE closing the window (sessionStorage JWT dies with the tab). + let cookie = ""; + let arpSessionId = ""; + if (ok) { + try { + const built = await buildArpFromContext(context, page); + cookie = extractAdobeCookieHeader(built.cookie) || built.cookie || ""; + arpSessionId = built.arp || ""; + } catch { + cookie = (await jarCookieHeader(context).catch(() => "")) || ""; + } + } + + log?.info?.( + "ADOBE-FIREFLY", + ok + ? `sign-in OK (account=${account?.slice(0, 24)}, cookieLen=${cookie.length}, arpLen=${arpSessionId.length})` + : "sign-in timed out — no AdobeID session" + ); + + // Close the visible window; the persistent profile keeps the SSO for later headless warms. + try { + await rt.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + return { + success: ok, + account, + accessToken: ok ? jwt : undefined, + cookie: ok ? cookie : undefined, + arpSessionId: ok ? arpSessionId : undefined, + }; + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `sign-in failed: ${err instanceof Error ? err.message : String(err)}` + ); + try { + await runtime?.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + return { success: false }; + } finally { + modeOverride = prev; + } +} + +async function pingGenerateInPage( + page: import("playwright").Page, + token: string, + arp: string, + log?: Log +): Promise { + try { + const res = await page.evaluate( + async ({ token, arp }) => { + const claims = JSON.parse( + atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) + ) as { user_id?: string }; + const prompt = "ping"; + const data = new TextEncoder().encode(String(claims.user_id || "") + "-" + prompt); + const hash = await crypto.subtle.digest("SHA-256", data); + const nonce = [...new Uint8Array(hash)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + const r = await fetch("https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", { + method: "POST", + headers: { + Authorization: "Bearer " + token, + "x-api-key": "clio-playground-web", + "content-type": "application/json", + accept: "*/*", + "x-nonce": nonce, + "x-arp-session-id": arp, + }, + credentials: "include", + body: JSON.stringify({ + n: 1, + seeds: [1], + output: { storeInputs: true }, + prompt, + referenceBlobs: [], + modelSpecificPayload: { size: "auto" }, + modelId: "gpt-image", + modelVersion: "2", + generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, + generationSettings: { detailLevel: 1 }, + }), + }); + return { status: r.status, body: (await r.text()).slice(0, 120) }; + }, + { token, arp } + ); + log?.info?.("ADOBE-FIREFLY", `Chrome ping generate status=${res.status}`); + return res.status === 200 || res.status === 202; + } catch (e) { + log?.warn?.( + "ADOBE-FIREFLY", + `Chrome ping error: ${e instanceof Error ? e.message : String(e)}` + ); + return false; + } +} + +/** + * Submit generate-async inside the warmed Chrome page (same TLS/cookie jar as SPA). + * Falls back to null so caller can use node fetch with the warmed ARP. + */ +export async function adobeFireflyGenerateInChrome(opts: { + accessToken: string; + arpSessionId: string; + payload: Record; + prompt: string; + log?: Log; +}): Promise<{ status: number; body: string; headers: Record } | null> { + if (!runtime?.page) return null; + try { + const res = await runtime.page.evaluate( + async ({ token, arp, payload, prompt }) => { + const claims = JSON.parse( + atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) + ) as { user_id?: string }; + const data = new TextEncoder().encode( + String(claims.user_id || "") + "-" + String(prompt || "").slice(0, 256) + ); + const hash = await crypto.subtle.digest("SHA-256", data); + const nonce = [...new Uint8Array(hash)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + const r = await fetch("https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", { + method: "POST", + headers: { + Authorization: "Bearer " + token, + "x-api-key": "clio-playground-web", + "content-type": "application/json", + accept: "*/*", + "x-nonce": nonce, + "x-arp-session-id": arp, + }, + credentials: "include", + body: JSON.stringify(payload), + }); + const headers: Record = {}; + r.headers.forEach((v, k) => { + headers[k] = v; + }); + return { status: r.status, body: await r.text(), headers }; + }, + { + token: opts.accessToken, + arp: opts.arpSessionId, + payload: opts.payload, + prompt: opts.prompt, + } + ); + return res; + } catch (e) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `in-Chrome generate failed: ${e instanceof Error ? e.message : String(e)}` + ); + return null; + } +} + +/** Test helper */ +export function __resetAdobeFireflyChromeRuntimeForTests(): void { + runtime = null; + warmChain = Promise.resolve(); +} diff --git a/open-sse/services/adobeFireflyClient.ts b/open-sse/services/adobeFireflyClient.ts index d2bd3e3dc8..bcc8987a75 100644 --- a/open-sse/services/adobeFireflyClient.ts +++ b/open-sse/services/adobeFireflyClient.ts @@ -25,21 +25,18 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { resolvePublicCred } from "../utils/publicCreds.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { - ADOBE_FIREFLY_IMAGE_MODELS as DISCOVERED_IMAGE_MODELS, - ADOBE_FIREFLY_VIDEO_MODELS as DISCOVERED_VIDEO_MODELS, - parseAdobeModelsDiscovery, - resolveAdobeImageModel as resolveDiscoveredImageModel, - resolveAdobeVideoModel as resolveDiscoveredVideoModel, - type AdobeFireflyCatalogModel as DiscoveredCatalogModel, - type AdobeFireflyImageModelSpec as DiscoveredImageModelSpec, - type AdobeFireflyVideoModelSpec as DiscoveredVideoModelSpec, + decodeAdobeJwtPayload, + findAllAdobeJwts, + isExactAdobeJwt, + stripAdobeJwts, +} from "./adobeFireflySecurity.ts"; +import { + parseAdobeModelsDiscovery as parseAdobeModelsDiscoveryContract, + type AdobeFireflyDiscoveredModel, } from "./adobeFireflyModels.ts"; -export { - DISCOVERED_IMAGE_MODELS as ADOBE_FIREFLY_IMAGE_MODELS, - DISCOVERED_VIDEO_MODELS as ADOBE_FIREFLY_VIDEO_MODELS, - parseAdobeModelsDiscovery, -}; +export { decodeAdobeJwtPayload } from "./adobeFireflySecurity.ts"; +export type { AdobeFireflyDiscoveredModel } from "./adobeFireflyModels.ts"; export const ADOBE_FIREFLY_IMAGE_SUBMIT_URL = "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async"; @@ -62,35 +59,171 @@ const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; const DEFAULT_SEC_CH_UA = '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"'; const DEFAULT_POLL_INTERVAL_MS = 3000; -/** - * Poll budget for image generate-async. Multi-ref gpt-image / nano jobs commonly - * exceed 3 minutes (upload + colligo + render at detailLevel 5). 180s was the - * previous default and produced widespread 504s on listing assets with screenshots. - */ -export const DEFAULT_IMAGE_TIMEOUT_MS = 300_000; +const DEFAULT_IMAGE_TIMEOUT_MS = 180_000; const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; -/** Extra poll budget per uploaded reference blob (large screenshots + image2image). */ -export const ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS = 60_000; -export const ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS = 600_000; const FIREFLY_ORIGIN = "https://firefly.adobe.com"; const FIREFLY_REFERER = "https://firefly.adobe.com/"; -/** - * Resolve poll timeout: explicit body.timeout_ms wins; else base + per-ref budget. - * Covers multi-screenshot listing jobs without unbounded waits. - */ -export function adobeFireflyImageTimeoutMs(opts?: { - timeoutMs?: number; - refCount?: number; -}): number { - const explicit = Number(opts?.timeoutMs); - if (Number.isFinite(explicit) && explicit > 0) { - return Math.min(ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS, Math.floor(explicit)); - } - const refs = Math.max(0, Math.floor(Number(opts?.refCount) || 0)); - const budget = DEFAULT_IMAGE_TIMEOUT_MS + refs * ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS; - return Math.min(ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS, budget); +export type AdobeFireflyImageModelId = + | "nano-banana-pro" + | "nano-banana" + | "nano-banana-2" + | "gpt-image" + | "gpt-image-2" + | "gpt-image-1.5" + | "flux-2" + | "flux-pro" + | "flux-ultra" + | "seedream-4" + | "seedream-5-lite" + | "runway-gen4-image"; + +export type AdobeFireflyVideoModelId = + "sora-2" | "sora-2-pro" | "veo-3.1" | "veo-3.1-fast" | "veo-3.1-ref" | "kling-3"; + +export interface AdobeFireflyImageModelSpec { + upstreamModelId: string; + upstreamModelVersion: string; + /** Payload builder family — nano uses Gemini-style size maps; gpt-image uses OpenAI detail levels. */ + family: "nano" | "gpt-image" | "generic"; } + +export interface AdobeFireflyVideoModelSpec { + engine: "sora2" | "sora2-pro" | "veo31-standard" | "veo31-fast" | "kling3"; + upstreamModel: string; + modelId?: string; + modelVersion?: string; + referenceMode?: "frame" | "image"; + defaultDuration: number; + defaultResolution: string; +} + +/** + * Upstream modelId/modelVersion pairs from firefly-3p models/discovery + * (captured 2026-07 — see adobe/get_models.txt). Friendly catalog ids map here. + */ +export const ADOBE_FIREFLY_IMAGE_MODELS: Record< + AdobeFireflyImageModelId, + AdobeFireflyImageModelSpec +> = { + // Gemini 3.0 (Nano Banana Pro) — discovery: gemini-flash / nano-banana-2 + "nano-banana-pro": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana-2", + family: "nano", + }, + // Gemini 2.5 (Nano Banana) — discovery: gemini-flash / nano-banana + "nano-banana": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana", + family: "nano", + }, + // Gemini 3.1 (Nano Banana 2) — discovery: gemini-flash / nano-banana-3 + "nano-banana-2": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana-3", + family: "nano", + }, + // GPT Image 2 — discovery modelVersion "2" (get_models: modelDisplayName "GPT Image 2") + "gpt-image": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "2", + family: "gpt-image", + }, + // Explicit catalog alias so pickers show "gpt-image-2" distinctly + "gpt-image-2": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "2", + family: "gpt-image", + }, + "gpt-image-1.5": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "1.5", + family: "gpt-image", + }, + "flux-2": { + upstreamModelId: "flux", + upstreamModelVersion: "2", + family: "generic", + }, + "flux-pro": { + upstreamModelId: "flux", + upstreamModelVersion: "fluxPro", + family: "generic", + }, + "flux-ultra": { + upstreamModelId: "flux", + upstreamModelVersion: "fluxUltra", + family: "generic", + }, + "seedream-4": { + upstreamModelId: "seedream", + upstreamModelVersion: "seedream_v4", + family: "generic", + }, + "seedream-5-lite": { + upstreamModelId: "seedream", + upstreamModelVersion: "seedream_v5_lite", + family: "generic", + }, + "runway-gen4-image": { + upstreamModelId: "runway-gen4-image", + upstreamModelVersion: "gen4_image", + family: "generic", + }, +}; + +export const ADOBE_FIREFLY_VIDEO_MODELS: Record< + AdobeFireflyVideoModelId, + AdobeFireflyVideoModelSpec +> = { + "sora-2": { + engine: "sora2", + upstreamModel: "openai:firefly:colligo:sora2", + defaultDuration: 8, + defaultResolution: "720p", + }, + "sora-2-pro": { + engine: "sora2-pro", + upstreamModel: "openai:firefly:colligo:sora2-pro", + defaultDuration: 8, + defaultResolution: "720p", + }, + "veo-3.1": { + engine: "veo31-standard", + upstreamModel: "google:firefly:colligo:veo31", + modelId: "veo", + modelVersion: "3.1-generate", + defaultDuration: 6, + defaultResolution: "720p", + }, + "veo-3.1-fast": { + engine: "veo31-fast", + upstreamModel: "google:firefly:colligo:veo31-fast", + modelId: "veo", + modelVersion: "3.1-fast-generate", + defaultDuration: 6, + defaultResolution: "720p", + }, + "veo-3.1-ref": { + engine: "veo31-standard", + upstreamModel: "google:firefly:colligo:veo31", + modelId: "veo", + modelVersion: "3.1-generate", + referenceMode: "image", + defaultDuration: 6, + defaultResolution: "720p", + }, + "kling-3": { + engine: "kling3", + upstreamModel: "kling:firefly:colligo:kling3", + modelId: "kling", + modelVersion: "kling_v3_standard_i2v", + defaultDuration: 5, + defaultResolution: "1080p", + }, +}; + const NANO_SIZE_MAP: Record> = { "1K": { "1:1": { width: 1024, height: 1024 }, @@ -210,26 +343,6 @@ export function adobeFireflyBalanceApiKey(): string { } /** Decode IMS JWT payload (no signature verification — client-side claim read only). */ -export function decodeAdobeJwtPayload(token: string): Record | null { - try { - // Do not call extractAdobeCredentialToken here (would recurse via guest checks). - let raw = String(token || "") - .trim() - .replace(/^bearer\s+/i, "") - .trim(); - // If a blob was passed, take the first JWT-shaped segment. - const m = raw.match(/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/); - if (m) raw = m[0]; - const part = raw.split(".")[1]; - if (!part) return null; - const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); - const obj = JSON.parse(json); - return obj && typeof obj === "object" ? (obj as Record) : null; - } catch { - return null; - } -} - /** AdobeID subject for x-account-id on balance / account_cluster calls. */ export function extractAdobeAccountIdFromToken(token: string): string { const payload = decodeAdobeJwtPayload(token); @@ -321,9 +434,7 @@ export function extractAdobeCredentialToken(raw: string): string { if (authMatch?.[1] && looksLikeAdobeJwt(authMatch[1])) return authMatch[1]; // Any eyJ… JWT in the blob (HAR / multi-line). Prefer user AdobeID tokens. - const jwtMatches = value.match( - /eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g - ); + const jwtMatches = findAllAdobeJwts(value); if (jwtMatches && jwtMatches.length > 0) { const sorted = [...jwtMatches].sort((a, b) => b.length - a.length); const user = sorted.find((t) => looksLikeAdobeJwt(t) && isAdobeUserAccessToken(t)); @@ -372,15 +483,13 @@ export function extractAdobeCookieHeader(raw: string): string { if (/^bearer\s+/i.test(line)) return false; if (looksLikeAdobeJwt(line)) return false; // Drop standalone eyJ… segments - if (/^eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}$/.test(line)) - return false; + if (isExactAdobeJwt(line)) return false; return true; }) .join("; "); // Also strip inline eyJ JWT tokens that may sit inside a cookie string - const noJwt = cleaned - .replace(/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g, "") + const noJwt = stripAdobeJwts(cleaned) .replace(/;\s*;/g, ";") .replace(/^;\s*|\s*;$/g, "") .trim(); @@ -450,32 +559,145 @@ export function normalizeAdobeOutputResolution( return "2K"; } -export function resolveAdobeImageModel( - model: string -): ReturnType { - try { - return resolveDiscoveredImageModel(model); - } catch (error) { - throw new AdobeFireflyError( - error instanceof Error ? error.message : "Unknown Adobe Firefly image model", - 400, - "unknown_model" - ); +export function resolveAdobeImageModel(model: string): { + id: AdobeFireflyImageModelId; + spec: AdobeFireflyImageModelSpec; +} { + const raw = String(model || "") + .trim() + .toLowerCase() + .replace(/^adobe-firefly\//, "") + .replace(/^firefly\//, ""); + + // Accept long catalog ids like firefly-nano-banana-pro-2k-16x9 + if ( + raw.includes("nano-banana2") || + raw.includes("nano-banana-2") || + raw.includes("nano-banana-3") + ) { + return { + id: "nano-banana-2", + spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-2"], + }; } + if (raw.includes("nano-banana-pro")) { + return { + id: "nano-banana-pro", + spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"], + }; + } + if (raw.includes("nano-banana")) { + return { + id: "nano-banana", + spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana"], + }; + } + if (raw.includes("gpt-image-1.5") || raw.includes("gpt-image1.5")) { + return { + id: "gpt-image-1.5", + spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"], + }; + } + // Prefer explicit "2" / "gpt-image-2" before generic gpt-image + if ( + raw === "gpt-image-2" || + raw.includes("gpt-image-2") || + raw.includes("gptimage2") || + raw === "gpt-image" || + raw.includes("gpt-image") + ) { + // Bare gpt-image and gpt-image-2 both map to upstream version "2" (GPT Image 2). + if (raw.includes("1.5")) { + return { + id: "gpt-image-1.5", + spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"], + }; + } + const id = + raw.includes("gpt-image-2") || raw.includes("gptimage2") ? "gpt-image-2" : "gpt-image"; + return { + id: id as AdobeFireflyImageModelId, + spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], + }; + } + if (raw.includes("flux-ultra") || raw.includes("fluxultra")) { + return { id: "flux-ultra", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-ultra"] }; + } + if (raw.includes("flux-pro") || raw.includes("fluxpro")) { + return { id: "flux-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-pro"] }; + } + if (raw.includes("flux")) { + return { id: "flux-2", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-2"] }; + } + if (raw.includes("seedream-5") || raw.includes("seedream_v5")) { + return { + id: "seedream-5-lite", + spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-5-lite"], + }; + } + if (raw.includes("seedream")) { + return { id: "seedream-4", spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-4"] }; + } + if (raw.includes("runway") && raw.includes("image")) { + return { + id: "runway-gen4-image", + spec: ADOBE_FIREFLY_IMAGE_MODELS["runway-gen4-image"], + }; + } + + if (raw in ADOBE_FIREFLY_IMAGE_MODELS) { + const id = raw as AdobeFireflyImageModelId; + return { id, spec: ADOBE_FIREFLY_IMAGE_MODELS[id] }; + } + + // Default to Nano Banana Pro (most common Firefly image path). + return { + id: "nano-banana-pro", + spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"], + }; } -export function resolveAdobeVideoModel( - model: string -): ReturnType { - try { - return resolveDiscoveredVideoModel(model); - } catch (error) { - throw new AdobeFireflyError( - error instanceof Error ? error.message : "Unknown Adobe Firefly video model", - 400, - "unknown_model" - ); +export function resolveAdobeVideoModel(model: string): { + id: AdobeFireflyVideoModelId; + spec: AdobeFireflyVideoModelSpec; +} { + const raw = String(model || "") + .trim() + .toLowerCase() + .replace(/^adobe-firefly\//, "") + .replace(/^firefly\//, ""); + + if (raw.includes("sora2-pro") || raw.includes("sora-2-pro") || raw.includes("sora2_pro")) { + return { id: "sora-2-pro", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2-pro"] }; } + if (raw.includes("sora2") || raw.includes("sora-2") || raw.includes("sora")) { + return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] }; + } + if (raw.includes("veo31-ref") || raw.includes("veo-3.1-ref") || raw.includes("veo31_ref")) { + return { + id: "veo-3.1-ref", + spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-ref"], + }; + } + if (raw.includes("veo31-fast") || raw.includes("veo-3.1-fast") || raw.includes("veo31_fast")) { + return { + id: "veo-3.1-fast", + spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-fast"], + }; + } + if (raw.includes("veo31") || raw.includes("veo-3.1") || raw.includes("veo")) { + return { id: "veo-3.1", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"] }; + } + if (raw.includes("kling")) { + return { id: "kling-3", spec: ADOBE_FIREFLY_VIDEO_MODELS["kling-3"] }; + } + + if (raw in ADOBE_FIREFLY_VIDEO_MODELS) { + const id = raw as AdobeFireflyVideoModelId; + return { id, spec: ADOBE_FIREFLY_VIDEO_MODELS[id] }; + } + + return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] }; } /** @@ -485,138 +707,29 @@ export function resolveAdobeVideoModel( * Explicit low/medium still honor the caller's choice. */ function gptDetailLevel(quality: unknown): number { - const q = String(quality ?? "high") + // Live firefly.adobe.com default for gpt-image is detailLevel 3 (medium). + const q = String(quality ?? "medium") .trim() .toLowerCase(); - if (q === "low" || q === "1k" || q === "1") return 1; - if (q === "medium" || q === "2k" || q === "standard" || q === "hd" || q === "3") return 3; - // high / 4k / ultra / auto / empty / unknown → max detail - return 5; -} - -export interface AdobeFireflyReferenceBlob { - id: string; - mediaType?: string; - usage?: string; - order?: number; -} - -function defaultAdobeReferenceUsage( - model: DiscoveredCatalogModel, - mediaType: string -): string | null { - const supported = model.capabilities.referenceInputs - .filter((capability) => capability.mediaType === mediaType) - .map((capability) => capability.usageType); - const priority = - model.modality === "video" - ? ["frame", "element", "style", "subject", "source", "general"] - : ["source", "general", "style", "element", "subject"]; - return priority.find((usage) => supported.includes(usage)) || supported[0] || null; -} - -/** Validate roles/counts against the resolved discovery schema and produce wire blobs. */ -export function normalizeAdobeReferenceBlobs( - model: DiscoveredCatalogModel, - references: AdobeFireflyReferenceBlob[] = [], - fallbackImageIds: string[] = [] -): Array> { - const requested = references.length - ? references - : fallbackImageIds.map((id) => ({ id, mediaType: "image" })); - const capabilities = model.capabilities.referenceInputs; - const totalLimit = model.capabilities.maxReferenceItems; - if (totalLimit !== null && requested.length > totalLimit) { - throw new AdobeFireflyError( - `${model.name} accepts at most ${totalLimit} total reference item(s)`, - 400, - "invalid_reference_count" - ); - } - - const counts = new Map(); - const normalized = requested.map((reference) => { - const id = String(reference.id || "").trim(); - if (!id) { - throw new AdobeFireflyError( - "Adobe Firefly reference id is required", - 400, - "invalid_reference" - ); - } - const mediaType = String(reference.mediaType || "image") - .trim() - .toLowerCase(); - const usage = - String(reference.usage || "") - .trim() - .toLowerCase() || defaultAdobeReferenceUsage(model, mediaType); - const capability = capabilities.find( - (candidate) => candidate.mediaType === mediaType && candidate.usageType === usage - ); - if (!usage || !capability) { - const allowed = capabilities - .filter((candidate) => candidate.mediaType === mediaType) - .map((candidate) => candidate.usageType) - .join(", "); - throw new AdobeFireflyError( - `${model.name} does not support ${mediaType} references with usage '${usage || "unspecified"}'` + - (allowed ? ` (allowed: ${allowed})` : ""), - 400, - "invalid_reference_usage" - ); - } - const key = `${mediaType}:${usage}`; - const count = (counts.get(key) || 0) + 1; - counts.set(key, count); - if (capability.maxItems !== null && count > capability.maxItems) { - throw new AdobeFireflyError( - `${model.name} accepts at most ${capability.maxItems} ${usage} ${mediaType} reference(s)`, - 400, - "invalid_reference_count" - ); - } - return { - id, - usage, - ...(usage === "frame" ? { order: reference.order ?? count } : {}), - }; - }); - - for (const capability of capabilities) { - if (capability.minItems <= 0) continue; - const key = `${capability.mediaType}:${capability.usageType}`; - const count = counts.get(key) || 0; - if (count < capability.minItems) { - throw new AdobeFireflyError( - `${model.name} requires at least ${capability.minItems} ${capability.usageType} ${capability.mediaType} reference(s)`, - 400, - "missing_required_reference" - ); - } - } - return normalized; + if (q === "high" || q === "4k" || q === "ultra") return 5; + if (q === "low" || q === "1k") return 1; + if (q === "medium" || q === "2k" || q === "standard" || q === "hd" || q === "auto") return 3; + return 3; } export function buildAdobeImagePayload(opts: { prompt: string; aspectRatio: string; outputResolution: "1K" | "2K" | "4K"; - modelSpec: DiscoveredImageModelSpec; + modelSpec: AdobeFireflyImageModelSpec; quality?: unknown; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; negativePrompt?: string; }): Record { const ratio = opts.aspectRatio === "auto" ? "1:1" : opts.aspectRatio || "1:1"; const seeds = [typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999)]; const negative = String(opts.negativePrompt || "").trim(); - const referenceBlobs = normalizeAdobeReferenceBlobs( - opts.modelSpec, - opts.references, - opts.sourceImageIds - ); const genSettings: Record = {}; if (negative) { genSettings.avoidKeywords = negative @@ -638,22 +751,28 @@ export function buildAdobeImagePayload(opts: { modelSpecificPayload: { size: "auto" }, modelId: opts.modelSpec.upstreamModelId, modelVersion: opts.modelSpec.upstreamModelVersion, - generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, + generationMetadata: { + module: "text2image", + submodule: "ff-image-generate", + }, generationSettings: { detailLevel: gptDetailLevel(opts.quality), ...genSettings, }, }; - if (referenceBlobs.length) { - // gpt-image references use the roles/counts validated from discovery. + if (opts.sourceImageIds?.length) { + // gpt-image subject references (mask path uses separate mask blob when present). payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; - payload.referenceBlobs = referenceBlobs; + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ + id: String(id), + usage: "subject", + })); payload.modelSpecificPayload = {}; } return payload; } - // Gemini Flash + generic (Flux / Seedream / Runway image): same 3P image shape. + // nano (Gemini Flash) + generic (Flux / Seedream / Runway image): same 3P image shape. // Live capture (web_providers/adobe_atach_images.txt): referenceBlobs with usage "general" // keep module "text2image" (not image2image) for nano multi-ref composition. const sizeMap = NANO_SIZE_MAP[opts.outputResolution] || NANO_SIZE_MAP["2K"]; @@ -668,7 +787,10 @@ export function buildAdobeImagePayload(opts: { groundSearch: false, skipCai: false, output: { storeInputs: true }, - generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, + generationMetadata: { + module: "text2image", + submodule: "ff-image-generate", + }, modelSpecificPayload: { parameters: { addWatermark: false }, aspectRatio: ratio, @@ -677,11 +799,17 @@ export function buildAdobeImagePayload(opts: { }; if (Object.keys(genSettings).length) payload.generationSettings = genSettings; - if (referenceBlobs.length) { - payload.referenceBlobs = referenceBlobs; - // Flux / Seedream / Runway image historically used image2image; Gemini keeps text2image. + if (opts.sourceImageIds?.length) { + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ + id: String(id), + usage: "general", + })); + // Flux / Seedream / Runway image historically used image2image; nano keeps text2image. if (opts.modelSpec.family === "generic") { - payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; + payload.generationMetadata = { + module: "image2image", + submodule: "ff-image-generate", + }; } } return payload; @@ -696,125 +824,149 @@ function videoSize(aspectRatio: string, resolution: string): { width: number; he return { width: Math.round((short * 16) / 9), height: short }; } -function parseSize(value: string): { width: number; height: number } | null { - const match = String(value || "").match(/^(\d+)[x:](\d+)$/i); - if (!match) return null; - const width = Number(match[1]); - const height = Number(match[2]); - return width > 0 && height > 0 ? { width, height } : null; -} - -function selectAdobeVideoSize( - model: DiscoveredVideoModelSpec, - aspectRatio: string, - resolution: string -): { width: number; height: number } { - const supported = model.capabilities.supportedSizes.map(parseSize).filter(Boolean) as Array<{ - width: number; - height: number; - }>; - const requestedExact = parseSize(aspectRatio); - if (requestedExact) { - const exact = supported.find( - (size) => size.width === requestedExact.width && size.height === requestedExact.height - ); - if (exact) return exact; - } - if (supported.length === 0) return videoSize(aspectRatio, resolution); - - const [ratioWidth, ratioHeight] = String(aspectRatio || "16:9") - .split(":") - .map(Number); - const targetRatio = ratioWidth > 0 && ratioHeight > 0 ? ratioWidth / ratioHeight : 16 / 9; - const targetLongEdge = String(resolution).includes("1080") ? 1920 : 1280; - return [...supported].sort((left, right) => { - const leftScore = - Math.abs(left.width / left.height - targetRatio) * 10_000 + - Math.abs(Math.max(left.width, left.height) - targetLongEdge); - const rightScore = - Math.abs(right.width / right.height - targetRatio) * 10_000 + - Math.abs(Math.max(right.width, right.height) - targetLongEdge); - return leftScore - rightScore; - })[0]; -} - -function validateAdobeDuration(model: DiscoveredVideoModelSpec, requested: number): number { - const capabilities = model.capabilities; - const duration = Math.floor(Number.isFinite(requested) ? requested : model.defaultDuration); - if ( - capabilities.supportedDurations.length > 0 && - !capabilities.supportedDurations.includes(duration) - ) { - throw new AdobeFireflyError( - `${model.name} supports duration(s): ${capabilities.supportedDurations.join(", ")} seconds`, - 400, - "invalid_duration" - ); - } - if ( - (capabilities.durationMin !== null && duration < capabilities.durationMin) || - (capabilities.durationMax !== null && duration > capabilities.durationMax) - ) { - throw new AdobeFireflyError( - `${model.name} duration must be between ${capabilities.durationMin ?? "?"} and ${capabilities.durationMax ?? "?"} seconds`, - 400, - "invalid_duration" - ); - } - return duration; -} - export function buildAdobeVideoPayload(opts: { prompt: string; aspectRatio: string; duration: number; - modelSpec: DiscoveredVideoModelSpec; + modelSpec: AdobeFireflyVideoModelSpec; resolution?: string; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; negativePrompt?: string; generateAudio?: boolean; }): Record { - const model = opts.modelSpec; - const properties = new Set(model.capabilities.schemaProperties); + const seedVal = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999); const aspect = opts.aspectRatio === "auto" ? "16:9" : opts.aspectRatio || "16:9"; - if ( - model.capabilities.supportedAspectRatios.length > 0 && - !model.capabilities.supportedAspectRatios.includes(aspect) - ) { - throw new AdobeFireflyError( - `${model.name} supports aspect ratio(s): ${model.capabilities.supportedAspectRatios.join(", ")}`, - 400, - "invalid_aspect_ratio" - ); - } - const duration = validateAdobeDuration(model, opts.duration); - const referenceBlobs = normalizeAdobeReferenceBlobs(model, opts.references, opts.sourceImageIds); - const seed = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999); - const resolution = opts.resolution || model.defaultResolution; - const payload: Record = { - modelId: model.upstreamModelId, - modelVersion: model.upstreamModelVersion, - prompt: opts.prompt, - generationMetadata: { - module: referenceBlobs.some((reference) => reference.usage === "frame") - ? "image2video" - : "text2video", - }, - }; + const duration = Math.max( + 1, + Math.min(30, Math.floor(opts.duration || opts.modelSpec.defaultDuration)) + ); + const resolution = opts.resolution || opts.modelSpec.defaultResolution; + const vidSize = videoSize(aspect, resolution); + const engine = opts.modelSpec.engine; + const sourceImageIds = opts.sourceImageIds || []; + const negative = String(opts.negativePrompt || ""); - if (properties.has("n")) payload.n = 1; - if (properties.has("seeds")) payload.seeds = [seed]; - if (properties.has("output")) payload.output = { storeInputs: true }; - if (properties.has("size")) payload.size = selectAdobeVideoSize(model, aspect, resolution); - if (properties.has("duration")) payload.duration = duration; - if (properties.has("generationSettings")) payload.generationSettings = { aspectRatio: aspect }; - if (properties.has("generateAudio")) payload.generateAudio = opts.generateAudio !== false; - if (properties.has("modelSpecificPayload")) payload.modelSpecificPayload = {}; - if (properties.has("referenceBlobs")) payload.referenceBlobs = referenceBlobs; - const negativePrompt = String(opts.negativePrompt || "").trim(); - if (negativePrompt && properties.has("negativePrompt")) payload.negativePrompt = negativePrompt; + if (engine === "veo31-standard" || engine === "veo31-fast") { + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "veo", + modelVersion: + opts.modelSpec.modelVersion || + (engine === "veo31-fast" ? "3.1-fast-generate" : "3.1-generate"), + output: { storeInputs: true }, + prompt: opts.prompt, + size: vidSize, + generateAudio: opts.generateAudio !== false, + referenceBlobs: [] as Array>, + generationMetadata: { module: "text2video" }, + modelSpecificPayload: { + parameters: { + durationSeconds: duration, + aspectRatio: aspect, + addWaterMark: false, + }, + }, + }; + if (sourceImageIds.length) { + const refs = payload.referenceBlobs as Array>; + if (opts.modelSpec.referenceMode === "image") { + for (const imageId of sourceImageIds.slice(0, 3)) { + refs.push({ id: String(imageId), usage: "asset" }); + } + } else { + sourceImageIds.slice(0, 2).forEach((imageId, idx) => { + refs.push({ id: String(imageId), usage: "general", order: idx + 1 }); + }); + } + payload.generationMetadata = { module: "image2video" }; + } + if (negative) payload.negativePrompt = negative; + return payload; + } + + if (engine === "kling3") { + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "kling", + modelVersion: "kling_v3_standard_i2v", + output: { storeInputs: true }, + prompt: opts.prompt, + size: vidSize, + generationMetadata: { + module: sourceImageIds.length ? "image2video" : "text2video", + }, + duration, + generationSettings: { aspectRatio: aspect }, + referenceBlobs: [] as Array>, + }; + if (sourceImageIds.length) { + const refs = payload.referenceBlobs as Array>; + sourceImageIds.slice(0, 2).forEach((imageId, idx) => { + refs.push({ id: String(imageId), usage: "frame", order: idx + 1 }); + }); + } + if (negative) payload.negativePrompt = negative; + return payload; + } + + // Sora 2 / Sora 2 Pro + const promptJson = JSON.stringify({ + prompt: opts.prompt, + duration, + ...(negative ? { negative_prompt: negative } : {}), + }); + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "sora", + modelVersion: engine === "sora2-pro" ? "sora-2-pro" : "sora-2", + size: vidSize, + duration, + fps: 24, + prompt: promptJson, + generationMetadata: { + module: sourceImageIds.length ? "image2video" : "text2video", + }, + model: opts.modelSpec.upstreamModel, + generateLoop: false, + transparentBackground: false, + seed: String(seedVal), + locale: "en-US", + camera: { + angle: "none", + shotSize: "none", + motion: null, + promptStyle: null, + }, + negativePrompt: negative, + jobMode: "standard", + debugGenerationEndpoint: "", + referenceBlobs: [] as Array>, + referenceFrames: [] as Array | null>, + referenceVideo: null, + cameraMotionReferenceVideo: null, + characterReference: null, + editReferenceVideo: null, + output: { storeInputs: true }, + }; + if (sourceImageIds.length) { + const firstId = String(sourceImageIds[0]); + payload.referenceBlobs = [{ id: firstId, usage: "general", promptReference: 1 }]; + const frames: Array | null> = [{ localBlobRef: firstId }, null]; + if (sourceImageIds.length > 1) { + const lastId = String(sourceImageIds[1]); + (payload.referenceBlobs as Array>).push({ + id: lastId, + usage: "general", + promptReference: 2, + }); + frames[1] = { localBlobRef: lastId }; + } + payload.referenceFrames = frames; + } return payload; } @@ -860,32 +1012,253 @@ export function buildAdobeSubmitNonce(accessToken: string, prompt: string): stri } /** - * Synthesize x-arp-session-id when no sherlockToken cookie is available. - * Shape matches adobe2api / GPT2Image-Pro: base64(JSON({sid, ftr})). - * Working clients ALWAYS send this header on generate-async. + * Live firefly.adobe.com Arkose public key (web_providers/adobe_atach_images.txt, 2026-07). + * Browser x-arp-session-id is base64(JSON({sid, ark, ftr})) — synthetic sessions without a + * real Arkose blob often get colligo HTTP 408 "system under load". Prefer pasted sherlockToken. */ -export function buildAdobeArpSessionId(): string { +export const ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY = "BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C"; +/** Live ftr magic (replaces older adobe2api `dUAL43-mnts-ants-d4_31ck__tt`). */ +export const ADOBE_FIREFLY_FTR_MAGIC = "__UDF43-m4_31ck"; + +/** + * True when a string looks like a Firefly ARP session (base64 JSON with sid). + */ +export function isValidAdobeArpSessionId(value: string): boolean { + const t = String(value || "").trim(); + if (t.length < 4) return false; + // Never treat Cookie name=value pairs (e.g. aux_sid=…, forter=…) as ARP. + // Live ARP is base64(JSON) or a bare opaque token — not "key=value". + if (/^[A-Za-z_][A-Za-z0-9_.%-]*=/.test(t) && !t.startsWith("eyJ")) return false; + try { + const padded = t + "=".repeat((4 - (t.length % 4)) % 4); + const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( + "utf8" + ); + // Reject binary garbage that "decodes" but isn't JSON (corrupted sherlock paste). + if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(json)) return false; + const obj = JSON.parse(json) as { + sid?: unknown; + ftr?: unknown; + ark?: unknown; + }; + return typeof obj.sid === "string" && obj.sid.length > 0; + } catch { + // Opaque short sherlockToken values (tests / non-JSON) when non-empty. + // No mid-string "=" (cookie pair leftovers); padding "=" at end is OK. + if (/=.+/.test(t.replace(/=+$/, ""))) return false; + return !looksLikeAdobeJwt(t) && /^[A-Za-z0-9+/_=-]+$/.test(t); + } +} + +/** + * Synthesize x-arp-session-id when no browser sherlockToken is available. + * Shape matches live successful generate (adobe/image_generate.txt): + * base64(JSON({sid, ark, bfp, ftr, fpjs})) + * ALWAYS send this header on generate-async / storage upload. + * Prefer real sherlockToken / cookie rebuild (forter+arkose+sid) when available. + */ +export function buildAdobeArpSessionId(region = "eu-west-1"): string { const nowMs = Date.now(); - const rand = randomBytes(16).toString("hex"); const sid = randomUUID(); - const pid = typeof process !== "undefined" && process.pid ? process.pid : 0; - // Magic suffix is part of the wire contract reverse-engineered by adobe2api. - const ftr = `${rand}_${nowMs}_${pid}_dUAL43-mnts-ants-d4_31ck__tt`; - const raw = JSON.stringify({ sid, ftr }); + const randHex = randomBytes(16).toString("hex"); + // Live ftr: {32hex}_{ms}__UDF43-m4_31ck_{b64}=-N-v2_tt + const mid = randomBytes(12).toString("base64url"); + const n = 1000 + Math.floor(Math.random() * 9000); + const ftr = `${randHex}_${nowMs}${ADOBE_FIREFLY_FTR_MAGIC}_${mid}=-${n}-v2_tt`; + // Arkose session-shaped string (public pk from firefly SPA). Without a real + // Arkose solve this may still 408; real sherlockToken is the stable path. + const arkSession = `${randomBytes(8).toString("hex")}.${Math.random().toFixed(10).slice(2)}`; + const ark = + `${arkSession}|r=${region}|meta=3|metabgclr=transparent|metaiconclr=%23757575|` + + `guitextcolor=%23000000|pk=${ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY}|at=40|sup=1|rid=13|ag=101|` + + `cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|` + + `surl=https%3A%2F%2Farks-client.adobe.com|` + + `smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager`; + // Successful browser ARP also carries Browser Fingerprint + FingerprintJS payload. + const bfp = randomUUID(); + const fpjs = JSON.stringify({ + requestId: `${nowMs}.${randomBytes(3).toString("base64url")}`, + visitorId: randomBytes(12).toString("base64url"), + }); + const raw = JSON.stringify({ sid, ark, bfp, ftr, fpjs }); return Buffer.from(raw, "utf-8").toString("base64"); } /** - * Pull sherlockToken / x-arp-session-id from a Cookie header if present. - * Browser generate sends Cookie.sherlockToken as x-arp-session-id. + * Pull sherlockToken / x-arp-session-id from Cookie header, HAR paste, or multi-line credential. + * Browser generate sends Cookie.sherlockToken (or the request header) as x-arp-session-id. + * Live value is base64({sid, ark, ftr}) — includes Arkose session data. + * + * Also handles PasswordBox mangling (JWT + ARP joined by a single space) and full fetch() + * copy/paste from DevTools (web_providers/adobe_atach_images.txt). */ export function extractAdobeArpSessionId(cookieOrBlob: string): string { const raw = String(cookieOrBlob || ""); - const m = raw.match(/(?:^|[;\s])sherlockToken=([^;]+)/i); - if (m?.[1]) return decodeURIComponent(m[1].trim()); - const m2 = raw.match(/(?:^|[;\s])x-arp-session-id=([^;]+)/i); - if (m2?.[1]) return decodeURIComponent(m2[1].trim()); - return ""; + if (!raw.trim()) return ""; + + const candidates: string[] = []; + const push = (v: string | undefined | null) => { + if (!v) return; + let t = v + .trim() + .replace(/^["']|["']$/g, "") + .trim(); + try { + // Cookie values are often URI-encoded + if (/%[0-9A-Fa-f]{2}/.test(t)) t = decodeURIComponent(t); + } catch { + /* keep raw */ + } + if (t) candidates.push(t); + }; + + // Cookie: sherlockToken=... + const m = raw.match(/(?:^|[;\s\n\r])sherlockToken=([^;\s\n\r]+)/i); + if (m?.[1]) push(m[1]); + + // Cookie or form: x-arp-session-id=... + const m2 = raw.match(/(?:^|[;\s\n\r])x-arp-session-id=([^;\s\n\r]+)/i); + if (m2?.[1]) push(m2[1]); + + // HAR / Network / fetch() headers: "x-arp-session-id": "eyJ..." or x-arp-session-id: eyJ... + const m3 = raw.match(/["']?x-arp-session-id["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); + if (m3?.[1]) push(m3[1]); + + // HAR: "sherlockToken": "eyJ..." + const m4 = raw.match(/["']?sherlockToken["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); + if (m4?.[1]) push(m4[1]); + + // Bare base64 ARP blob on its own line (line 2 of two-line paste) + for (const line of raw.split(/[\r\n]+/)) { + const t = line.trim().replace(/^["']|["']$/g, ""); + // Skip pure JWT lines + if (looksLikeAdobeJwt(t)) continue; + if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); + } + + // JWT + ARP joined by whitespace (single-line PasswordBox paste collapses \n → space) + // Split on whitespace only — NOT on "=" — so we never treat "aux_sid=…" as a token. + const withoutJwt = stripAdobeJwts(raw, " "); + for (const token of withoutJwt.split(/[\s,;"']+/)) { + let t = token.trim(); + // If this chunk is name=value from a Cookie header, only keep the value when + // the name is sherlockToken / x-arp-session-id; skip aux_sid, forter, etc. + const eq = t.indexOf("="); + if (eq > 0 && eq < 40 && /^[A-Za-z0-9_.%-]+$/.test(t.slice(0, eq))) { + const name = t.slice(0, eq).toLowerCase(); + if (name === "sherlocktoken" || name === "x-arp-session-id") { + t = t.slice(eq + 1).trim(); + } else { + continue; + } + } + if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); + } + + // Prefer ARP that decodes to JSON with sid+ark (real browser session over opaque short tokens) + const ranked = candidates + .map((c) => c.replace(/^["']|["']$/g, "").trim()) + .filter((v) => isValidAdobeArpSessionId(v)); + ranked.sort((a, b) => scoreAdobeArpCandidate(b) - scoreAdobeArpCandidate(a)); + return ranked[0] || ""; +} + +/** Higher = more like a live firefly-3p x-arp-session-id (sid+ark+ftr[+bfp+fpjs] base64). */ +function scoreAdobeArpCandidate(value: string): number { + let score = value.length; + try { + const padded = value + "=".repeat((4 - (value.length % 4)) % 4); + const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( + "utf8" + ); + const obj = JSON.parse(json) as { + sid?: unknown; + ark?: unknown; + ftr?: unknown; + bfp?: unknown; + fpjs?: unknown; + }; + if (typeof obj.sid === "string" && obj.sid) score += 1000; + if (typeof obj.ark === "string" && obj.ark.length > 20) score += 500; + if (typeof obj.ftr === "string" && obj.ftr.includes(ADOBE_FIREFLY_FTR_MAGIC)) score += 200; + if (typeof obj.ark === "string" && obj.ark.includes(ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY)) + score += 100; + // Live successful generates (adobe/image_generate.txt) include browser fingerprint fields. + if (typeof obj.bfp === "string" && obj.bfp.length >= 8) score += 150; + if (typeof obj.fpjs === "string" && obj.fpjs.length > 10) score += 150; + } catch { + /* opaque sherlockToken */ + } + return score; +} + +/** + * True when the credential blob already contains a browser ARP / sherlockToken + * OR enough cookie pieces to rebuild one (ff_session_guid + arkose + forterToken). + * Synthetic-only ARP is a fallback — real cookie pieces are required for stable generate. + */ +export function hasBrowserAdobeArpSession(sessionCookieOrBlob?: string): boolean { + const blob = String(sessionCookieOrBlob || ""); + if (extractAdobeArpSessionId(blob)) return true; + // Rebuild path counts as browser ARP (same pieces the SPA uses for sherlockToken). + const sid = blob.match(/(?:^|[;\s])ff_session_guid=([^;\s]+)/i)?.[1]; + const ark = blob.match(/(?:^|[;\s])arkose=([^;\s]+)/i)?.[1]; + const ftr = + blob.match(/(?:^|[;\s])forterToken=([^;\s]+)/i)?.[1] || + blob.match(/(?:^|[;\s])forter=([^;\s]+)/i)?.[1]; + return Boolean(sid && ark && ftr && !/^[a-f0-9]{32},\d+$/i.test(ftr)); +} + +/** + * Resolve ARP for a Firefly request. + * Prefer cookie rebuild (ff_session_guid + arkose + forterToken [+bfp/fpjs]) over a + * frozen sherlockToken paste — Forter advances while the pasted ARP goes stale. + * Fall back to sherlockToken / x-arp-session-id extract, then synthetic rich ARP. + * Mint once per generate/upload chain and reuse (browser uses the same ARP for upload+submit); + * on 408 the submit loop rotates ARP separately. + */ +export function resolveAdobeArpSessionId(sessionCookieOrBlob?: string): string { + const blob = String(sessionCookieOrBlob || ""); + // Lazy require of rebuild helper to avoid circular import at module load. + // Inline minimal rebuild here (sid+ark+ftr) so resolve stays self-contained. + const getCookie = (name: string): string => { + const m = blob.match(new RegExp(`(?:^|[;\\s\\n\\r])${name}=([^;\\s\\n\\r]+)`, "i")); + if (!m?.[1]) return ""; + let v = m[1].trim(); + try { + if (/%[0-9A-Fa-f]{2}/.test(v)) v = decodeURIComponent(v); + } catch { + /* keep */ + } + return v; + }; + const sid = getCookie("ff_session_guid"); + const ark = getCookie("arkose"); + let ftr = getCookie("forterToken") || getCookie("forter"); + try { + if (/%[0-9A-Fa-f]{2}/.test(ftr)) ftr = decodeURIComponent(ftr); + } catch { + /* keep */ + } + if (ftr.endsWith("v2") && !ftr.endsWith("v2_tt")) ftr = `${ftr}_tt`; + // Skip localStorage-style "id,timestamp" forter values + if (/^[a-f0-9]{32},\d+$/i.test(ftr)) ftr = ""; + if (sid && ark && ftr) { + const bfp = getCookie("bfp"); + let fpjs = getCookie("fpjs"); + try { + if (fpjs && /%[0-9A-Fa-f]{2}/.test(fpjs)) fpjs = decodeURIComponent(fpjs); + } catch { + /* keep */ + } + const obj: Record = { sid, ark, ftr }; + if (bfp) obj.bfp = bfp; + if (fpjs) obj.fpjs = fpjs; + return Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); + } + const extracted = extractAdobeArpSessionId(blob); + if (extracted) return extracted; + return buildAdobeArpSessionId(); } export function buildAdobeSubmitHeaders( @@ -898,17 +1271,18 @@ export function buildAdobeSubmitHeaders( prompt?: string; } ): Record { - // Live capture + working open-source clients (GPT2Image-Pro / adobe2api): - // Authorization + x-api-key + deterministic x-nonce + ALWAYS x-arp-session-id. - // Do NOT attach firefly.adobe.com page Cookie to firefly-3p (wrong origin / soft 408). - void extras?.cookie; + // Live capture (web_providers/adobe_atach_images.txt) + working clients: + // Authorization + x-api-key + x-nonce + ALWAYS x-arp-session-id (sid+ark+ftr). + // Do NOT attach firefly.adobe.com page Cookie to firefly-3p (wrong origin). + // Prefer real sherlockToken from cookie blob; synthetic ARP is fallback only. + const cookieBlob = String(extras?.cookie || "").trim(); const deterministic = extras?.nonce || (extras?.prompt ? buildAdobeSubmitNonce(accessToken, extras.prompt) : "") || generateAdobeNonce(); - // Prefer pasted sherlockToken; otherwise mint a synthetic ARP session (required). - const arp = - (extras?.arpSessionId && String(extras.arpSessionId).trim()) || buildAdobeArpSessionId(); + // Explicit arpSessionId wins (caller may pass synthetic short test ids or real browser ARP). + const explicitArp = extras?.arpSessionId ? String(extras.arpSessionId).trim() : ""; + const arp = explicitArp || extractAdobeArpSessionId(cookieBlob) || buildAdobeArpSessionId(); const headers: Record = { ...browserHeaders(), Authorization: `Bearer ${accessToken}`, @@ -964,6 +1338,11 @@ export function buildAdobeUploadHeaders( * Supports: image_url, image, images[], image_urls[], input_image(s), reference_images, * provider_options.*, and prompt_image fields used by the WinUI Media page. */ +export { + extractAdobeSourceImageReferences, + normalizeAdobeReferenceBlobs, +} from "./adobeFireflyReferences.ts"; + export function extractAdobeSourceImageSources(body: unknown, max = 4): string[] { if (!body || typeof body !== "object") return []; const b = body as Record; @@ -1050,49 +1429,6 @@ export function extractAdobeSourceImageSources(body: unknown, max = 4): string[] return out.slice(0, max); } -export interface AdobeFireflySourceImageReference { - source: string; - usage?: string; - order?: number; -} - -/** Structured extension used when a caller needs style/element/frame semantics. */ -export function extractAdobeSourceImageReferences( - body: unknown, - max = 4 -): AdobeFireflySourceImageReference[] { - const record = body && typeof body === "object" ? (body as Record) : {}; - const explicit = record.adobe_reference_inputs ?? record.adobeReferenceInputs; - if (Array.isArray(explicit)) { - const references: AdobeFireflySourceImageReference[] = []; - for (const value of explicit) { - if (references.length >= max) break; - if (!value || typeof value !== "object") continue; - const item = value as Record; - const mediaType = String(item.media_type ?? item.mediaType ?? "image").toLowerCase(); - if (mediaType !== "image") continue; - const source = - typeof item.source === "string" - ? item.source - : typeof item.url === "string" - ? item.url - : typeof item.image_url === "string" - ? item.image_url - : ""; - if (!source.trim()) continue; - const usage = String(item.usage ?? item.usage_type ?? item.usageType ?? "").trim(); - const orderValue = Number(item.order); - references.push({ - source: source.trim(), - ...(usage ? { usage } : {}), - ...(Number.isInteger(orderValue) && orderValue > 0 ? { order: orderValue } : {}), - }); - } - return references; - } - return extractAdobeSourceImageSources(body, max).map((source) => ({ source })); -} - export function parseAdobeImageSourceBytes(source: string): { buffer: Buffer; contentType: string; @@ -1125,7 +1461,10 @@ export function parseAdobeImageSourceBytes(source: string): { "bad_image" ); } - return { buffer, contentType: mime.startsWith("image/") ? mime : "image/png" }; + return { + buffer, + contentType: mime.startsWith("image/") ? mime : "image/png", + }; } // Raw base64 without data: prefix @@ -1175,10 +1514,15 @@ export async function uploadAdobeFireflyImage(opts: { bytes: Buffer | Uint8Array; contentType?: string; sessionCookie?: string; + /** Reuse the same ARP as generate-async (browser does). */ + arpSessionId?: string; /** Used for deterministic x-nonce (optional). */ prompt?: string; fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + log?: { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + }; }): Promise { const fetchImpl = opts.fetchImpl || fetch; const buffer = Buffer.isBuffer(opts.bytes) ? opts.bytes : Buffer.from(opts.bytes); @@ -1195,8 +1539,10 @@ export async function uploadAdobeFireflyImage(opts: { const sessionCookie = String(opts.sessionCookie || "").trim(); const cookieHeader = extractAdobeCookieHeader(sessionCookie); + // One ARP for the whole chain — do not mint a new synthetic id per upload. const arpSessionId = - extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie); + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(cookieHeader || sessionCookie); const contentType = (opts.contentType && opts.contentType.trim()) || (buffer[0] === 0xff && buffer[1] === 0xd8 @@ -1208,11 +1554,11 @@ export async function uploadAdobeFireflyImage(opts: { const resp = await fetchImpl(ADOBE_FIREFLY_IMAGE_UPLOAD_URL, { method: "POST", headers: buildAdobeUploadHeaders(opts.accessToken, contentType, { - arpSessionId: arpSessionId || undefined, + arpSessionId, cookie: cookieHeader || undefined, prompt: opts.prompt || "upload", }), - body: buffer as unknown as BodyInit, + body: Uint8Array.from(buffer), }); const text = await resp.text().catch(() => ""); @@ -1260,16 +1606,25 @@ export async function resolveAdobeSourceImageIds(opts: { body: unknown; max?: number; sessionCookie?: string; + /** Shared ARP for upload+generate (required for stable Firefly 3P). */ + arpSessionId?: string; prompt?: string; fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + log?: { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + }; }): Promise { - const max = Math.max(1, Math.min(32, opts.max ?? 4)); + const max = Math.max(1, Math.min(8, opts.max ?? 4)); const sources = extractAdobeSourceImageSources(opts.body, max); if (!sources.length) return []; const fetchImpl = opts.fetchImpl || fetch; const ids: string[] = []; + // One ARP for all uploads in this request (browser reuses the same header). + const arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(opts.sessionCookie); for (const src of sources) { // Already a Firefly storage id (uuid) @@ -1310,6 +1665,7 @@ export async function resolveAdobeSourceImageIds(opts: { bytes: buffer, contentType, sessionCookie: opts.sessionCookie, + arpSessionId, prompt: opts.prompt, fetchImpl, log: opts.log, @@ -1320,31 +1676,6 @@ export async function resolveAdobeSourceImageIds(opts: { return ids; } -export async function resolveAdobeSourceImageReferences(opts: { - accessToken: string; - body: unknown; - max?: number; - sessionCookie?: string; - prompt?: string; - fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise { - const max = Math.max(1, Math.min(32, opts.max ?? 4)); - const references = extractAdobeSourceImageReferences(opts.body, max); - if (references.length === 0) return []; - const ids = await resolveAdobeSourceImageIds({ - ...opts, - max, - body: { images: references.map((reference) => reference.source) }, - }); - return ids.map((id, index) => ({ - id, - mediaType: "image", - ...(references[index]?.usage ? { usage: references[index].usage } : {}), - ...(references[index]?.order ? { order: references[index].order } : {}), - })); -} - /** Transient Adobe 3P overload / rate / edge errors worth retrying. */ export function isAdobeTransientSubmitError(status: number, bodyText: string): boolean { if (status === 408 || status === 429 || status === 502 || status === 503 || status === 504) { @@ -1397,13 +1728,29 @@ export function buildAdobeDiscoveryHeaders(accessToken: string): Record { const form = new URLSearchParams({ client_id: opts.clientId, @@ -1595,7 +1941,7 @@ async function imsCheckToken(opts: { if (!resp.ok) { return { - state: "failed", + ok: false, status: resp.status, error: sanitizeErrorMessage( data?.error_description || data?.error || text.slice(0, 200) || `HTTP ${resp.status}` @@ -1606,14 +1952,14 @@ async function imsCheckToken(opts: { const token = String(data?.access_token || "").trim(); if (!token) { return { - state: "failed", + ok: false, status: 401, error: sanitizeErrorMessage( data?.error_description || data?.error || "IMS response missing access_token" ), }; } - return { state: "ok", token, data: data || {} }; + return { ok: true, token, data: data || {} }; } /** @@ -1660,7 +2006,7 @@ export async function exchangeAdobeCookieForAccessToken( guestAllowed: false, fetchImpl, }); - if (authed.state === "ok") { + if (authed.ok === true) { if ( isAdobeGuestAccessToken(authed.token) || authed.data.account_type === "guest" || @@ -1683,7 +2029,7 @@ export async function exchangeAdobeCookieForAccessToken( guestAllowed: true, fetchImpl, }); - if (guest.state === "ok") { + if (guest.ok === true) { if ( guest.data.account_type === "guest" || guest.data.guestId || @@ -1794,7 +2140,11 @@ export interface AdobeFireflyCreditsBalance { raw?: unknown; } -function readQuotaBlock(block: unknown): { total: number; used: number; available: number } { +function readQuotaBlock(block: unknown): { + total: number; + used: number; + available: number; +} { if (!block || typeof block !== "object") return { total: 0, used: 0, available: 0 }; const q = (block as Record).quota && @@ -1882,10 +2232,17 @@ export async function fetchAdobeCreditsBalance( // ── Models discovery ──────────────────────────────────────────────────────── +/** + * Parse POST /v2/models/discovery response into flat model/version rows. + */ +export function parseAdobeModelsDiscovery(body: unknown): AdobeFireflyDiscoveredModel[] { + return parseAdobeModelsDiscoveryContract(body); +} + export async function discoverAdobeFireflyModels( accessToken: string, fetchImpl: typeof fetch = fetch -) { +): Promise { const resp = await fetchImpl(ADOBE_FIREFLY_MODELS_DISCOVERY_URL, { method: "POST", headers: buildAdobeDiscoveryHeaders(accessToken), @@ -1919,8 +2276,14 @@ export async function pollAdobeJob(opts: { kind: "image" | "video"; timeoutMs: number; pollIntervalMs?: number; + /** Optional session cookie so a mid-poll 401 can renew JWT once via CDP. */ + sessionCookie?: string; + sessionFingerprint?: string; fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + log?: { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + }; }): Promise<{ mediaUrl: string; latest: unknown }> { const fetchImpl = opts.fetchImpl || fetch; const deadline = Date.now() + opts.timeoutMs; @@ -1928,12 +2291,14 @@ export async function pollAdobeJob(opts: { opts.pollIntervalMs && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : DEFAULT_POLL_INTERVAL_MS; let attempt = 0; let latest: unknown = {}; + let accessToken = opts.accessToken; + let authRefreshAttempted = false; while (Date.now() < deadline) { attempt += 1; const pollResp = await fetchImpl(opts.pollUrl, { method: "GET", - headers: buildAdobePollHeaders(opts.accessToken), + headers: buildAdobePollHeaders(accessToken), }); if (pollResp.status === 401 || pollResp.status === 403) { @@ -1945,6 +2310,44 @@ export async function pollAdobeJob(opts: { "quota_exhausted" ); } + // One CDP JWT renewal mid-poll (long jobs can outlive a near-expiry IMS token). + if (!authRefreshAttempted && opts.sessionCookie) { + authRefreshAttempted = true; + try { + const { + rotateAdobeFireflySessionOnError, + fingerprintAdobeCredential, + estimateAdobeTokenExpiry, + } = await import("./adobeFireflySession.ts"); + const fp = + String(opts.sessionFingerprint || "").trim() || + fingerprintAdobeCredential( + [accessToken, opts.sessionCookie].filter(Boolean).join("\n") + ); + const refreshed = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: opts.sessionCookie, + arpSessionId: "", + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint: fp, + source: "rebuild", + }, + { attempt: 3, authFailure: true, tryBrowser: true, log: opts.log } + ); + if (refreshed?.accessToken && isAdobeUserAccessToken(refreshed.accessToken)) { + accessToken = refreshed.accessToken; + opts.log?.info?.( + "ADOBE-FIREFLY", + `poll auth ${pollResp.status}; retrying once with renewed JWT` + ); + continue; + } + } catch { + /* fall through to auth error */ + } + } throw new AdobeFireflyError("Adobe Firefly token invalid or expired", 401, "auth"); } @@ -1998,11 +2401,23 @@ export async function pollAdobeJob(opts: { throw new AdobeFireflyError(`Adobe Firefly ${opts.kind} generation timed out`, 504, "timeout"); } -// Colligo often returns instant 408 with x-colligo-timeout:0.0 under load. -// Keep retries short: hammering Adobe with 8 long waits makes the Media page -// look broken while balance still works. SPA succeeds on a healthy queue/token. -const SUBMIT_MAX_ATTEMPTS = 4; -const SUBMIT_BASE_DELAY_MS = 1200; +// Colligo often returns instant 408 with x-colligo-timeout:0.0 under load OR when +// generate-async is hammered in a batch. Space submits (gate) + reuse sticky ARP; +// do NOT thrash synthetic rebuilds on every retry (identical forter → no-op). +// More attempts: 1–2 reuse sticky ARP when forter is fresh; stale forter / attempt 3+ → off-screen Chrome warm. +const SUBMIT_MAX_ATTEMPTS = 5; +/** Base backoff after 408; combined with withAdobeFireflySubmitGate (~12s min gap). */ +function submitBaseDelayMs(): number { + if ( + process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS != null && + process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS !== "" + ) { + return Math.max(0, Number(process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS) || 0); + } + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) + return 20; + return 8000; +} export async function adobeFireflyGenerateImage(opts: { accessToken: string; @@ -2013,13 +2428,21 @@ export async function adobeFireflyGenerateImage(opts: { quality?: unknown; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; negativePrompt?: string; /** Optional Cookie blob — used only to lift sherlockToken → x-arp-session-id */ sessionCookie?: string; + /** Shared ARP (sid+ark+ftr). Reuse with uploads; do not mint per retry. */ + arpSessionId?: string; + /** Session cache key from ensureAdobeFireflySession — sticky ARP across batch jobs. */ + sessionFingerprint?: string; + /** Chrome profile key (provider connection id) for CDP warm/login isolation. */ + sessionBrowserKey?: string; timeoutMs?: number; fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + log?: { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + }; }): Promise<{ url: string; b64_json?: string; latest: unknown }> { const fetchImpl = opts.fetchImpl || fetch; const { spec } = resolveAdobeImageModel(opts.model); @@ -2033,33 +2456,58 @@ export async function adobeFireflyGenerateImage(opts: { quality: opts.quality, seed: opts.seed, sourceImageIds: opts.sourceImageIds, - references: opts.references, negativePrompt: opts.negativePrompt, }); const sessionCookie = String(opts.sessionCookie || "").trim(); - const cookieHeader = extractAdobeCookieHeader(sessionCookie); - // Prefer real browser sherlockToken; buildAdobeSubmitHeaders mints synthetic ARP if empty. - const arpSessionId = - extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie); + let activeCookie = extractAdobeCookieHeader(sessionCookie) || sessionCookie; + // Prefer real browser sherlockToken / cookie rebuild (forter+arkose). Only the raw + // credential paste counts as "browser ARP" — never the pure synthetic fallback. + const hadBrowserArp = hasBrowserAdobeArpSession(activeCookie); + let arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(activeCookie); let submitData: unknown = {}; let submitHeaders: Headers | Record = new Headers(); let lastSubmitError = ""; let sawSystemUnderLoad = false; + let accessToken = opts.accessToken; + let authRefreshAttempted = false; + const { + withAdobeFireflySubmitGate, + markAdobeFireflyArpSuccess, + noteAdobeFireflySubmitFailure, + rotateAdobeFireflySessionOnError, + resolveAdobeArpSessionIdSmart, + fingerprintAdobeCredential, + estimateAdobeTokenExpiry, + } = await import("./adobeFireflySession.ts"); + + // Stable sticky key — do NOT include arpSessionId (it changes and would break sticky). + const fingerprint = + String(opts.sessionFingerprint || "").trim() || + fingerprintAdobeCredential([accessToken, activeCookie].filter(Boolean).join("\n")); + const browserSessionKey = String(opts.sessionBrowserKey || "").trim() || fingerprint; + + // Gate ONLY the actual generate-async HTTP call (min gap). CDP warm / backoff run + // outside so interactive browser login and other Firefly submits are not blocked for minutes. + let submitOk = false; for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { - // Deterministic x-nonce from user_id+prompt (adobe2api/GPT2Image-Pro). Fresh ARP each attempt. - const submitResp = await fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, { - method: "POST", - headers: buildAdobeSubmitHeaders(opts.accessToken, { - arpSessionId: arpSessionId || undefined, - prompt: opts.prompt, - cookie: cookieHeader || undefined, - }), - body: JSON.stringify(payload), - }); + const submitResp = await withAdobeFireflySubmitGate(() => + fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, { + method: "POST", + headers: buildAdobeSubmitHeaders(accessToken, { + arpSessionId, + prompt: opts.prompt, + cookie: activeCookie || undefined, + }), + body: JSON.stringify(payload), + }) + ); if (submitResp.status === 401 || submitResp.status === 403) { + noteAdobeFireflySubmitFailure(); const accessError = submitResp.headers.get("x-access-error") || ""; if (accessError === "taste_exhausted") { throw new AdobeFireflyError( @@ -2068,8 +2516,35 @@ export async function adobeFireflyGenerateImage(opts: { "quota_exhausted" ); } + if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) { + authRefreshAttempted = true; + const refreshed = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: activeCookie, + arpSessionId, + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint, + browserSessionKey, + source: "rebuild", + }, + { attempt, authFailure: true, tryBrowser: true, log: opts.log } + ).catch(() => null); + if (refreshed?.accessToken && refreshed?.arpSessionId) { + accessToken = refreshed.accessToken; + activeCookie = refreshed.cookie || activeCookie; + arpSessionId = refreshed.arpSessionId; + opts.log?.info?.( + "ADOBE-FIREFLY", + `image submit auth ${submitResp.status}; retrying once with renewed CDP session` + ); + continue; + } + } throw new AdobeFireflyError( - "Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p), not page cookies alone.", + "Adobe Firefly session is no longer authenticated and automatic browser renewal failed. " + + "Sign in once through the Adobe Firefly browser login to restore durable renewal.", 401, "auth" ); @@ -2082,20 +2557,73 @@ export async function adobeFireflyGenerateImage(opts: { } lastSubmitError = `Adobe Firefly image submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { - // Exponential backoff: 2s, 4s, 8s, 16s… capped at 45s (+ jitter) + const { getAdobeForterAgeMs: forterAgeMsFn, extractAdobeForterTimestampMs: forterTsFn } = + await import("./adobeFireflySession.ts"); + // Only treat as known-stale when the cookie embeds a parseable forter timestamp. + // Missing timestamp (tests / synthetic ARP) must keep the full retry ladder. + const forterTs = forterTsFn(activeCookie || ""); + const forterAgeBefore = forterAgeMsFn(activeCookie || ""); + const forterKnownStale = + forterTs > 0 && Number.isFinite(forterAgeBefore) && forterAgeBefore > 4 * 60_000; + // Stale risk session: at most 2 attempts (warm once + one retry). Avoid ~600s thrash. + if (forterKnownStale && attempt >= 2) { + noteAdobeFireflySubmitFailure(); + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("image", attempt, { hadBrowserArp }) + + " Risk session looks expired — open Providers → Adobe Firefly → Sign in with browser once.", + 408, + "system_under_load" + ); + } + try { + if (activeCookie) { + const rotated = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: activeCookie, + arpSessionId, + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint, + browserSessionKey, + source: "rebuild", + }, + { + // Stale forter warms immediately; fresh forter quiet-reuses on 1–2 then warms. + attempt, + tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0", + log: opts.log, + } + ); + accessToken = rotated.accessToken || accessToken; + activeCookie = rotated.cookie || activeCookie; + arpSessionId = rotated.arpSessionId; + } else { + arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { + rotate: true, + }); + } + } catch { + // Keep prior ARP — synthetic thrash rarely recovers colligo 408. + } + const base = submitBaseDelayMs(); const delay = - Math.min(45_000, SUBMIT_BASE_DELAY_MS * Math.pow(2, attempt - 1)) + - Math.floor(Math.random() * 750); + base <= 50 + ? base + : Math.min(90_000, base * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 1500); opts.log?.info?.( "ADOBE-FIREFLY", - `image submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms` + `image submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})` ); await sleep(delay); continue; } + noteAdobeFireflySubmitFailure(); if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("image", attempt), + formatAdobeSystemUnderLoadError("image", attempt, { + hadBrowserArp, + }), 408, "system_under_load" ); @@ -2108,14 +2636,26 @@ export async function adobeFireflyGenerateImage(opts: { submitData = await submitResp.json().catch(() => ({})); submitHeaders = submitResp.headers; + // Sticky: remember ARP that colligo accepted so the next batch image reuses it. + markAdobeFireflyArpSuccess(fingerprint, arpSessionId); + submitOk = true; break; } + if (!submitOk && !lastSubmitError) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), + 408, + "system_under_load" + ); + } let pollUrl = extractAdobeResultLink(submitHeaders, submitData); if (!pollUrl) { if (sawSystemUnderLoad) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS), + formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS, { + hadBrowserArp, + }), 408, "system_under_load" ); @@ -2127,15 +2667,11 @@ export async function adobeFireflyGenerateImage(opts: { } pollUrl = normalizeAdobePollUrl(pollUrl); - const pollTimeoutMs = adobeFireflyImageTimeoutMs({ - timeoutMs: opts.timeoutMs, - refCount: opts.references?.length ?? opts.sourceImageIds?.length ?? 0, - }); const { mediaUrl, latest } = await pollAdobeJob({ pollUrl, - accessToken: opts.accessToken, + accessToken, kind: "image", - timeoutMs: pollTimeoutMs, + timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_IMAGE_TIMEOUT_MS, fetchImpl, log: opts.log, }); @@ -2154,14 +2690,27 @@ export async function adobeFireflyGenerateVideo(opts: { resolution?: unknown; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; negativePrompt?: string; generateAudio?: boolean; sessionCookie?: string; + /** Shared ARP (sid+ark+ftr). Reuse with frame uploads. */ + arpSessionId?: string; + /** Session cache key from ensureAdobeFireflySession — sticky ARP across batch jobs. */ + sessionFingerprint?: string; + /** Chrome profile key (provider connection id) for CDP warm/login isolation. */ + sessionBrowserKey?: string; timeoutMs?: number; fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise<{ url: string; b64_json?: string; format: string; latest: unknown }> { + log?: { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + }; +}): Promise<{ + url: string; + b64_json?: string; + format: string; + latest: unknown; +}> { const fetchImpl = opts.fetchImpl || fetch; const { spec } = resolveAdobeVideoModel(opts.model); const aspectRatio = normalizeAdobeAspectRatio(opts.aspectRatio ?? opts.size, "16:9"); @@ -2186,32 +2735,54 @@ export async function adobeFireflyGenerateVideo(opts: { resolution, seed: opts.seed, sourceImageIds: opts.sourceImageIds, - references: opts.references, negativePrompt: opts.negativePrompt, generateAudio: opts.generateAudio, }); const sessionCookie = String(opts.sessionCookie || "").trim(); - const cookieHeader = extractAdobeCookieHeader(sessionCookie); - const arpSessionId = - extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie); + let activeCookie = extractAdobeCookieHeader(sessionCookie) || sessionCookie; + const hadBrowserArp = hasBrowserAdobeArpSession(activeCookie); + let arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(activeCookie); let submitData: unknown = {}; let submitHeaders: Headers | Record = new Headers(); let lastSubmitError = ""; let sawSystemUnderLoad = false; + let accessToken = opts.accessToken; + let authRefreshAttempted = false; + const { + withAdobeFireflySubmitGate, + markAdobeFireflyArpSuccess, + noteAdobeFireflySubmitFailure, + rotateAdobeFireflySessionOnError, + resolveAdobeArpSessionIdSmart, + fingerprintAdobeCredential, + estimateAdobeTokenExpiry, + } = await import("./adobeFireflySession.ts"); + + const fingerprint = + String(opts.sessionFingerprint || "").trim() || + fingerprintAdobeCredential([accessToken, activeCookie].filter(Boolean).join("\n")); + const browserSessionKey = String(opts.sessionBrowserKey || "").trim() || fingerprint; + + let videoSubmitOk = false; for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { - const submitResp = await fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, { - method: "POST", - headers: buildAdobeSubmitHeaders(opts.accessToken, { - arpSessionId: arpSessionId || undefined, - prompt: opts.prompt, - cookie: cookieHeader || undefined, - }), - body: JSON.stringify(payload), - }); + const submitResp = await withAdobeFireflySubmitGate(() => + fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, { + method: "POST", + headers: buildAdobeSubmitHeaders(accessToken, { + arpSessionId, + prompt: opts.prompt, + cookie: activeCookie || undefined, + }), + body: JSON.stringify(payload), + }) + ); if (submitResp.status === 401 || submitResp.status === 403) { + noteAdobeFireflySubmitFailure(); const accessError = submitResp.headers.get("x-access-error") || ""; if (accessError === "taste_exhausted") { throw new AdobeFireflyError( @@ -2220,8 +2791,35 @@ export async function adobeFireflyGenerateVideo(opts: { "quota_exhausted" ); } + if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) { + authRefreshAttempted = true; + const refreshed = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: activeCookie, + arpSessionId, + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint, + browserSessionKey, + source: "rebuild", + }, + { attempt, authFailure: true, tryBrowser: true, log: opts.log } + ).catch(() => null); + if (refreshed?.accessToken && refreshed?.arpSessionId) { + accessToken = refreshed.accessToken; + activeCookie = refreshed.cookie || activeCookie; + arpSessionId = refreshed.arpSessionId; + opts.log?.info?.( + "ADOBE-FIREFLY", + `video submit auth ${submitResp.status}; retrying once with renewed CDP session` + ); + continue; + } + } throw new AdobeFireflyError( - "Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p), not page cookies alone.", + "Adobe Firefly session is no longer authenticated and automatic browser renewal failed. " + + "Sign in once through the Adobe Firefly browser login to restore durable renewal.", 401, "auth" ); @@ -2234,19 +2832,69 @@ export async function adobeFireflyGenerateVideo(opts: { } lastSubmitError = `Adobe Firefly video submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { + const { getAdobeForterAgeMs: forterAgeMsFn, extractAdobeForterTimestampMs: forterTsFn } = + await import("./adobeFireflySession.ts"); + const forterTs = forterTsFn(activeCookie || ""); + const forterAgeBefore = forterAgeMsFn(activeCookie || ""); + const forterKnownStale = + forterTs > 0 && Number.isFinite(forterAgeBefore) && forterAgeBefore > 4 * 60_000; + if (forterKnownStale && attempt >= 2) { + noteAdobeFireflySubmitFailure(); + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("video", attempt, { hadBrowserArp }) + + " Risk session looks expired — open Providers → Adobe Firefly → Sign in with browser once.", + 408, + "system_under_load" + ); + } + try { + if (activeCookie) { + const rotated = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: activeCookie, + arpSessionId, + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint, + browserSessionKey, + source: "rebuild", + }, + { + attempt, + tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0", + log: opts.log, + } + ); + accessToken = rotated.accessToken || accessToken; + activeCookie = rotated.cookie || activeCookie; + arpSessionId = rotated.arpSessionId; + } else { + arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { + rotate: true, + }); + } + } catch { + /* keep prior ARP */ + } + const base = submitBaseDelayMs(); const delay = - Math.min(45_000, SUBMIT_BASE_DELAY_MS * Math.pow(2, attempt - 1)) + - Math.floor(Math.random() * 750); + base <= 50 + ? base + : Math.min(90_000, base * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 1500); opts.log?.info?.( "ADOBE-FIREFLY", - `video submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms` + `video submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})` ); await sleep(delay); continue; } + noteAdobeFireflySubmitFailure(); if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("video", attempt), + formatAdobeSystemUnderLoadError("video", attempt, { + hadBrowserArp, + }), 408, "system_under_load" ); @@ -2259,14 +2907,25 @@ export async function adobeFireflyGenerateVideo(opts: { submitData = await submitResp.json().catch(() => ({})); submitHeaders = submitResp.headers; + markAdobeFireflyArpSuccess(fingerprint, arpSessionId); + videoSubmitOk = true; break; } + if (!videoSubmitOk && !lastSubmitError) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), + 408, + "system_under_load" + ); + } let pollUrl = extractAdobeResultLink(submitHeaders, submitData); if (!pollUrl) { if (sawSystemUnderLoad) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS), + formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS, { + hadBrowserArp, + }), 408, "system_under_load" ); @@ -2280,9 +2939,11 @@ export async function adobeFireflyGenerateVideo(opts: { const { mediaUrl, latest } = await pollAdobeJob({ pollUrl, - accessToken: opts.accessToken, + accessToken, kind: "video", timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_VIDEO_TIMEOUT_MS, + sessionCookie: activeCookie || sessionCookie || undefined, + sessionFingerprint: fingerprint, fetchImpl, log: opts.log, }); diff --git a/open-sse/services/adobeFireflyReferences.ts b/open-sse/services/adobeFireflyReferences.ts new file mode 100644 index 0000000000..5c3a24a7ed --- /dev/null +++ b/open-sse/services/adobeFireflyReferences.ts @@ -0,0 +1,97 @@ +import { AdobeFireflyError } from "./adobeFireflyClient.ts"; +import type { AdobeFireflyVideoModelSpec } from "./adobeFireflyClient.ts"; + +export interface AdobeSourceImageReference { + source: string; + usage?: string; + order?: number; +} + +export function normalizeAdobeReferenceBlobs( + modelSpec: AdobeFireflyVideoModelSpec, + references: unknown +): Array<{ id: string; usage: string; order?: number }> { + if (!Array.isArray(references)) return []; + + const maxReferences = modelSpec.referenceMode === "image" ? 3 : 2; + if (references.length > maxReferences) { + throw new AdobeFireflyError( + `Adobe Firefly model accepts at most ${maxReferences} ${ + modelSpec.referenceMode === "image" ? "asset" : "frame" + } image references`, + 400, + "bad_image" + ); + } + + return references.map((reference, index) => { + if (!reference || typeof reference !== "object") { + throw new AdobeFireflyError("Invalid Adobe Firefly reference image", 400, "bad_image"); + } + const value = reference as Record; + const id = typeof value.id === "string" ? value.id.trim() : ""; + if (!id) { + throw new AdobeFireflyError("Adobe Firefly reference image id is required", 400, "bad_image"); + } + + const expectedUsage = modelSpec.referenceMode === "image" ? "asset" : "frame"; + const usage = typeof value.usage === "string" ? value.usage.trim() : expectedUsage; + if (usage !== expectedUsage) { + throw new AdobeFireflyError( + `Adobe Firefly model does not support image references with usage '${usage}'`, + 400, + "bad_image" + ); + } + + return expectedUsage === "frame" ? { id, usage, order: index + 1 } : { id, usage }; + }); +} + +export function extractAdobeSourceImageReferences( + body: unknown, + max = 4 +): AdobeSourceImageReference[] { + if (!body || typeof body !== "object") return []; + const inputs = (body as Record).adobe_reference_inputs; + if (!Array.isArray(inputs)) return []; + + const references: AdobeSourceImageReference[] = []; + for (const input of inputs) { + if (!input || typeof input !== "object") continue; + const value = input as Record; + if ( + value.type !== undefined && + value.type !== "input_image" && + value.type !== "image" && + value.type !== "image_url" + ) { + continue; + } + + const imageUrl = value.image_url; + const source = + typeof value.source === "string" + ? value.source.trim() + : typeof imageUrl === "string" + ? imageUrl.trim() + : imageUrl && + typeof imageUrl === "object" && + typeof (imageUrl as Record).url === "string" + ? String((imageUrl as Record).url).trim() + : typeof value.url === "string" + ? value.url.trim() + : ""; + if (!source || (!source.startsWith("data:image/") && !/^https?:\/\//i.test(source))) continue; + + const usage = + typeof value.usage === "string" && value.usage.trim() ? value.usage.trim() : undefined; + const order = + typeof value.order === "number" && Number.isInteger(value.order) && value.order > 0 + ? value.order + : undefined; + references.push({ source, ...(usage ? { usage } : {}), ...(order ? { order } : {}) }); + if (references.length >= max) break; + } + return references; +} diff --git a/open-sse/services/adobeFireflySecurity.ts b/open-sse/services/adobeFireflySecurity.ts new file mode 100644 index 0000000000..e4e7e57081 --- /dev/null +++ b/open-sse/services/adobeFireflySecurity.ts @@ -0,0 +1,54 @@ +const ADOBE_JWT_IN_TEXT_REGEX = + /eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/; +const ADOBE_JWT_IN_TEXT_GLOBAL_REGEX = + /eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g; +const ADOBE_JWT_EXACT_REGEX = + /^eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}$/; +const FIREFLY_3P_HOST_SUFFIX = "firefly-3p.ff.adobe.io"; + +export function decodeAdobeJwtPayload(token: string): Record | null { + try { + let raw = String(token || "") + .trim() + .replace(/^bearer\s+/i, "") + .trim(); + const match = raw.match(ADOBE_JWT_IN_TEXT_REGEX); + if (match) raw = match[0]; + const part = raw.split(".")[1]; + if (!part) return null; + const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); + const value: unknown = JSON.parse(json); + return value && typeof value === "object" ? (value as Record) : null; + } catch { + return null; + } +} + +export function findAllAdobeJwts(value: string): string[] { + return value.match(ADOBE_JWT_IN_TEXT_GLOBAL_REGEX) ?? []; +} + +export function isExactAdobeJwt(value: string): boolean { + return ADOBE_JWT_EXACT_REGEX.test(value); +} + +export function stripAdobeJwts(value: string, replacement = ""): string { + return value.replace(ADOBE_JWT_IN_TEXT_GLOBAL_REGEX, replacement); +} + +function hostnameMatches(hostname: string, expected: string): boolean { + const normalized = hostname.toLowerCase().replace(/\.$/, ""); + return normalized === expected || normalized.endsWith(`.${expected}`); +} + +export function isAdobeFireflyApiUrl(rawUrl: string): boolean { + try { + return hostnameMatches(new URL(rawUrl).hostname, FIREFLY_3P_HOST_SUFFIX); + } catch { + return false; + } +} + +export function isAdobeLoginCookieDomain(domain: string): boolean { + return hostnameMatches(domain.replace(/^\./, ""), "adobelogin.com"); +} diff --git a/open-sse/services/adobeFireflySession.ts b/open-sse/services/adobeFireflySession.ts new file mode 100644 index 0000000000..d8ab034993 --- /dev/null +++ b/open-sse/services/adobeFireflySession.ts @@ -0,0 +1,1002 @@ +/** + * Adobe Firefly durable session manager. + * + * Goal: same as other OmniRoute web-cookie providers (notion-web, perplexity-web): + * paste Cookie (+ optional IMS JWT) once and use pure HTTP — **no browser window**. + * + * 1) Extract / cache IMS user JWT from paste (or short-lived memory/disk cache) + * 2) Rebuild x-arp-session-id from cookie pieces (ff_session_guid + arkose + forterToken) + * or pasted sherlockToken — never launch Chrome by default + * 3) Sticky working ARP across batch jobs + submit spacing (colligo rate-limit defense) + * 4) Packaged-safe Chrome/CDP warm on stale risk state, JWT expiry, or 408 recovery. + * The durable browser profile holds Adobe SSO; Playwright is not required. + */ + +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + AdobeFireflyError, + buildAdobeArpSessionId, + extractAdobeArpSessionId, + extractAdobeCookieHeader, + extractAdobeCredentialToken, + isAdobeUserAccessToken, + looksLikeAdobeCookieBlob, + looksLikeAdobeJwt, + decodeAdobeJwtPayload, + resolveAdobeAccessToken, + exchangeAdobeCookieForAccessToken, +} from "./adobeFireflyClient.ts"; + +export interface AdobeFireflySession { + accessToken: string; + cookie: string; + arpSessionId: string; + /** Epoch ms when the IMS token is expected to expire (best-effort). */ + tokenExpiresAt: number; + updatedAt: number; + /** Hash of the original credential paste (cache key). */ + fingerprint: string; + /** Stable provider connection id used to isolate browser SSO/cookie state per Adobe account. */ + browserSessionKey?: string; + source: "paste" | "ims" | "browser" | "cache" | "rebuild"; +} + +export interface AdobeFireflySessionResolveOpts { + credentials?: { + apiKey?: string; + accessToken?: string; + connectionId?: string; + providerSpecificData?: { + cookie?: unknown; + access_token?: unknown; + accessToken?: unknown; + browserSessionKey?: unknown; + } | null; + } | null; + /** Force browser / cookie ARP rebuild (e.g. after HTTP 408). */ + forceRefresh?: boolean; + /** Prefer minting a brand-new ARP (retry path). */ + rotateArp?: boolean; + fetchImpl?: typeof fetch; + log?: { + info?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; + }; + /** Disable durable CDP refresh (tests / hosts without Chrome or Edge). */ + allowBrowserRefresh?: boolean; +} + +const sessionCache = new Map(); +const browserRefreshInFlight = new Map>(); +/** Last ARP that produced HTTP 2xx on generate-async — prefer until colligo 408. */ +const lastWorkingArpByFingerprint = new Map(); +/** After a failed force-warm, skip re-launching Chrome for this fingerprint for a short window. */ +const browserWarmFailureCooldown = new Map(); +const BROWSER_WARM_FAIL_COOLDOWN_MS = 90_000; +/** Serialize Firefly generate submits + enforce a quiet period (colligo rate-limits look like 408). */ +let adobeSubmitChain: Promise = Promise.resolve(); +let lastAdobeSubmitAt = 0; + +/** Do not thrash rebuilds: a working ARP stays sticky for this long unless 408 clears it. */ +const WORKING_ARP_STICKY_MS = 25 * 60_000; +/** Forter token age above this → consider risk session stale (informational / recovery). */ +const FORTER_STALE_MS = 4 * 60_000; +/** After this many successful submits in a row, add an extra quiet period (colligo batch throttle). */ +const BATCH_SUCCESS_COOLDOWN_EVERY = 3; +const BATCH_SUCCESS_EXTRA_GAP_MS = 15_000; + +let consecutiveAdobeSubmitSuccesses = 0; + +/** Minimum gap between generate-async submits (ms). Prevents batch thrashing → 408. */ +function minSubmitGapMs(): number { + if ( + process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS != null && + process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS !== "" + ) { + return Math.max(0, Number(process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS) || 0); + } + // Unit tests must not serialize multi-second gaps between cases that share the process-global gate. + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) + return 0; + // Live colligo rejects thrash after a few generates even with sticky ARP — 12s default. + return 12_000; +} + +/** Extra gap after every N successful submits (mid-batch death defense). */ +function batchExtraGapMs(): number { + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) + return 0; + if ( + consecutiveAdobeSubmitSuccesses > 0 && + consecutiveAdobeSubmitSuccesses % BATCH_SUCCESS_COOLDOWN_EVERY === 0 + ) { + return Number(process.env.ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS || BATCH_SUCCESS_EXTRA_GAP_MS); + } + return 0; +} +/** Refresh IMS token this many ms before JWT expiry. */ +const JWT_REFRESH_SKEW_MS = 10 * 60_000; +/** + * Proactively browser-warm the risk session when the Forter token is older than this. + * Colligo 408s a stale Forter/ARP; warming before the first submit avoids the wasted 408. + * Kept above a single batch's duration so mid-batch requests reuse the sticky working ARP. + */ +const FORTER_PROACTIVE_WARM_MS = 3 * 60_000; + +/** + * Browser Forter-warm is the DEFAULT engine for Adobe Firefly (the only reliable way to + * keep the Forter/Arkose risk session fresh — pure HTTP goes stale and 408s). It stays on + * unless explicitly disabled with ADOBE_FIREFLY_BROWSER_REFRESH=0. The legacy opt-in value + * "1" still enables it; any other value (including unset) now also enables it. + */ +export function adobeFireflyBrowserEnabled(): boolean { + return process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0"; +} +/** Persist sessions under DATA_DIR so restarts keep JWT + last cookie. */ +const SESSION_DIR_NAME = "adobe-firefly-sessions"; + +function dataDir(): string { + return ( + String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || + join(process.cwd(), ".data") + ); +} + +function sessionFilePath(fingerprint: string): string { + const dir = join(dataDir(), SESSION_DIR_NAME); + try { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + } catch { + /* ignore */ + } + return join(dir, `${fingerprint}.json`); +} + +export function fingerprintAdobeCredential(raw: string): string { + return createHash("sha256") + .update(String(raw || "").trim()) + .digest("hex") + .slice(0, 32); +} + +/** Pull a single cookie value from a Cookie header / paste blob. */ +export function getAdobeCookieValue(cookieOrBlob: string, name: string): string { + const raw = String(cookieOrBlob || ""); + if (!raw || !name) return ""; + const re = new RegExp( + `(?:^|[;\\s\\n\\r])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}=([^;\\s\\n\\r]+)`, + "i" + ); + const m = raw.match(re); + if (!m?.[1]) return ""; + let v = m[1].trim().replace(/^["']|["']$/g, ""); + try { + if (/%[0-9A-Fa-f]{2}/.test(v)) v = decodeURIComponent(v); + } catch { + /* keep */ + } + return v; +} + +/** Normalize Forter token to the live ftr shape ending in -v2_tt. */ +export function normalizeAdobeForterToken(value: string): string { + let f = String(value || "").trim(); + if (!f) return ""; + try { + if (/%[0-9A-Fa-f]{2}/.test(f)) f = decodeURIComponent(f); + } catch { + /* keep */ + } + // Cookie sometimes stores "id,timestamp" (localStorage form) — not usable as ftr. + if (/^[a-f0-9]{32},\d+$/i.test(f)) return ""; + if (f.endsWith("v2") && !f.endsWith("v2_tt")) f = `${f}_tt`; + return f; +} + +/** Epoch ms embedded in forterToken (`…_{ms}__UDF43…`), or 0 if unknown. */ +export function extractAdobeForterTimestampMs(cookieOrBlob: string): number { + const ftr = + normalizeAdobeForterToken(getAdobeCookieValue(cookieOrBlob, "forterToken")) || + normalizeAdobeForterToken(getAdobeCookieValue(cookieOrBlob, "forter")) || + ""; + const m = ftr.match(/_(\d{13})__/); + return m ? Number(m[1]) : 0; +} + +export function getAdobeForterAgeMs(cookieOrBlob: string): number { + const ts = extractAdobeForterTimestampMs(cookieOrBlob); + if (!ts) return Number.POSITIVE_INFINITY; + return Math.max(0, Date.now() - ts); +} + +/** Remember an ARP that just got generate-async 2xx — batch jobs must stick to it. */ +export function markAdobeFireflyArpSuccess(fingerprint: string, arpSessionId: string): void { + const fp = String(fingerprint || "").trim(); + const arp = String(arpSessionId || "").trim(); + if (!fp || !arp) return; + lastWorkingArpByFingerprint.set(fp, { arp, at: Date.now() }); + consecutiveAdobeSubmitSuccesses += 1; + const cached = sessionCache.get(fp); + if (cached) { + cached.arpSessionId = arp; + cached.updatedAt = Date.now(); + sessionCache.set(fp, cached); + saveDiskSession(cached); + } else { + // Persist sticky ARP even when session map was not primed (fingerprint-only mark). + try { + const path = sessionFilePath(fp); + if (existsSync(path)) { + const obj = JSON.parse(readFileSync(path, "utf8")) as AdobeFireflySession; + obj.arpSessionId = arp; + obj.updatedAt = Date.now(); + writeFileSync(path, JSON.stringify(obj, null, 2), "utf8"); + sessionCache.set(fp, { ...obj, fingerprint: fp }); + } + } catch { + /* best-effort */ + } + } +} + +export function clearAdobeFireflyWorkingArp(fingerprint: string): void { + lastWorkingArpByFingerprint.delete(String(fingerprint || "").trim()); +} + +export function noteAdobeFireflySubmitFailure(): void { + consecutiveAdobeSubmitSuccesses = 0; +} + +/** + * Serialize Firefly generate-async calls and enforce a quiet period. + * Colligo often returns 408 "system under load" when submits are hammered in a batch + * or after a few successes in a row with the same risk session. + */ +export async function withAdobeFireflySubmitGate(fn: () => Promise): Promise { + const run = adobeSubmitChain.then(async () => { + const gap = minSubmitGapMs() + batchExtraGapMs(); + const wait = Math.max(0, lastAdobeSubmitAt + gap - Date.now()); + if (wait > 0) { + await new Promise((r) => setTimeout(r, wait)); + } + try { + return await fn(); + } finally { + lastAdobeSubmitAt = Date.now(); + } + }); + // Keep the chain alive even if fn throws + adobeSubmitChain = run.then( + () => undefined, + () => undefined + ); + return run; +} + +/** + * Rebuild x-arp-session-id from browser cookie components. + * Live successful generate-async ARP is base64(JSON({sid, ark, ftr, bfp?, fpjs?})). + * Returns "" when required pieces are missing. + */ +export function buildAdobeArpSessionIdFromCookies( + cookieOrBlob: string, + extras?: { region?: string; bfp?: string; fpjs?: string } +): string { + const blob = String(cookieOrBlob || ""); + if (!blob.trim()) return ""; + + const sid = + getAdobeCookieValue(blob, "ff_session_guid") || getAdobeCookieValue(blob, "sid") || ""; + const ark = getAdobeCookieValue(blob, "arkose") || ""; + const ftr = + normalizeAdobeForterToken(getAdobeCookieValue(blob, "forterToken")) || + normalizeAdobeForterToken(getAdobeCookieValue(blob, "forter")) || + ""; + if (!sid || !ark || !ftr) return ""; + + let bfp = extras?.bfp || getAdobeCookieValue(blob, "bfp") || ""; + let fpjsRaw = extras?.fpjs || getAdobeCookieValue(blob, "fpjs") || ""; + if (fpjsRaw) { + try { + if (/%[0-9A-Fa-f]{2}/.test(fpjsRaw)) fpjsRaw = decodeURIComponent(fpjsRaw); + } catch { + /* keep */ + } + } + + // Prefer rebuilding over a stale sherlockToken when cookie pieces exist — + // forterToken timestamps advance as the SPA warms risk SDKs. + const obj: Record = { sid, ark, ftr }; + if (bfp) obj.bfp = bfp; + if (fpjsRaw) obj.fpjs = fpjsRaw; + return Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); +} + +/** True when the blob can rebuild a full ARP without a pasted sherlockToken. */ +export function canRebuildAdobeArpFromCookies(cookieOrBlob: string): boolean { + return Boolean(buildAdobeArpSessionIdFromCookies(cookieOrBlob)); +} + +/** + * Resolve the best ARP for a request: + * 1) force-rotate → mint fresh synthetic (or rebuild if cookies present) + * 2) rebuild from cookie pieces (forter/arkose/sid) — usually fresher than sherlock + * 3) explicit sherlockToken / x-arp-session-id from paste + * 4) synthetic rich ARP + */ +export function resolveAdobeArpSessionIdSmart( + cookieOrBlob?: string, + opts?: { rotate?: boolean } +): string { + const blob = String(cookieOrBlob || ""); + if (opts?.rotate) { + const rebuilt = buildAdobeArpSessionIdFromCookies(blob); + if (rebuilt) return rebuilt; + return buildAdobeArpSessionId(); + } + const rebuilt = buildAdobeArpSessionIdFromCookies(blob); + const extracted = extractAdobeArpSessionId(blob); + // Prefer rebuild when both exist: cookie forter is updated by the SPA more often + // than the frozen sherlockToken the user pasted minutes ago. + if (rebuilt && extracted) { + const rebuiltFtr = (() => { + try { + const j = JSON.parse( + Buffer.from(rebuilt + "=".repeat((4 - (rebuilt.length % 4)) % 4), "base64").toString( + "utf8" + ) + ) as { ftr?: string }; + return String(j.ftr || ""); + } catch { + return ""; + } + })(); + const extractedFtr = (() => { + try { + const j = JSON.parse( + Buffer.from(extracted + "=".repeat((4 - (extracted.length % 4)) % 4), "base64").toString( + "utf8" + ) + ) as { ftr?: string }; + return String(j.ftr || ""); + } catch { + return ""; + } + })(); + // Prefer the ARP whose forter timestamp is newer (…_ms__UDF43…). + const ts = (ftr: string) => { + const m = ftr.match(/_(\d{13})__/); + return m ? Number(m[1]) : 0; + }; + if (ts(rebuiltFtr) >= ts(extractedFtr)) return rebuilt; + return extracted; + } + if (rebuilt) return rebuilt; + if (extracted) return extracted; + return buildAdobeArpSessionId(); +} + +/** Merge cookie name=value pairs (new wins). Single-line Cookie header. */ +export function mergeAdobeCookieHeaders(base: string, updates: string): string { + const map = new Map(); + const ingest = (raw: string) => { + for (const part of String(raw || "").split(";")) { + const idx = part.indexOf("="); + if (idx <= 0) continue; + let name = part.slice(0, idx).trim(); + let value = part.slice(idx + 1).trim(); + if (!name) continue; + try { + name = decodeURIComponent(name); + } catch { + /* keep */ + } + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (/[\r\n\0]/.test(value)) continue; + map.set(name, value); + } + }; + ingest(extractAdobeCookieHeader(base) || base); + ingest(extractAdobeCookieHeader(updates) || updates); + return [...map.entries()].map(([k, v]) => `${k}=${v}`).join("; "); +} + +/** Serialize session back into a multi-line credential paste (JWT + Cookie). */ +export function serializeAdobeFireflyCredential( + session: Pick +): string { + const lines: string[] = []; + if (session.accessToken) lines.push(session.accessToken.trim()); + if (session.arpSessionId) lines.push(session.arpSessionId.trim()); + if (session.cookie) lines.push(session.cookie.trim()); + return lines.join("\n"); +} + +export function estimateAdobeTokenExpiry(accessToken: string): number { + const payload = decodeAdobeJwtPayload(accessToken); + if (!payload) return Date.now() + 60 * 60_000; + const created = Number(payload.created_at || 0); + const expiresIn = Number(payload.expires_in || 0); + if (created > 0 && expiresIn > 0) return created + expiresIn; + // Fallback: treat as 20h from now if claims missing + return Date.now() + 20 * 60 * 60_000; +} + +function diskSessionsEnabled(): boolean { + // Unit tests and explicit opt-out skip durable disk cache (avoids sticky IMS skips). + if (process.env.ADOBE_FIREFLY_SESSION_DISK === "0") return false; + if (process.env.NODE_ENV === "test") return false; + if (process.env.VITEST || process.env.NODE_TEST_CONTEXT) return false; + return true; +} + +function loadDiskSession(fingerprint: string): AdobeFireflySession | null { + if (!diskSessionsEnabled()) return null; + try { + const path = sessionFilePath(fingerprint); + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf8"); + const obj = JSON.parse(raw) as AdobeFireflySession; + if (!obj?.accessToken || !isAdobeUserAccessToken(obj.accessToken)) return null; + return { ...obj, fingerprint, source: "cache" }; + } catch { + return null; + } +} + +function saveDiskSession(session: AdobeFireflySession): void { + if (!diskSessionsEnabled()) return; + try { + const path = sessionFilePath(session.fingerprint); + writeFileSync(path, JSON.stringify(session, null, 2), "utf8"); + } catch { + /* best-effort */ + } +} + +function collectCredentialBlobs( + credentials: AdobeFireflySessionResolveOpts["credentials"] +): string[] { + const out: string[] = []; + const push = (v: unknown) => { + if (typeof v === "string" && v.trim()) out.push(v.trim()); + }; + push(credentials?.apiKey); + push(credentials?.accessToken); + push(credentials?.providerSpecificData?.cookie); + push(credentials?.providerSpecificData?.access_token); + push(credentials?.providerSpecificData?.accessToken); + return out; +} + +/** + * Browser warm for Firefly risk session (Forter/Arkose + IMS JWT refresh). + * Uses the same persistent pure-CDP profile as interactive sign-in, including in pkg builds. + * Never throws — returns null when unavailable. + */ +/** + * Best-effort write refreshed JWT+Cookie back to provider_connections so restarts + * and WinUI sync do not keep serving a guest/stale paste after a successful warm. + */ +async function writeBackAdobeFireflyCredentials( + session: AdobeFireflySession, + log?: AdobeFireflySessionResolveOpts["log"] +): Promise { + const connectionId = String(session.browserSessionKey || "").trim(); + if (!connectionId || connectionId === "legacy-default") return; + if (!isAdobeUserAccessToken(session.accessToken)) return; + // Skip when connectionId looks like a credential fingerprint (32 hex) without a real UUID. + // Real OmniRoute connection ids are UUIDs; still attempt write-back for any non-empty key. + try { + const { updateProviderConnection } = await import("@/lib/db/providers"); + const credential = serializeAdobeFireflyCredential(session); + await updateProviderConnection(connectionId, { + apiKey: credential, + providerSpecificData: { + mode: "browser-profile", + adobeFireflyMode: "browser-profile", + cookie: session.cookie || credential, + access_token: session.accessToken, + browserSessionKey: connectionId, + arpSessionId: session.arpSessionId || "", + refreshedAt: Date.now(), + }, + }); + log?.info?.( + "ADOBE-FIREFLY", + `wrote refreshed JWT+Cookie to connection ${connectionId.slice(0, 8)}…` + ); + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `credential write-back skipped: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +export async function refreshAdobeSessionViaBrowser( + session: AdobeFireflySession, + log?: AdobeFireflySessionResolveOpts["log"], + opts?: { force?: boolean; proveWithPing?: boolean } +): Promise { + const force = opts?.force === true; + // Browser warm is the default engine now — only the explicit kill switch disables it. + if (!adobeFireflyBrowserEnabled()) return null; + + const coolKey = String(session.browserSessionKey || session.fingerprint || "").trim(); + const coolUntil = coolKey ? browserWarmFailureCooldown.get(coolKey) || 0 : 0; + if (force && coolUntil > Date.now()) { + log?.warn?.( + "ADOBE-FIREFLY", + `skip CDP warm (cooldown ${Math.ceil((coolUntil - Date.now()) / 1000)}s after recent failure)` + ); + return null; + } + + try { + const baseFtr = extractAdobeForterTimestampMs(session.cookie || ""); + const { refreshAdobeFireflyViaCdp } = await import("./adobeFireflyBrowserLogin.ts"); + const warmed = await refreshAdobeFireflyViaCdp({ + cookie: session.cookie, + accessToken: session.accessToken, + log, + timeoutMs: force ? 90_000 : 75_000, + sessionKey: session.browserSessionKey || session.fingerprint, + }); + if (!warmed) { + if (force && coolKey) { + browserWarmFailureCooldown.set(coolKey, Date.now() + BROWSER_WARM_FAIL_COOLDOWN_MS); + } + return null; + } + if (coolKey) browserWarmFailureCooldown.delete(coolKey); + + // Prefer warm cookie as authority for risk pieces (do not re-merge stale forter over new). + // On force warm, prefer the warmed cookie as authority (do not re-merge hours-old forter + // from the previous session blob over a freshly minted jar). + const nextCookie = force + ? warmed.cookie || session.cookie + : warmed.cookie + ? mergeAdobeCookieHeaders(session.cookie || "", warmed.cookie) + : session.cookie; + const warmFtr = extractAdobeForterTimestampMs(nextCookie); + const warmAge = warmFtr > 0 ? Math.max(0, Date.now() - warmFtr) : Number.POSITIVE_INFINITY; + // Force path: require a parseable forter younger than FORTER_STALE (or strictly newer than base). + if (force) { + const advanced = + warmFtr > 0 && (baseFtr <= 0 || warmFtr > baseFtr || warmAge < FORTER_STALE_MS); + if (!advanced) { + log?.warn?.( + "ADOBE-FIREFLY", + `CDP warm rejected: forter not advanced (base=${baseFtr}, warm=${warmFtr || 0}, ageMs=${Number.isFinite(warmAge) ? warmAge : "inf"})` + ); + return null; + } + } + + const nextArp = + warmed.arpSessionId || + buildAdobeArpSessionIdFromCookies(nextCookie) || + extractAdobeArpSessionId(nextCookie); + if (!nextArp) return null; + + const nextToken = + (warmed.accessToken && isAdobeUserAccessToken(warmed.accessToken) + ? warmed.accessToken + : "") || session.accessToken; + if (!isAdobeUserAccessToken(nextToken)) return null; + + const next: AdobeFireflySession = { + ...session, + accessToken: nextToken, + cookie: nextCookie, + arpSessionId: nextArp, + tokenExpiresAt: estimateAdobeTokenExpiry(nextToken), + updatedAt: Date.now(), + browserSessionKey: session.browserSessionKey || session.fingerprint, + source: "browser", + }; + sessionCache.set(session.fingerprint, next); + saveDiskSession(next); + clearAdobeFireflyWorkingArp(session.fingerprint); + void writeBackAdobeFireflyCredentials(next, log); + log?.info?.( + "ADOBE-FIREFLY", + `durable CDP warm refreshed session (arpLen=${next.arpSessionId.length}, force=${force}, forterTs=${warmFtr || 0}, forterDeltaMs=${warmFtr && baseFtr ? warmFtr - baseFtr : 0})` + ); + return next; + } catch (err) { + if (force && coolKey) { + browserWarmFailureCooldown.set(coolKey, Date.now() + BROWSER_WARM_FAIL_COOLDOWN_MS); + } + log?.warn?.( + "ADOBE-FIREFLY", + `browser CDP session refresh failed: ${err instanceof Error ? err.message : String(err)}` + ); + return null; + } +} + +/** + * Resolve a durable Firefly session from stored credentials. + * Caches in memory + DATA_DIR; rebuilds ARP from cookies; optionally warms via durable CDP. + */ +export async function ensureAdobeFireflySession( + opts: AdobeFireflySessionResolveOpts +): Promise { + const blobs = collectCredentialBlobs(opts.credentials); + if (blobs.length === 0) { + throw new AdobeFireflyError( + "Adobe Firefly credentials missing. Paste the IMS JWT (Authorization: Bearer on firefly-3p) " + + "and ideally the full firefly.adobe.com Cookie (with sherlockToken / forterToken / arkose) once.", + 401, + "missing_credentials" + ); + } + + const joined = blobs.join("\n"); + // Prefer stable connection-scoped fingerprint so JWT/cookie refresh does not orphan + // the session cache / sticky ARP map (paste hash changes every warm write-back). + const connectionId = String( + opts.credentials?.connectionId || + opts.credentials?.providerSpecificData?.browserSessionKey || + "" + ).trim(); + const fingerprint = connectionId + ? fingerprintAdobeCredential(`conn:${connectionId}`) + : fingerprintAdobeCredential(joined); + const browserSessionKey = connectionId || fingerprint; + + // forceRefresh / rotate always drop in-memory cache for this fingerprint + if (opts.forceRefresh) sessionCache.delete(fingerprint); + + // Also try legacy paste-hash session files (pre-connection-scoped fingerprints). + const legacyFingerprint = fingerprintAdobeCredential(joined); + const cached = + sessionCache.get(fingerprint) || + loadDiskSession(fingerprint) || + (legacyFingerprint !== fingerprint ? loadDiskSession(legacyFingerprint) : null); + if (cached && !opts.forceRefresh) { + // Re-key legacy disk session under the stable connection fingerprint. + const normalized = { + ...cached, + fingerprint, + browserSessionKey: cached.browserSessionKey || browserSessionKey, + }; + sessionCache.set(fingerprint, normalized); + } + + const fetchImpl = opts.fetchImpl || fetch; + let accessToken = ""; + let cookie = ""; + let pasteHadUserJwt = false; + + // Prefer JWT from the live paste (authoritative for this request) + for (const b of blobs) { + const tok = extractAdobeCredentialToken(b); + if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) { + accessToken = tok; + pasteHadUserJwt = true; + break; + } + } + // A browser-refreshed disk token must survive process restarts. Prefer it when the pasted + // token is absent or near expiry; the fingerprint still binds it to these credentials. + const pastedExpiresAt = accessToken ? estimateAdobeTokenExpiry(accessToken) : 0; + const cachedExpiresAt = cached?.accessToken + ? cached.tokenExpiresAt > 0 + ? cached.tokenExpiresAt + : estimateAdobeTokenExpiry(cached.accessToken) + : 0; + if ( + cached?.accessToken && + isAdobeUserAccessToken(cached.accessToken) && + cachedExpiresAt - Date.now() >= JWT_REFRESH_SKEW_MS && + (!accessToken || pastedExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS) + ) { + accessToken = cached.accessToken; + pasteHadUserJwt = false; + } + + // Cookie blob + for (const b of blobs) { + const c = extractAdobeCookieHeader(b); + if (c) { + cookie = c; + break; + } + if (looksLikeAdobeCookieBlob(b)) { + cookie = extractAdobeCookieHeader(b) || b; + break; + } + } + if (!cookie && cached?.cookie) cookie = cached.cookie; + if (cached?.cookie && cookie) cookie = mergeAdobeCookieHeaders(cached.cookie, cookie); + + // Cookie-only or near-expiry JWT → try IMS exchange (needs real IMS cookies on adobelogin.com) + const tokenExpiresAt = accessToken ? estimateAdobeTokenExpiry(accessToken) : 0; + const needJwtRefresh = + !accessToken || + !pasteHadUserJwt || + (tokenExpiresAt > 0 && tokenExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS); + + if (needJwtRefresh && cookie) { + try { + const refreshed = await exchangeAdobeCookieForAccessToken(cookie, fetchImpl); + if (isAdobeUserAccessToken(refreshed)) { + accessToken = refreshed; + opts.log?.info?.("ADOBE-FIREFLY", "IMS cookie exchange produced a user JWT"); + } + } catch { + // Fall through — pure firefly cookies still yield guest-only; keep existing JWT. + } + } + + const cookieBlob = cookie || extractAdobeCookieHeader(joined) || ""; + + if (!accessToken) { + // Try the pure-HTTP resolve (paste JWT / IMS exchange). When the browser engine is on, + // a missing/guest token is NOT fatal here — the off-screen Chrome warm below reads the + // live user JWT from a signed-in profile (the "one-time browser sign-in" path). Only + // surface the guest/missing error when the browser engine is disabled. + try { + accessToken = await resolveAdobeAccessToken(opts.credentials, fetchImpl); + } catch (err) { + if (!adobeFireflyBrowserEnabled()) throw err; + opts.log?.info?.( + "ADOBE-FIREFLY", + "no user JWT from paste/cookie — will read it from the signed-in Chrome profile" + ); + } + } + + const cookieForSession = cookie || cookieBlob; + const forterTs = extractAdobeForterTimestampMs(cookieForSession); + const working = lastWorkingArpByFingerprint.get(fingerprint); + const workingFresh = + working && Date.now() - working.at < WORKING_ARP_STICKY_MS ? working.arp : ""; + + // Prefer last ARP that actually got generate-async 2xx (batch stability). + // Rebuild from cookie pieces / sherlockToken — pure HTTP, no browser. + let arpSessionId = ""; + if (!opts.forceRefresh && !opts.rotateArp && workingFresh) { + arpSessionId = workingFresh; + } else if (!opts.forceRefresh && !opts.rotateArp && cached?.arpSessionId) { + arpSessionId = cached.arpSessionId; + } else { + arpSessionId = resolveAdobeArpSessionIdSmart(cookieForSession || joined, { + rotate: Boolean(opts.rotateArp), + }); + } + + let session: AdobeFireflySession = { + accessToken, + cookie: cookieForSession, + arpSessionId: String(arpSessionId || ""), + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken || cached?.accessToken || ""), + updatedAt: Date.now(), + fingerprint, + browserSessionKey, + source: workingFresh ? "cache" : cached?.source || "paste", + }; + // Prefer connection-scoped browser profile always (never empty → legacy-default). + if (!session.browserSessionKey) session.browserSessionKey = browserSessionKey; + + // Off-screen Chrome Forter-warm is now the DEFAULT engine (kill switch: + // ADOBE_FIREFLY_BROWSER_REFRESH=0). Warm proactively when we lack a usable session so the + // first submit doesn't eat a colligo 408, and so a signed-in profile can supply the user + // JWT with no JWT/cookie paste ("one-time browser sign-in" model): + // - explicit forceRefresh / rotateArp, or + // - no AdobeID user JWT yet (profile may hold one — cookie/JWT-free path), or + // - stale Forter risk session and no recently-accepted (sticky 2xx) ARP to reuse. + const jwtIsUser = isAdobeUserAccessToken(session.accessToken); + const jwtNeedsBrowserRefresh = + !jwtIsUser || session.tokenExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS; + const forterAgeMs = getAdobeForterAgeMs(session.cookie); + const riskStale = !workingFresh && forterAgeMs > FORTER_PROACTIVE_WARM_MS; + const shouldWarm = + adobeFireflyBrowserEnabled() && + opts.allowBrowserRefresh !== false && + (opts.forceRefresh || opts.rotateArp || jwtNeedsBrowserRefresh || riskStale); + // A persistent signed-in browser profile can refresh even when the stored cookie is empty. + const canWarm = true; + if (shouldWarm && canWarm) { + const key = fingerprint; + let inflight = browserRefreshInFlight.get(key); + if (!inflight) { + inflight = refreshAdobeSessionViaBrowser(session, opts.log, { + force: true, + proveWithPing: Boolean(opts.forceRefresh), + }).finally(() => { + browserRefreshInFlight.delete(key); + }); + browserRefreshInFlight.set(key, inflight); + } + const warmed = await inflight; + if (warmed) { + session = { ...warmed, fingerprint }; + opts.log?.info?.( + "ADOBE-FIREFLY", + `durable CDP session warm applied (reason=${opts.forceRefresh ? "force" : opts.rotateArp ? "rotate" : jwtNeedsBrowserRefresh ? "jwt-expiry" : "stale-forter"})` + ); + } + } + + // Final ARP if still empty + if (!session.arpSessionId) { + session.arpSessionId = resolveAdobeArpSessionIdSmart(session.cookie || joined); + } + // Re-apply sticky working ARP if warm did not produce a newer forter-based ARP + if (workingFresh && !opts.forceRefresh && !opts.rotateArp) { + const warmForterTs = extractAdobeForterTimestampMs(session.cookie); + if (!(warmForterTs > forterTs)) { + session.arpSessionId = workingFresh; + session.source = "cache"; + } + } + + // No usable AdobeID user JWT after the warm → marker-only credentials or cold profile. + if (!isAdobeUserAccessToken(session.accessToken)) { + throw new AdobeFireflyError( + "Adobe Firefly is not signed in. On Providers → Adobe Firefly → Add Account (OAuth) choose " + + '"Sign in with browser" (fresh login window) or "Paste JWT / Cookie". After browser sign-in ' + + "the app stores JWT+Cookie and keeps the risk session fresh automatically.", + 401, + "not_signed_in" + ); + } + if (session.tokenExpiresAt <= Date.now() + 30_000) { + throw new AdobeFireflyError( + "Adobe Firefly browser session expired and could not renew automatically. Re-open the " + + "Adobe Firefly account and sign in once so the durable browser profile can renew future JWTs.", + 401, + "session_expired" + ); + } + + // Dead Forter risk session: colligo returns 408 for ~minutes/hours of retries. Fail closed + // with a re-login instruction instead of burning ~600s of generate-async attempts. + // Only when forter timestamp is parseable and old — missing timestamp is not treated as stale + // (JWT-only / synthetic ARP / unit fixtures). + const finalForterTs = extractAdobeForterTimestampMs(session.cookie); + const finalForterAge = getAdobeForterAgeMs(session.cookie); + const hasStickyWorking = + Boolean(workingFresh) && + Date.now() - (lastWorkingArpByFingerprint.get(fingerprint)?.at || 0) < WORKING_ARP_STICKY_MS; + if ( + finalForterTs > 0 && + Number.isFinite(finalForterAge) && + finalForterAge > FORTER_STALE_MS && + !hasStickyWorking && + opts.allowBrowserRefresh !== false + ) { + throw new AdobeFireflyError( + "Adobe Firefly risk session expired (Forter/Arkose). Open Providers → Adobe Firefly → " + + "Add Account (OAuth) → Sign in with browser once. After sign-in the app stores a fresh " + + "JWT+Cookie and refreshes them automatically for later generates.", + 401, + "risk_session_stale" + ); + } + + session.fingerprint = fingerprint; + session.browserSessionKey = session.browserSessionKey || browserSessionKey; + sessionCache.set(fingerprint, session); + saveDiskSession(session); + // Keep SQLite in sync when we have a real connection + user JWT (best-effort). + if (session.source === "browser" || session.source === "rebuild") { + void writeBackAdobeFireflyCredentials(session, opts.log); + } + return session; +} + +/** + * After a colligo 408: clear sticky ARP, try browser warm for a NEW forter, fall back carefully. + * Rebuilding from the same forter cookie is a no-op and must not burn all retries. + * + * Policy: + * - Fresh forter + attempt 1–2 → quiet reuse (rate-limit masquerading as 408). + * - Stale forter (age > FORTER_STALE_MS) OR attempt ≥ 3 → off-screen Chrome warm immediately. + */ +export async function rotateAdobeFireflySessionOnError( + session: AdobeFireflySession, + opts?: { + tryBrowser?: boolean; + log?: AdobeFireflySessionResolveOpts["log"]; + /** Attempt index (1-based) for backoff policy. */ + attempt?: number; + /** 401/403: bypass quiet ARP reuse and refresh JWT + cookies immediately. */ + authFailure?: boolean; + } +): Promise { + if (session.tokenExpiresAt <= 0) { + session = { + ...session, + tokenExpiresAt: estimateAdobeTokenExpiry(session.accessToken), + }; + } + const prevArp = session.arpSessionId; + const attempt = opts?.attempt ?? 1; + const forterTs = extractAdobeForterTimestampMs(session.cookie); + const forterAgeMs = forterTs > 0 ? Math.max(0, Date.now() - forterTs) : null; + // Only treat as "known stale" when the cookie embeds a forter timestamp we can age. + // Unknown age (synthetic ARP / tests) keeps the quiet 1–2 reuse path. + const forterKnownStale = forterAgeMs != null && forterAgeMs > FORTER_STALE_MS; + + // Attempt 1–2 when forter is not known-stale: keep same ARP (colligo short load / rate limit). + // Hours-old forter → skip quiet reuse and warm Chrome immediately (else all 5 attempts 408). + if (attempt <= 2 && !forterKnownStale && !opts?.authFailure) { + const same: AdobeFireflySession = { + ...session, + updatedAt: Date.now(), + source: "cache", + }; + sessionCache.set(session.fingerprint, same); + saveDiskSession(same); + opts?.log?.info?.( + "ADOBE-FIREFLY", + `408 recovery: reusing ARP (quiet period, attempt ${attempt}, forterAgeMs=${forterAgeMs ?? "unknown"})` + ); + return same; + } + + // Known-stale forter or attempt 3+: cookie rebuild is a no-op. CDP warm mints a fresh + // Forter/ARP via offscreen headed Chrome by default (colligo rejects true headless). + // ADOBE_FIREFLY_CHROME_HEADLESS=1 is debug-only and usually keeps returning 408. + clearAdobeFireflyWorkingArp(session.fingerprint); + noteAdobeFireflySubmitFailure(); + + const tryBrowser = + opts?.tryBrowser !== false && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0"; + if (tryBrowser) { + opts?.log?.info?.( + "ADOBE-FIREFLY", + `${opts?.authFailure ? "auth" : "408"} recovery: durable CDP warm (attempt=${attempt}, forterKnownStale=${forterKnownStale}, forterAgeMs=${forterAgeMs ?? "unknown"})` + ); + const warmed = await refreshAdobeSessionViaBrowser(session, opts?.log, { + force: true, + proveWithPing: true, + }); + if (warmed?.arpSessionId) { + const next = { ...warmed, fingerprint: session.fingerprint }; + sessionCache.set(session.fingerprint, next); + saveDiskSession(next); + opts?.log?.info?.( + "ADOBE-FIREFLY", + `${opts?.authFailure ? "auth" : "408"} recovery: CDP warm done (arp changed=${warmed.arpSessionId !== prevArp}, forterTs=${extractAdobeForterTimestampMs(warmed.cookie)})` + ); + return next; + } + } + + const rebuilt = resolveAdobeArpSessionIdSmart(session.cookie, { + rotate: true, + }); + const next: AdobeFireflySession = { + ...session, + arpSessionId: rebuilt && rebuilt !== prevArp ? rebuilt : session.arpSessionId, + updatedAt: Date.now(), + source: "rebuild", + }; + sessionCache.set(session.fingerprint, next); + saveDiskSession(next); + return next; +} + +/** Test helper — clear in-memory session cache. */ +export function __resetAdobeFireflySessionCacheForTests(): void { + sessionCache.clear(); + browserRefreshInFlight.clear(); + lastWorkingArpByFingerprint.clear(); + browserWarmFailureCooldown.clear(); + lastAdobeSubmitAt = 0; + consecutiveAdobeSubmitSuccesses = 0; + adobeSubmitChain = Promise.resolve(); +} diff --git a/open-sse/services/adobeFireflyUpscale.ts b/open-sse/services/adobeFireflyUpscale.ts index 92edc6b340..ce045907c1 100644 --- a/open-sse/services/adobeFireflyUpscale.ts +++ b/open-sse/services/adobeFireflyUpscale.ts @@ -156,9 +156,10 @@ export function resolveAdobeCreativityLevel(opts: { return clampLevel(normalizeExplicitCreativity(Number(explicit))); } - const percent = typeof opts.creativityPercent === "number" && Number.isFinite(opts.creativityPercent) - ? Math.max(0, Math.min(100, opts.creativityPercent)) - : 0; + const percent = + typeof opts.creativityPercent === "number" && Number.isFinite(opts.creativityPercent) + ? Math.max(0, Math.min(100, opts.creativityPercent)) + : 0; return clampLevel(percent / 100); } @@ -265,11 +266,7 @@ export async function adobeFireflyUpscaleImage(opts: { const blobId = String(opts.blobId || "").trim(); if (!blobId) { - throw new AdobeFireflyError( - "Adobe Firefly upscale requires a source image", - 400, - "bad_image" - ); + throw new AdobeFireflyError("Adobe Firefly upscale requires a source image", 400, "bad_image"); } const factor = normalizeFactor(opts.upsamplerFactor, spec.factors); diff --git a/open-sse/services/antigravityProjectPersist.ts b/open-sse/services/antigravityProjectPersist.ts index 1068c4d3ec..b7f55343c2 100644 --- a/open-sse/services/antigravityProjectPersist.ts +++ b/open-sse/services/antigravityProjectPersist.ts @@ -39,7 +39,7 @@ export function preferAntigravityConnectionsWithStoredProject { - if (typeof connection.projectId === "string" && connection.projectId) return true; + if (typeof connection.projectId === "string" && connection.projectId.trim()) return true; let psd = connection.providerSpecificData; if (typeof psd === "string") { try { @@ -48,12 +48,9 @@ export function preferAntigravityConnectionsWithStoredProject).projectId === "string" && - (psd as Record).projectId - ); + if (!psd || typeof psd !== "object") return false; + const projectId = (psd as Record).projectId; + return typeof projectId === "string" && projectId.trim().length > 0; }; const withStoredProject = connections.filter(hasStoredProject); return withStoredProject.length > 0 ? withStoredProject : connections; diff --git a/open-sse/services/antigravityProjectPersistence.ts b/open-sse/services/antigravityProjectPersistence.ts index f34445fe00..e842492aae 100644 --- a/open-sse/services/antigravityProjectPersistence.ts +++ b/open-sse/services/antigravityProjectPersistence.ts @@ -1,13 +1,39 @@ /** * Re-export from `antigravityProjectPersist.ts` plus a connection-preference helper. + * + * The persistence layer for a runtime-discovered Antigravity projectId lives in + * the sibling file `antigravityProjectPersist.ts` (named by its core function). + * This module adds `preferAntigravityConnectionsWithStoredProject()`, used by the + * quota-strategy engine to give priority to connections whose projectId has + * already been discovered and persisted. */ + import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts"; + export { persistDiscoveredAntigravityProjectId }; -export function preferAntigravityConnectionsWithStoredProject( - connections: Array> -): Array> { - return connections.filter( - (conn) => conn != null && typeof conn.projectId === "string" && conn.projectId.trim().length > 0 - ); +/** + * Prefer Antigravity connections with a discovered/stored `projectId` for + * reset-aware quota routing. + * + * This is a preference, not a hard requirement: when no candidate has a stored + * projectId, retain the full pool rather than making freshly-added accounts unusable. + */ +function hasStoredProjectId(connection: Record): boolean { + if (typeof connection.projectId === "string" && connection.projectId.trim().length > 0) { + return true; + } + const providerSpecificData = connection.providerSpecificData; + if (providerSpecificData && typeof providerSpecificData === "object") { + const nested = (providerSpecificData as Record).projectId; + if (typeof nested === "string" && nested.trim().length > 0) return true; + } + return false; +} + +export function preferAntigravityConnectionsWithStoredProject>( + connections: T[] +): T[] { + const withStoredProject = connections.filter(hasStoredProjectId); + return withStoredProject.length > 0 ? withStoredProject : connections; } diff --git a/open-sse/services/claudeAdaptiveThinking.ts b/open-sse/services/claudeAdaptiveThinking.ts index d65c79de2b..f50ea6bc21 100644 --- a/open-sse/services/claudeAdaptiveThinking.ts +++ b/open-sse/services/claudeAdaptiveThinking.ts @@ -54,7 +54,7 @@ export function normalizeClaudeAdaptiveThinking): number { } 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"); } @@ -615,6 +606,10 @@ export type CompatFilterOptions = { failOpen?: boolean; }; +export function hasHardCapabilityFailure(reasons: string[]): boolean { + return reasons.some((reason) => HARD_COMPAT_REASONS.has(reason)); +} + /** * Summarize a capability-filter exhaustion for a 400-class combo error (#8488). * Returns null when the empty pool is not attributable to hard requirements. @@ -718,9 +713,7 @@ export function filterTargetsByRequestCompatibility( if (compatible.length === targets.length) return targets; if (compatible.length === 0) { - const hardRejected = rejected.some((entry) => - entry.reasons.some((r) => HARD_COMPAT_REASONS.has(r)) - ); + const hardRejected = rejected.some((entry) => hasHardCapabilityFailure(entry.reasons)); const failOpen = options?.failOpen === true; log.debug?.( diff --git a/open-sse/services/combo/fusionPanel.ts b/open-sse/services/combo/fusionPanel.ts index a0f3d36d30..20540d5850 100644 --- a/open-sse/services/combo/fusionPanel.ts +++ b/open-sse/services/combo/fusionPanel.ts @@ -51,9 +51,9 @@ export function extractFusionPanelSpec( panel.push(step.comboName); return; } - // #8894 widened ComboStep with ComboProviderWildcardStep, which carries a - // modelPattern instead of a model. getComboModelString() already resolves any - // step shape (and returns null for the ones with no concrete model id). + // Provider-wildcard steps have no concrete model to dispatch — fusion is a + // fixed-size panel of literal models/combo-refs, not a wildcard-expanding + // strategy (see file header). Skip rather than push an undefined model. const modelStr = getComboModelString(step); if (modelStr) panel.push(modelStr); }); diff --git a/open-sse/services/combo/quotaStrategies.ts b/open-sse/services/combo/quotaStrategies.ts index 4234b29433..ad47e5d2df 100644 --- a/open-sse/services/combo/quotaStrategies.ts +++ b/open-sse/services/combo/quotaStrategies.ts @@ -89,9 +89,7 @@ async function getQuotaAwareConnectionsForTarget( ? (connections as Array>) : []; if (provider === "antigravity" || provider === "agy") { - activeConnections = preferAntigravityConnectionsWithStoredProject( - activeConnections - ) as Array>; + activeConnections = preferAntigravityConnectionsWithStoredProject(activeConnections); } if ( !resetAwareConnectionCache.has(provider) && diff --git a/open-sse/services/compression/engines/cavemanAdapter.ts b/open-sse/services/compression/engines/cavemanAdapter.ts index 464d3e79b8..0fa8e5bb9e 100644 --- a/open-sse/services/compression/engines/cavemanAdapter.ts +++ b/open-sse/services/compression/engines/cavemanAdapter.ts @@ -221,6 +221,14 @@ const LITE_SCHEMA: EngineConfigField[] = [ label: "Preserve system prompt", defaultValue: true, }, + { + key: "compressToolResults", + type: "boolean", + label: "Proactively truncate long tool results", + description: + "Truncates tool results over 2,000 characters during Lite compression. Emergency overflow protection may still trim content when the context exceeds the model budget.", + defaultValue: true, + }, ]; function validateLiteConfig(config: Record): EngineValidationResult { @@ -231,6 +239,7 @@ function validateLiteConfig(config: Record): EngineValidationRe ) { errors.push("preserveSystemPrompt must be a boolean"); } + validateBoolean(config, "compressToolResults", errors); return { valid: errors.length === 0, errors }; } @@ -253,9 +262,17 @@ export const liteEngine: CompressionEngine = { }, apply(body, options) { const adapter = adaptBodyForCompression(body); + const stepCompressToolResults = options?.stepConfig?.compressToolResults; const result = applyLiteCompression(adapter.body, { ...options, preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + // buildStepOptions() already merges global config.lite with explicit step.config + // (step wins) into stepConfig, so consume that single effective value instead of + // AND-ing root and step values — an explicit step `true` must override a global `false`. + compressToolResults: + typeof stepCompressToolResults === "boolean" + ? stepCompressToolResults + : (options?.config?.lite?.compressToolResults ?? true), }); return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result; }, diff --git a/open-sse/services/compression/engines/ccr/index.ts b/open-sse/services/compression/engines/ccr/index.ts index d0b5ba10e7..d2136fc8f1 100644 --- a/open-sse/services/compression/engines/ccr/index.ts +++ b/open-sse/services/compression/engines/ccr/index.ts @@ -35,7 +35,6 @@ * - Only replace blocks ≥ minChars (default 600). * - `stackable: true`, `stackPriority: 4` (runs just after session-dedup(3)). */ - import crypto from "node:crypto"; import { deleteAllCcrBlocks, @@ -292,8 +291,10 @@ function rehydrateEntry(hash: string, principalId: string, now: number): CcrEntr // Re-admit through the same budgets a fresh store would face. If the block no longer // fits, it stays on disk and is served straight from the row instead of being cached. - const { principalId: owner, bytes } = entry; - if (enforcePrincipalBudget(owner, bytes) && enforceGlobalBudget(owner, bytes)) { + if ( + enforcePrincipalBudget(entry.principalId, entry.bytes) && + enforceGlobalBudget(entry.principalId, entry.bytes) + ) { const key = buildStoreKey(hash, principalId === ANON ? undefined : principalId); ccrStore.set(key, entry); ccrTotalBytes += entry.bytes; diff --git a/open-sse/services/compression/lite.ts b/open-sse/services/compression/lite.ts index 6c795766fd..4be635da0e 100644 --- a/open-sse/services/compression/lite.ts +++ b/open-sse/services/compression/lite.ts @@ -17,6 +17,7 @@ interface LiteCompressionOptions { model?: string; supportsVision?: boolean | null; preserveSystemPrompt?: boolean; + compressToolResults?: boolean; } function trimTrailingHorizontalWhitespace(line: string): string { @@ -253,9 +254,11 @@ export function applyLiteCompression( current = r2.body; if (r2.applied) techniquesApplied.push("system-dedup"); - const r3 = compressToolResults(current); - current = r3.body; - if (r3.applied) techniquesApplied.push("tool-compress"); + if (options?.compressToolResults !== false) { + const r3 = compressToolResults(current); + current = r3.body; + if (r3.applied) techniquesApplied.push("tool-compress"); + } const r4 = removeRedundantContent(current, options); current = r4.body; diff --git a/open-sse/services/compression/stepDetailConfig.ts b/open-sse/services/compression/stepDetailConfig.ts index ff2d2a39c9..f911d81e79 100644 --- a/open-sse/services/compression/stepDetailConfig.ts +++ b/open-sse/services/compression/stepDetailConfig.ts @@ -14,6 +14,8 @@ export function resolveStepDetailConfig( config: CompressionConfig | undefined ) { switch (engine) { + case "lite": + return config?.lite ?? {}; case "headroom": return config?.headroom ?? {}; case "session-dedup": diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 8785ddb550..fad53e56ac 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -349,6 +349,7 @@ function runCompression( const result = applyLiteCompression(compressionBody, { ...options, preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + ...options?.config?.lite, }); return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result; } diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 665af5988f..5905a7b49f 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -157,6 +157,12 @@ export interface LiveZoneConfig { enabled: boolean; } +/** Lite detail settings for proactive request-time transformations. */ +export interface LiteConfig { + /** Truncate tool-result strings over 2,000 characters before provider dispatch. */ + compressToolResults: boolean; +} + export interface CompressionPipelineStep { engine: CompressionEngineId; intensity?: CavemanIntensity | RtkIntensity; @@ -218,6 +224,8 @@ export interface CompressionConfig { languageConfig?: CompressionLanguageConfig; aggressive?: AggressiveConfig; ultra?: UltraConfig; + /** Lite proactive transformation detail settings. */ + lite?: LiteConfig; /** Headroom SmartCrusher detail settings (minRows gate). */ headroom?: HeadroomConfig; /** Session Dedup detail settings (minBlockChars / fuzzy, #8388). */ @@ -395,6 +403,7 @@ export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = { ultraEngine: "heuristic", ultraSlmPrewarm: false, liveZone: { enabled: false }, + lite: { compressToolResults: true }, codexResponsesConfig: { ...DEFAULT_CODEX_RESPONSES_CONFIG }, }; diff --git a/open-sse/services/firecrawlQuotaFetcher.ts b/open-sse/services/firecrawlQuotaFetcher.ts index 021ccedfc9..232b7c3781 100644 --- a/open-sse/services/firecrawlQuotaFetcher.ts +++ b/open-sse/services/firecrawlQuotaFetcher.ts @@ -121,10 +121,6 @@ export function getFirecrawlBaseUrl(connection?: Record): strin export async function fetchFirecrawlQuota( connectionId: string, connection?: Record - // FirecrawlQuota, not the base QuotaInfo: every return here is a full credit - // breakdown (remainingCredits / planCredits / extraCreditsInferred / overPlan), - // and the narrower annotation made the custom-base literal below an excess- - // property error. FirecrawlQuota extends QuotaInfo, so callers are unaffected. ): Promise { const cached = quotaCache.get(connectionId); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { diff --git a/open-sse/services/reasoningCache.ts b/open-sse/services/reasoningCache.ts index e23a388d6a..dc81b14e69 100644 --- a/open-sse/services/reasoningCache.ts +++ b/open-sse/services/reasoningCache.ts @@ -64,6 +64,8 @@ const REASONING_REPLAY_MODEL_PATTERNS = [ ]; const DEEPSEEK_V4_MODEL_PATTERN = /deepseek[-/]v4[-.](flash|pro)/i; +const K3_REASONING_REPLAY_MODEL_PATTERN = /(?:^|\/)(?:kimi-)?k3(?:$|-)/i; +const NATIVE_K27_REASONING_REPLAY_MODEL_PATTERN = /(?:^|\/)kimi-k2\.7-code(?:$|-)/i; export function isDeepSeekReasoningModel(params: { provider: string; @@ -94,6 +96,14 @@ export function requiresReasoningReplay(params: { if (normalizedInterleavedField === "reasoning_content") return true; if (normalizedInterleavedField === "reasoning_details") return false; + if (K3_REASONING_REPLAY_MODEL_PATTERN.test(normalizedModel)) return true; + if ( + (normalizedProvider === "moonshot" || normalizedProvider === "kimi") && + NATIVE_K27_REASONING_REPLAY_MODEL_PATTERN.test(normalizedModel) + ) { + return true; + } + // DeepSeek legacy reasoner family has an inverse contract: do not replay. if (/deepseek-reasoner/i.test(normalizedModel) || /deepseek-r1/i.test(normalizedModel)) { return false; diff --git a/open-sse/services/responsesInputPolicy.ts b/open-sse/services/responsesInputPolicy.ts new file mode 100644 index 0000000000..d80dcc7bec --- /dev/null +++ b/open-sse/services/responsesInputPolicy.ts @@ -0,0 +1,55 @@ +type JsonRecord = Record; + +const SERVER_ITEM_ID_PATTERN = /^(rs|fc|resp|msg)_/; + +/** + * Applies the persistence-independent policy for replayed Responses input items. + * Stored references can only be resolved by the upstream that created them, so + * they are always removed. Self-contained encrypted reasoning is retained only + * when the selected connection explicitly opts in. + */ +export function applyResponsesInputPolicy( + body: Record, + preserveEncryptedReasoning = false +): void { + if (Array.isArray(body.input) && body.input.length === 0) { + body.input = [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }, + ]; + } + + if (!Array.isArray(body.input)) return; + + body.input = body.input.filter((item) => { + if (typeof item === "string" && SERVER_ITEM_ID_PATTERN.test(item)) { + return false; + } + + const record = + item && typeof item === "object" && !Array.isArray(item) ? (item as JsonRecord) : null; + if (!record) return true; + + if (record.type === "item_reference") { + return false; + } + + if ( + record.type === "reasoning" && + (!preserveEncryptedReasoning || + typeof record.encrypted_content !== "string" || + record.encrypted_content.trim().length === 0) + ) { + return false; + } + + if (typeof record.id === "string" && SERVER_ITEM_ID_PATTERN.test(record.id)) { + delete record.id; + } + + return true; + }); +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 43dcba1a6d..aba42bfcd8 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -48,6 +48,7 @@ import { refreshGoogleToken } from "./tokenRefresh/providers/google.ts"; import { ensureAntigravityProjectAssigned } from "./antigravityProjectBootstrap.ts"; import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts"; import { refreshCodexToken } from "./tokenRefresh/providers/codex.ts"; +import { refreshOpenferenceToken } from "./tokenRefresh/providers/openference.ts"; import { refreshKiroToken } from "./tokenRefresh/providers/kiro.ts"; import { refreshQoderToken } from "./tokenRefresh/providers/qoder.ts"; import { refreshGitHubToken } from "./tokenRefresh/providers/github.ts"; @@ -62,6 +63,7 @@ export { refreshClaudeOAuthToken, refreshGoogleToken, refreshCodexToken, + refreshOpenferenceToken, refreshKiroToken, refreshQoderToken, refreshGitHubToken, @@ -339,10 +341,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: !(credentials.projectId || credentials.providerSpecificData?.projectId) ) { try { - const discovered = await ensureAntigravityProjectAssigned( - result.accessToken, - fetch - ); + const discovered = await ensureAntigravityProjectAssigned(result.accessToken, fetch); if (discovered) { result.projectId = discovered; result.providerSpecificData = { @@ -362,7 +361,8 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: }); } } catch (discoveryError) { - const msg = discoveryError instanceof Error ? discoveryError.message : String(discoveryError); + const msg = + discoveryError instanceof Error ? discoveryError.message : String(discoveryError); log?.warn?.("TOKEN", `Antigravity projectId discovery failed: ${msg}`); } } @@ -376,6 +376,9 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: case "codex": return await refreshCodexToken(credentials.refreshToken, log, proxyConfig); + case "openference": + return await refreshOpenferenceToken(credentials.refreshToken, log, proxyConfig); + case "qoder": return await refreshQoderToken(credentials.refreshToken, log, proxyConfig); @@ -439,6 +442,7 @@ export function supportsTokenRefresh(provider) { "agy", "claude", "codex", + "openference", "qoder", "github", "kiro", diff --git a/open-sse/services/tokenRefresh/providers/openference.ts b/open-sse/services/tokenRefresh/providers/openference.ts new file mode 100644 index 0000000000..5e717acc79 --- /dev/null +++ b/open-sse/services/tokenRefresh/providers/openference.ts @@ -0,0 +1,92 @@ +// @ts-nocheck +import { OAUTH_ENDPOINTS } from "../../../config/constants.ts"; +import { runWithProxyContext } from "../../../utils/proxyFetch.ts"; +import { buildFormParams } from "../shared.ts"; + +/** + * Specialized refresh for Openference OAuth tokens. + * Openference uses rotating (one-time-use) oar_* refresh tokens. + */ +export async function refreshOpenferenceToken(refreshToken, log, proxyConfig: unknown = null) { + try { + const response = await runWithProxyContext(proxyConfig, () => + fetch(OAUTH_ENDPOINTS.openference.token, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: buildFormParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: OAUTH_ENDPOINTS.openference.clientId, + }), + }) + ); + + if (!response.ok) { + const errorText = await response.text(); + + let errorCode = null; + try { + const parsed = JSON.parse(errorText); + errorCode = + parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null); + } catch { + // not JSON, ignore + } + + if ( + errorCode === "invalid_grant" || + errorCode === "token_expired" || + errorCode === "invalid_token" + ) { + log?.error?.( + "TOKEN_REFRESH", + "Openference refresh token already used or invalid. Re-authentication required.", + { + status: response.status, + errorCode, + } + ); + return { error: "unrecoverable_refresh_error", code: errorCode }; + } + + if (response.status === 401) { + const code = errorCode || "unauthorized"; + log?.error?.( + "TOKEN_REFRESH", + "Openference OAuth token endpoint returned 401. Re-authentication required.", + { + status: response.status, + errorCode: code, + } + ); + return { error: "unrecoverable_refresh_error", code }; + } + + log?.error?.("TOKEN_REFRESH", "Failed to refresh Openference token", { + status: response.status, + error: errorText, + }); + return null; + } + + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Openference token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Openference token: ${error.message}`); + return null; + } +} diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index dca61a31fa..e98c4d98db 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -37,6 +37,102 @@ async function getPath() { return _path || null; } +type UsageRecord = Record; + +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) { diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index b490fe4bd5..05dad32149 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -84,6 +84,10 @@ export function hasValidContent(msg: ClaudeMessage): boolean { return msg.content.some( (block) => (block.type === "text" && block.text?.trim()) || + (block.type === "thinking" && block.thinking?.trim()) || + (block.type === "redacted_thinking" && + typeof block.data === "string" && + block.data.trim()) || block.type === "tool_use" || block.type === "tool_result" || // #7777: media-only user turns are real content — dropping them diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 727fcbbdec..29b65303f6 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -686,5 +686,38 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown { addPlaceholders(cleaned); + // Phase 7: Recursive type:"object" injection for nested schemas (#9268). + // Gemini/Vertex requires every node with properties/required to have an explicit + // `type: "object"`. Some clients (e.g. Composio-exported tools) emit nested + // schemas with `properties` but no `type`, causing a Gemini 400. Follow the + // `removeUnsupportedKeywords()`/`addPlaceholders()` visitor pattern. + function injectObjectType(obj: unknown): void { + if (!obj || typeof obj !== "object") return; + + if (Array.isArray(obj)) { + for (const item of obj) { + injectObjectType(item); + } + return; + } + + const record = obj as JsonRecord; + if ( + !record.type && + (record.properties !== undefined || record.required !== undefined) + ) { + record.type = "object"; + } + + // Recurse into remaining values. + for (const value of Object.values(record)) { + if (value && typeof value === "object") { + injectObjectType(value); + } + } + } + + injectObjectType(cleaned); + return cleaned; } diff --git a/open-sse/translator/helpers/responsesApiHelper.ts b/open-sse/translator/helpers/responsesApiHelper.ts index 1f856b11f1..625daf174f 100644 --- a/open-sse/translator/helpers/responsesApiHelper.ts +++ b/open-sse/translator/helpers/responsesApiHelper.ts @@ -2,10 +2,16 @@ * Convert OpenAI Responses API format to standard chat completions format. * Delegates to the canonical translator to avoid logic duplication. */ +import { requiresReasoningReplay } from "../../services/reasoningCache.ts"; import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts"; import { toRecord } from "../request/openai-responses/helpers.ts"; -export function convertResponsesApiFormat(body, credentials = null, provider = null) { +export function convertResponsesApiFormat( + body: Record, + credentials: unknown = null, + provider: unknown = null, + model: unknown = null +): Record { const bodyModel = toRecord(body).model; const requestedModel = typeof bodyModel === "string" && bodyModel.trim().length > 0 @@ -13,5 +19,25 @@ export function convertResponsesApiFormat(body, credentials = null, provider = n ? bodyModel : `${provider}/${bodyModel}` : provider; - return openaiResponsesToOpenAIRequest(requestedModel, body, null, credentials); + const credentialRecord = + credentials && typeof credentials === "object" && !Array.isArray(credentials) + ? (credentials as Record) + : {}; + const translationCredentials = requiresReasoningReplay({ + provider: String(provider ?? ""), + model: String(model ?? ""), + allowLegacyFallback: false, + }) + ? { ...credentialRecord, _preserveReasoningContent: true } + : credentials; + const converted = openaiResponsesToOpenAIRequest( + requestedModel, + body, + null, + translationCredentials + ); + if (!converted || typeof converted !== "object" || Array.isArray(converted)) { + throw new TypeError("Responses request conversion must produce an object"); + } + return converted as Record; } diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 8497989f09..2db1da2545 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -13,7 +13,6 @@ import { providerHonorsOpenAIFormatCacheControl, resolveConnectionCacheOverride, } from "../utils/cacheControlPolicy.ts"; -import { requiresAuthenticReasoningContent } from "../utils/reasoningContentInjector.ts"; import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts"; import { coerceToolSchemas, @@ -162,7 +161,11 @@ function isReasoningOnlyReplayTarget(provider: unknown, model: unknown): boolean /(^|\/)deepseek/i.test(normalizedModel) || normalizedProvider === "xiaomi-mimo" || /(^|\/)mimo/i.test(normalizedModel) || - requiresAuthenticReasoningContent(normalizedProvider, normalizedModel) + requiresReasoningReplay({ + provider: normalizedProvider, + model: normalizedModel, + allowLegacyFallback: false, + }) ); } @@ -233,6 +236,17 @@ export function translateRequest( const connectionCacheOverride = resolveConnectionCacheOverride( (credentials as { providerSpecificData?: unknown } | null)?.providerSpecificData ); + const normalizedProvider = String(provider ?? ""); + const normalizedModel = String(model ?? ""); + const isKimiCoding = + normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey"; + const requiresExplicitReasoningReplay = requiresReasoningReplay({ + provider: normalizedProvider, + model: normalizedModel, + allowLegacyFallback: false, + }); + const preserveResponsesReasoning = + sourceFormat === FORMATS.OPENAI_RESPONSES && requiresExplicitReasoningReplay; // Phase 2: Apply thinking budget control before normalization result = applyThinkingBudget(result); @@ -318,12 +332,16 @@ export function translateRequest( options?.preserveCacheControl === true && providerHonorsOpenAIFormatCacheControl(provider, connectionCacheOverride); const step1Credentials = - options?.copilotClient || hasTargetHint || preserveCacheControl + options?.copilotClient || + hasTargetHint || + preserveCacheControl || + preserveResponsesReasoning ? { ...(credentials && typeof credentials === "object" ? credentials : {}), ...(options?.copilotClient ? { _copilotClient: true } : {}), ...(hasTargetHint ? { _targetFormat: targetFormat } : {}), ...(preserveCacheControl ? { _preserveCacheControl: true } : {}), + ...(preserveResponsesReasoning ? { _preserveReasoningContent: true } : {}), } : credentials; result = toOpenAI(model, result, stream, step1Credentials); @@ -352,7 +370,27 @@ export function translateRequest( ...(hasProvider ? { _provider: provider } : {}), } : credentials; - result = fromOpenAI(model, result, stream, translationCredentials); + // #9780 — carry the Responses namespace identity map across the pivot. + // Target translators return a brand-new object (buildKiroPayload et + // al.), dropping the non-enumerable property step 1 attached; the + // #7936 seam then gets null and namespace sub-tool calls come back + // flattened, which Codex rejects with `unsupported call: `. + const identityMap = (result as Record)._namespaceToolIdentityMap; + const translated = fromOpenAI(model, result, stream, translationCredentials); + if ( + identityMap instanceof Map && + translated && + typeof translated === "object" && + !((translated as Record)._namespaceToolIdentityMap instanceof Map) + ) { + Object.defineProperty(translated, "_namespaceToolIdentityMap", { + value: identityMap, + enumerable: false, + configurable: true, + writable: true, + }); + } + result = translated; } } } @@ -361,14 +399,6 @@ export function translateRequest( // Resolve reasoning-replay status up-front: it gates both the reasoning_content // strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for // replay providers) and the cache re-injection further down. - const normalizedProvider = String(provider ?? ""); - const normalizedModel = String(model ?? ""); - const isKimiCoding = - normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey"; - const requiresAuthenticReasoning = requiresAuthenticReasoningContent( - normalizedProvider, - normalizedModel - ); const resolvedCapabilities = getResolvedModelCapabilities({ provider: normalizedProvider, model: normalizedModel, @@ -377,7 +407,10 @@ export function translateRequest( provider: normalizedProvider, model: normalizedModel, thinkingEnabled: hasThinkingConfig(result), - supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }), + supportsReasoning: supportsReasoning({ + provider: normalizedProvider, + model: normalizedModel, + }), interleavedField: resolvedCapabilities?.interleavedField ?? null, }); @@ -450,7 +483,7 @@ export function translateRequest( if ( targetFormat === FORMATS.OPENAI && - !requiresAuthenticReasoning && + !requiresExplicitReasoningReplay && result.messages && Array.isArray(result.messages) ) { @@ -475,7 +508,7 @@ export function translateRequest( // isReasoner / normalizedProvider / normalizedModel / resolvedCapabilities were // resolved up-front (before the OpenAI-format filter) so the #4849 reasoning strip // could honor reasoning-replay providers. - if (isReasoner && !isKimiCoding && result.messages && Array.isArray(result.messages)) { + if (isReasoner && result.messages && Array.isArray(result.messages)) { const canReplayReasoningOnly = isReasoningOnlyReplayTarget(normalizedProvider, normalizedModel); for (const [messageIndex, msg] of result.messages.entries()) { @@ -524,29 +557,51 @@ export function translateRequest( // Has tool_use blocks but no thinking block yet. // Reasoning models (Kimi K2, etc.) require a thinking block before tool_use // on multi-turn or they regenerate the same tool call infinitely. - const hasThinkingBlock = msg.content.some( + const thinkingBlock = msg.content.find( (b) => b?.type === "thinking" || b?.type === "redacted_thinking" ); - if (hasThinkingBlock) continue; + const hasNonEmptyClientThinking = + thinkingBlock?.type === "thinking" && + typeof thinkingBlock.thinking === "string" && + thinkingBlock.thinking.trim().length > 0; + if (thinkingBlock && (!isKimiCoding || hasNonEmptyClientThinking)) continue; const toolUseBlocks = msg.content.filter((b) => b?.type === "tool_use"); const firstToolUseId = toolUseBlocks[0]?.id; const firstToolUseIdx = msg.content.findIndex((b) => b?.type === "tool_use"); - // Try reasoning cache first + // Client reasoning wins above. Otherwise try authentic replay before + // retaining Kimi Code's empty protocol marker as the final fallback. if (firstToolUseId) { const cached = lookupReasoning(firstToolUseId); if (cached) { - msg.content.splice(firstToolUseIdx, 0, { - type: "thinking", - thinking: cached, - }); + if (thinkingBlock) { + thinkingBlock.type = "thinking"; + thinkingBlock.thinking = cached; + delete thinkingBlock.data; + delete thinkingBlock.signature; + } else { + msg.content.splice(firstToolUseIdx, 0, { + type: "thinking", + thinking: cached, + }); + } recordReplay(); continue; } } - if (requiresAuthenticReasoning) continue; - // Fallback: inject placeholder (must be non-empty for kimi-coding) + if (isKimiCoding) { + if (thinkingBlock) { + thinkingBlock.type = "thinking"; + thinkingBlock.thinking = ""; + delete thinkingBlock.data; + delete thinkingBlock.signature; + } else { + msg.content.splice(firstToolUseIdx, 0, { type: "thinking", thinking: "" }); + } + continue; + } + if (requiresExplicitReasoningReplay) continue; msg.content.splice(firstToolUseIdx, 0, { type: "thinking", thinking: NON_ANTHROPIC_THINKING_PLACEHOLDER, @@ -570,7 +625,7 @@ export function translateRequest( const cacheKey = hasToolCalls ? msg.tool_calls[0]?.id - : getAssistantMessageCacheKey(result, 0); + : getAssistantMessageCacheKey(result, messageIndex); if (cacheKey) { const cached = lookupReasoning(cacheKey); if (cached) { @@ -583,7 +638,7 @@ export function translateRequest( // Native Moonshot K3/K2.7 accepts only the real prior reasoning. If it // was not supplied and the cache missed, leave it absent so upstream can // enforce its contract instead of corrupting history with a placeholder. - if (requiresAuthenticReasoning) { + if (requiresExplicitReasoningReplay) { if (msg.reasoning_content === "") delete msg.reasoning_content; continue; } @@ -596,7 +651,7 @@ export function translateRequest( // deepseek-v4-flash accepts an ABSENT reasoning_content field (the 400 is // specific to empty-string, and even that is endpoint-dependent). Omit // the field instead; providers that genuinely enforce the contract - // (kimi-coding, moonshot authentic-reasoning) have their own paths above. + // (kimi-coding, moonshot reasoning replay) have their own paths above. if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) { if (requiresReasoningContentPresence(normalizedProvider, normalizedModel)) { msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER; @@ -735,6 +790,7 @@ export function initState(sourceFormat) { inThinking: false, parseTextualReasoningTags: false, funcArgsBuf: {}, + funcArgsEscapeState: {}, funcNames: {}, funcCallIds: {}, funcArgsDone: {}, diff --git a/open-sse/translator/paramSupport.ts b/open-sse/translator/paramSupport.ts index caad2ab5a3..85dcec35ae 100644 --- a/open-sse/translator/paramSupport.ts +++ b/open-sse/translator/paramSupport.ts @@ -63,7 +63,12 @@ const STRIP_RULES: StripRule[] = [ // MoonshotAI/kimi-cli#1124), and by upstream decolua/9router#2460. Scoped to // OmniRoute's actual volcengine Kimi id (not a broad /kimi/i regex) so it // never clamps an unrelated future Kimi listing whose Ark cap may differ. - { provider: "volcengine", match: /^kimi-k2-5-260127$/, maxOutputCap: 32768, clampToModelMaxOutput: true }, + { + provider: "volcengine", + match: /^kimi-k2-5-260127$/, + maxOutputCap: 32768, + clampToModelMaxOutput: true, + }, // #7364: Z.AI's glm-4.6v vision endpoint enforces a 32768 max_tokens ceiling // server-side and 400s when a client sends a larger explicit max_tokens (e.g. a // client defaulting to 65536). Scoped to both wire paths that can reach this @@ -75,6 +80,19 @@ const STRIP_RULES: StripRule[] = [ // glmProvider.ts, maxOutputTokens: 32768, so clampToModelMaxOutput suffices). { provider: "zai", match: /^glm-4\.6v$/i, maxOutputCap: 32768 }, { provider: "glm", match: /^glm-4\.6v$/i, clampToModelMaxOutput: true }, + // Azure gpt-4o-mini deployments cap completion tokens at 16384 and 400 on + // anything larger: "max_tokens is too large: 32000. This model supports at + // most 16384 completion tokens". OmniRoute's own tool-calling floor + // (DEFAULT_MIN_TOKENS = 32000, applied by adjustMaxTokens) raises even a tiny + // explicit max_tokens to 32000 whenever tools are present, so every agentic + // client trips this on its first turn. PROVIDER_MAX_TOKENS is not the right + // lever here: it is provider-wide, and the same Azure resource also serves + // GPT-5 deployments whose ceiling is far higher. Azure deployment names are + // operator-chosen, hence a prefix match rather than an exact id, and the + // models are passthrough (no catalog maxOutputTokens for clampToModelMaxOutput + // to read), hence the fixed cap. + { provider: "azure-openai", match: /^gpt-4o-mini/i, maxOutputCap: 16384 }, + { provider: "azure-ai", match: /^gpt-4o-mini/i, maxOutputCap: 16384 }, ]; function matches(rule: StripRule, model: string): boolean { diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 6d7a79b8a4..9c2822dc02 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -73,6 +73,19 @@ function toolOutputContentToString(output: unknown): string { return parts.join("\n"); } +function getReasoningSummaryText(item: JsonRecord): string { + if (!Array.isArray(item.summary)) return ""; + return item.summary + .map((part) => toString(toRecord(part).text)) + .filter((text) => text.length > 0) + .join("\n\n"); +} + +function appendReasoningContent(current: unknown, next: string): string { + const existing = typeof current === "string" ? current : ""; + return existing ? `${existing}\n\n${next}` : next; +} + /** * Convert OpenAI Responses API request to OpenAI Chat Completions format */ @@ -83,13 +96,13 @@ export function openaiResponsesToOpenAIRequest( credentials: unknown ): unknown { void stream; - void credentials; const collapseToPlainString = requiresPlainStringContent(extractProviderHint(model)); const root = toRecord(body); if (root.input === undefined) return body; const credentialRecord = toRecord(credentials); const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData); + const preserveReasoningContent = credentialRecord._preserveReasoningContent === true; const rawInputItems = normalizeResponsesInputForChat(root.input); // Tools may be declared at the Responses top level or in one or more @@ -204,6 +217,7 @@ export function openaiResponsesToOpenAIRequest( // Group items by conversation turn let currentAssistantMsg: JsonRecord | null = null; let pendingToolResults: JsonRecord[] = []; + let pendingReasoningContent = ""; // Upstream providers reject messages:[] with "400: at least one message is required". // When the client sends input:[] (empty), inject a placeholder user message — mirrors @@ -220,11 +234,20 @@ export function openaiResponsesToOpenAIRequest( const itemType = toString(item.type) || (item.role ? "message" : ""); if (itemType === "message") { + const role = toString(item.role); // Flush pending assistant message with tool calls if (currentAssistantMsg) { messages.push(currentAssistantMsg); currentAssistantMsg = null; } + if (role !== "assistant" && pendingReasoningContent) { + messages.push({ + role: "assistant", + content: null, + reasoning_content: pendingReasoningContent, + }); + pendingReasoningContent = ""; + } // Flush pending tool results if (pendingToolResults.length > 0) { @@ -269,7 +292,12 @@ export function openaiResponsesToOpenAIRequest( }) : item.content; - messages.push({ role: toString(item.role), content }); + const message: JsonRecord = { role, content }; + if (role === "assistant" && pendingReasoningContent) { + message.reasoning_content = pendingReasoningContent; + pendingReasoningContent = ""; + } + messages.push(message); continue; } @@ -294,6 +322,10 @@ export function openaiResponsesToOpenAIRequest( content: null, tool_calls: [], }; + if (pendingReasoningContent) { + currentAssistantMsg.reasoning_content = pendingReasoningContent; + pendingReasoningContent = ""; + } } const toolCalls = Array.isArray(currentAssistantMsg.tool_calls) @@ -353,6 +385,10 @@ export function openaiResponsesToOpenAIRequest( content: null, tool_calls: [], }; + if (pendingReasoningContent) { + currentAssistantMsg.reasoning_content = pendingReasoningContent; + pendingReasoningContent = ""; + } } const toolCalls = Array.isArray(currentAssistantMsg.tool_calls) ? currentAssistantMsg.tool_calls @@ -401,7 +437,21 @@ export function openaiResponsesToOpenAIRequest( } if (itemType === "reasoning") { - // Skip reasoning items - they are display-only metadata + // Responses reasoning summaries are normally display metadata. Preserve them only + // when the routed upstream explicitly requires prior reasoning to continue a turn. + if (preserveReasoningContent) { + const reasoning = getReasoningSummaryText(item); + if (reasoning) { + if (currentAssistantMsg) { + currentAssistantMsg.reasoning_content = appendReasoningContent( + currentAssistantMsg.reasoning_content, + reasoning + ); + } else { + pendingReasoningContent = appendReasoningContent(pendingReasoningContent, reasoning); + } + } + } continue; } @@ -430,6 +480,13 @@ export function openaiResponsesToOpenAIRequest( if (currentAssistantMsg) { messages.push(currentAssistantMsg); } + if (pendingReasoningContent) { + messages.push({ + role: "assistant", + content: null, + reasoning_content: pendingReasoningContent, + }); + } if (pendingToolResults.length > 0) { for (const toolResult of pendingToolResults) { messages.push(toolResult); @@ -752,8 +809,19 @@ export function openaiResponsesToOpenAIRequest( delete result.prompt_cache_retention; if (namespaceToolIdentityMap.size > 0) { - // chatCore extracts and deletes this transient side channel before dispatch. + // chatCore extracts and deletes these transient side channels before dispatch. // Non-enumerability keeps internal request metadata off the upstream wire. + // + // Two properties on purpose (#9780): `_toolNameMap` is also the alias + // channel for openai-to-claude/gemini, which overwrite it on a pivot, so + // the identity map needs a name of its own. `_toolNameMap` stays populated + // for the existing consumers (executors/base.ts, cliproxyapi, antigravity). + Object.defineProperty(result, "_namespaceToolIdentityMap", { + value: namespaceToolIdentityMap, + enumerable: false, + configurable: true, + writable: true, + }); Object.defineProperty(result, "_toolNameMap", { value: namespaceToolIdentityMap, enumerable: false, diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index d459350d35..1fc6153804 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -31,6 +31,19 @@ import { // normalizeUpstreamFailure is re-exported for external importers (tests). export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts"; +/** Carries escapeJsonStringValues's scan state (whether we're inside a JSON + * string, and whether the fragment ended mid-escape-sequence) across calls + * for the SAME tool call — see escapeJsonStringValues's own doc comment for + * why this must persist across chunks rather than reset per call. */ +interface JsonStringEscapeState { + inString: boolean; + pendingEscape: boolean; +} + +function createJsonStringEscapeState(): JsonStringEscapeState { + return { inString: false, pendingEscape: false }; +} + /** * Escape control characters (newlines, tabs, carriage returns) that appear * inside JSON string values, ensuring the resulting string is valid JSON. @@ -38,18 +51,42 @@ export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts"; * newlines (0x0A) instead of \n escapes inside tool call argument JSON. * Only escapes characters inside string contexts to avoid double-escaping * already-proper JSON or corrupting structural newlines. + * + * `arguments` deltas arrive as arbitrary fragments of one continuous JSON + * string (OpenAI's Chat Completions streaming contract only guarantees each + * `tool_calls[].function.arguments` delta is the next slice, not that it + * starts/ends on a quote or escape boundary) — a large multi-line argument + * value routinely gets split mid-string. `escapeState` must therefore be the + * SAME object passed in on every call for a given tool call index, not a + * fresh `{inString: false}` each time: resetting per call made the + * in-string/out-of-string decision (and therefore whether a raw newline + * gets escaped) depend on where a chunk boundary happened to fall, which + * produced a real, reported bug — a single reassembled arguments string + * with a mix of real newlines and literal two-character `\n` sequences, + * breaking generated code (e.g. Python) that embeds multi-line content. */ -function escapeJsonStringValues(json: string): string { +function escapeJsonStringValues(json: string, escapeState: JsonStringEscapeState): string { let result = ""; - let inString = false; + let { inString, pendingEscape } = escapeState; for (let i = 0; i < json.length; i++) { const ch = json[i]; - // Inside a string, skip over escape sequences + // This char is the one immediately following a backslash from a + // previous iteration (possibly in a prior fragment) — it's already + // "consumed" by that escape sequence, pass it through untouched. + if (pendingEscape) { + result += ch; + pendingEscape = false; + continue; + } + + // Inside a string, an unescaped backslash starts an escape sequence — + // the char AFTER it (next iteration, possibly in the next fragment) + // must not be reinterpreted as a quote/control-char in its own right. if (inString && ch === "\\") { - result += ch + (json[i + 1] ?? ""); - i++; + result += ch; + pendingEscape = true; continue; } @@ -69,6 +106,8 @@ function escapeJsonStringValues(json: string): string { result += ch; } + escapeState.inString = inString; + escapeState.pendingEscape = pendingEscape; return result; } @@ -451,11 +490,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; @@ -471,6 +521,7 @@ function emitToolCall(state, emit, tc) { delete state.funcArgsDone[tcIdx]; delete state.funcItemAdded[tcIdx]; delete state.funcItemDone[tcIdx]; + delete state.funcArgsEscapeState?.[tcIdx]; } if (funcName) state.funcNames[tcIdx] = funcName; @@ -517,7 +568,14 @@ function emitToolCall(state, emit, tc) { if (tc.function?.arguments) { const refCallId = state.funcCallIds[tcIdx] || newCallId; const existingArgs = state.funcArgsBuf[tcIdx] || ""; - const sanitized = escapeJsonStringValues(tc.function.arguments); + if (!state.funcArgsEscapeState) state.funcArgsEscapeState = {}; + if (!state.funcArgsEscapeState[tcIdx]) { + state.funcArgsEscapeState[tcIdx] = createJsonStringEscapeState(); + } + const sanitized = escapeJsonStringValues( + tc.function.arguments, + state.funcArgsEscapeState[tcIdx] + ); const nextArgs = appendToolCallArgumentDelta(existingArgs, sanitized); const emittedDelta = nextArgs.slice(existingArgs.length); state.funcArgsBuf[tcIdx] = nextArgs; @@ -536,9 +594,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 = diff --git a/open-sse/translator/webTools.ts b/open-sse/translator/webTools.ts index 7ae5c7732b..ac3efbee14 100644 --- a/open-sse/translator/webTools.ts +++ b/open-sse/translator/webTools.ts @@ -356,18 +356,24 @@ export function toArgumentsString(value: unknown): string { } } -export interface SerializeToolOptions { - /** Hardened mode for thinking/reasoning models: repeat the instruction - * both before AND after the tool list, use a more distinctive tag format, - * and explicitly tell the model not to claim tools are unavailable. */ - hardened?: boolean; -} +/** + * Serialize an OpenAI `tools` array into a system-prompt block that instructs the + * web UI model how to invoke a tool (emit a `{...}` block). Returns an + * empty string when there are no usable tools. + * + * Each invocation generates a per-request nonce that is embedded in the tool format + * instructions. The parser (parseToolCallsFromText) requires this nonce in the model's + * `` JSON to distinguish legitimate tool calls from bare JSON, code-fenced JSON, + * or copy-attacked envelopes (#9343). + */ +export function serializeToolsToPrompt(tools: unknown): string { + if (!Array.isArray(tools) || tools.length === 0) return ""; -// ── Tool list rendering (shared between standard and hardened) ───────────────── + const nonce = getToolNonce(tools); + if (!nonce) return ""; -function renderToolList(tools: OpenAIToolDef[]): string[] { const lines: string[] = []; - for (const t of tools) { + for (const t of tools as OpenAIToolDef[]) { const fn = t?.function; if (!fn?.name) continue; const desc = typeof fn.description === "string" && fn.description ? fn.description : ""; @@ -381,52 +387,19 @@ function renderToolList(tools: OpenAIToolDef[]): string[] { `- ${fn.name}${desc ? `: ${desc}` : ""}${params ? `\n parameters: ${params}` : ""}` ); } - return lines; -} -/** - * Serialize an OpenAI `tools` array into a system-prompt block that instructs the - * web UI model how to invoke a tool (emit a `{...}` block). Returns an - * empty string when there are no usable tools. - * - * When `options.hardened` is set (intended for thinking/reasoning models), the - * contract is more emphatic: the `` format example is shown before the tool - * list, an explicit "IMPORTANT" directive is appended after the list, and the - * model is told not to claim tools are unavailable. - */ -export function serializeToolsToPrompt(tools: unknown, options?: SerializeToolOptions): string { - if (!Array.isArray(tools) || tools.length === 0) return ""; - - // #9343: the per-request nonce is mandatory in BOTH modes — the parser rejects - // any JSON without the matching `_nonce` binding. - const nonce = getToolNonce(tools); - if (!nonce) return ""; - - const defs = tools as OpenAIToolDef[]; - const lines = renderToolList(defs); if (lines.length === 0) return ""; - if (options?.hardened) { - return [ - "You have access to the following tools and you MUST use them when appropriate.", - "", - `{"name": "", "arguments": { ... }, "_nonce": "${nonce}"}`, - `Every tool call MUST include the secret binding "_nonce": "${nonce}" exactly as shown.`, - "", - "Available tools:", - ...lines, - "", - "IMPORTANT: You CAN and MUST use these tools. Do NOT say you cannot use tools or that", - "tools are unavailable — you have them and they are ready. If a task requires a tool,", - "call it using the TOOL block format described above.", - ].join("\n"); - } - return [ - "You can call tools. To call a tool, reply with a single line containing a block", + "The client application provides tools beyond your built-in ones. They are NOT in your " + + "native tool registry; they are invoked via a plain-text protocol: the client parses " + + "your reply and executes the tool on the user machine. Treat these client tools as " + + "fully available to you; never claim they are unavailable. To invoke one, reply with " + + "a single line containing a block", `with JSON that includes the secret binding "_nonce": "${nonce}":`, `{"name": "", "arguments": { ... }, "_nonce": "${nonce}"}`, - "Only emit the block when you actually want to call a tool; otherwise answer normally.", + "These client tools ARE available to you in this conversation. Only emit the " + + "block when you actually want to call a tool; otherwise answer normally.", "", "Available tools:", ...lines, @@ -457,10 +430,7 @@ export function parseToolCallsFromText( requestedTools?: unknown ): { content: string; toolCalls: OpenAIToolCall[] | null } { const requestedToolNames = getRequestedToolNames(requestedTools); - if ( - typeof text !== "string" || - (!text.includes("") && !text.includes("") && !text.includes("; } +/** One-line nudge appended to the latest user message. Web-UI models weigh the + * current user turn far more heavily than a large system block, and ChatGPT's + * injection heuristics distrust long instructions embedded in user content — + * so the full contract stays in the system block (trailing, see below) and the + * user turn only carries a short pointer back to it, naming the tools. */ +function buildToolReminder(toolPrompt: string): string { + const names = (toolPrompt.match(/^- [^:\n]+/gm) || []).map((s) => s.slice(2).trim()).join(", "); + return ( + "\n\n[Client protocol reminder: the client-tool contract in the system instructions " + + "is active in this conversation. These client tools ARE available via the " + + "block protocol" + + (names ? ": " + names : "") + + ".]" + ); +} + /** - * Extract tools from an OpenAI request body and prepend a tool-system-prompt - * to the messages array when tools are present. Every web-cookie executor - * that wants tool-call support calls this once before building its upstream - * request body. + * Extract tools from an OpenAI request body and inject the tool contract when + * tools are present. Every web-cookie executor that wants tool-call support + * calls this once before building its upstream request body. + * + * Placement matters: the contract used to be PREPENDED as the first system + * message. Executors fold all system messages into one block, so with agentic + * clients whose system prompts exceed ~28K chars the contract sat at the head + * of a huge block and web models (chatgpt-web observed) ignored it, answering + * "tool X is not in my tool set" instead of emitting blocks. Dual + * placement fixes it: the full contract goes AFTER the client messages (folds + * to the tail of the system block) and a one-line reminder rides at the end of + * the latest user message. Measured on cgpt-web/gpt-5.5-thinking with a + * 30K-char system prompt: prepend 0/3 tool calls, dual placement 16/17 across + * 30K-250K prompts, 30-tool sets, multi-turn tool history, and streaming. */ export function prepareToolMessages( bodyObj: Record, - messages: Array<{ role: string; content: unknown }>, - options?: SerializeToolOptions + messages: Array<{ role: string; content: unknown }> ): ToolPrepResult { const requestedTools = bodyObj.tools; const hasTools = Array.isArray(requestedTools) && requestedTools.length > 0; if (!hasTools) return { hasTools: false, requestedTools, effectiveMessages: messages }; - const toolPrompt = serializeToolsToPrompt(requestedTools, options); - return { - hasTools: true, - requestedTools, - effectiveMessages: [{ role: "system", content: toolPrompt }, ...messages], - }; + const toolPrompt = serializeToolsToPrompt(requestedTools); + if (!toolPrompt) return { hasTools: true, requestedTools, effectiveMessages: messages }; + + const effectiveMessages = [...messages]; + const reminder = buildToolReminder(toolPrompt); + for (let i = effectiveMessages.length - 1; i >= 0; i--) { + const msg = effectiveMessages[i]; + if (msg?.role !== "user") continue; + if (typeof msg.content === "string") { + effectiveMessages[i] = { ...msg, content: msg.content + reminder }; + } else if (Array.isArray(msg.content)) { + effectiveMessages[i] = { + ...msg, + content: [...msg.content, { type: "text", text: reminder }], + }; + } + break; + } + effectiveMessages.push({ role: "system", content: toolPrompt }); + return { hasTools: true, requestedTools, effectiveMessages }; } interface ToolCompletionResult { diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index 106c29ecba..a38085ae9e 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -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; - // 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 diff --git a/open-sse/utils/cursorAgentProtobuf/imageEncoding.ts b/open-sse/utils/cursorAgentProtobuf/imageEncoding.ts new file mode 100644 index 0000000000..efb7fc6376 --- /dev/null +++ b/open-sse/utils/cursorAgentProtobuf/imageEncoding.ts @@ -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 +): 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); +} diff --git a/open-sse/utils/cursorImages.ts b/open-sse/utils/cursorImages.ts index 29e669f57e..1ac6fbafd1 100644 --- a/open-sse/utils/cursorImages.ts +++ b/open-sse/utils/cursorImages.ts @@ -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). @@ -30,14 +30,56 @@ 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. +type SharpFactory = (typeof import("sharp"))["default"]; + +let sharpFactoryPromise: Promise | undefined; + +function loadSharp(): Promise { + sharpFactoryPromise ??= import("sharp").then((module) => module.default); + return sharpFactoryPromise; +} + +/** 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 +106,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:[][;base64], const comma = url.indexOf(","); @@ -86,16 +147,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 +170,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 +287,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 +320,7 @@ async function readCapped(response: Response, cap: number): Promise { 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 +355,312 @@ async function readCapped(response: Response, cap: number): Promise { // 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 { + const sharp = await loadSharp(); + 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 => { + 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 { +export async function resolveCursorImages( + imageUrls: string[], + options?: { detail?: string } +): Promise { 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 +675,27 @@ export async function resolveCursorImages(imageUrls: string[]): Promise 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 +705,11 @@ export async function resolveCursorImages(imageUrls: string[]): Promise) stack.push({ t: "v", v: (frame.o as Record)[next.value] }); } -export function estimateSizeFast(value: unknown): number { +/** + * @param byteLimit - early-exit threshold (default ESTIMATE_SIZE_BYTE_LIMIT, + * 256 KiB). Pass the actual threshold you're comparing against (see + * chatCore/logTruncation.ts::truncateForLog) so raising that threshold + * doesn't silently cap what this function is even capable of reporting — + * the byte check and the node-budget fail-closed fallback both key off this + * value, not the fixed module constant, when a caller supplies one. + */ +export function estimateSizeFast(value: unknown, byteLimit = ESTIMATE_SIZE_BYTE_LIMIT): number { let bytes = 0; let visitsLeft = ESTIMATE_SIZE_NODE_BUDGET; const seen = new WeakSet(); const stack: Frame[] = [{ t: "v", v: value }]; while (stack.length > 0) { - if (visitsLeft <= 0) return ESTIMATE_SIZE_BYTE_LIMIT + 1; + if (visitsLeft <= 0) return byteLimit + 1; const frame = stack.pop()!; if (!isValueFrame(frame)) { @@ -96,7 +109,7 @@ export function estimateSizeFast(value: unknown): number { const ty = typeof v; if (ty === "string" || ty === "number" || ty === "boolean") { bytes = addPrimitiveBytes(bytes, v as string | number | boolean); - if (bytes > ESTIMATE_SIZE_BYTE_LIMIT) return bytes; + if (bytes > byteLimit) return bytes; continue; } if (ty === "object") { diff --git a/open-sse/utils/functionalGatewayMirrors.ts b/open-sse/utils/functionalGatewayMirrors.ts index 5a8cbca65d..2620980153 100644 --- a/open-sse/utils/functionalGatewayMirrors.ts +++ b/open-sse/utils/functionalGatewayMirrors.ts @@ -19,6 +19,8 @@ export const FUNCTIONAL_GATEWAY_MIRROR_SUFFIX = " (via "; +const FUNCTIONAL_GATEWAY_MIRROR = Symbol("functionalGatewayMirror"); + export interface FunctionalGatewayMirrorsDeps { /** Ordered list of passthrough gateway provider ids to consider as mirrors. */ gatewayProviderIds: string[]; @@ -40,9 +42,14 @@ interface GatewayMirrorCatalogEntry { root?: unknown; name?: unknown; display_name?: unknown; + [FUNCTIONAL_GATEWAY_MIRROR]?: true; [key: string]: unknown; } +export function isFunctionalGatewayMirror(model: GatewayMirrorCatalogEntry): boolean { + return model?.[FUNCTIONAL_GATEWAY_MIRROR] === true; +} + /** * Append `/` mirror entries for every eligible model. * Returns the original array reference unchanged when nothing is eligible. @@ -88,14 +95,14 @@ export function appendFunctionalGatewayMirrors; @@ -30,24 +32,6 @@ const THINKING_MODEL_PATTERNS: RegExp[] = [ /\bmimo\b/i, // xiaomi-tokenplan mimo family (e.g. xiaomi-tokenplan/mimo-v2.5-pro) ]; -const AUTHENTIC_REASONING_MODEL_PATTERN = /(?:^|\/)kimi-k(?:3|2\.7-code)(?:$|-)/i; - -/** - * Native Moonshot K3/K2.7 replay must use the original reasoning content. - * A fabricated placeholder changes preserved-thinking history and is not a - * valid substitute when the client and reasoning cache both lack the field. - */ -export function requiresAuthenticReasoningContent(provider: unknown, model: unknown): boolean { - const normalizedProvider = String(provider ?? "") - .trim() - .toLowerCase(); - const normalizedModel = String(model ?? "").trim(); - return ( - (normalizedProvider === "moonshot" || normalizedProvider === "kimi") && - AUTHENTIC_REASONING_MODEL_PATTERN.test(normalizedModel) - ); -} - export function isThinkingMessageModel(model: string | undefined | null): boolean { if (!model || typeof model !== "string") return false; return THINKING_MODEL_PATTERNS.some((re) => re.test(model)); @@ -62,7 +46,11 @@ export function shouldInjectReasoningContentPlaceholder( .toLowerCase(); return ( (normalizedProvider === "moonshot" || normalizedProvider === "kimi") && - !requiresAuthenticReasoningContent(normalizedProvider, model) && + !requiresReasoningReplay({ + provider: normalizedProvider, + model: String(model ?? ""), + allowLegacyFallback: false, + }) && isThinkingMessageModel(model) ); } diff --git a/open-sse/utils/reasoningFields.ts b/open-sse/utils/reasoningFields.ts index a8b858e25e..21fc22cab1 100644 --- a/open-sse/utils/reasoningFields.ts +++ b/open-sse/utils/reasoningFields.ts @@ -62,10 +62,38 @@ export function hasAnyReasoningSignal(value: unknown): boolean { ); } +const STRIPPABLE_REASONING_FIELDS = [ + "reasoning_content", + "reasoning", + "reasoning_text", + "thinking", + "thought", +] as const; + +/** + * Strip the internal replay placeholder from a single string reasoning field, + * deleting the field when nothing meaningful remains. Returns true only when a + * present string field was fully stripped to empty (absent/non-string fields + * return false so callers can distinguish "removed" from "never had text"). + */ +function stripPlaceholderFromField(target: JsonRecord, field: string): boolean { + const value = target[field]; + if (typeof value !== "string") return false; + const stripped = stripInternalReasoningPlaceholder(value); + if (stripped === "") { + delete target[field]; + return true; + } + if (stripped !== value) target[field] = stripped; + return false; +} + export function copyOpenAICompatibleReasoningFields(source: JsonRecord, target: JsonRecord) { if (source.reasoning_content !== undefined) target.reasoning_content = source.reasoning_content; if (source.reasoning !== undefined) target.reasoning = source.reasoning; if (source.reasoning_text !== undefined) target.reasoning_text = source.reasoning_text; + if (source.thinking !== undefined) target.thinking = source.thinking; + if (source.thought !== undefined) target.thought = source.thought; if (Array.isArray(source.reasoning_details)) target.reasoning_details = source.reasoning_details; if (!getReadableReasoningValue(target)) { const mirrored = getUnsupportedReasoningValue(source); @@ -73,15 +101,31 @@ export function copyOpenAICompatibleReasoningFields(source: JsonRecord, target: } // ponytail: the internal replay placeholder is request scaffolding, never // real reasoning — models echo it and it poisons client history + the cache - // (#8081 echo). Strip it from anything we forward to the client. - if (typeof target.reasoning_content === "string") { - const stripped = stripInternalReasoningPlaceholder(target.reasoning_content); - if (stripped === "") delete target.reasoning_content; - else if (stripped !== target.reasoning_content) target.reasoning_content = stripped; + // (#8081 echo). Strip it from anything we forward to the client, including + // non-standard reasoning fields (reasoning_text / thinking / thought) and + // reasoning_details items that non-OpenAI-compatible upstreams (e.g. + // Venice) use (#9765 uncovered path). + for (const field of STRIPPABLE_REASONING_FIELDS) { + stripPlaceholderFromField(target, field); } - if (typeof target.reasoning === "string") { - const stripped = stripInternalReasoningPlaceholder(target.reasoning); - if (stripped === "") delete target.reasoning; - else if (stripped !== target.reasoning) target.reasoning = stripped; + if (Array.isArray(target.reasoning_details)) { + const cleaned: unknown[] = []; + for (const detail of target.reasoning_details) { + const record = asReasoningRecord(detail); + const next: JsonRecord = { ...record }; + // Track whether the item originally carried text/content at all so + // non-text details (e.g. `reasoning.encrypted` carrying only `data`) + // survive untouched. + const hadText = typeof next.text === "string"; + const hadContent = typeof next.content === "string"; + stripPlaceholderFromField(next, "text"); + stripPlaceholderFromField(next, "content"); + const textGone = next.text === undefined; + const contentGone = next.content === undefined; + if ((hadText || hadContent) && textGone && contentGone) continue; + cleaned.push(next); + } + if (cleaned.length === 0) delete target.reasoning_details; + else target.reasoning_details = cleaned; } } diff --git a/open-sse/utils/requestLogger.ts b/open-sse/utils/requestLogger.ts index f2ef74e84e..79a307c688 100644 --- a/open-sse/utils/requestLogger.ts +++ b/open-sse/utils/requestLogger.ts @@ -1,4 +1,5 @@ import { getPendingById } from "@/lib/usage/usageHistory"; +import { getChatLogMaxDepth } from "@/lib/logEnv"; import { sanitizeErrorMessage } from "./error.ts"; type JsonRecord = Record; @@ -148,7 +149,7 @@ export function cloneBoundedForLog(value: unknown, depth = 0, key: string | null if (ArrayBuffer.isView(value)) { return `[binary ${(value as ArrayBufferView).byteLength} bytes]`; } - if (depth >= 6) return "[MaxDepth]"; + if (depth >= getChatLogMaxDepth()) return "[MaxDepth]"; if (Array.isArray(value)) { // Idempotence (#7847): an already-bounded array is [marker, ...tail] — MAX_LOG_ARRAY_ITEMS + 1 diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index ca6e9efe53..b1f8d1d34b 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -23,6 +23,7 @@ import { hasActiveDeltaValue, injectThinkingSignature, } from "./streamHelpers.ts"; +import { rejectEmptyChoicesStream, buildEmptyChoicesStreamError } from "./streamEmptyChoices.ts"; import { calculateCost } from "@/lib/usage/costCalculator"; import { buildOmniRouteSseMetadataComment } from "@/domain/omnirouteResponseMeta"; import { sseCommentsEnabled } from "./sseHeartbeat.ts"; @@ -725,6 +726,9 @@ export function createSSEStream(options: StreamOptions = {}) { } : null; + // Tracks whether any valuable chunk was forwarded; empty at flush => retryable 502 (#9268) + let forwardedValuableChunk = false; + // Track content length for usage estimation (both modes) let totalContentLength = 0; // Passthrough: accumulate content and reasoning separately for call log response body @@ -995,6 +999,7 @@ export function createSSEStream(options: StreamOptions = {}) { const output = formatSSE(itemSanitized, sourceFormat); clientPayloadCollector.push(itemSanitized); reqLogger?.appendConvertedChunk?.(output); + forwardedValuableChunk = true; controller.enqueue(encoder.encode(output)); }; @@ -2619,6 +2624,27 @@ export function createSSEStream(options: StreamOptions = {}) { return; } + // #9268: reject a translate-mode stream that forwarded no valuable chunk + // (all-empty `choices: []`) instead of completing with an empty 200. + if ( + mode === STREAM_MODE.TRANSLATE && + rejectEmptyChoicesStream({ + forwardedValuableChunk, + hasValidUsage: hasValidUsage(state?.usage), + providerPayloadCollector, + clientPayloadCollector, + targetFormat, + model, + usage: state?.usage, + onFailure, + onComplete, + clearPendingRequestFromStream, + }) + ) { + controller.error(markPendingRequestCleared(buildEmptyChoicesStreamError())); + return; + } + // Flush remaining events (only once at stream end) const flushed = translateResponse(targetFormat, sourceFormat, null, state); diff --git a/open-sse/utils/streamEmptyChoices.ts b/open-sse/utils/streamEmptyChoices.ts new file mode 100644 index 0000000000..05f8d8e4b9 --- /dev/null +++ b/open-sse/utils/streamEmptyChoices.ts @@ -0,0 +1,123 @@ +/** + * Empty-stream rejection for the SSE transform (#9268). + * + * A streaming provider can complete a turn having forwarded nothing usable — + * every chunk carried an empty `choices: []` (no content, no tool_calls, no + * finish_reason, e.g. a Gemini turn where the model emitted nothing). The SSE + * transform drops those chunks silently, so without a guard the stream would + * terminate with a clean empty 200, which clients treat as a valid empty turn + * and retry to their cap with no error to stop on. + * + * The transform is the only place that knows a chunk was actually forwarded, so + * `createSSEStream` threads a `forwardedValuableChunk` boolean and the + * flush-time callbacks. All rejection logic lives here so the frozen + * `open-sse/utils/stream.ts` only carries the minimal call-site wiring. + * + * Mirrors the non-streaming `isEmptyContentResponse` behavior in + * `open-sse/handlers/chatCore.ts` (empty content → retryable 502), and the + * #8649 disconnect-aware wrapper's "Provider returned empty content" outcome. + */ +import { buildErrorBody } from "./error.ts"; +import { buildStreamSummaryFromEvents } from "./streamPayloadCollector.ts"; + +type StructuredSSEEventLike = { + index: number; + timestamp?: string; + event?: string; + data: unknown; +}; + +type StructuredSSECollectorLike = { + getEvents: () => StructuredSSEEventLike[]; + build: (summary?: unknown, opts?: { includeEvents?: boolean }) => unknown; +}; + +type EmptyChoicesRejectContext = { + /** True when any chunk with content/tool_calls/finish_reason was forwarded. */ + forwardedValuableChunk: boolean; + /** Valid usage accumulated on the stream state (usage-only streams are fine). */ + hasValidUsage: boolean; + /** Provider-side event collector (for the onComplete providerPayload summary). */ + providerPayloadCollector: StructuredSSECollectorLike; + /** Client-side payload collector (for the onComplete clientPayload). */ + clientPayloadCollector: StructuredSSECollectorLike; + targetFormat?: string; + model?: string | null; + usage?: unknown; + onFailure?: ((payload: { + status: number; + message: string; + code?: string; + type?: string; + }) => boolean | void | Promise) | null; + onComplete?: ((payload: { + status: number; + usage: unknown; + responseBody?: unknown; + providerPayload?: unknown; + clientPayload?: unknown; + error?: string | null; + errorCode?: string | null; + }) => void) | null; + clearPendingRequestFromStream?: () => void; +}; + +/** + * Returns `true` when the empty-stream condition was detected and the caller + * must abort the stream (controller.error + early return); `false` when the + * stream legitimately forwarded content/usage and should complete normally. + */ +export function rejectEmptyChoicesStream(ctx: EmptyChoicesRejectContext): boolean { + if (ctx.forwardedValuableChunk || ctx.hasValidUsage) return false; + + const error = new Error( + "Provider returned empty content — stream forwarded no valuable chunks" + ) as Error & { statusCode: number; code: string }; + error.statusCode = 502; + error.code = "empty_content"; + + if (ctx.onFailure) { + try { + ctx.onFailure({ status: 502, message: error.message, code: "empty_content" }); + } catch { + // best-effort — must never break the stream error path + } + } + + const errorBody = buildErrorBody(502, error.message); + if (ctx.onComplete) { + try { + ctx.onComplete({ + status: 502, + usage: ctx.usage, + responseBody: errorBody, + error: error.message, + errorCode: "empty_content", + providerPayload: ctx.providerPayloadCollector.build( + buildStreamSummaryFromEvents( + ctx.providerPayloadCollector.getEvents(), + ctx.targetFormat, + ctx.model + ), + { includeEvents: false } + ), + clientPayload: ctx.clientPayloadCollector.build(errorBody, { includeEvents: false }), + }); + } catch { + // best-effort + } + } + + ctx.clearPendingRequestFromStream?.(); + return true; +} + +/** The retryable error the caller should surface via controller.error. */ +export function buildEmptyChoicesStreamError(): Error & { statusCode: number; code: string } { + const error = new Error( + "Provider returned empty content — stream forwarded no valuable chunks" + ) as Error & { statusCode: number; code: string }; + error.statusCode = 502; + error.code = "empty_content"; + return error; +} diff --git a/open-sse/utils/streamFailureFinalization.ts b/open-sse/utils/streamFailureFinalization.ts index dfd4def6be..7d4e57ffba 100644 --- a/open-sse/utils/streamFailureFinalization.ts +++ b/open-sse/utils/streamFailureFinalization.ts @@ -29,6 +29,59 @@ export type PipelineStreamErrorHandler = (event: { statusCode: number; }) => boolean; +export type ClientDisconnectEvent = { reason: string; duration: number }; + +/** + * #9653: a client that closes its connection right after reading a fully-completed + * SSE stream can race the stream's own completion bookkeeping — the bytes already + * reached the client, but the transform stream's completion callback (which flips + * `isStreamCompletionRecorded()` to true) hasn't finished bubbling up yet when the + * disconnect handler fires. Persisting immediately in that case records a false + * 499 with zero token usage for a request that actually delivered its full response. + * + * This wraps a disconnect finalizer with a grace period: instead of finalizing + * immediately, poll `isStreamCompletionRecorded()` until it flips true (a real + * completion landed — nothing more to do) or the deadline passes (genuinely gone — + * finalize as a 499 same as before). Pass `gracePeriodMs <= 0` to disable and + * finalize immediately, matching the pre-#9653 behavior. + */ +export function createClientDisconnectGraceHandler({ + isStreamCompletionRecorded, + gracePeriodMs, + finalize, + pollIntervalMs = 250, + setTimeoutFn = setTimeout, +}: { + isStreamCompletionRecorded: () => boolean; + gracePeriodMs: number; + finalize: (event: ClientDisconnectEvent) => unknown; + pollIntervalMs?: number; + setTimeoutFn?: (callback: () => void, ms: number) => unknown; +}): (event: ClientDisconnectEvent) => boolean { + return (event) => { + if (isStreamCompletionRecorded()) return true; + if (gracePeriodMs <= 0) { + finalize(event); + return true; + } + + const deadline = Date.now() + gracePeriodMs; + const poll = () => { + if (isStreamCompletionRecorded()) return; + if (Date.now() >= deadline) { + finalize(event); + return; + } + setTimeoutFn(poll, pollIntervalMs); + }; + setTimeoutFn(poll, pollIntervalMs); + + // Claim "handled" immediately so the caller's own immediate-finalize fallback + // doesn't fire while the grace-period poll is still pending. + return true; + }; +} + export function finalizeStreamRequestLog({ pendingRequestId, model, @@ -107,9 +160,7 @@ export function createStreamFailureFinalizers({ const message = failure.message || "Upstream stream error"; const code = failure.code || failure.type || String(status); const classification = - failure.code || failure.type - ? { code: failure.code, type: failure.type } - : undefined; + failure.code || failure.type ? { code: failure.code, type: failure.type } : undefined; if (!isFailureCompletionRecorded()) { const errorBody = buildErrorBody(status, message, undefined, classification); diff --git a/package-lock.json b/package-lock.json index 5fa43f17a8..02db692d69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "bottleneck": "^2.19.5", "clsx": "^2.1.1", "commander": "^15.0.0", + "cron-parser": "^5.6.2", "csv-stringify": "^6.7.0", "dompurify": "^3.4.13", "express": "^5.2.1", @@ -73,6 +74,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", @@ -3560,7 +3562,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" } @@ -3693,6 +3694,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3709,6 +3713,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3725,6 +3732,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3741,6 +3751,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3757,6 +3770,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3773,6 +3789,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3789,6 +3808,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3805,6 +3827,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3821,6 +3846,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3843,6 +3871,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3865,6 +3896,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3887,6 +3921,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3909,6 +3946,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3931,6 +3971,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3953,6 +3996,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3975,6 +4021,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5346,6 +5395,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5362,6 +5414,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5378,6 +5433,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5394,6 +5452,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -10611,6 +10672,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -10628,6 +10692,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -10645,6 +10712,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -10662,6 +10732,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -12708,6 +12781,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -12721,6 +12797,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -12734,6 +12813,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -12747,6 +12829,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -12760,6 +12845,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -12773,6 +12861,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -13598,14 +13689,11 @@ } }, "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } + "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", @@ -13670,9 +13758,9 @@ } }, "node_modules/better-sqlite3": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", - "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", "license": "MIT", "optional": true, "dependencies": { @@ -13954,16 +14042,14 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/braces": { @@ -15794,6 +15880,18 @@ "node": ">= 6" } }, + "node_modules/cron-parser": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.7.0.tgz", + "integrity": "sha512-iSpDHpwwW/GhIg4JVODYlWUEpMNSimaHvqOhHpOz1W+Y97z1lL1nf+dpcF17cNwFRpTtKN9devgi1fxflp3Phw==", + "license": "MIT", + "dependencies": { + "luxon": "^3.7.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -24392,25 +24490,6 @@ "node": ">= 14" } }, - "node_modules/libxmljs2/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/libxmljs2/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/libxmljs2/node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", @@ -25433,6 +25512,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -26883,24 +26971,6 @@ "node": "*" } }, - "node_modules/minimatch/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -32174,13 +32244,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/rimraf/node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -32778,7 +32841,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", @@ -32828,7 +32890,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" }, diff --git a/package.json b/package.json index b1430ec223..d0e6b13c25 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", "version": "3.8.50", - "description": "Unified AI router with 290 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", + "description": "Unified AI router with 291 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", @@ -36,9 +36,9 @@ "scripts/dev/sync-env.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/build-next-isolated.mjs", "scripts/build/runtime-env.mjs", "README.md", "LICENSE", @@ -225,7 +225,7 @@ "test:e2e": "node scripts/dev/run-playwright-tests.mjs test tests/e2e/*.spec.ts", "test:protocols:e2e": "node scripts/dev/run-protocol-clients-tests.mjs", "test:vitest": "vitest run --config vitest.mcp.config.ts", - "test:vitest:ui": "vitest run --config vitest.config.ts tests/unit/ui", + "test:vitest:ui": "vitest run --config vitest.config.ts", "test:mutation": "stryker run", "test:ecosystem": "node scripts/dev/run-ecosystem-tests.mjs", "test:system": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/e2e/system-failover.test.ts", @@ -268,6 +268,7 @@ "bottleneck": "^2.19.5", "clsx": "^2.1.1", "commander": "^15.0.0", + "cron-parser": "^5.6.2", "csv-stringify": "^6.7.0", "dompurify": "^3.4.13", "express": "^5.2.1", @@ -311,6 +312,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", @@ -419,7 +421,7 @@ }, "overrides": { "fast-xml-parser": "^5.10.1", - "sharp": "^0.35.0", + "sharp": "^0.35.3", "postcss": "^8.5.18", "ip-address": "^10.3.1", "qs": "^6.15.2", @@ -447,14 +449,27 @@ "adm-zip": "^0.6.0", "promptfoo": { "js-yaml": "^5.2.2", - "@apidevtools/json-schema-ref-parser": { - "js-yaml": "^4.3.1" - }, "undici": "^7.29.0" }, "socket.io-parser": "^4.2.7", "tar": "^7.5.21", - "nanoid": "^3.3.17", + "brace-expansion": "^5.0.9", + "minimatch": { + "brace-expansion": "^1.1.18" + }, + "libxmljs2": { + "minimatch": { + "brace-expansion": "^2.1.4" + } + }, + "rimraf": { + "minimatch": { + "brace-expansion": "^2.1.4" + } + }, + "@apidevtools/json-schema-ref-parser": { + "js-yaml": "^4.3.1" + }, "@eslint/eslintrc": { "js-yaml": "^4.3.1" }, @@ -464,11 +479,9 @@ "xmlbuilder2": { "js-yaml": "^4.3.1" }, + "nanoid": "^3.3.17", "monaco-editor": { "dompurify": "^3.4.13" - }, - "@apidevtools/json-schema-ref-parser": { - "js-yaml": "^4.3.1" } } } diff --git a/public/providers/openference.svg b/public/providers/openference.svg new file mode 100644 index 0000000000..525d9ae0a4 --- /dev/null +++ b/public/providers/openference.svg @@ -0,0 +1,5 @@ + + Openference + + + diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index 412fe7b079..5a528f09b6 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -48,10 +48,7 @@ import fs from "node:fs/promises"; import fsSync from "node:fs"; import path from "node:path"; -import { - colocateLlmlinguaOptionals, - SEED_PACKAGES, -} from "./colocateOptionals.mjs"; +import { colocateLlmlinguaOptionals, SEED_PACKAGES } from "./colocateOptionals.mjs"; /** * Check whether a path exists (async). @@ -78,7 +75,7 @@ async function exists(targetPath) { * (relative to projectRoot) and destination (relative to outDir) can be joined * for either path/platform. @type {{label:string, src:string[], dest:string[]}[]} */ -const NATIVE_ASSET_ENTRIES = [ +export const NATIVE_ASSET_ENTRIES = [ { label: "wreq-js native runtime", src: ["node_modules", "wreq-js", "rust"], @@ -90,14 +87,23 @@ const NATIVE_ASSET_ENTRIES = [ 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)", + label: "better-sqlite3 prebuilt native binaries", src: ["node_modules", "better-sqlite3", "prebuilds"], dest: ["node_modules", "better-sqlite3", "prebuilds"], }, + { + // onnxruntime-node's dist/binding.js dlopen()s a platform-specific + // libonnxruntime.so.1 shipped under bin/napi-v3/// — a + // *dynamic* native load Next.js's standalone file trace can't see (same + // blind spot class as the LLMLingua closure below, just for a .so instead + // of a JS import). Without this the standalone bundle boots with + // "Error: libonnxruntime.so.1: cannot open shared object file: No such + // file or directory" the first time transformers/llmlingua actually try + // to run ONNX inference. + label: "onnxruntime-node native binaries (libonnxruntime .so + .node addon)", + src: ["node_modules", "onnxruntime-node", "bin"], + dest: ["node_modules", "onnxruntime-node", "bin"], + }, { // 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 @@ -759,8 +765,7 @@ export function assembleStandalone({ rootDir: projectRoot, targetNodeModulesDir: path.join(resolvedOutDir, "node_modules"), seeds: [...SEED_PACKAGES, "@huggingface/transformers"], - log: (message) => - console.log(`[assembleStandalone] ${message.trim()}`), + log: (message) => console.log(`[assembleStandalone] ${message.trim()}`), }); } diff --git a/scripts/build/colocateOptionals.mjs b/scripts/build/colocateOptionals.mjs index 367a95c112..073a59fbed 100644 --- a/scripts/build/colocateOptionals.mjs +++ b/scripts/build/colocateOptionals.mjs @@ -157,9 +157,7 @@ export function colocateLlmlinguaOptionals({ if (!existsSync(targetNm)) { return { skipped: true, - reason: targetNodeModulesDir - ? "no target node_modules" - : "no standalone dist/node_modules", + reason: targetNodeModulesDir ? "no target node_modules" : "no standalone dist/node_modules", }; } @@ -198,9 +196,7 @@ export function colocateLlmlinguaOptionals({ }); copied++; } catch (err) { - log( - ` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}` - ); + log(` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`); } } diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index 2c65c2f6dd..76da82fb9e 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -49,6 +49,7 @@ export const INTENTIONALLY_INTERNAL = new Set([ "commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/* "compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config) "compressionDetailNormalizers", // db-internal: importado só por db/compression.ts (normalizeSessionDedupConfig/normalizeCcrConfig/buildDetailConfigDefaults/applyDetailConfigUpdate — normalizadores do detail-config split do compression.ts, #8404) + "connectionRuntimeState", // intentionally-internal: warmupScheduler sqlite/redis stores importam diretamente de @/lib/db/connectionRuntimeState (Rule #2) "vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2) "detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler) "discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery diff --git a/scripts/check/check-migration-numbering.mjs b/scripts/check/check-migration-numbering.mjs index 5e174bf0dc..35f9ffca60 100644 --- a/scripts/check/check-migration-numbering.mjs +++ b/scripts/check/check-migration-numbering.mjs @@ -42,12 +42,14 @@ export const KNOWN_DUPLICATE_VERSIONS = new Set([ // --------------------------------------------------------------------------- // ALLOWLIST 2 — gaps de sequência CONHECIDOS. -// Fonte: auditoria do disco (src/lib/db/migrations/) — a sequência pula 026 e 055. -// Estes números nunca tiveram arquivo físico (slots legados que viraram outros -// números via RENAMED_MIGRATION_COMPATIBILITY em migrationRunner.ts). Congelados -// para que o gate bloqueie apenas NOVOS buracos inexplicados na sequência. +// Fonte: auditoria do disco (src/lib/db/migrations/). Além dos slots legados, +// 143–145 estão reservados pelas migrations Radar que já existem na série +// empilhada. O job registry foi promovido de 139 para 146 pela tabela +// RENAMED_MIGRATION_COMPATIBILITY para não ocupar esses slots em trânsito. +// O stale-enforcement remove automaticamente cada reserva quando o arquivo +// correspondente aterrissar na release. // --------------------------------------------------------------------------- -export const KNOWN_GAPS = new Set(["026", "055", "121"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12) +export const KNOWN_GAPS = new Set(["026", "055", "121", "143", "144", "145"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12) function pad3(n) { return String(n).padStart(3, "0"); diff --git a/scripts/check/check-test-discovery.mjs b/scripts/check/check-test-discovery.mjs index 09ce751efe..aae091aa00 100644 --- a/scripts/check/check-test-discovery.mjs +++ b/scripts/check/check-test-discovery.mjs @@ -118,12 +118,38 @@ export const COLLECTORS = [ { glob: "src/shared/components/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] }, { glob: "src/shared/hooks/__tests__/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] }, { glob: "src/app/(dashboard)/**/__tests__/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] }, - // vitest.config.ts via test:vitest:ui (roda com path-filter `tests/unit/ui`, então o - // conjunto EFETIVO é a interseção do include `tests/unit/**/*.test.tsx` com o filtro) + // vitest.config.ts via test:vitest:ui. The script uses the config-wide include list. { - glob: "tests/unit/ui/**/*.test.tsx", + glob: "tests/unit/**/*.test.tsx", sources: ["package.json", "vitest.config.ts"], - anchors: { "package.json": "tests/unit/ui", "vitest.config.ts": "tests/unit/**/*.test.tsx" }, + anchors: { "package.json": "test:vitest:ui", "vitest.config.ts": "tests/unit/**/*.test.tsx" }, + }, + // vitest.config.ts include — open-sse/__tests__ files collected by vitest.config.ts. + // These were previously listed as orphans because the COLLECTORS only modelled the + // tests/unit/**/*.test.tsx include; the open-sse globs were missing. Both the top-level + // glob and the more-specific services sub-path glob from vitest.config.ts are listed so + // the drift-check anchors remain exact matches to the config file text. + { + glob: "open-sse/**/__tests__/**/*.test.ts", + sources: ["vitest.config.ts"], + anchors: { "vitest.config.ts": "open-sse/**/__tests__/**/*.test.ts" }, + }, + // vitest.config.ts include — src/lib/memory and src/lib/skills __tests__ collected by vitest.config.ts. + { + glob: "src/lib/memory/__tests__/**/*.test.ts", + sources: ["vitest.config.ts"], + anchors: { "vitest.config.ts": "src/lib/memory/__tests__/**/*.test.ts" }, + }, + { + glob: "src/lib/skills/__tests__/**/*.test.ts", + sources: ["vitest.config.ts"], + anchors: { "vitest.config.ts": "src/lib/skills/__tests__/**/*.test.ts" }, + }, + // vitest.config.ts include — single-file entry for the .test.ts encryption file. + { + glob: "tests/unit/encryption.test.ts", + sources: ["vitest.config.ts"], + anchors: { "vitest.config.ts": "tests/unit/encryption.test.ts" }, }, // Playwright — test:e2e (o script passa tests/e2e/*.spec.ts; testMatch **/*.spec.ts) { glob: "tests/e2e/*.spec.ts", sources: ["package.json"] }, diff --git a/scripts/check/check-test-runner-api.mjs b/scripts/check/check-test-runner-api.mjs index f99eda9cc4..e7c7adcd25 100644 --- a/scripts/check/check-test-runner-api.mjs +++ b/scripts/check/check-test-runner-api.mjs @@ -1,12 +1,17 @@ import fs from "node:fs"; import path from "node:path"; +import { pathToFileURL } from "node:url"; -// Dirs collected ONLY by vitest (vitest.mcp.config.ts include globs for .ts tests). -// Keep in sync with vitest.mcp.config.ts. A test here MUST import from "vitest". +// Dirs collected ONLY by Vitest (vitest.mcp.config.ts and vitest.config.ts). +// Keep in sync with both configs. A test here MUST import from "vitest". const VITEST_ONLY_DIRS = [ "tests/unit/autoCombo", "open-sse/services/autoCombo", "open-sse/mcp-server", + "open-sse/services/__tests__", + "open-sse/translator/helpers/__tests__", + "src/lib/memory/__tests__", + "src/lib/skills/__tests__", ]; function walk(dir, root, out = []) { @@ -47,7 +52,7 @@ export function findRunnerMismatches(root) { return bad; } -if (import.meta.url === `file://${process.argv[1]}`) { +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { const root = process.cwd(); const bad = findRunnerMismatches(root); if (bad.length) { diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index 05690e1eac..aa1082fef5 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -221,6 +221,17 @@ export const FULL_CI_SKIP = new Set(["check:pr-evidence", "check:codeql-ratchet" // Gates that need a specific env to behave like CI (else they compare against the wrong base). export const FULL_CI_ENV = { "check:test-masking": { GITHUB_BASE_REF: "main" } }; +const FULL_CI_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; +const FULL_CI_TIMEOUT_OVERRIDES_MS = { + // Measured at 19m38s on the loaded release-v3.8.50 devbox. The former generic + // 10m ceiling killed a green scan before it could report its result. + "check:test-masking": 30 * 60 * 1000, +}; + +export function fullCiTimeoutFor(gateId) { + return FULL_CI_TIMEOUT_OVERRIDES_MS[gateId] ?? FULL_CI_DEFAULT_TIMEOUT_MS; +} + /** * Parse a ci.yml text and return the ordered, de-duplicated list of gate commands to run. * Each entry: { id, job, args:["run",