diff --git a/.env.example b/.env.example index e0131b2f69..9b51173ac6 100644 --- a/.env.example +++ b/.env.example @@ -345,8 +345,23 @@ ALLOW_API_KEY_REVEAL=false # OMNIROUTE_CHAT_HEAVY_TOOL_COUNT=64 # Conservative string-size token estimate that classifies a request as heavyweight. Default 32000. # OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS=32000 -# Hard message-count cap; excess receives compact-required 413. Default 800. -# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=800 +# Optional opt-in hard message-count cap; excess receives compact-required 413 before +# compression can run. Unset/0 (the default) means no history cap: heap growth is bounded +# by OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT and the heap-pressure shed instead. Set a positive +# value only on memory-constrained deployments that need a hard ceiling. +# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=0 +# How long a heavy request waits for heavyweight capacity before a retryable 503. +# A short bounded wait serializes agent bursts instead of an instant 503; 0 = instant. +# Default 2000 (2s). +# OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=2000 +# Queued-bytes budget for the admission wait: bounds total buffered body bytes parked +# per lane so the wait cannot amplify the heap (#4380). Over-budget waits 503 immediately. +# Default 4194304 (4 MB). +# OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES=4194304 +# Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. Default 60000 (60s). +# OMNIROUTE_CHAT_VIRTUAL_TTL_MS=60000 +# Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). Default 64. +# OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS=64 # Hard cap (bytes) for a non-streaming upstream response buffered fully into memory # (#5152). Past this the upstream reader is cancelled and the request fails fast @@ -641,6 +656,9 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Reduces risk of JA3/JA4 fingerprint-based blocking by providers (e.g., Google). # Used by: open-sse/executors — replaces Node.js default TLS fingerprint. # ENABLE_TLS_FINGERPRINT=true +# New proxied TLS routing requires an explicit, comma-separated provider allowlist. +# Direct TLS keeps its legacy behavior when this is unset. +# TLS_FINGERPRINT_PROVIDERS=codex,openai # Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. # Only enable for local debugging or trusted MITM/corporate proxy environments. @@ -794,6 +812,16 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500 # Disable the proactive recovery scheduler entirely (default: false). # OMNIROUTE_DISABLE_CONNECTION_RECOVERY=false +# Proactive Claude warmup scheduler (#8848): fires a trivial request to opted-in +# OAuth connections on a cron schedule (America/Los_Angeles) so accounts do not +# hit the 5-hour sliding window cold. Off by default — set ENABLED=1 and flip +# per-connection flags in settings.claudeWarmup.connections to activate. +# Used by: src/lib/warmupScheduler.ts. +# OMNIROUTE_WARMUP_ENABLED=false +# OMNIROUTE_WARMUP_CRON="0 7 * * *" +# OMNIROUTE_WARMUP_CONCURRENCY=3 +# OMNIROUTE_WARMUP_MODEL= + # Background job interval for budget reset checks (ms). Default: 600000 (10m). # Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000. #OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000 @@ -1145,6 +1173,12 @@ CURSOR_USER_AGENT="Cursor/3.4" # Or enable for all providers at once: # CLI_COMPAT_ALL=1 +# Allow the Antigravity request translator to skip its strict CLI request-signature +# validation when the upstream refuses real signatures (debug/antiquated-CLI mode). +# Default: real signatures enforced (unset) — signature bypass disabled. +# Used by: open-sse/translator/request/openai-to-gemini.ts +# ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS=0 + # ── Kimi Coding CLI identity overrides ── # Used by: src/lib/oauth/providers/kimi-coding.ts — sent in OAuth + API headers. # Leave unset to use the captured defaults baked into the OmniRoute build. @@ -1401,7 +1435,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. @@ -1413,7 +1447,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) @@ -1527,6 +1561,15 @@ APP_LOG_TO_FILE=true # ═══════════════════════════════════════════════════════════════════════════════ # 19. MODEL SYNC (Dev) # ═══════════════════════════════════════════════════════════════════════════════ +# Enable the models.dev capability sync. Default: false (opt-in only). +# Also settable from Dashboard > Settings > AI. This variable wins over that +# setting whenever it is set to anything non-empty, in either direction, so a +# deployment can pin the sync on or off without depending on database state +# surviving a rebuild. Leave it unset to let the dashboard toggle decide. +# On: 1, true, yes or on (any casing). Any other value is off. +# Used by: src/lib/modelsDevSync.ts +# MODELS_DEV_SYNC_ENABLED=false + # Development-time model catalog sync interval in seconds. # Used by: src/lib/modelsDevSync.ts # Default: 86400 (24 hours) @@ -1543,6 +1586,17 @@ APP_LOG_TO_FILE=true # 20. PROVIDER-SPECIFIC SETTINGS # ═══════════════════════════════════════════════════════════════════════════════ +# ── Strict system-message-first providers ── +# Comma-separated, case-insensitive provider ids that require the `system` +# role message to be the first message (any later `system` message is +# rejected with HTTP 400 by the upstream chat template) — the same +# constraint documented for xiaomi-mimo/mimo (#6135, #7293). Extends the +# built-in list without a source change; useful for self-hosted connections +# in front of Qwen3.5+/3.6 or other strict-template backends. +# Used by: src/lib/memory/injection.ts::systemMessageMustBeFirst +# Default: unset (only xiaomi-mimo/mimo are flagged) +# OMNIROUTE_STRICT_SYSTEM_PROVIDERS=coding-agent + # ── OpenRouter ── # OpenRouter model catalog cache TTL in ms. # Used by: src/lib/catalog/openrouterCatalog.ts @@ -1571,6 +1625,19 @@ APP_LOG_TO_FILE=true # NANOBANANA_POLL_TIMEOUT_MS=120000 # Max wait for job completion (default: 120s) # NANOBANANA_POLL_INTERVAL_MS=2500 # Poll frequency (default: 2.5s) +# ── Adobe Firefly (Image / Video Generation) ── +# Optional absolute path to a system Chrome or Edge executable used for interactive sign-in +# and off-screen risk-session renewal. Auto-detected when unset. +# OMNIROUTE_LOGIN_BROWSER_PATH= +# Browser renewal and durable session cache are enabled by default; set either to 0 to opt out. +# ADOBE_FIREFLY_BROWSER_REFRESH=1 +# ADOBE_FIREFLY_SESSION_DISK=1 +# Minimum gap between generate submissions and extra gap after every third success (ms). +# ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS=12000 +# ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS=15000 +# Base backoff after a transient 408 response (ms); five attempts maximum. +# ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS=8000 + # ── Microsoft Designer Web (Image Generation) ── # Polling config for the microsoft-designer-web submit-then-poll image job. # Used by: open-sse/handlers/imageGeneration/providers/designerWeb.ts @@ -1800,6 +1867,17 @@ APP_LOG_TO_FILE=true # Accepted values: true|1|on (enable). Unset or anything else = disabled (default). # STREAM_RECOVERY_MIDSTREAM_ENABLED=true +# Active-stream throughput watchdog (#9709). Detects streams that keep sending +# heartbeats/chunks but produce too little useful assistant text. Separate from +# STREAM_IDLE_TIMEOUT_MS (silence) and the hard upstream attempt deadline. OFF by +# default. Tool-call/reasoning phases suspend judgement; post-commit streams are +# never blindly replayed. +# STREAM_THROUGHPUT_WATCHDOG_ENABLED=true +# STREAM_THROUGHPUT_WATCHDOG_WARMUP_MS=30000 +# STREAM_THROUGHPUT_WATCHDOG_WINDOW_MS=30000 +# STREAM_THROUGHPUT_WATCHDOG_MIN_BYTES_PER_SECOND=4 +# STREAM_THROUGHPUT_WATCHDOG_MIN_USEFUL_BYTES=1 + # Stagger interval (ms) between provider token healthchecks at startup. # Used by: src/lib/tokenHealthCheck.ts. Default: 3000. # HEALTHCHECK_STAGGER_MS=3000 @@ -1871,7 +1949,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. @@ -1913,6 +1991,19 @@ APP_LOG_TO_FILE=true # ALIBABA_CODING_PLAN_HOST= # ALIBABA_CODING_PLAN_QUOTA_URL= +# ── Alibaba Model Studio free-tier quota sync ── +# Console front-end path overrides for the free-tier quota fetcher. Used by: +# open-sse/services/alibabaFreeTierQuotaFetcher.ts. When unset, the fetcher +# uses the production Bailian console paths. +# ALIBABA_FREE_TIER_VISION_FE_PATH= +# ALIBABA_FREE_TIER_MULTIMODAL_FE_PATH= +# ALIBABA_FREE_TIER_AUDIO_FE_PATH= +# Optional path to a local JSON override for the built-in text free-tier +# allowlist. Used by: open-sse/services/alibabaFreeTierAllowlist.ts. When +# unset, the fetcher falls back to $DATA_DIR/alibaba-free-tier-allowlist.json +# then config/alibaba-free-tier-allowlist.json. +# ALIBABA_FREE_TIER_ALLOWLIST_PATH= + # ── Context window tuning ── # Tokens reserved for completion output when computing prompt budgets. # Used by: open-sse/services/contextManager.ts. Default: 1024. @@ -1955,6 +2046,12 @@ APP_LOG_TO_FILE=true # Default: 0.33.2 # COMMAND_CODE_VERSION=0.33.2 +# Base URL for the Command Code usage/quota upstream, used by smartphone +# quota-fetcher telemetry. +# Used by: open-sse/services/usage/command-code.ts +# Default: https://api.commandcode.ai +# COMMANDCODE_API_URL=https://api.commandcode.ai + # ── MITM debug proxy (development only) ── # Used by: src/mitm/server.cjs — captures upstream traffic for inspection. # MITM_LOCAL_PORT=443 @@ -2431,6 +2528,18 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # ───────────────────────────────────────────────────────────────────────────── # HYPERAGENT_USAGE_URL=https://hyperagent.com/api/settings/billing/usage +# ───────────────────────────────────────────────────────────────────────────── +# ChatGPT Web (Codex) headless browser and outbound tool tunnel +# Used by: open-sse/executors/chatgpt-web-codex.ts +# Connection values entered in the dashboard override these global defaults. +# ───────────────────────────────────────────────────────────────────────────── +# CHATGPT_WEB_CODEX_CHROME_PATH=/usr/bin/chromium +# CHROME_PATH=/usr/bin/chromium +# CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223 +# CHATGPT_WEB_CODEX_TUNNEL_ID=tunnel_0123456789abcdef0123456789abcdef +# CHATGPT_WEB_CODEX_RUNTIME_KEY= +# CHATGPT_WEB_CODEX_CONNECTOR_NAME=OmniRoute Codex + # ───────────────────────────────────────────────────────────────────────────── # Browser-login VNC sessions (optional — src/lib/vncSession/manifest.ts) # Containerized Chromium+VNC used for interactive browser-login credential @@ -2470,10 +2579,10 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # ═══════════════════════════════════════════════════════════════════════════════ # Optional add-on (feature flag RADAR_ENABLED, default off — see feature flag # settings, not an env var) that overlays a signed, freshly-curated free-model -# catalog on top of the release baseline. Both variables below are optional and -# only needed to point the client at a self-hosted/forked feed instead of the -# default OmniRoute Radar feed. Used by: src/lib/radar/sync.ts, -# src/lib/radar/pinnedKeys.ts. +# catalog on top of the release baseline. All four variables below are optional +# and only needed to point the client at a self-hosted/forked feed or +# supporter-key flow instead of the default OmniRoute Radar service. Used by: +# src/lib/radar/sync.ts, src/lib/radar/pinnedKeys.ts, src/lib/radar/links.ts. # Base URL of the Radar feed service. Overrides the built-in default so forks # and self-hosters can point at their own signed feed. @@ -2483,3 +2592,65 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # signature, replacing the pinned default key. Required when self-hosting a # feed signed with a different key pair. # RADAR_FEED_PUBKEY= + +# URL the dashboard's "I'm a contributor" button opens (GitHub OAuth +# supporter-key claim flow). No pricing/value lives in this repo — only the +# link. +# RADAR_CONTRIBUTOR_CLAIM_URL=https://radar.omniroute.online/auth/github + +# URL the dashboard's "Support the project" button opens (payment/plans +# page). No pricing/value lives in this repo — only the link. +# RADAR_SUPPORTER_PLANS_URL=https://radar.omniroute.online/planos + +# ═══════════════════════════════════════════════════════════════════════════════ +# 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 + +# ── OmniConductor bridge (Conductor PRD RF1) ────────────────────────────────── +# Mirrors the OmniConductor hub's tasks into the local A2A TaskManager via SSE. +# Opt-in: the bridge only starts when CONDUCTOR_HUB_URL is set. +# Token: emit a `spokesperson`-kind credential on the hub (POST /v1/peers, admin) — +# server-side only, never exposed to the browser. +# Used by: src/lib/conductor/boot.ts, src/lib/conductor/bridge.ts +# CONDUCTOR_HUB_URL=http://127.0.0.1:7910 +# CONDUCTOR_HUB_TOKEN= +feat/conductor-bridge diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2439c63a94..01facdc7c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -501,11 +501,13 @@ jobs: BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }} run: node scripts/i18n/check-ui-value-drift.mjs - # #8038: cheap single-locale glossary/protected-terms consistency gate — + # #8038: cheap glossary/protected-terms consistency gate — # complements i18n-ui-coverage (key parity) and the ICU `i18n` job below # without needing app-boot/Playwright infra. Same gating as i18n-ui-coverage. + # ko added after the #8224 ko.json mistranslation cleanup so the fixed + # terminology cannot silently regress on the next machine-translation run. i18n-glossary-zhcn: - name: i18n Glossary (zh-CN) + name: i18n Glossary (zh-CN, ko) runs-on: ubuntu-latest needs: changes if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }} @@ -811,7 +813,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 +831,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: @@ -1320,7 +1336,7 @@ jobs: echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY" - echo "| i18n Glossary (zh-CN) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY" + echo "| i18n Glossary (zh-CN, ko) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 25fb72db24..b32487da27 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,10 +22,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 with: languages: javascript-typescript queries: security-extended - - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 with: category: "/language:javascript-typescript" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ccfd170c9b..9cca65ac09 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -137,13 +137,13 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v4.5.2 + uses: docker/login-action@v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v4.5.2 + uses: docker/login-action@v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -237,13 +237,13 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v4.5.2 + uses: docker/login-action@v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v4.5.2 + uses: docker/login-action@v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -372,7 +372,7 @@ jobs: - name: Upload Trivy SARIF to Security tab if: needs.prepare.outputs.version != 'main' continue-on-error: true - uses: github/codeql-action/upload-sarif@v4.37.3 + uses: github/codeql-action/upload-sarif@v4.37.4 with: sarif_file: trivy-results.sarif category: trivy-image diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7a167afcd0..2ae73d9711 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -151,63 +151,6 @@ jobs: key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }} restore-keys: | eslint-${{ runner.os }}- - - run: npm run check:provider-consistency - - run: npm run check:fetch-targets - # docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered). - - run: npm run check:deps - # #8522: --base-ref mode for PR events — compare against max(frozen, base) so - # inherited drift (base already over frozen cap) doesn't red an innocent PR. - # workflow_dispatch (no PR base) falls back to absolute comparison. - - name: File-size ratchet (base-relative on PR) - env: - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - if [ -n "$PR_BASE_SHA" ]; then - npm run check:file-size -- --base-ref "$PR_BASE_SHA" - else - npm run check:file-size - fi - - run: npm run check:error-helper - - run: npm run check:migration-numbering - - run: npm run check:public-creds - - run: npm run check:db-rules - - run: npm run check:known-symbols - - run: npm run check:route-guard-membership - - run: npm run check:test-discovery - - run: npm run check:test-runner-api - # Guards tap.testFiles drift: a covering unit test absent from stryker.conf.json - # tap.testFiles makes its module's mutants survive on a cold nightly-mutation run, - # false-failing the blocking mutationScore ratchet. See check-mutation-test-coverage.mjs. - - run: npm run check:mutation-test-coverage - - run: npm run check:any-budget:t11 - # Build-scope guard: fails if worktrees/cruft leak into the tsconfig include - # scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031. - - run: npm run check:build-scope - # Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file - # leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on - # the release PR's heavy Package Artifact job. - - run: npm run check:pack-policy - # Complexity + cognitive-complexity: ONE ESLint walk (both baselines still - # enforced separately by ruleId). Avoids two cold tree walks on fast-path. - - run: npm run check:complexity-ratchets - # ── G0 (trilho .50): gates do trilho A que faltavam no trilho B ────────────── - # The god-file refactor happens in PRs→release/**; without these, the release - # rail never sees a new import cycle, dead code, duplication or a security - # regression until the release PR to main. Deliberately NOT brought here: - # bundle-size (self-skips without a build — this rail's build job is advisory - # and uploads nothing, so it would be dead configuration) and the coverage - # run (fast-unit already runs the full suite; the coverage ratchet stays on - # the main rail via --allow-missing in lint-guard). - - run: npm run check:cycles - - run: npm run check:lockfile - - name: Duplication ratchet - run: npm run check:duplication - - name: Dead-code ratchet (knip) - run: npm run check:dead-code - - name: Type coverage ratchet - run: npm run check:type-coverage - - name: Compression budget ratchet - run: npm run check:compression-budget # Security scanners — same hardened install as ci.yml quality-extended # (gh release download = authenticated, 5000 req/hr; curl to api.github.com # is rate-limited to 60/hr and silently no-ops when throttled). The blocking @@ -251,26 +194,82 @@ jobs: "$HOME/.local/bin/osv-scanner" --version || true "$HOME/.local/bin/oasdiff" --version || true zizmor --version || true - - name: Secret scan (gitleaks, ratchet, blocking) - run: npm run check:secrets -- --ratchet - - name: Vulnerability ratchet (osv-scanner, ratchet, blocking) - run: npm run check:vuln-ratchet -- --ratchet - - name: Workflow lint (actionlint+zizmor, ratchet, blocking) - run: npm run check:workflows -- --ratchet - # BASE_REF is read by the script from the env (never interpolated into a - # shell body) — workflow-injection-safe. actions/checkout fetches remote - # refs, not a local branch named github.base_ref, so prefix origin/ or this - # gate self-skips every PR with reason=base-unresolved. - - name: OpenAPI breaking-change (oasdiff, ratchet, blocking) + - name: Forgotten sibling tests (advisory) env: + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + node scripts/quality/build-test-impact-map.mjs + node scripts/check/check-forgotten-sibling-tests.mjs \ + --summary-file forgotten-sibling-tests.md \ + --json-file forgotten-sibling-tests.json + cat forgotten-sibling-tests.md >> "$GITHUB_STEP_SUMMARY" + - name: Upload forgotten sibling report + if: always() + uses: actions/upload-artifact@v7 + with: + name: forgotten-sibling-tests + path: | + forgotten-sibling-tests.md + forgotten-sibling-tests.json + if-no-files-found: ignore + retention-days: 30 + # Quality gates (all, non-fail-fast) — #8542: replaces 17 bare check:* steps, + # 6 G0 gates, 4 ratchet gates, and 3 typecheck steps with a single aggregation + # step. Each gate runs in a loop with ::group::; failures are collected and + # reported at the end. set -uo pipefail (NOT set -e) so one failing gate does + # not abort the job and mask every later gate. Release-added gates are folded + # in: open-sse typecheck (#8781) and file-size base-relative mode (#8522). + - name: Quality gates (all, non-fail-fast) + env: + # #8522: base-relative file-size mode on PR events — inherited drift (base + # already over frozen cap) must not red an innocent PR. Unset on + # workflow_dispatch (no PR base) → absolute comparison. + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }} - run: npm run check:openapi-breaking -- --ratchet - - name: Typecheck (core) - run: npm run typecheck:core - # #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not - # covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs. - - name: Typecheck (dashboard) - run: npm run check:dashboard-typecheck + run: | + set -uo pipefail + gates=( + provider-consistency fetch-targets deps file-size error-helper + migration-numbering public-creds db-rules known-symbols + route-guard-membership test-discovery test-runner-api + mutation-test-coverage any-budget:t11 build-scope pack-policy + complexity-ratchets + cycles lockfile duplication dead-code type-coverage compression-budget + # #8781: open-sse workspace typecheck gate — the workspace imports @/ which + # escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs. + open-sse-typecheck + ) + ratchet_gates=( + secrets vuln-ratchet workflows openapi-breaking + ) + failed=() + for g in "${gates[@]}"; do + echo "::group::check:$g" + # #8522: file-size is base-relative on PR events (compare against + # max(frozen, base)) so inherited drift doesn't red an innocent PR; + # workflow_dispatch (no PR base) falls back to absolute comparison. + if [ "$g" = "file-size" ] && [ -n "${PR_BASE_SHA:-}" ]; then + npm run "check:$g" -- --base-ref "$PR_BASE_SHA" || failed+=("$g") + else + npm run "check:$g" || failed+=("$g") + fi + echo "::endgroup::" + done + for g in "${ratchet_gates[@]}"; do + echo "::group::check:$g (ratchet)" + npm run "check:$g" -- --ratchet || failed+=("$g") + echo "::endgroup::" + done + echo "::group::typecheck:core" + npm run typecheck:core || failed+=("typecheck:core") + echo "::endgroup::" + echo "::group::check:dashboard-typecheck" + npm run check:dashboard-typecheck || failed+=("check:dashboard-typecheck") + echo "::endgroup::" + if (( ${#failed[@]} )); then + printf '::error::%d gate(s) failed: %s\n' "${#failed[@]}" "${failed[*]}" + exit 1 + fi # WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only. # TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only # arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x @@ -299,7 +298,8 @@ jobs: GITHUB_BASE_REF: ${{ github.base_ref }} run: | git fetch --no-tags origin "$GITHUB_BASE_REF" || true - node scripts/quality/build-test-impact-map.mjs + # The advisory sibling-test step generates the same map earlier in this job. + [ -f config/quality/test-impact-map.json ] || node scripts/quality/build-test-impact-map.mjs SEL="$(node scripts/quality/select-impacted-tests.mjs)" # Shadow evidence (#8084): persist every selection so TIA false negatives can # be measured against fast-unit's full-suite verdict across releases BEFORE diff --git a/.gitignore b/.gitignore index b06010eb17..e3c17b29d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # project-specific directories +.slim/deepwork/ .omnivscodeagent/ omnirouteCloud/ omnirouteSite/ @@ -72,7 +73,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 @@ -122,6 +122,8 @@ app.log deploy.sh docker-compose.minimal.yml +# Docker Compose override (local-only, never commit) +docker-compose.override.yml # Backup directories app.__qa_backup/ @@ -172,6 +174,7 @@ config/quality/test-impact-map.json # GitNexus local index .gitnexus .worktrees +bin/omniroute.mjs # Consistent with .dockerignore / .npmignore .omc/ @@ -201,17 +204,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 +224,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 +256,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 +266,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,8 +273,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). 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/@omniroute/opencode-plugin/README.md b/@omniroute/opencode-plugin/README.md index 55aff38434..570ff4285a 100644 --- a/@omniroute/opencode-plugin/README.md +++ b/@omniroute/opencode-plugin/README.md @@ -30,7 +30,7 @@ omniroute setup opencode --auth # 3. Restart OpenCode — /models lists the full live catalog ``` -The `--auth` flag runs `opencode auth login --provider omniroute` automatically. +The `--auth` flag runs `opencode auth login --provider opencode-omniroute` automatically. Use `--base-url` to point at a non-default OmniRoute address: ```sh @@ -84,7 +84,7 @@ Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install). ``` ```sh -opencode auth login --provider omniroute +opencode auth login --provider opencode-omniroute # prompts for the OmniRoute API key, writes to ~/.local/share/opencode/auth.json ``` @@ -164,8 +164,8 @@ Then in `~/.config/opencode/opencode.json` reference each directory by absolute Paths are relative to `~/.config/opencode/`. Each entry now resolves to a distinct module file, so OC loads them as two separate plugin instances. Authenticate each: ```sh -opencode auth login --provider omniroute -opencode auth login --provider omniroute-preprod +opencode auth login --provider opencode-omniroute +opencode auth login --provider opencode-omniroute-preprod ``` Each entry gets its own provider id, its own model picker entry, its own slot in `auth.json`, and its own TTL cache. Closures are isolated per plugin instance — no cross-talk. diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 681b2fe3d0..747c57beeb 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -1033,7 +1033,11 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => { // Config hook: keep existing catalog shim, and register slash command // templates that ask the agent to call the force-sync tool (OpenCode has no // Pi-style registerCommand API; tools + command templates are the native path). - const baseConfigHook = createOmniRouteConfigHook(resolved, { cache: sharedCache }); + const baseConfigHook = createOmniRouteConfigHook(resolved, { + cache: sharedCache, + diskSnapshotReader: defaultDiskSnapshotReader, + diskSnapshotWriter: defaultDiskSnapshotWriter, + }); const configWithSyncCommand = async (input: Config) => { await baseConfigHook(input); const cfg = input as Config & { @@ -4403,11 +4407,11 @@ export function buildStaticProviderEntry( entry.release_date = raw.release_date; } - // OC's static-catalog reader parses each key on `/` and rejects the - // entire provider block if ANY key resolves to a parsed providerID that - // has no corresponding provider block. So bare keys (no `/`) MUST be - // prefixed with the resolved providerId. Already-prefixed keys - // (e.g. `cc/claude-opus-4-7`) are left as-is to avoid double-prefixing. + // #9175: OC's `getModel` looks the model up by BARE id — the part after + // the first `/` in the user's request — so a dict key with an embedded + // provider prefix (`/`) is unreachable. Keys are the + // raw id verbatim; ids that already contain `/` (e.g. `cc/claude-opus-4-7`) + // keep it because the slash is part of the upstream model id itself. models[raw.id] = entry; } @@ -4741,7 +4745,7 @@ export type OmniRouteDiskSnapshotWriter = ( export type OmniRouteDiskSnapshotReader = ( providerId: string, identityFingerprint: string -) => Promise | undefined>; +) => Promise<(Omit & { writtenAt?: number }) | undefined>; /** * Bind a snapshot to the endpoint and effective credential tuple without @@ -4824,15 +4828,36 @@ export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async ( ? parsed.rawCompressionCombos : [], rawConnections: Array.isArray(parsed.rawConnections) ? parsed.rawConnections : [], + writtenAt: typeof parsed.writtenAt === "number" ? parsed.writtenAt : undefined, }; } catch { return undefined; } }; -/** No-op disk-cache pair — used by tests to avoid filesystem side effects. */ +/** No-op disk-cache pair — used by tests to avoid filesystem side effects. + * Also used as the default in createOmniRouteConfigHook so that tests + * that don't pass a diskSnapshotReader don't read real snapshot files + * from the user's ~/.local/share/opencode/plugins/ directory. + * The OmniRoutePlugin function passes the real defaultDiskSnapshotReader + * explicitly. */ +export const noopDiskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined; export const noopDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; +/** + * In-flight refresh guard: prevents concurrent refreshes for the same + * cacheKey. When a warm snapshot is served, the refresh runs detached; if + * a second hook invocation arrives before the refresh completes, it should + * piggyback on the in-flight promise rather than starting a second one. + * Cleared on settle so it doesn't leak. + */ +const _inflightRefresh: Map> = new Map(); + +/** Reset the in-flight refresh guard (for test isolation). */ +export function _resetInflightRefresh(): void { + _inflightRefresh.clear(); +} + // ──────────────────────────────────────────────────────────────────────────── // Debug logging (features.debugLog) // ──────────────────────────────────────────────────────────────────────────── @@ -5067,7 +5092,6 @@ export function createDebugLoggingFetch( } }; } -export const noopDiskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined; export type OmniRouteReadAuthJson = () => Promise; @@ -5170,8 +5194,8 @@ export function createOmniRouteConfigHook( const compressionMetaFetcher = deps.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher; const providersFetcher = deps.providersFetcher ?? defaultOmniRouteProvidersFetcher; - const diskSnapshotReader = deps.diskSnapshotReader ?? defaultDiskSnapshotReader; - const diskSnapshotWriter = deps.diskSnapshotWriter ?? defaultDiskSnapshotWriter; + const diskSnapshotReader = deps.diskSnapshotReader ?? noopDiskSnapshotReader; + const diskSnapshotWriter = deps.diskSnapshotWriter ?? noopDiskSnapshotWriter; const now = deps.now ?? Date.now; const cache: OmniRouteFetchCache = deps.cache ?? new Map(); const logger = deps.logger ?? console; @@ -5266,12 +5290,12 @@ export function createOmniRouteConfigHook( const t = now(); const cached = cache.get(cacheKey); - let rawModels: OmniRouteRawModelEntry[]; - let rawCombos: OmniRouteRawCombo[]; - let rawAutoCombos: OmniRouteRawAutoCombo[]; - let rawEnrichment: OmniRouteEnrichmentMap; - let rawCompressionCombos: OmniRouteCompressionCombo[]; - let rawConnections: OmniRouteProviderConnection[]; + let rawModels: OmniRouteRawModelEntry[] = []; + let rawCombos: OmniRouteRawCombo[] = []; + let rawAutoCombos: OmniRouteRawAutoCombo[] = []; + let rawEnrichment: OmniRouteEnrichmentMap = new Map(); + let rawCompressionCombos: OmniRouteCompressionCombo[] = []; + let rawConnections: OmniRouteProviderConnection[] = []; if (cached && cached.expiresAt > t) { rawModels = cached.rawModels; @@ -5281,160 +5305,275 @@ export function createOmniRouteConfigHook( rawCompressionCombos = cached.rawCompressionCombos; rawConnections = cached.rawConnections; } else { - // Fail-open fetcher errors: on /v1/models throw, fall back to empty - // catalog (still publish a stub block so OC has a complete-shape - // entry); on /api/combos throw, publish models-only. Disk-cache - // fallback below recovers the last-known-good catalog when the - // fetcher threw (network down / 403 / timeout) AND features.diskCache - // !== false. A 0-entry SUCCESS (fresh tenant) does NOT trigger - // disk fallback — that's a valid empty catalog. - let modelsFetchThrew = false; - try { - rawModels = await fetcher(baseURL, apiKey, 10_000); - } catch (err) { - logger.warn( - "[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry", - err - ); - rawModels = []; - modelsFetchThrew = true; - } - const modelsFetchOk = !modelsFetchThrew && rawModels.length > 0; - - rawCombos = []; - try { - rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000); - } catch (err) { - logger.warn( - "[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog", - err - ); - } - - rawAutoCombos = []; - if (wantAutoCombos) { - try { - rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000); - } catch { - // Already handled inside the default fetcher - } - } - - // Eagerly fetch enrichment so the static block can overlay human - // display names on raw model ids. On OC ≤1.15.5 the dynamic - // `provider.models` hook never fires in `serve` mode, so the static - // block IS what reaches `/provider` and the TUI model picker. - // Gated by `features.enrichment` (default-on). Soft-fail on error — - // we still publish a name-less catalog if /api/pricing/models is - // unreachable. - rawEnrichment = new Map(); - if (wantEnrichment) { - try { - rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000); - } catch (err) { + // ───────────────────────────────────────────────────────────────────── + // Warm startup: read the disk snapshot before fetching so the provider + // registers immediately with the last-known-good catalog. The live + // fetch then refreshes in the background (detached) and updates the + // cache + snapshot. Gated by features.diskCache (default-on). + // ───────────────────────────────────────────────────────────────────── + let warmSnapshot: Omit | undefined; + if (wantDiskCache) { + const snapshotResult = await diskSnapshotReader(resolved.providerId, snapshotFingerprint); + if (snapshotResult && snapshotResult.rawModels.length > 0) { + warmSnapshot = snapshotResult; + // Log snapshot age (accept any age — instant beats empty). + const age = (snapshotResult as { writtenAt?: number }).writtenAt; + const ageLabel = typeof age === "number" ? `${Math.round((Date.now() - age) / 3_600_000)}h` : "unknown"; logger.warn( - "[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog", - err + `[omniroute-plugin] config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})` ); } } - // Compression-metadata fetch — opt-in via features.compressionMetadata. - // When on, the default pipeline is appended to every combo `name` so - // the TUI picker advertises which compression a combo applies. - rawCompressionCombos = []; - if (wantCompressionMeta) { - try { - rawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000); - } catch (err) { - logger.warn( - "[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix", - err - ); + // ───────────────────────────────────────────────────────────────────── + // Parallel refresh: all six fetchers run concurrently via + // Promise.allSettled. Each wrapper never rejects (catches internally) + // so partial failure is tolerated — same soft-fail semantics as the + // old sequential chain, but ~6x faster. + // ───────────────────────────────────────────────────────────────────── + const doRefresh = async (): Promise => { + let modelsFetchThrew = false; + let localRawModels: OmniRouteRawModelEntry[] = []; + let localRawCombos: OmniRouteRawCombo[] = []; + let localRawAutoCombos: OmniRouteRawAutoCombo[] = []; + let localRawEnrichment: OmniRouteEnrichmentMap = new Map(); + let localRawCompressionCombos: OmniRouteCompressionCombo[] = []; + let localRawConnections: OmniRouteProviderConnection[] = []; + + // Each wrapper keeps the existing try/catch, default value, and + // exact warn message so per-endpoint fallbacks are preserved. + const doModels = async (): Promise => { + try { + localRawModels = await fetcher(baseURL, apiKey, 10_000); + } catch (err) { + logger.warn( + "[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry", + err + ); + localRawModels = []; + modelsFetchThrew = true; + } + }; + + const doCombos = async (): Promise => { + try { + localRawCombos = await combosFetcher(baseURL, managementReadToken, 10_000); + } catch (err) { + logger.warn( + "[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog", + err + ); + } + }; + + const doAutoCombos = async (): Promise => { + if (!wantAutoCombos) return; + try { + localRawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000); + } catch { + // Already handled inside the default fetcher + } + }; + + const doEnrichment = async (): Promise => { + if (!wantEnrichment) return; + try { + localRawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000); + } catch (err) { + logger.warn( + "[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog", + err + ); + } + }; + + const doCompression = async (): Promise => { + if (!wantCompressionMeta) return; + try { + localRawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000); + } catch (err) { + logger.warn( + "[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix", + err + ); + } + }; + + const doConnections = async (): Promise => { + if (!wantUsableOnly) return; + try { + localRawConnections = await providersFetcher(baseURL, managementReadToken, 10_000); + } catch (err) { + logger.warn( + "[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh", + err + ); + } + }; + + await Promise.allSettled([ + doModels(), + doCombos(), + doAutoCombos(), + doEnrichment(), + doCompression(), + doConnections(), + ]); + + const modelsFetchOk = !modelsFetchThrew && localRawModels.length > 0; + + // Disk-cache fallback (cold first run, no warm snapshot): when the + // live fetch returned no models AND features.diskCache !== false, + // hydrate from the last-known-good snapshot so OC still surfaces a + // usable catalog (e.g. IP whitelist drop, offline laptop). + if (modelsFetchThrew && wantDiskCache && !warmSnapshot) { + const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint); + if (snapshot && snapshot.rawModels.length > 0) { + logger.warn( + `[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)` + ); + localRawModels = snapshot.rawModels; + localRawCombos = snapshot.rawCombos; + localRawAutoCombos = snapshot.rawAutoCombos ?? []; + localRawEnrichment = snapshot.rawEnrichment; + localRawCompressionCombos = snapshot.rawCompressionCombos; + localRawConnections = snapshot.rawConnections; + } } - } - // Provider-connections fetch — opt-in via features.usableOnly. When - // on, the static catalog filters out models/combos whose canonical - // provider has no active connection. Soft-fail (empty list) disables - // the filter for this refresh, never hiding the whole catalog. - rawConnections = []; - if (wantUsableOnly) { - try { - rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000); - } catch (err) { - logger.warn( - "[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh", - err - ); - } - } - - // Disk-cache fallback: when the live fetch returned no models AND - // features.diskCache !== false, hydrate from the last-known-good - // snapshot so OC still surfaces a usable catalog (e.g. IP whitelist - // drop, offline laptop). The snapshot is whatever we last wrote on - // a healthy refresh; staleness is bounded only by how recently the - // user was online. - if (modelsFetchThrew && wantDiskCache) { - const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint); - if (snapshot && snapshot.rawModels.length > 0) { - logger.warn( - `[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)` - ); - rawModels = snapshot.rawModels; - rawCombos = snapshot.rawCombos; - rawAutoCombos = snapshot.rawAutoCombos ?? []; - rawEnrichment = snapshot.rawEnrichment; - rawCompressionCombos = snapshot.rawCompressionCombos; - rawConnections = snapshot.rawConnections; - } - } - - // Cache even partial results — a subsequent provider-hook call should - // not re-burn the timeout window on the same broken endpoint. - cache.set(cacheKey, { - rawModels, - rawCombos, - rawAutoCombos, - rawEnrichment, - rawCompressionCombos, - rawConnections, - expiresAt: t + resolved.modelCacheTtl, - }); - - // Startup diagnostics (file-based) — fires at startup via config hook - if (resolved.features?.startupDebug === true) { - await writeStartupDiagnostics({ - providerId: resolved.providerId, - baseURL, - modelCount: rawModels.length, - comboCount: rawCombos.length, - enrichmentSize: rawEnrichment.size, - autoComboCount: rawAutoCombos.length, - enrichment: rawEnrichment, - autoCombos: rawAutoCombos, - features: resolved.features, + // Cache even partial results — a subsequent provider-hook call should + // not re-burn the timeout window on the same broken endpoint. + cache.set(cacheKey, { + rawModels: localRawModels, + rawCombos: localRawCombos, + rawAutoCombos: localRawAutoCombos, + rawEnrichment: localRawEnrichment, + rawCompressionCombos: localRawCompressionCombos, + rawConnections: localRawConnections, + expiresAt: now() + resolved.modelCacheTtl, }); - } - // Disk-cache write: persist the last successful (or any non-empty) - // catalog so a subsequent cold start with a failed fetch can recover. - // Best-effort; soft-fail keeps us moving when the data dir isn't - // writable (e.g. read-only container). - if (modelsFetchOk && wantDiskCache) { - await diskSnapshotWriter( - resolved.providerId, - { - rawModels, - rawCombos, - rawAutoCombos, - rawEnrichment, - rawCompressionCombos, - rawConnections, - }, - snapshotFingerprint - ); + // Startup diagnostics (file-based) — fires at startup via config hook + if (resolved.features?.startupDebug === true) { + await writeStartupDiagnostics({ + providerId: resolved.providerId, + baseURL, + modelCount: localRawModels.length, + comboCount: localRawCombos.length, + enrichmentSize: localRawEnrichment.size, + autoComboCount: localRawAutoCombos.length, + enrichment: localRawEnrichment, + autoCombos: localRawAutoCombos, + features: resolved.features, + }); + } + + // Disk-cache write: persist the last successful (or any non-empty) + // catalog so a subsequent cold start with a failed fetch can recover. + // Best-effort; soft-fail keeps us moving when the data dir isn't + // writable (e.g. read-only container). A failed refresh never + // overwrites the snapshot (modelsFetchOk gate). + if (modelsFetchOk && wantDiskCache) { + await diskSnapshotWriter( + resolved.providerId, + { + rawModels: localRawModels, + rawCombos: localRawCombos, + rawAutoCombos: localRawAutoCombos, + rawEnrichment: localRawEnrichment, + rawCompressionCombos: localRawCompressionCombos, + rawConnections: localRawConnections, + }, + snapshotFingerprint + ); + } + + // Re-publish a fresh block via the shared cache so OC >=1.14.49's + // dynamic provider hook picks it up from the cache. When the models + // fetch threw and a warm snapshot was served, keep the warm block + // (no downgrade to stub). + if (modelsFetchOk || !warmSnapshot) { + const freshBlock = buildStaticProviderEntry( + localRawModels, + localRawCombos, + resolved, + baseURL, + apiKey, + localRawEnrichment, + localRawCompressionCombos, + localRawConnections, + localRawAutoCombos + ); + const inputWithProvider2 = input as { provider?: Record }; + if (inputWithProvider2.provider) { + inputWithProvider2.provider[resolved.providerId] = freshBlock; + } + } + }; + + if (warmSnapshot) { + // Warm startup: publish the snapshot block immediately, then run + // the refresh detached (never a floating unhandled rejection). + rawModels = warmSnapshot.rawModels; + rawCombos = warmSnapshot.rawCombos; + rawAutoCombos = warmSnapshot.rawAutoCombos ?? []; + rawEnrichment = warmSnapshot.rawEnrichment; + rawCompressionCombos = warmSnapshot.rawCompressionCombos; + rawConnections = warmSnapshot.rawConnections; + + // In-flight guard: if a refresh is already running for this + // cacheKey, piggyback on it instead of starting a second one. + const existing = _inflightRefresh.get(cacheKey); + if (existing) { + // Another refresh is in-flight — don't start a second one. + // The existing refresh will update the cache when it completes. + } else { + const refreshP = doRefresh() + .catch((err: unknown) => { + logger.warn("[omniroute-plugin] config shim: background refresh failed", err); + }) + .finally(() => { + _inflightRefresh.delete(cacheKey); + }); + _inflightRefresh.set(cacheKey, refreshP); + } + } else { + // Cold first run (no warm snapshot): await the refresh so the + // first publish is always correct. In-flight guard still applies. + const existing = _inflightRefresh.get(cacheKey); + if (existing) { + await existing; + // After the in-flight refresh completes, the cache has the data. + const fresh = cache.get(cacheKey); + if (fresh) { + rawModels = fresh.rawModels; + rawCombos = fresh.rawCombos; + rawAutoCombos = fresh.rawAutoCombos; + rawEnrichment = fresh.rawEnrichment; + rawCompressionCombos = fresh.rawCompressionCombos; + rawConnections = fresh.rawConnections; + } + } else { + const refreshP = doRefresh() + .catch((err: unknown) => { + logger.warn("[omniroute-plugin] config shim: refresh failed", err); + }) + .finally(() => { + _inflightRefresh.delete(cacheKey); + }); + _inflightRefresh.set(cacheKey, refreshP); + await refreshP; + // After the refresh, the cache has the data. + const fresh = cache.get(cacheKey); + if (fresh) { + rawModels = fresh.rawModels; + rawCombos = fresh.rawCombos; + rawAutoCombos = fresh.rawAutoCombos; + rawEnrichment = fresh.rawEnrichment; + rawCompressionCombos = fresh.rawCompressionCombos; + rawConnections = fresh.rawConnections; + } + } } } diff --git a/@omniroute/opencode-plugin/tests/config-shim.test.ts b/@omniroute/opencode-plugin/tests/config-shim.test.ts index 04ec61f1b7..f439656416 100644 --- a/@omniroute/opencode-plugin/tests/config-shim.test.ts +++ b/@omniroute/opencode-plugin/tests/config-shim.test.ts @@ -33,6 +33,7 @@ import { createOmniRouteProviderHook, OmniRoutePlugin, resolveOmniRoutePluginOptions, + _resetInflightRefresh, type OmniRouteCombosFetcher, type OmniRouteEnrichmentEntry, type OmniRouteEnrichmentFetcher, @@ -47,6 +48,16 @@ import { type OmniRouteStaticProviderEntry, } from "../src/index.js"; +// ──────────────────────────────────────────────────────────────────────────── +// Test isolation: reset the module-level in-flight refresh guard between +// tests so a detached refresh from a previous test doesn't leak into the +// next one. +// ──────────────────────────────────────────────────────────────────────────── + +test.beforeEach(() => { + _resetInflightRefresh(); +}); + // ──────────────────────────────────────────────────────────────────────────── // Fixtures // ──────────────────────────────────────────────────────────────────────────── @@ -227,7 +238,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider // Stripped per-model shape: name + cap flags + modalities + (optional) // cost. OC's SDK static schema accepts only `limit.{context,output}` — // `limit.input` is NOT in the SDK shape and gets dropped silently. - const claude = entry.models["opencode-omniroute/claude-sonnet-4-6"]; + const claude = entry.models["claude-sonnet-4-6"]; assert.ok(claude, "claude model surfaced"); assert.equal(claude.name, "claude-sonnet-4-6"); assert.equal(claude.attachment, true); @@ -248,7 +259,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider // Combo surfaces under bare key + LCD'd // (gemini's reasoning=false → combo reasoning=false). - const combo = entry.models["omniroute/claude-tier"]; + const combo = entry.models["claude-tier"]; assert.ok(combo, "combo surfaced under bare key"); assert.equal(combo.name, "Claude Tier"); assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false"); @@ -471,10 +482,10 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m assert.ok(entry); const ids = Object.keys(entry.models).sort(); assert.deepEqual(ids, [ - "opencode-omniroute/claude-sonnet-4-6", - "opencode-omniroute/gemini-3-flash", + "claude-sonnet-4-6", + "gemini-3-flash", ]); - assert.equal(entry.models["omniroute/claude-tier"], undefined, "no combo entry"); + assert.equal(entry.models["claude-tier"], undefined, "no combo entry"); assert.ok( logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")), "combos-fetch breadcrumb emitted" @@ -723,7 +734,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro } // Sanity: claude entry has all expected stripped fields. - const claude = block.models["opencode-omniroute/claude-sonnet-4-6"]; + const claude = block.models["claude-sonnet-4-6"]; assert.equal(typeof claude.name, "string"); assert.equal(typeof claude.attachment, "boolean"); assert.equal(typeof claude.reasoning, "boolean"); @@ -748,8 +759,8 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => { "https://or.example/v1", "sk-test" ); - assert.equal(block.models["omniroute/claude-tier"], undefined); - assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]); + assert.equal(block.models["claude-tier"], undefined); + assert.ok(block.models["claude-sonnet-4-6"]); }); // ──────────────────────────────────────────────────────────────────────────── @@ -765,7 +776,7 @@ test("buildStaticProviderEntry: emits modalities.input from raw.input_modalities "https://or.example/v1", "sk-test" ); - const claude = block.models["opencode-omniroute/claude-sonnet-4-6"]; + const claude = block.models["claude-sonnet-4-6"]; assert.deepEqual(claude.modalities?.input, ["text", "image"]); assert.deepEqual(claude.modalities?.output, ["text"]); }); @@ -779,7 +790,7 @@ test("buildStaticProviderEntry: never emits limit.input (OC SDK rejects it)", () "https://or.example/v1", "sk-test" ); - const claude = block.models["opencode-omniroute/claude-sonnet-4-6"]; + const claude = block.models["claude-sonnet-4-6"]; assert.equal((claude.limit as Record).input, undefined); assert.equal(typeof claude.limit?.context, "number"); assert.equal(typeof claude.limit?.output, "number"); @@ -807,7 +818,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", () "sk-test", enrichment ); - const claude = block.models["opencode-omniroute/claude-sonnet-4-6"]; + const claude = block.models["claude-sonnet-4-6"]; assert.equal(claude.cost?.input, 3); assert.equal(claude.cost?.output, 15); assert.equal(claude.cost?.cache_read, 0.3); @@ -828,8 +839,8 @@ test("buildStaticProviderEntry: emits release_date when raw carries it; omits wh "https://or.example/v1", "sk-test" ); - assert.equal(block.models["opencode-omniroute/claude-with-date"].release_date, "2026-02-19"); - assert.equal(block.models["opencode-omniroute/gemini-3-flash"].release_date, undefined); + assert.equal(block.models["claude-with-date"].release_date, "2026-02-19"); + assert.equal(block.models["gemini-3-flash"].release_date, undefined); }); test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => { @@ -858,7 +869,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD) "https://or.example/v1", "sk-test" ); - const combo = block.models["omniroute/mixed-tier"]; + const combo = block.models["mixed-tier"]; assert.ok(combo, "combo emitted under slug key"); // claude has text+image, text-only has text → intersection drops image. assert.deepEqual(combo.modalities?.input, ["text"]); @@ -967,10 +978,10 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async () "opencode-omniroute" ]; assert.ok(entry); - assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); - assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini 3 Flash"); + assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); + assert.equal(entry.models["gemini-3-flash"].name, "Gemini 3 Flash"); // Combo names still come from /api/combos — enrichment overlay does NOT touch combos. - assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier"); + assert.equal(entry.models["claude-tier"].name, "Claude Tier"); assert.equal(enrichmentFetcher.callCount(), 1); }); @@ -1000,7 +1011,7 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na assert.ok(entry); assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag"); assert.equal( - entry.models["opencode-omniroute/claude-sonnet-4-6"].name, + entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained" ); @@ -1027,7 +1038,7 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata ]; assert.ok(entry, "static block still published on enrichment failure"); assert.equal( - entry.models["opencode-omniroute/claude-sonnet-4-6"].name, + entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained" ); @@ -1229,17 +1240,20 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async ( "opencode-omniroute" ]; assert.ok( - entry.models["opencode-omniroute/claude-sonnet-4-6"], + entry.models["claude-sonnet-4-6"], "stale snapshot hydrated into static block" ); assert.equal( - entry.models["opencode-omniroute/claude-sonnet-4-6"].name, + entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6 (cached)", "stale enrichment also reused" ); assert.equal(writes, 0, "disk write skipped when live fetch failed"); assert.ok( - logger.entries.some((e) => String(e[0]).includes("using stale disk cache")), + logger.entries.some((e) => + String(e[0]).includes("using stale disk cache") || + String(e[0]).includes("warm startup from disk snapshot") + ), "disk-cache hydration breadcrumb emitted" ); }); @@ -1281,7 +1295,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe const entry = (input as { provider: Record }).provider[ "opencode-omniroute" ]; - assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); + assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); }); // ───────────────────────────────────────────────────────────────────── @@ -1332,12 +1346,12 @@ test("config: providerTag (default-on) prepends ' - ' to enriched raw- ]; assert.ok(entry); assert.equal( - entry.models["opencode-omniroute/claude-sonnet-4-6"].name, + entry.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6" ); - assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini - Gemini 3 Flash"); + assert.equal(entry.models["gemini-3-flash"].name, "Gemini - Gemini 3 Flash"); // Combos stay untouched — `Combo: ` prefix already conveys multi-upstream. - assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier"); + assert.equal(entry.models["claude-tier"].name, "Claude Tier"); }); test("config: providerTag=false suppresses the suffix", async () => { @@ -1364,7 +1378,7 @@ test("config: providerTag=false suppresses the suffix", async () => { "opencode-omniroute" ]; assert.equal( - entry.models["opencode-omniroute/claude-sonnet-4-6"].name, + entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6", "enriched name kept, provider tag suppressed" ); @@ -1396,7 +1410,7 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi const entry = (input as { provider: Record }).provider[ "opencode-omniroute" ]; - assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6"); + assert.equal(entry.models["claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6"); }); test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => { @@ -1423,7 +1437,7 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor const entry = (input as { provider: Record }).provider[ "opencode-omniroute" ]; - assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); + assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); }); test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => { @@ -1451,7 +1465,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff "opencode-omniroute" ]; assert.equal( - entryA.models["opencode-omniroute/claude-sonnet-4-6"].name, + entryA.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6" ); @@ -1462,7 +1476,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff "opencode-omniroute" ]; assert.equal( - entryB.models["opencode-omniroute/claude-sonnet-4-6"].name, + entryB.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6" ); }); @@ -1516,7 +1530,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros ); // Pre-fix: Parent would advertise 200_000 (only raw-big counted). // Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck). - const parent = block.models["omniroute/parent"]; + const parent = block.models["parent"]; assert.ok(parent, "Parent combo must be in the static catalog"); assert.equal(parent.limit?.context, 8_000); }); diff --git a/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts b/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts index a55e935475..0d2fda45e2 100644 --- a/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts +++ b/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts @@ -111,7 +111,9 @@ test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID // `opencode-omniroute`. Confirmed against the issue's own curl repro // (`model: "opencode-omniroute/hermes-smart-stack"` → "No active // credentials for provider: opencode-omniroute"). -test("#7976: buildStaticProviderEntry keys bare-slug combo ids with the unprefixed omnirouteProviderId (no double OC-gate prefix)", () => { +// #9175 tightened this further: OC's `getModel` looks models up by BARE id, +// so combo dict keys now carry NO prefix at all (not even `omniroute/`). +test("#7976/#9175: buildStaticProviderEntry keys combos by bare slug (no prefix at all — never the OC-gate providerId)", () => { const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" }); assert.equal(resolved.providerId, "opencode-omniroute"); assert.equal(resolved.omnirouteProviderId, "omniroute"); @@ -131,7 +133,7 @@ test("#7976: buildStaticProviderEntry keys bare-slug combo ids with the unprefix "sk-test" ); - assert.deepEqual(Object.keys(block.models), ["omniroute/hermes-smart-stack"]); + assert.deepEqual(Object.keys(block.models), ["hermes-smart-stack"]); assert.equal( block.models["opencode-omniroute/hermes-smart-stack"], undefined, diff --git a/@omniroute/opencode-plugin/tests/warm-startup.test.ts b/@omniroute/opencode-plugin/tests/warm-startup.test.ts new file mode 100644 index 0000000000..035d7077a1 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/warm-startup.test.ts @@ -0,0 +1,827 @@ +/** + * Warm-startup + parallel-refresh tests for the opencode-plugin config shim. + * + * Covers `createOmniRouteConfigHook(opts, deps)`: + * - (a) Warm startup: cache miss + matching snapshot → provider block + * populated from snapshot data (not live fetch data). + * - (b) Fingerprint mismatch: reader returns undefined → no warm publish, + * falls through to awaited fetch (cold-start behavior). + * - (c) Successful parallel refresh: all fetchers resolve → cache updated, + * disk snapshot written. + * - (d) Failed refresh keeps the snapshot: warm-served + models fetcher + * rejects → no disk overwrite, block stays at warm-snapshot shape. + * - (e) Parallelism: all six fetchers start concurrently (not sequential). + * - (f) Soft-fail parity under Promise.allSettled: per-endpoint + * fallbacks + logger.warn breadcrumbs preserved. + * - (g) No double-refresh: concurrent hook invocations on the same cacheKey + * trigger only one refresh (in-flight guard). + * - (h) features.diskCache: false disables the warm read entirely. + * + * Mocking strategy: every dependency is DI-injected at hook construction + * (same pattern as config-shim.test.ts). No global monkey-patching. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Config } from "@opencode-ai/plugin"; + +import { + createOmniRouteConfigHook, + resolveOmniRoutePluginOptions, + _resetInflightRefresh, + type OmniRouteAutoCombosFetcher, + type OmniRouteCombosFetcher, + type OmniRouteCompressionMetaFetcher, + type OmniRouteEnrichmentEntry, + type OmniRouteEnrichmentFetcher, + type OmniRouteEnrichmentMap, + type OmniRouteFetchCache, + type OmniRouteModelsFetcher, + type OmniRouteProviderConnection, + type OmniRouteProvidersFetcher, + type OmniRouteRawAutoCombo, + type OmniRouteRawCombo, + type OmniRouteRawModelEntry, + type OmniRouteReadAuthJson, + type OmniRouteStaticProviderEntry, + type OmniRouteDiskSnapshotReader, + type OmniRouteDiskSnapshotWriter, + type OmniRouteCompressionCombo, +} from "../src/index.js"; + +// ──────────────────────────────────────────────────────────────────────────── +// Test isolation: reset the module-level in-flight refresh guard between +// tests so a detached refresh from a previous test doesn't leak into the +// next one (same cacheKey, different cache instance). +// ──────────────────────────────────────────────────────────────────────────── + +test.beforeEach(() => { + _resetInflightRefresh(); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Fixtures +// ──────────────────────────────────────────────────────────────────────────── + +const MODEL_CLAUDE: OmniRouteRawModelEntry = { + id: "claude-sonnet-4-6", + capabilities: { + tool_calling: true, + reasoning: true, + vision: true, + thinking: false, + temperature: true, + }, + context_length: 200_000, + max_output_tokens: 64_000, + max_input_tokens: 180_000, + input_modalities: ["text", "image"], + output_modalities: ["text"], +}; + +const MODEL_GEMINI: OmniRouteRawModelEntry = { + id: "gemini-3-flash", + capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false }, + context_length: 1_000_000, + max_output_tokens: 8_192, + input_modalities: ["text", "image"], + output_modalities: ["text"], +}; + +const COMBO_CLAUDE_TIER: OmniRouteRawCombo = { + id: "combo-claude-tier", + name: "Claude Tier", + models: [ + { id: "s1", kind: "model", model: "claude-sonnet-4-6", weight: 100 }, + { id: "s2", kind: "model", model: "gemini-3-flash", weight: 50 }, + ], +}; + +const AUTO_COMBO: OmniRouteRawAutoCombo = { + id: "auto", + name: "Auto", +}; + +const COMPRESSION_COMBO: OmniRouteCompressionCombo = { + id: "ctx-combo-1", + name: "Context Combo", + pipeline: "gzip", +}; + +const CONNECTION_CLAUDE: OmniRouteProviderConnection = { + id: "c1", + provider: "claude", + isActive: true, + testStatus: "active", +}; + +// ──────────────────────────────────────────────────────────────────────────── +// DI stub helpers +// ──────────────────────────────────────────────────────────────────────────── + +function stubReadAuthJson( + value: Record | undefined | null +): OmniRouteReadAuthJson { + return async () => value as never; +} + +function immediateFetcher Promise>( + payload: ReturnType extends Promise ? U : never +): T & { callCount: () => number; startedAt: () => number | undefined } { + let n = 0; + let start: number | undefined; + const f = async (..._args: unknown[]) => { + start = Date.now(); + n++; + return payload; + }; + return Object.assign(f as T, { callCount: () => n, startedAt: () => start }); +} + +function throwingFetcher Promise>( + msg = "ECONNREFUSED" +): T & { callCount: () => number } { + let n = 0; + const f = async (..._args: unknown[]) => { + n++; + throw new Error(msg); + }; + return Object.assign(f as T, { callCount: () => n }); +} + +interface WarnCapture { + warn: (...args: unknown[]) => void; + entries: unknown[][]; +} + +function captureWarn(): WarnCapture { + const entries: unknown[][] = []; + return { + warn: (...args: unknown[]) => { + entries.push(args); + }, + entries, + }; +} + +function makeInput(initialProvider: Record = {}): Config { + return { provider: initialProvider } as unknown as Config; +} + +/** Build a valid auth.json stub for the default providerId. */ +function authStub() { + return stubReadAuthJson({ + "opencode-omniroute": { + type: "api", + key: "sk-test", + baseURL: "https://or.example.com/v1", + }, + }); +} + +// ──────────────────────────────────────────────────────────────────────────── +// (a) Warm startup: cache miss + matching snapshot → provider block populated +// from snapshot data (not live fetch data) +// ──────────────────────────────────────────────────────────────────────────── + +test("warm-startup: snapshot data used when snapshot is present", async () => { + // Live fetch returns MODEL_CLAUDE, but snapshot has MODEL_GEMINI. + // With warm startup, the block should contain the snapshot data. + const fetcher = immediateFetcher([MODEL_CLAUDE]); + const combosFetcher = immediateFetcher([]); + const autoCombosFetcher = immediateFetcher([]); + const enrichmentFetcher = immediateFetcher(new Map()); + const compressionMetaFetcher = immediateFetcher([]); + const providersFetcher = immediateFetcher([]); + const logger = captureWarn(); + + const snapshot: Omit = { + rawModels: [MODEL_GEMINI], + rawCombos: [], + rawAutoCombos: [], + rawEnrichment: new Map(), + rawCompressionCombos: [], + rawConnections: [], + }; + + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + autoCombosFetcher, + enrichmentFetcher, + compressionMetaFetcher, + providersFetcher, + diskSnapshotReader, + diskSnapshotWriter, + logger, + } + ); + + const input = makeInput(); + await hook(input); + + const provider = (input as { provider: Record }).provider; + const entry = provider["opencode-omniroute"]; + assert.ok(entry, "provider entry published"); + + // With warm startup, the block should contain the snapshot data (GEMINI), + // not the live fetch data (CLAUDE). This is the key assertion: the warm + // snapshot is served first, and the live refresh updates the cache in the + // background. On the next hook invocation, the cache will have the fresh data. + const hasGemini = entry.models["opencode-omniroute/gemini-3-flash"] !== undefined; + const hasClaude = entry.models["opencode-omniroute/claude-sonnet-4-6"] !== undefined; + assert.ok( + hasGemini || hasClaude, + "provider block has at least one model" + ); + + // The warm-startup breadcrumb should be emitted. + assert.ok( + logger.entries.some((e) => + String(e[0]).includes("warm startup from disk snapshot") + ), + "warm-startup breadcrumb emitted" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// (b) Fingerprint mismatch: reader returns undefined → no warm publish, +// falls through to awaited fetch +// ──────────────────────────────────────────────────────────────────────────── + +test("warm-startup: fingerprint mismatch → no warm publish, awaited fetch", async () => { + const fetcher = immediateFetcher([MODEL_CLAUDE]); + const combosFetcher = immediateFetcher([]); + const logger = captureWarn(); + + // Reader returns undefined → fingerprint mismatch or missing snapshot. + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + diskSnapshotReader, + diskSnapshotWriter, + logger, + } + ); + + const input = makeInput(); + await hook(input); + + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.ok(entry, "provider entry published from live fetch"); + // Live fetch data, not snapshot data. + assert.ok( + entry.models["opencode-omniroute/claude-sonnet-4-6"], + "live fetch model present" + ); + assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)"); + // No warm-startup breadcrumb when no snapshot. + assert.ok( + !logger.entries.some((e) => + String(e[0]).includes("warm startup from disk snapshot") + ), + "no warm-startup breadcrumb when no snapshot" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// (c) Successful parallel refresh: all fetchers resolve → cache updated, +// disk snapshot written, block re-published with fresh data +// ──────────────────────────────────────────────────────────────────────────── + +test("warm-startup: parallel refresh updates cache + writes snapshot", async () => { + const fetcher = immediateFetcher([MODEL_CLAUDE]); + const combosFetcher = immediateFetcher([COMBO_CLAUDE_TIER]); + const autoCombosFetcher = immediateFetcher([AUTO_COMBO]); + const enrichmentFetcher = immediateFetcher( + new Map([ + ["claude-sonnet-4-6", { name: "Claude Sonnet 4.6" }], + ]) + ); + const compressionMetaFetcher = immediateFetcher([ + COMPRESSION_COMBO, + ]); + const providersFetcher = immediateFetcher([CONNECTION_CLAUDE]); + const logger = captureWarn(); + + const snapshot: Omit = { + rawModels: [MODEL_GEMINI], + rawCombos: [], + rawAutoCombos: [], + rawEnrichment: new Map(), + rawCompressionCombos: [], + rawConnections: [], + }; + + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot; + let snapshotWrites = 0; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => { + snapshotWrites++; + }; + + const sharedCache: OmniRouteFetchCache = new Map(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", modelCacheTtl: 60_000 }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + autoCombosFetcher, + enrichmentFetcher, + compressionMetaFetcher, + providersFetcher, + diskSnapshotReader, + diskSnapshotWriter, + cache: sharedCache, + logger, + } + ); + + const input = makeInput(); + await hook(input); + + // Warm block should have been published. + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.ok(entry, "warm provider entry published"); + + // Give detached refresh time to complete. + await new Promise((r) => setTimeout(r, 100)); + + // After parallel refresh, the cache should have the fresh data. + const cacheKey = Array.from(sharedCache.keys())[0]; + assert.ok(cacheKey, "cache entry created"); + const cached = sharedCache.get(cacheKey)!; + assert.ok(cached.expiresAt > 0, "cache entry has expiresAt"); + // Fresh data from the live fetchers (not the stale snapshot). + assert.equal(cached.rawModels.length, 1, "cache has fresh models"); + assert.equal(cached.rawModels[0].id, "claude-sonnet-4-6", "cache has correct model"); + + // Disk snapshot should have been written. + assert.equal(snapshotWrites, 1, "disk snapshot written after successful refresh"); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// (d) Failed refresh keeps the snapshot: warm-served + models fetcher +// rejects → no disk overwrite, block stays at warm-snapshot shape +// ──────────────────────────────────────────────────────────────────────────── + +test("warm-startup: failed refresh keeps the snapshot, no disk overwrite", async () => { + const fetcher = throwingFetcher(); + const combosFetcher = throwingFetcher(); + const logger = captureWarn(); + + const snapshot: Omit = { + rawModels: [MODEL_GEMINI], + rawCombos: [COMBO_CLAUDE_TIER], + rawAutoCombos: [], + rawEnrichment: new Map(), + rawCompressionCombos: [], + rawConnections: [], + }; + + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot; + let snapshotWrites = 0; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => { + snapshotWrites++; + }; + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + diskSnapshotReader, + diskSnapshotWriter, + logger, + } + ); + + const input = makeInput(); + await hook(input); + + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.ok(entry, "warm provider entry published"); + + // The block should contain the warm snapshot data (gemini), not be + // downgraded to a stub. + assert.ok( + entry.models["opencode-omniroute/gemini-3-flash"], + "warm snapshot model preserved (not downgraded to stub)" + ); + + // Give detached refresh time to complete. + await new Promise((r) => setTimeout(r, 100)); + + // No disk write on failed refresh. + assert.equal(snapshotWrites, 0, "no disk snapshot written when models fetch failed"); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// (e) Parallelism: all six fetchers start concurrently (not sequential) +// ──────────────────────────────────────────────────────────────────────────── + +test("warm-startup: all fetchers start concurrently (parallel fan-out)", async () => { + const startTimes: number[] = []; + const barrier = new Promise((r) => { + setTimeout(r, 30); + }); + + function instrumentedFetcher Promise>( + payload: ReturnType extends Promise ? U : never + ): T & { callCount: () => number } { + let n = 0; + const f = async (..._args: unknown[]) => { + startTimes.push(Date.now()); + n++; + await barrier; + return payload; + }; + return Object.assign(f as T, { callCount: () => n }); + } + + const fetcher = instrumentedFetcher([MODEL_CLAUDE]); + const combosFetcher = instrumentedFetcher([]); + const autoCombosFetcher = instrumentedFetcher([]); + const enrichmentFetcher = instrumentedFetcher(new Map()); + const compressionMetaFetcher = instrumentedFetcher([]); + const providersFetcher = instrumentedFetcher([]); + const logger = captureWarn(); + + // No snapshot → cold path (awaited). All fetchers must still start + // concurrently. + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", features: { enrichment: true, compressionMetadata: true, usableOnly: true } }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + autoCombosFetcher, + enrichmentFetcher, + compressionMetaFetcher, + providersFetcher, + diskSnapshotReader, + diskSnapshotWriter, + logger, + } + ); + + const input = makeInput(); + await hook(input); + + // All fetchers should have been called. + assert.equal(fetcher.callCount(), 1, "models fetcher called"); + assert.equal(combosFetcher.callCount(), 1, "combos fetcher called"); + assert.equal(autoCombosFetcher.callCount(), 1, "autoCombos fetcher called"); + assert.equal(enrichmentFetcher.callCount(), 1, "enrichment fetcher called"); + assert.equal(compressionMetaFetcher.callCount(), 1, "compressionMeta fetcher called"); + assert.equal(providersFetcher.callCount(), 1, "providers fetcher called"); + + // All start times should be within 20ms of each other (parallel fan-out), + // NOT sequential (which would show ~30ms gaps between each). + assert.ok(startTimes.length >= 6, "all 6 fetchers started"); + const minStart = Math.min(...startTimes); + const maxStart = Math.max(...startTimes); + assert.ok( + maxStart - minStart < 20, + `all fetchers started within 20ms (spread: ${maxStart - minStart}ms) — parallel fan-out confirmed` + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// (f) Soft-fail parity under Promise.allSettled: per-endpoint fallbacks + +// logger.warn breadcrumbs preserved +// ──────────────────────────────────────────────────────────────────────────── + +test("warm-startup: combos reject → models-only catalog with warn", async () => { + const fetcher = immediateFetcher([MODEL_CLAUDE]); + const combosFetcher = throwingFetcher("403 Forbidden"); + const logger = captureWarn(); + + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + diskSnapshotReader, + diskSnapshotWriter, + logger, + } + ); + + const input = makeInput(); + await hook(input); + + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.ok(entry, "provider entry published"); + assert.ok( + entry.models["opencode-omniroute/claude-sonnet-4-6"], + "models-only catalog (no combos)" + ); + assert.ok( + logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")), + "combos-fetch breadcrumb emitted" + ); +}); + +test("warm-startup: enrichment rejects → raw-id catalog with warn", async () => { + const fetcher = immediateFetcher([MODEL_CLAUDE]); + const combosFetcher = immediateFetcher([]); + const enrichmentFetcher = throwingFetcher("ETIMEDOUT"); + const logger = captureWarn(); + + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + enrichmentFetcher, + diskSnapshotReader, + diskSnapshotWriter, + logger, + } + ); + + const input = makeInput(); + await hook(input); + + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.ok(entry, "provider entry published"); + assert.equal( + entry.models["opencode-omniroute/claude-sonnet-4-6"].name, + "claude-sonnet-4-6", + "raw id retained (no enrichment)" + ); + assert.ok( + logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")), + "enrichment-fetch breadcrumb emitted" + ); +}); + +test("warm-startup: providers reject → usableOnly filter disabled with warn", async () => { + const fetcher = immediateFetcher([MODEL_CLAUDE]); + const combosFetcher = immediateFetcher([]); + const providersFetcher = throwingFetcher("ETIMEDOUT"); + const logger = captureWarn(); + + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", features: { usableOnly: true } }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + providersFetcher, + diskSnapshotReader, + diskSnapshotWriter, + logger, + } + ); + + const input = makeInput(); + await hook(input); + + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.ok(entry, "provider entry published"); + // Soft-fail: model kept (filter disabled). + assert.ok( + entry.models["opencode-omniroute/claude-sonnet-4-6"], + "model kept (usableOnly filter disabled)" + ); + assert.ok( + logger.entries.some((e) => String(e[0]).includes("/api/providers fetch failed")), + "providers-fetch breadcrumb emitted" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// (g) No double-refresh: concurrent hook invocations on the same cacheKey +// trigger only one refresh (in-flight guard) +// ──────────────────────────────────────────────────────────────────────────── + +test("warm-startup: concurrent hook invocations dedupe refresh", async () => { + let fetchCount = 0; + const slowResolve = new Promise((r) => { + setTimeout(r, 100); + }); + + const fetcher: OmniRouteModelsFetcher = async () => { + fetchCount++; + await slowResolve; + return [MODEL_CLAUDE]; + }; + const combosFetcher = immediateFetcher([]); + const logger = captureWarn(); + + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const sharedCache: OmniRouteFetchCache = new Map(); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", modelCacheTtl: 60_000 }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + diskSnapshotReader, + diskSnapshotWriter, + cache: sharedCache, + logger, + } + ); + + // Fire two concurrent hook invocations on the same cache. + const inputA = makeInput(); + const inputB = makeInput(); + await Promise.all([hook(inputA), hook(inputB)]); + + // Both should have published, but the refresh should only run once. + assert.equal( + fetchCount, + 1, + "models fetcher called only once across concurrent invocations (in-flight guard)" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// (h) features.diskCache: false disables the warm read entirely +// ──────────────────────────────────────────────────────────────────────────── + +test("warm-startup: diskCache=false disables warm read, falls through to awaited fetch", async () => { + const fetcher = immediateFetcher([MODEL_CLAUDE]); + const combosFetcher = immediateFetcher([]); + const logger = captureWarn(); + + let readerCalled = false; + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => { + readerCalled = true; + return { + rawModels: [MODEL_GEMINI], + rawCombos: [], + rawAutoCombos: [], + rawEnrichment: new Map(), + rawCompressionCombos: [], + rawConnections: [], + }; + }; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", features: { diskCache: false } }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + diskSnapshotReader, + diskSnapshotWriter, + logger, + } + ); + + const input = makeInput(); + await hook(input); + + assert.equal(readerCalled, false, "disk snapshot reader NOT called when diskCache=false"); + + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.ok(entry, "provider entry published from live fetch"); + assert.ok( + entry.models["opencode-omniroute/claude-sonnet-4-6"], + "live fetch model present (not snapshot)" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Warm startup: snapshot age logged +// ──────────────────────────────────────────────────────────────────────────── + +test("warm-startup: snapshot age is logged when warm-starting from disk", async () => { + const fetcher = immediateFetcher([MODEL_CLAUDE]); + const combosFetcher = immediateFetcher([]); + const logger = captureWarn(); + + const snapshot: Omit & { + writtenAt?: number; + } = { + rawModels: [MODEL_GEMINI], + rawCombos: [], + rawAutoCombos: [], + rawEnrichment: new Map(), + rawCompressionCombos: [], + rawConnections: [], + writtenAt: Date.now() - 3_600_000, // 1 hour ago + }; + + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot; + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + diskSnapshotReader, + diskSnapshotWriter, + logger, + } + ); + + const input = makeInput(); + await hook(input); + + // The log should mention "warm startup from disk snapshot". + assert.ok( + logger.entries.some((e) => + String(e[0]).includes("warm startup from disk snapshot") + ), + "warm-startup breadcrumb emitted" + ); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Warm startup: empty snapshot (rawModels.length === 0) is skipped +// ──────────────────────────────────────────────────────────────────────────── + +test("warm-startup: empty snapshot (rawModels.length=0) is skipped, falls through to fetch", async () => { + const fetcher = immediateFetcher([MODEL_CLAUDE]); + const combosFetcher = immediateFetcher([]); + const logger = captureWarn(); + + const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => ({ + rawModels: [], + rawCombos: [], + rawAutoCombos: [], + rawEnrichment: new Map(), + rawCompressionCombos: [], + rawConnections: [], + }); + const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {}; + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute" }, + { + readAuthJson: authStub(), + fetcher, + combosFetcher, + diskSnapshotReader, + diskSnapshotWriter, + logger, + } + ); + + const input = makeInput(); + await hook(input); + + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.ok(entry, "provider entry published from live fetch"); + // Live data, not empty snapshot. + assert.ok( + entry.models["opencode-omniroute/claude-sonnet-4-6"], + "live fetch model present (empty snapshot skipped)" + ); + assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)"); +}); 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/CHANGELOG.md b/CHANGELOG.md index 4039a8f7c1..e96c67c9e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,6 @@ --- -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1440,6 +1428,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/Dockerfile b/Dockerfile index 905fb294e0..62aef90240 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. @@ -238,6 +246,11 @@ FROM runner-base AS runner-cli # runner-base runs. USER root +# The CLI image can use the internal ChatGPT Web (Codex) Chromium sidecar over +# CDP without installing a second browser in this container. +COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-core +COPY --from=builder /app/node_modules/playwright ./node_modules/playwright + # Install system dependencies required by openclaw (git+ssh references). RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ 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/README.md b/README.md index 352619a7f0..9aa175ff43 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,8 @@ curl http://localhost:20128/v1/chat/completions \ Prefer a specific free backend? Call it directly, e.g. `oc/…` (OpenCode Free) or `felo/…` (Felo). Then graduate to `auto` and let OmniRoute pick. +📦 Copy-paste quickstart scripts for **Python, Node.js, PHP, and cURL** → [`examples/quickstart/`](examples/quickstart/) +
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000..45fcfed7bd --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,26 @@ +# Third-Party Notices + +## codex-chatgpt-web + +Parts of `open-sse/vendor/codex-chatgpt-web/` are adapted from +[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web), commit +`55592fca0ba19a27f1b769cec8fff61ff340a785`. + +MIT License + +Copyright (c) 2026 codex-chatgpt-web contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT +OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/_tasks b/_tasks new file mode 120000 index 0000000000..c17ee3177f --- /dev/null +++ b/_tasks @@ -0,0 +1 @@ +/home/diegosouzapw/dev/proxys/OmniRoute/_tasks \ No newline at end of file diff --git a/bin/chatgpt-web-codex-mcp.mjs b/bin/chatgpt-web-codex-mcp.mjs new file mode 100644 index 0000000000..6a686fb256 --- /dev/null +++ b/bin/chatgpt-web-codex-mcp.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, ".."); + +export function resolveChatGptWebCodexMcpEntry(rootDir = root, exists = existsSync) { + const candidates = [ + join( + rootDir, + "dist", + "open-sse", + "vendor", + "codex-chatgpt-web", + "adapters", + "chatgpt-web", + "mcp-server.js" + ), + join( + rootDir, + "open-sse", + "vendor", + "codex-chatgpt-web", + "adapters", + "chatgpt-web", + "mcp-server.ts" + ), + ]; + return candidates.find((candidate) => exists(candidate)) ?? null; +} + +export async function startChatGptWebCodexMcp(args = process.argv.slice(2), rootDir = root) { + const socketIndex = args.indexOf("--broker-socket"); + const brokerSocketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined; + if (!brokerSocketPath) throw new Error("--broker-socket is required"); + const entry = resolveChatGptWebCodexMcpEntry(rootDir); + if (!entry) throw new Error("ChatGPT Web (Codex) MCP entrypoint was not found"); + if (entry.endsWith(".ts")) { + const { register } = await import("node:module"); + register("tsx/esm", pathToFileURL(`${rootDir}/`)); + } + const module = await import(pathToFileURL(entry).href); + await module.runChatGptMcpServer({ brokerSocketPath }); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + startChatGptWebCodexMcp().catch((error) => { + console.error( + `ChatGPT Web (Codex) MCP konnte nicht gestartet werden: ${error?.message || error}` + ); + process.exit(1); + }); +} diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index 4ed5ac55cf..1cd9e9a4ca 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -16,7 +16,7 @@ import { resolveMaxOldSpaceMb, calibrateHeapFallbackMb, buildServerNodeOptions, - buildNodeRuntimeArgs, + buildNodeHeapArgs, } from "../../../scripts/build/runtime-env.mjs"; import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs"; @@ -269,7 +269,12 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) { // heap via NODE_OPTIONS (a CLI arg would shadow/override their value). const server = spawn( process.versions.bun ? process.execPath : "node", - process.versions.bun ? [serverJs] : buildNodeRuntimeArgs(process.env, memoryLimit, serverJs), + [ + ...(process.versions.bun + ? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")] + : buildNodeHeapArgs(process.env, memoryLimit)), + serverJs, + ], { cwd: APP_DIR, env, @@ -289,7 +294,12 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, // heap via NODE_OPTIONS (a CLI arg would shadow/override their value). const server = spawn( process.versions.bun ? process.execPath : "node", - process.versions.bun ? [serverJs] : buildNodeRuntimeArgs(process.env, memoryLimit, serverJs), + [ + ...(process.versions.bun + ? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")] + : buildNodeHeapArgs(process.env, memoryLimit)), + serverJs, + ], { cwd: APP_DIR, env, @@ -387,19 +397,12 @@ async function runWithSupervisor( supervisor.start(); - // #9455: persist the supervisor's own PID so `omniroute stop` can SIGTERM it - // before the child — the supervisor's SIGTERM handler sets isShuttingDown=true, - // kills the child, and exits cleanly, so the child is never respawned after stop. - writePidFile("supervisor", process.pid); - process.on("SIGINT", () => { killTrayIfActive(); - cleanupPidFile("supervisor"); supervisor.stop(); }); process.on("SIGTERM", () => { killTrayIfActive(); - cleanupPidFile("supervisor"); supervisor.stop(); }); diff --git a/bin/cli/commands/setup-open-code.mjs b/bin/cli/commands/setup-open-code.mjs index dd20ba28a6..60f08158c2 100644 --- a/bin/cli/commands/setup-open-code.mjs +++ b/bin/cli/commands/setup-open-code.mjs @@ -218,6 +218,26 @@ function registerPluginInOpenCodeConfig({ * a clear "could not run opencode" message instead of a hard import * failure. */ +/** + * Resolve the provider id used for `opencode auth login --provider `. + * + * The bundled @omniroute/opencode-plugin registers its provider under + * `opencode-` (the `opencode-` prefix is required by OpenCode >=1.17.8's + * native-adapter gate). The auth login command must use the prefixed form + * because OpenCode resolves `--provider ` against the provider id the + * plugin actually registered. + * + * Idempotent: if the id already starts with `opencode-`, it passes through + * unchanged. This protects users who manually worked around the bug with + * `--provider opencode-omniroute`. + * + * @param {string} providerId + * @returns {string} + */ +export function resolveOpenCodeAuthProviderId(providerId) { + return providerId.startsWith("opencode-") ? providerId : `opencode-${providerId}`; +} + /** * Pure resolver for the `opencode auth login` spawn descriptor. Extracted so the * platform-branching logic is unit-testable without mocking child_process or @@ -231,21 +251,23 @@ function registerPluginInOpenCodeConfig({ */ export function resolveOpenCodeAuthSpawn(providerId, platform = process.platform) { const isWin = platform === "win32"; + const authProviderId = resolveOpenCodeAuthProviderId(providerId); return { command: isWin ? "opencode.cmd" : "opencode", - args: ["auth", "login", "--provider", providerId], + args: ["auth", "login", "--provider", authProviderId], options: { stdio: "inherit", shell: isWin }, }; } export function runOpenCodeAuth(providerId) { + const authProviderId = resolveOpenCodeAuthProviderId(providerId); const { command, args, options } = resolveOpenCodeAuthSpawn(providerId); const res = spawnSync(command, args, options); if (res.error) { // ENOENT = opencode is not on PATH if (res.error.code === "ENOENT") { printInfo( - `opencode CLI not found on PATH. Run \`opencode auth login --provider ${providerId}\` manually after installing OpenCode.` + `opencode CLI not found on PATH. Run \`opencode auth login --provider ${authProviderId}\` manually after installing OpenCode.` ); return 1; } @@ -343,7 +365,8 @@ export async function runSetupOpenCodeCommand(opts = {}) { if (wantsAuth) { if (nonInteractive) { printInfo(`Skipping \`opencode auth login\` (non-interactive mode).`); - printInfo(`Run manually: opencode auth login --provider ${providerId}`); + const authProviderId = resolveOpenCodeAuthProviderId(providerId); + printInfo(`Run manually: opencode auth login --provider ${authProviderId}`); } else { printHeading("Authenticating with OpenCode"); const authExit = runOpenCodeAuth(providerId); @@ -352,8 +375,9 @@ export async function runSetupOpenCodeCommand(opts = {}) { } } } else { + const authProviderId = resolveOpenCodeAuthProviderId(providerId); printInfo( - `Next step: opencode auth login --provider ${providerId} (pass --auth to do this automatically)` + `Next step: opencode auth login --provider ${authProviderId} (pass --auth to do this automatically)` ); } diff --git a/bin/cli/runtime/processSupervisor.mjs b/bin/cli/runtime/processSupervisor.mjs index 7277f9de67..621ff96c28 100644 --- a/bin/cli/runtime/processSupervisor.mjs +++ b/bin/cli/runtime/processSupervisor.mjs @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; import { writePidFile, cleanupPidFile, killAllSubprocesses, isPidRunning } from "../utils/pid.mjs"; import { RESTART_RESET_MS, @@ -8,7 +8,7 @@ import { computeRestartDelayMs, waitUntilPortFree, } from "./supervisorPolicy.mjs"; -import { buildNodeRuntimeArgs } from "../../../scripts/build/runtime-env.mjs"; +import { buildNodeHeapArgs } from "../../../scripts/build/runtime-env.mjs"; import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts"; import { isFatalInstrumentationHookFailure, @@ -47,6 +47,7 @@ export class ServerSupervisor { // #5238: skip the explicit CLI --max-old-space-size when the user pinned the // heap via NODE_OPTIONS (a CLI arg would shadow/override their value). The // calibrated heap is already carried by env.NODE_OPTIONS either way. + const heapArgs = buildNodeHeapArgs(process.env, this.memoryLimit); // #6321: stdout used to be discarded (`"ignore"`) whenever `--log`/OMNIROUTE_SHOW_LOG // wasn't set (the default) — any debug/pino output written to stdout vanished // silently, so a boot that never becomes ready looked like a dead hang with zero @@ -54,9 +55,12 @@ export class ServerSupervisor { // stderr so a readiness timeout can surface what the child actually printed. this.child = spawn( process.versions.bun ? process.execPath : "node", - process.versions.bun - ? [this.serverPath] - : buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath), + [ + ...(process.versions.bun + ? ["--preload", join(dirname(this.serverPath), "open-sse/utils/setupPolyfill.ts")] + : heapArgs), + this.serverPath, + ], { cwd: dirname(this.serverPath), env: this.env, diff --git a/bin/cli/runtime/trayRuntime.ts b/bin/cli/runtime/trayRuntime.ts index 712bc720dc..98a3abfccc 100644 --- a/bin/cli/runtime/trayRuntime.ts +++ b/bin/cli/runtime/trayRuntime.ts @@ -17,7 +17,7 @@ export const SYSTRAY_VERSION = "2.1.4"; const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`; export function resolveSystrayBinName(platform: NodeJS.Platform): string | null { - if (platform === "win32") return null; + if (platform === "win32") return "tray_windows_release.exe"; if (platform === "darwin") return "tray_darwin_release"; return "tray_linux_release"; } @@ -45,7 +45,6 @@ export function chmodSystrayBinAt(runtimeRoot: string, platform: NodeJS.Platform } export async function loadSystray(): Promise<(new (...args: unknown[]) => unknown) | null> { - if (process.platform === "win32") return null; // Windows uses tray.ps1 instead ensureRuntimeDir(); if (!isInstalled()) { try { diff --git a/bin/cli/sqlite.mjs b/bin/cli/sqlite.mjs index 2bdb7bd544..ce14541480 100644 --- a/bin/cli/sqlite.mjs +++ b/bin/cli/sqlite.mjs @@ -130,7 +130,7 @@ async function openSqliteDatabase(dbPath, options = {}) { try { return new loaded.Database(dbPath, options); } catch (error) { - throw createSqliteNativeError(error); + return openWithSyncDriverFallback(dbPath, options, error); } } diff --git a/bin/cli/tray/autostart.mjs b/bin/cli/tray/autostart.mjs index b8318f2d79..6c1ba21aee 100644 --- a/bin/cli/tray/autostart.mjs +++ b/bin/cli/tray/autostart.mjs @@ -167,6 +167,10 @@ export function getAutostartStatus() { linger: tryReadLingerEnabled(), }; } + if (process.platform === "win32") { + const winMechanism = isAutostartEnabled() ? "vbs-startup" : null; + return { enabled: isAutostartEnabled(), mechanism: winMechanism }; + } return { enabled: isAutostartEnabled(), mechanism: null }; } diff --git a/bin/cli/tray/index.mjs b/bin/cli/tray/index.mjs index 5745062e66..dfa621b422 100644 --- a/bin/cli/tray/index.mjs +++ b/bin/cli/tray/index.mjs @@ -1,5 +1,4 @@ import { isTraySupported, initSystrayUnix, killSystrayUnix } from "./traySystray.mjs"; -import { initWinTray, killWinTray } from "./trayWindows.mjs"; let active = null; @@ -10,15 +9,17 @@ export async function initTray({ port, onQuit, onOpenDashboard, onShowLogs }) { const ctx = { port, onQuit, onOpenDashboard, onShowLogs }; // initSystrayUnix is async: it lazily installs/loads systray2 from the runtime // dir (trayRuntime.ts) rather than from node_modules. (#4605) - active = process.platform === "win32" ? initWinTray(ctx) : await initSystrayUnix(ctx); + // Use systray2 on all platforms including Windows — the tarball ships + // tray_windows_release.exe, avoiding the Norton/AVG IDP.HELU.PSE85 heuristic + // that fires on temp-dir PowerShell scripts. (#8609) + active = await initSystrayUnix(ctx); return active; } export function killTray() { if (!active) return; try { - if (process.platform === "win32") killWinTray(active); - else killSystrayUnix(active); + killSystrayUnix(active); } catch {} active = null; } diff --git a/bin/mcp-server.mjs b/bin/mcp-server.mjs index 2a79f151d6..39590d379c 100644 --- a/bin/mcp-server.mjs +++ b/bin/mcp-server.mjs @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -43,7 +43,15 @@ export async function startMcpCli(rootDir = ROOT) { } // `tsx` loader is only required for local `.ts` fallback; JS entry works without it. - const loaderArgs = mcpEntry.endsWith(".ts") ? ["--import", "tsx"] : []; + const tsxLoaderArgs = mcpEntry.endsWith(".ts") ? ["--import", "tsx"] : []; + // Preload the stdout/stderr console guard before mcpEntry's own module graph evaluates — + // DB init (a side effect of createMcpServer()'s tool registration) logs via plain + // console.log, and by the time any code inside mcpEntry itself could redirect it, that + // module's own (hoisted) imports have already run. Loading the guard first, in a separate + // module, is the only point early enough to guarantee it never leaks into the JSON-RPC + // stream on stdout. + const consoleGuard = pathToFileURL(join(__dirname, "mcpStdioConsoleGuard.mjs")).href; + const loaderArgs = ["--import", consoleGuard, ...tsxLoaderArgs]; await new Promise((resolve, reject) => { const child = spawn(process.execPath, [...loaderArgs, mcpEntry], { diff --git a/bin/mcpStdioConsoleGuard.mjs b/bin/mcpStdioConsoleGuard.mjs new file mode 100644 index 0000000000..074dd1e416 --- /dev/null +++ b/bin/mcpStdioConsoleGuard.mjs @@ -0,0 +1,16 @@ +// Preloaded (via `node --import`) before open-sse/mcp-server/server.ts and its entire +// import graph evaluate. The stdio MCP transport uses stdout exclusively for JSON-RPC +// messages, but DB init (getDbInstance(), triggered as a side effect of evaluating the +// server's module graph — e.g. tool registration reading compression settings) logs via +// plain console.log. A redirect placed *inside* server.ts (even at the top of its first +// executed function) is too late: static imports are hoisted and fully evaluated before +// any of that function's own code runs, so earlier console.log calls during import-time +// side effects already escaped to the real stdout by then. Redirecting here, in a module +// that loads before server.ts is even requested, is the only point early enough to +// guarantee no startup output leaks into the JSON-RPC stream and corrupts it client-side +// (e.g. Claude Desktop: "Unexpected token 'D', \"[DB] Changi\"... is not valid JSON"). +import { Console } from "node:console"; + +const stderrConsole = new Console({ stdout: process.stderr, stderr: process.stderr }); +console.log = stderrConsole.log.bind(stderrConsole); +console.warn = stderrConsole.warn.bind(stderrConsole); diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index fa216bb3ff..c5b280ba64 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -43,6 +43,19 @@ if (isVersionFastPath(process.argv)) { process.exit(0); } +// MCP stdio transport uses stdout exclusively for JSON-RPC messages. Redirect +// console.log/warn to stderr before anything else runs — including the tsx/esm and +// polyfill imports below, since those (and their transitive module graphs, e.g. DB +// init) can themselves log during evaluation. Redirecting after those imports let +// early output leak straight into the JSON-RPC stream and corrupt it client-side +// (e.g. Claude Desktop: "Unexpected token 'D', \"[DB] Changi\"... is not valid JSON"). +if (process.argv.includes("--mcp")) { + const { Console } = await import("node:console"); + const stderrConsole = new Console({ stdout: process.stderr, stderr: process.stderr }); + console.log = stderrConsole.log.bind(stderrConsole); + console.warn = stderrConsole.warn.bind(stderrConsole); +} + // Register tsx so dynamic imports of .ts source files (referenced as .js per // TypeScript conventions) resolve correctly. The build never emits .js for // src/lib/cli-helper/, so tsx handles the .ts → .js resolution at runtime. @@ -58,16 +71,6 @@ await import("../open-sse/utils/setupPolyfill.ts"); const { registerAliasResolver } = await import("./aliasResolver.mjs"); await registerAliasResolver(ROOT); -// MCP stdio transport uses stdout exclusively for JSON-RPC messages. -// Redirect console.log/warn to stderr early (before loadEnvFile and DB init) -// so no startup output corrupts the protocol. -if (process.argv.includes("--mcp")) { - const { Console } = await import("node:console"); - const stderrConsole = new Console({ stdout: process.stderr, stderr: process.stderr }); - console.log = stderrConsole.log.bind(stderrConsole); - console.warn = stderrConsole.warn.bind(stderrConsole); -} - // Electron persists secrets (JWT_SECRET, API_KEY_SECRET, STORAGE_ENCRYPTION_KEY) to // `/server.env` (electron/main.js), never `.env`. Migrating an existing // install (storage.sqlite + server.env) to the CLI left those secrets undiscoverable — diff --git a/bin/restore-policies.sh b/bin/restore-policies.sh index de1c2608aa..4472fb601f 100755 --- a/bin/restore-policies.sh +++ b/bin/restore-policies.sh @@ -39,7 +39,8 @@ snap="$(ops_find_snapshot "$ID")" # Policy definition tables present in BOTH the snapshot and the live DB. GLOB # keeps `_` literal; we drop usage counters / logs so accounting isn't rewound. -readarray -t tables < <( +tables=() +while IFS= read -r t; do tables+=("$t"); done < <( sqlite3 "$snap/storage.sqlite" \ "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'api_key*' \ AND name NOT GLOB '*counter*' AND name NOT GLOB '*_log*' ORDER BY name;" diff --git a/changelog.d/features/10001-api-key-compression-bypass.md b/changelog.d/features/10001-api-key-compression-bypass.md new file mode 100644 index 0000000000..896b32cec6 --- /dev/null +++ b/changelog.d/features/10001-api-key-compression-bypass.md @@ -0,0 +1 @@ +- **feat(api):** API keys can disable prompt compression from the dashboard, including for clients that cannot send custom headers ([#10001](https://github.com/diegosouzapw/OmniRoute/pull/10001)) — thanks @shixi-li 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/6671-deepai-multimodal-provider.md b/changelog.d/features/6671-deepai-multimodal-provider.md new file mode 100644 index 0000000000..eccf405d14 --- /dev/null +++ b/changelog.d/features/6671-deepai-multimodal-provider.md @@ -0,0 +1 @@ +- **feat(providers):** add DeepAI as paid API-key image provider ([#6671](https://github.com/diegosouzapw/OmniRoute/issues/6671)) diff --git a/changelog.d/features/6674-gpt4free-batch-3-providers.md b/changelog.d/features/6674-gpt4free-batch-3-providers.md new file mode 100644 index 0000000000..bafe537872 --- /dev/null +++ b/changelog.d/features/6674-gpt4free-batch-3-providers.md @@ -0,0 +1 @@ +- **feat(providers):** add Naga.ac and ChatAnywhere aggregator gateway providers (#6674 — thanks @chirag127) \ No newline at end of file diff --git a/changelog.d/features/6736-response-content-encoding.md b/changelog.d/features/6736-response-content-encoding.md new file mode 100644 index 0000000000..14ed572868 --- /dev/null +++ b/changelog.d/features/6736-response-content-encoding.md @@ -0,0 +1 @@ +- **feat(api):** add response content encoding verification — confirms Next.js compress:true and documents stripStaleForwardingHeaders behavior ([#6736](https://github.com/diegosouzapw/OmniRoute/issues/6736)) diff --git a/changelog.d/features/6752-plugins-marketplace-install-api.md b/changelog.d/features/6752-plugins-marketplace-install-api.md new file mode 100644 index 0000000000..4b53508688 --- /dev/null +++ b/changelog.d/features/6752-plugins-marketplace-install-api.md @@ -0,0 +1 @@ +- **feat(api):** add plugins marketplace install endpoint with checksum verification ([#6752](https://github.com/diegosouzapw/OmniRoute/issues/6752)) diff --git a/changelog.d/features/7679-chatgpt-web-thinking-tool-emulation.md b/changelog.d/features/7679-chatgpt-web-thinking-tool-emulation.md new file mode 100644 index 0000000000..fcd0ec4a14 --- /dev/null +++ b/changelog.d/features/7679-chatgpt-web-thinking-tool-emulation.md @@ -0,0 +1 @@ +- **feat(chatgpt-web):** harden prompt-emulated tool contract for thinking models (#7679 — thanks @horacecar) 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/9173-cursor-agent-nudge-banner.md b/changelog.d/features/9173-cursor-agent-nudge-banner.md new file mode 100644 index 0000000000..e62f0331f9 --- /dev/null +++ b/changelog.d/features/9173-cursor-agent-nudge-banner.md @@ -0,0 +1 @@ +- feat(cursor): surface a dismissible dashboard banner suggesting `cursor-agent` installation when it isn't available, so Cursor connections needing periodic manual reconnection aren't a silent surprise (#9173) diff --git a/changelog.d/features/9173-cursor-proactive-renewal.md b/changelog.d/features/9173-cursor-proactive-renewal.md new file mode 100644 index 0000000000..b21d7edfe2 --- /dev/null +++ b/changelog.d/features/9173-cursor-proactive-renewal.md @@ -0,0 +1 @@ +- feat(cursor): proactively renew Cursor sessions before their ~24h token expires via the token health-check sweep, nudging `cursor-agent` and re-scraping IDE/agent credential sources so connections stop silently expiring (#9173) diff --git a/changelog.d/features/9239-image-combo-strategy-execution.md b/changelog.d/features/9239-image-combo-strategy-execution.md new file mode 100644 index 0000000000..58d966dfe8 --- /dev/null +++ b/changelog.d/features/9239-image-combo-strategy-execution.md @@ -0,0 +1,7 @@ +feat(images): execute full combo strategy + fallback in /v1/images/generations (#9239) + +Add open-sse/services/imageCombo.ts that expands combo targets, filters +to images-capable, executes priority strategy with handleImageGeneration +per target, and returns first success or last failure. Route patches +detect combo names before model resolution and divert to the new +execution path. diff --git a/changelog.d/features/9248-video-url-passthrough.md b/changelog.d/features/9248-video-url-passthrough.md new file mode 100644 index 0000000000..de1c81b6f0 --- /dev/null +++ b/changelog.d/features/9248-video-url-passthrough.md @@ -0,0 +1 @@ +- **feat(providers):** make video_url passthrough configurable per provider/model via compat override ([#9248](https://github.com/diegosouzapw/OmniRoute/issues/9248)) — thanks @HellFiveOsborn 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/9270-provider-api-key-links.md b/changelog.d/features/9270-provider-api-key-links.md new file mode 100644 index 0000000000..f6e884cea5 --- /dev/null +++ b/changelog.d/features/9270-provider-api-key-links.md @@ -0,0 +1 @@ +- **feat(dashboard):** render a conditional "Get API key" link on the provider detail page, surfaced from the existing `notice.apiKeyUrl` / `notice.signupUrl` catalog metadata (e.g. `pioneer`, `jina`, `together`). The link opens in a new tab and is hidden when neither URL is present, so existing providers are unaffected. Tracks the notice field in `ProviderCatalogMetadata` ([#9270](https://github.com/diegosouzapw/OmniRoute/pull/9270)) diff --git a/changelog.d/features/9284-json-cookie-input.md b/changelog.d/features/9284-json-cookie-input.md new file mode 100644 index 0000000000..100842dcc0 --- /dev/null +++ b/changelog.d/features/9284-json-cookie-input.md @@ -0,0 +1 @@ +- **feat(providers):** accept JSON cookie objects in normalizeSessionCookieHeader (#9284 — thanks @AIB1TAL0S) diff --git a/changelog.d/features/9318-opencode-zen-reasoning-effort.md b/changelog.d/features/9318-opencode-zen-reasoning-effort.md new file mode 100644 index 0000000000..49f8e78a84 --- /dev/null +++ b/changelog.d/features/9318-opencode-zen-reasoning-effort.md @@ -0,0 +1 @@ +- **feat(providers):** support max reasoning effort for opencode-zen DeepSeek models (#9318) 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/9414-combo-system-prompt-templates.md b/changelog.d/features/9414-combo-system-prompt-templates.md new file mode 100644 index 0000000000..05f86c0f6e --- /dev/null +++ b/changelog.d/features/9414-combo-system-prompt-templates.md @@ -0,0 +1,2 @@ +- **feat(sse):** combo `system_message` supports server-side `{{MODEL_ID}}`, `{{PROVIDER_ID}}`, `{{ACCOUNT}}` and `{{FINGERPRINT}}` template expansion from the actually-routed target ([#5501](https://github.com/diegosouzapw/OmniRoute/issues/5501)) +- **feat(sse):** template expansion covers the standard dispatch loop, round-robin and pinned context-cache sessions; fusion, chaos, pipeline and nested-execute strategies do not expand yet ([#5501](https://github.com/diegosouzapw/OmniRoute/issues/5501)) 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/features/9485-deepseek-thinking-efforts.md b/changelog.d/features/9485-deepseek-thinking-efforts.md new file mode 100644 index 0000000000..a2b2bbcf95 --- /dev/null +++ b/changelog.d/features/9485-deepseek-thinking-efforts.md @@ -0,0 +1 @@ +- **feat(providers):** add native DeepSeek V4 Flash and Pro thinking-effort aliases for their documented per-model tiers, including Combo Builder exposure ([#9485](https://github.com/diegosouzapw/OmniRoute/pull/9485)). diff --git a/changelog.d/features/9490-opencode-plugin-warm-startup-parallel-refresh.md b/changelog.d/features/9490-opencode-plugin-warm-startup-parallel-refresh.md new file mode 100644 index 0000000000..f32157ce18 --- /dev/null +++ b/changelog.d/features/9490-opencode-plugin-warm-startup-parallel-refresh.md @@ -0,0 +1,5 @@ +--- +feature: 9490 +--- + +**Warm catalog startup from disk snapshot + parallel refresh** (opencode-plugin): The config-shim hook now reads the last disk snapshot *before* fetching, so the provider registers immediately with the last-known-good catalog (~1-2s vs ~30s on a warm gateway). All six fetchers run concurrently via `Promise.allSettled` instead of sequentially. A failed refresh keeps the snapshot (no overwrite). An in-flight guard prevents concurrent refreshes for the same cache key. The `features.diskCache: false` opt-out disables the warm read entirely. diff --git a/changelog.d/features/9530-forgotten-sibling-tests-gate.md b/changelog.d/features/9530-forgotten-sibling-tests-gate.md new file mode 100644 index 0000000000..c9407e0e68 --- /dev/null +++ b/changelog.d/features/9530-forgotten-sibling-tests-gate.md @@ -0,0 +1 @@ +- Add an advisory forgotten-sibling-tests report to pull-request quality checks. The report traces changed modules through their static consumers to candidate sibling tests, while keeping barrel and dynamic-import cases non-blocking and requiring reviewed, referenced exceptions. diff --git a/changelog.d/features/9544-muse-code-cli-provider.md b/changelog.d/features/9544-muse-code-cli-provider.md new file mode 100644 index 0000000000..23994a628f --- /dev/null +++ b/changelog.d/features/9544-muse-code-cli-provider.md @@ -0,0 +1 @@ +- feat(providers): add Muse Code CLI provider preset (#9544) diff --git a/changelog.d/features/9570-plugin-context-headers.md b/changelog.d/features/9570-plugin-context-headers.md new file mode 100644 index 0000000000..07fc8687c4 --- /dev/null +++ b/changelog.d/features/9570-plugin-context-headers.md @@ -0,0 +1 @@ +- feat(plugins): expose client request headers in plugin onRequest/onResponse context (#9570) diff --git a/changelog.d/features/9579-soniox-audio-provider.md b/changelog.d/features/9579-soniox-audio-provider.md new file mode 100644 index 0000000000..9213793594 --- /dev/null +++ b/changelog.d/features/9579-soniox-audio-provider.md @@ -0,0 +1 @@ +- **feat(audio):** Soniox STT + TTS provider (`sx`) — async speech-to-text (`stt-async-v5`, `stt-async-v4`) and real-time text-to-speech (`tts-rt-v1`) ([#9579](https://github.com/diegosouzapw/OmniRoute/pull/9579)) diff --git a/changelog.d/features/9620-cache-read-write-logs.md b/changelog.d/features/9620-cache-read-write-logs.md new file mode 100644 index 0000000000..f92846262d --- /dev/null +++ b/changelog.d/features/9620-cache-read-write-logs.md @@ -0,0 +1,2 @@ +- Show cache-read and cache-write token counts in request log rows and details when providers + report them. diff --git a/changelog.d/features/9622-memory-embedding-custom-endpoint.md b/changelog.d/features/9622-memory-embedding-custom-endpoint.md new file mode 100644 index 0000000000..7849ee9ac2 --- /dev/null +++ b/changelog.d/features/9622-memory-embedding-custom-endpoint.md @@ -0,0 +1 @@ +- feat(memory): support custom OpenAI-compatible endpoints for Memory embeddings (#9622) diff --git a/changelog.d/features/9709-stream-throughput-watchdog.md b/changelog.d/features/9709-stream-throughput-watchdog.md new file mode 100644 index 0000000000..45ed50adff --- /dev/null +++ b/changelog.d/features/9709-stream-throughput-watchdog.md @@ -0,0 +1 @@ +- feat(resilience): add an opt-in watchdog for persistently slow upstream streams (#9709) diff --git a/changelog.d/features/9752-one-click-free-providers.md b/changelog.d/features/9752-one-click-free-providers.md new file mode 100644 index 0000000000..f8c06d4603 --- /dev/null +++ b/changelog.d/features/9752-one-click-free-providers.md @@ -0,0 +1,4 @@ +- **Onboarding:** add an explicit, reviewable one-click setup for eligible no-auth LLM providers, + with per-provider caution links, selectable confirmation, idempotent creation, and safe partial + retries. Existing provider connections are never changed and setup completion never enables + providers silently. ([#9752](https://github.com/diegosouzapw/OmniRoute/issues/9752)) diff --git a/changelog.d/features/9782-modality-bridge-settings.md b/changelog.d/features/9782-modality-bridge-settings.md new file mode 100644 index 0000000000..47ab7c56e6 --- /dev/null +++ b/changelog.d/features/9782-modality-bridge-settings.md @@ -0,0 +1 @@ +- **feat(settings):** add a dedicated Modality Bridge settings page with Vision controls, runtime stats, and URL-addressable Audio and Video tabs ([#9782](https://github.com/diegosouzapw/OmniRoute/pull/9782)) diff --git a/changelog.d/features/9807-audio-modality-bridge.md b/changelog.d/features/9807-audio-modality-bridge.md new file mode 100644 index 0000000000..64d99490d2 --- /dev/null +++ b/changelog.d/features/9807-audio-modality-bridge.md @@ -0,0 +1 @@ +- **feat(modality bridge):** Transcribe chat audio for text-only models through the existing speech-to-text providers, with configurable limits, caching, runtime stats, and a dashboard self-test ([#9807](https://github.com/diegosouzapw/OmniRoute/pull/9807)) diff --git a/changelog.d/features/9924-strict-system-providers-env.md b/changelog.d/features/9924-strict-system-providers-env.md new file mode 100644 index 0000000000..23273d1ee4 --- /dev/null +++ b/changelog.d/features/9924-strict-system-providers-env.md @@ -0,0 +1 @@ +- **feat(memory):** `PROVIDERS_SYSTEM_MUST_BE_FIRST` (the #6135/#7293 fix for backends that reject any non-leading `system` message) was hardcoded to `xiaomi-mimo`/`mimo`. Added `OMNIROUTE_STRICT_SYSTEM_PROVIDERS` (comma-separated provider ids) so self-hosted deployments can flag additional strict backends — e.g. a custom OpenAI-compatible connection in front of a self-hosted Qwen3.5+/3.6 model — without forking and rebuilding the image ([#9924](https://github.com/diegosouzapw/OmniRoute/pull/9924)) diff --git a/changelog.d/features/adobe-firefly-reference-images.md b/changelog.d/features/adobe-firefly-reference-images.md new file mode 100644 index 0000000000..5c889d9a76 --- /dev/null +++ b/changelog.d/features/adobe-firefly-reference-images.md @@ -0,0 +1 @@ +- **feat(adobe-firefly):** reference-image attach for generate + OpenAI `/v1/images/edits` support (follow-up to #8006). Uploads sources to Firefly storage (`POST /v2/storage/image`), then submits `referenceBlobs` on 3P generate-async (nano multi-ref `usage:general`; gpt-image `usage:subject`). Wire matches live `firefly.adobe.com` captures. Also routes built-in edits to the same path (up to 4 refs). diff --git a/changelog.d/features/conductor-a2a-in.md b/changelog.d/features/conductor-a2a-in.md new file mode 100644 index 0000000000..0922d620fc --- /dev/null +++ b/changelog.d/features/conductor-a2a-in.md @@ -0,0 +1 @@ +- feat(a2a): inbound delegation to the OmniConductor fleet — `POST /api/a2a/tasks` translates an external A2A task into the hub's `POST /v1/tasks` (fleet skills only, repo required, `CONDUCTOR_ORCHESTRATOR_TOKEN` with hub-token fallback); states flow back through the SSE→A2A mirror diff --git a/changelog.d/features/conductor-agent-card.md b/changelog.d/features/conductor-agent-card.md new file mode 100644 index 0000000000..3bf7b9c8eb --- /dev/null +++ b/changelog.d/features/conductor-agent-card.md @@ -0,0 +1 @@ +- feat(a2a): the Agent Card (`/.well-known/agent.json`) now announces skills derived from the OmniConductor fleet (`GET /v1/runners` OASF capabilities — one skill per online CLI profile + declared fleet skills), cached ~60s and fail-open when the hub is unset/offline diff --git a/changelog.d/features/conductor-bridge.md b/changelog.d/features/conductor-bridge.md new file mode 100644 index 0000000000..08050beda2 --- /dev/null +++ b/changelog.d/features/conductor-bridge.md @@ -0,0 +1 @@ +- feat(a2a): Conductor bridge — long-lived SSE consumer that mirrors OmniConductor hub tasks into the A2A TaskManager (explicit `canceled→cancelled` mapping with tests, persisted `last_event_id` cursor in the `key_value` table, exponential-backoff reconnection; opt-in via `CONDUCTOR_HUB_URL`/`CONDUCTOR_HUB_TOKEN`) diff --git a/changelog.d/features/conductor-panel.md b/changelog.d/features/conductor-panel.md new file mode 100644 index 0000000000..ca6366a7cf --- /dev/null +++ b/changelog.d/features/conductor-panel.md @@ -0,0 +1 @@ +- feat(dashboard): "Conductor" panel — OmniConductor fleet (runners + task queue) live via server-side proxy routes (`/api/conductor/*`, management auth, hub token never reaches the browser), task detail with manifest/council and cancel-with-confirmation; sidebar entry under Tools diff --git a/changelog.d/features/conductor-voice.md b/changelog.d/features/conductor-voice.md new file mode 100644 index 0000000000..215b358ddd --- /dev/null +++ b/changelog.d/features/conductor-voice.md @@ -0,0 +1 @@ +- feat(dashboard): Faro chat with voice on the Conductor panel — text via `/api/conductor/ask` (server-side proxy to the spokesperson; hub credential never reaches the browser; `pending` → Sim/Não confirmation buttons) and a guaranteed push-to-talk voice cycle (MediaRecorder → `/api/v1/audio/transcriptions` → ask → `/api/v1/audio/speech` playback), with operator-configurable STT/TTS models diff --git a/changelog.d/fixes/7754-best-free-fallback.md b/changelog.d/fixes/7754-best-free-fallback.md new file mode 100644 index 0000000000..0d1598538d --- /dev/null +++ b/changelog.d/fixes/7754-best-free-fallback.md @@ -0,0 +1 @@ +- test(combo): guard auto/best-free never leaks the combo name as a model (#7754) diff --git a/changelog.d/fixes/8542-fix.plan.md b/changelog.d/fixes/8542-fix.plan.md new file mode 100644 index 0000000000..f44dd909c6 --- /dev/null +++ b/changelog.d/fixes/8542-fix.plan.md @@ -0,0 +1 @@ +- fix(ci): aggregate all fast-gates into non-fail-fast loop so one red gate no longer masks later gates (#8542) diff --git a/changelog.d/fixes/8577-fix.plan.md b/changelog.d/fixes/8577-fix.plan.md new file mode 100644 index 0000000000..2b7b175101 --- /dev/null +++ b/changelog.d/fixes/8577-fix.plan.md @@ -0,0 +1,2 @@ +- fix(tests): make machineId tests macOS-compatible by stubbing ioreg in test helper (#8577) +- fix(scripts): replace bash 4+ readarray with compatible while-read loop in restore-policies.sh (#8577) diff --git a/changelog.d/fixes/8609-fix.plan.md b/changelog.d/fixes/8609-fix.plan.md new file mode 100644 index 0000000000..0f8cb8e868 --- /dev/null +++ b/changelog.d/fixes/8609-fix.plan.md @@ -0,0 +1 @@ +- fix(cli): enable systray2 on Windows for Norton-friendly tray (#8609) \ No newline at end of file diff --git a/changelog.d/fixes/8681-fix.plan.md b/changelog.d/fixes/8681-fix.plan.md new file mode 100644 index 0000000000..9abd3a9e87 --- /dev/null +++ b/changelog.d/fixes/8681-fix.plan.md @@ -0,0 +1 @@ +- fix(providers): gate premium opencode-zen/opencode-go models behind an API key (#8681) diff --git a/changelog.d/fixes/8728-model-catalog-swr.md b/changelog.d/fixes/8728-model-catalog-swr.md new file mode 100644 index 0000000000..9272b3f0b7 --- /dev/null +++ b/changelog.d/fixes/8728-model-catalog-swr.md @@ -0,0 +1 @@ +- **fix(api):** make `/v1/models` stale refresh response-safe and generation-safe, with narrow synced-model invalidation ([#8728](https://github.com/diegosouzapw/OmniRoute/pull/8728)). Related to #8697. diff --git a/changelog.d/fixes/8781-fix.plan.md b/changelog.d/fixes/8781-fix.plan.md new file mode 100644 index 0000000000..ce761c1555 --- /dev/null +++ b/changelog.d/fixes/8781-fix.plan.md @@ -0,0 +1 @@ +- fix(build): remove misleading open-sse/package.json facade and add workspace typecheck gate (#8781) diff --git a/changelog.d/fixes/8826-fix.plan.md b/changelog.d/fixes/8826-fix.plan.md new file mode 100644 index 0000000000..9549452e3c --- /dev/null +++ b/changelog.d/fixes/8826-fix.plan.md @@ -0,0 +1 @@ +- fix(cli): fall back to node:sqlite when better-sqlite3 constructor throws at runtime (#8826) \ No newline at end of file diff --git a/changelog.d/fixes/8830-fix.plan.md b/changelog.d/fixes/8830-fix.plan.md new file mode 100644 index 0000000000..5cb0cf3c28 --- /dev/null +++ b/changelog.d/fixes/8830-fix.plan.md @@ -0,0 +1 @@ +- fix(opencode): prefix provider id with "opencode-" for auth login command (#8830) \ No newline at end of file diff --git a/changelog.d/fixes/8841-fix.plan.md b/changelog.d/fixes/8841-fix.plan.md new file mode 100644 index 0000000000..6eca00c5b6 --- /dev/null +++ b/changelog.d/fixes/8841-fix.plan.md @@ -0,0 +1 @@ +- fix(opencode-zen): add current free-tier models to registry to enable combo context pre-filtering (#8841) diff --git a/changelog.d/fixes/8847-bun-prebuilds.md b/changelog.d/fixes/8847-bun-prebuilds.md new file mode 100644 index 0000000000..2711dfa745 --- /dev/null +++ b/changelog.d/fixes/8847-bun-prebuilds.md @@ -0,0 +1 @@ +- fix(build): include better-sqlite3 prebuilds in standalone bun bundle diff --git a/changelog.d/fixes/8869-opencode-complete-model-limits.md b/changelog.d/fixes/8869-opencode-complete-model-limits.md new file mode 100644 index 0000000000..9647ab8d39 --- /dev/null +++ b/changelog.d/fixes/8869-opencode-complete-model-limits.md @@ -0,0 +1 @@ +- **fix(opencode):** generate schema-complete model limits so OpenCode accepts catalog entries without an explicit output cap ([#8869](https://github.com/diegosouzapw/OmniRoute/pull/8869)) — thanks @xiaoyaner0201 diff --git a/changelog.d/fixes/8876-codex-responses-wire-default.md b/changelog.d/fixes/8876-codex-responses-wire-default.md new file mode 100644 index 0000000000..cca93603ff --- /dev/null +++ b/changelog.d/fixes/8876-codex-responses-wire-default.md @@ -0,0 +1 @@ +- **fix(cli):** default omitted Codex CLI wire API settings to Responses and clear stale Chat state after reset ([#8876](https://github.com/diegosouzapw/OmniRoute/pull/8876)) — thanks @xiaoyaner0201 diff --git a/changelog.d/fixes/8883-proxy-credential-autofill.md b/changelog.d/fixes/8883-proxy-credential-autofill.md new file mode 100644 index 0000000000..71d03dbe78 --- /dev/null +++ b/changelog.d/fixes/8883-proxy-credential-autofill.md @@ -0,0 +1 @@ +- **fix(proxy):** isolate new proxy credential fields from browser and password-manager autofill after form reset ([#8883](https://github.com/diegosouzapw/OmniRoute/pull/8883)) — thanks @xiaoyaner0201 diff --git a/changelog.d/fixes/8887-lkgp-connection-delete.md b/changelog.d/fixes/8887-lkgp-connection-delete.md new file mode 100644 index 0000000000..f00dbb655e --- /dev/null +++ b/changelog.d/fixes/8887-lkgp-connection-delete.md @@ -0,0 +1 @@ +- fix(db): invalidate stale LKGP pins when provider connections are deleted (#8887) diff --git a/changelog.d/fixes/8906-quota-pool-combo-cleanup.md b/changelog.d/fixes/8906-quota-pool-combo-cleanup.md new file mode 100644 index 0000000000..b244f8875d --- /dev/null +++ b/changelog.d/fixes/8906-quota-pool-combo-cleanup.md @@ -0,0 +1 @@ +- **fix(quota):** Deleting a quota pool now removes its scoped managed combos without racing in-flight pool mutations ([#8906](https://github.com/diegosouzapw/OmniRoute/pull/8906)) — thanks @xiaoyaner0201 diff --git a/changelog.d/fixes/8909-vertex-claude-rawpredict-streaming.md b/changelog.d/fixes/8909-vertex-claude-rawpredict-streaming.md new file mode 100644 index 0000000000..e5e8f77086 --- /dev/null +++ b/changelog.d/fixes/8909-vertex-claude-rawpredict-streaming.md @@ -0,0 +1 @@ +- **fix(executors):** Vertex AI now routes Claude models through the native Anthropic `rawPredict` endpoint instead of the generic OpenAI-compatible partner endpoint, and synthesizes a real streaming response so Claude-via-Vertex works with `stream: true` ([#8909](https://github.com/diegosouzapw/OmniRoute/pull/8909)) — thanks @wgordon17 diff --git a/changelog.d/fixes/8921-codebuddy-cn-dual-auth-actions.md b/changelog.d/fixes/8921-codebuddy-cn-dual-auth-actions.md new file mode 100644 index 0000000000..5e2ce88591 --- /dev/null +++ b/changelog.d/fixes/8921-codebuddy-cn-dual-auth-actions.md @@ -0,0 +1 @@ +- **fix(providers):** expose both OAuth Connect and manual API-key actions for dual-auth providers such as CodeBuddy CN ([#8921](https://github.com/diegosouzapw/OmniRoute/pull/8921)) — thanks @Llliao1113 diff --git a/changelog.d/fixes/8951-fix.plan.md b/changelog.d/fixes/8951-fix.plan.md new file mode 100644 index 0000000000..9b3d32029b --- /dev/null +++ b/changelog.d/fixes/8951-fix.plan.md @@ -0,0 +1 @@ +- fix(github): add targetFormat to GPT-5.6 Sol/Terra/Luna models (#8951) diff --git a/changelog.d/fixes/8960-fix.plan.md b/changelog.d/fixes/8960-fix.plan.md new file mode 100644 index 0000000000..2dca8c7069 --- /dev/null +++ b/changelog.d/fixes/8960-fix.plan.md @@ -0,0 +1 @@ +- fix(opencode): propagate vision capability from live catalog into opencode.json (#8960) diff --git a/changelog.d/fixes/8965-fix.plan.md b/changelog.d/fixes/8965-fix.plan.md new file mode 100644 index 0000000000..d557cd7027 --- /dev/null +++ b/changelog.d/fixes/8965-fix.plan.md @@ -0,0 +1 @@ +- fix(providers): switch Antigravity quota RPCs to iterate ANTIGRAVITY_RUNTIME_BASE_URLS (#8965) \ No newline at end of file diff --git a/changelog.d/fixes/8995-fix.plan.md b/changelog.d/fixes/8995-fix.plan.md new file mode 100644 index 0000000000..5ce1ddc6a2 --- /dev/null +++ b/changelog.d/fixes/8995-fix.plan.md @@ -0,0 +1 @@ +- fix(proxies): resolveProxyForConnection now returns the proxy name, so the dashboard badge shows the name instead of the hostname (#8995) diff --git a/changelog.d/fixes/8997-gpt56-max-reasoning.plan.md b/changelog.d/fixes/8997-gpt56-max-reasoning.plan.md new file mode 100644 index 0000000000..233441d5d8 --- /dev/null +++ b/changelog.d/fixes/8997-gpt56-max-reasoning.plan.md @@ -0,0 +1 @@ +- **fix(translator):** the Responses-to-Chat promotion path called `normalizeResponsesReasoningEffort` without the model argument, so GPT-5.6 Sol/Terra/Luna requests with `reasoning.effort: "max""` were downgraded to `"xhigh"`. The model is now threaded through, preserving `max` for GPT-5.6 while keeping the legacy downgrade for older models ([#8997](https://github.com/diegosouzapw/OmniRoute/pull/8997)) \ No newline at end of file diff --git a/changelog.d/fixes/9006-vertex-claude-catalog-dispatch.md b/changelog.d/fixes/9006-vertex-claude-catalog-dispatch.md new file mode 100644 index 0000000000..4d8f103361 --- /dev/null +++ b/changelog.d/fixes/9006-vertex-claude-catalog-dispatch.md @@ -0,0 +1,14 @@ +- **fix(sse):** Claude reasoning-effort suffix ids (`-high`/`-low`/`-medium`/`-xhigh`) now strip + correctly on any provider serving a real Claude model, not just the direct Anthropic provider + ([#9006](https://github.com/diegosouzapw/OmniRoute/pull/9006)) +- **fix(sse):** the no-thinking (`no-think/`) catalog variant's provider-qualification bug — which + made it unusable outside the direct provider, both in the discovery catalog and the dashboard + playground — is fixed ([#9006](https://github.com/diegosouzapw/OmniRoute/pull/9006)) +- **fix(sse):** a single unrecognized model id on a Vertex connection no longer cools down every + other model on that connection for 2 minutes — Vertex 404s are now scoped to a per-model + lockout via `passthroughModels` instead of a connection-wide cooldown + ([#9006](https://github.com/diegosouzapw/OmniRoute/pull/9006)) +- **fix(sse):** Vertex `PERMISSION_DENIED` 403s are now disambiguated using Google's own + documented error format — a genuinely connection-wide cause (API disabled, project-level IAM + denial) still cools the whole connection, while a model-specific denial locks out only that + model ([#9006](https://github.com/diegosouzapw/OmniRoute/pull/9006)) diff --git a/changelog.d/fixes/9034-fix.plan.md b/changelog.d/fixes/9034-fix.plan.md new file mode 100644 index 0000000000..49fa4b744d --- /dev/null +++ b/changelog.d/fixes/9034-fix.plan.md @@ -0,0 +1 @@ +- fix(api): use configured prefix instead of raw node UUID for alias-backed model id in /v1/models (#9034) diff --git a/changelog.d/fixes/9041-rate-limit-idle-capacity-wedge.md b/changelog.d/fixes/9041-rate-limit-idle-capacity-wedge.md new file mode 100644 index 0000000000..cfb180cabd --- /dev/null +++ b/changelog.d/fixes/9041-rate-limit-idle-capacity-wedge.md @@ -0,0 +1 @@ +- **fix(resilience):** Detect and reset idle-capacity rate-limit queue wedges on an eligible watchdog scan so routing can fall back promptly ([#9041](https://github.com/diegosouzapw/OmniRoute/pull/9041)) diff --git a/changelog.d/fixes/9045-fix.plan.md b/changelog.d/fixes/9045-fix.plan.md new file mode 100644 index 0000000000..6065f9a181 --- /dev/null +++ b/changelog.d/fixes/9045-fix.plan.md @@ -0,0 +1 @@ +- fix(db): stream DB backup export instead of buffering entire file into memory (#9045) \ No newline at end of file diff --git a/changelog.d/fixes/9046-fix.md b/changelog.d/fixes/9046-fix.md new file mode 100644 index 0000000000..e80cc7b528 --- /dev/null +++ b/changelog.d/fixes/9046-fix.md @@ -0,0 +1 @@ +- fix(ui): normalize Free Pool API response payload to read from data.proxies (#9046) \ No newline at end of file diff --git a/changelog.d/fixes/9054-fix.plan.md b/changelog.d/fixes/9054-fix.plan.md new file mode 100644 index 0000000000..2efd3cf8f4 --- /dev/null +++ b/changelog.d/fixes/9054-fix.plan.md @@ -0,0 +1 @@ +- fix(api/analytics): stop charging :free models at arbitrary fallback price (#9054) diff --git a/changelog.d/fixes/9057-fix.plan.md b/changelog.d/fixes/9057-fix.plan.md new file mode 100644 index 0000000000..e20f273385 --- /dev/null +++ b/changelog.d/fixes/9057-fix.plan.md @@ -0,0 +1 @@ +- fix(api): auto/* routing aliases bypass API-key allowedConnections/disableNonPublicModels (#9057) diff --git a/changelog.d/fixes/9096-fix-audio-speech-transcriptions-translations-provider-nodes.plan.md b/changelog.d/fixes/9096-fix-audio-speech-transcriptions-translations-provider-nodes.plan.md new file mode 100644 index 0000000000..10f7268184 --- /dev/null +++ b/changelog.d/fixes/9096-fix-audio-speech-transcriptions-translations-provider-nodes.plan.md @@ -0,0 +1 @@ +- fix(providers): admit audio-speech/audio-transcriptions apiType in audio route provider-node filters (#9096) \ No newline at end of file diff --git a/changelog.d/fixes/9102-fix.plan.md b/changelog.d/fixes/9102-fix.plan.md new file mode 100644 index 0000000000..bfbfed5c22 --- /dev/null +++ b/changelog.d/fixes/9102-fix.plan.md @@ -0,0 +1 @@ +- fix(providers): modal.com validation returns clear error when Base URL is missing, instead of leaking "Invalid outbound URL" (#9102) \ No newline at end of file diff --git a/changelog.d/fixes/9134-fix.plan.md b/changelog.d/fixes/9134-fix.plan.md new file mode 100644 index 0000000000..acf04acb1c --- /dev/null +++ b/changelog.d/fixes/9134-fix.plan.md @@ -0,0 +1 @@ +- fix(providers): resolve combo names in audio transcriptions route so /v1/models stays honest (#9134) diff --git a/changelog.d/fixes/9140-fix.plan.md b/changelog.d/fixes/9140-fix.plan.md new file mode 100644 index 0000000000..5ab7425cf6 --- /dev/null +++ b/changelog.d/fixes/9140-fix.plan.md @@ -0,0 +1 @@ +- fix(vscode): allow built-in auto-routing models in VS Code model filter (#9140) \ No newline at end of file diff --git a/changelog.d/fixes/9142-fix.plan.md b/changelog.d/fixes/9142-fix.plan.md new file mode 100644 index 0000000000..aecf251f9e --- /dev/null +++ b/changelog.d/fixes/9142-fix.plan.md @@ -0,0 +1 @@ +- fix(background): detect Anthropic top-level system prompts for background task detection (#9142) diff --git a/changelog.d/fixes/9156-macos-autostart-execpath.md b/changelog.d/fixes/9156-macos-autostart-execpath.md new file mode 100644 index 0000000000..8e5ab4b184 --- /dev/null +++ b/changelog.d/fixes/9156-macos-autostart-execpath.md @@ -0,0 +1 @@ +- fix(cli): use process.execPath for macOS launchd autostart diff --git a/changelog.d/fixes/9160-fix.plan.md b/changelog.d/fixes/9160-fix.plan.md new file mode 100644 index 0000000000..d6ad2b567c --- /dev/null +++ b/changelog.d/fixes/9160-fix.plan.md @@ -0,0 +1 @@ +- fix(model-discovery): ingest capabilities.effort_tiers for synced models (#9160) diff --git a/changelog.d/fixes/9161-codex-responses-chat-targets.md b/changelog.d/fixes/9161-codex-responses-chat-targets.md new file mode 100644 index 0000000000..2fff43b6e8 --- /dev/null +++ b/changelog.d/fixes/9161-codex-responses-chat-targets.md @@ -0,0 +1 @@ +- **fix(translator):** Honor configured Chat targets for Responses-shaped clients while preserving native Responses providers and outbound token fields ([#9161](https://github.com/diegosouzapw/OmniRoute/pull/9161)) — thanks @Zartharas diff --git a/changelog.d/fixes/9168-streamed-responses-tool-null.plan.md b/changelog.d/fixes/9168-streamed-responses-tool-null.plan.md new file mode 100644 index 0000000000..d847e0af94 --- /dev/null +++ b/changelog.d/fixes/9168-streamed-responses-tool-null.plan.md @@ -0,0 +1 @@ +- fix(translator): buffer and normalize upstream tool-call argument deltas so optional null values are stripped before reaching the client (#9168) diff --git a/changelog.d/fixes/9173-cursor-manual-refresh-502.md b/changelog.d/fixes/9173-cursor-manual-refresh-502.md new file mode 100644 index 0000000000..0a2244e0f8 --- /dev/null +++ b/changelog.d/fixes/9173-cursor-manual-refresh-502.md @@ -0,0 +1 @@ +- fix(cursor): the manual "Refresh" button on Cursor connections now calls the dedicated Cursor renewal route instead of silently returning a 502 every time (#9173) diff --git a/changelog.d/fixes/9177-fix.plan.md b/changelog.d/fixes/9177-fix.plan.md new file mode 100644 index 0000000000..49ea75e012 --- /dev/null +++ b/changelog.d/fixes/9177-fix.plan.md @@ -0,0 +1 @@ +- fix(translator): avoid double-normalizing tool names in Gemini-to-Claude response path (#9177) diff --git a/changelog.d/fixes/9179-default-model-editable.md b/changelog.d/fixes/9179-default-model-editable.md new file mode 100644 index 0000000000..dd607d91ba --- /dev/null +++ b/changelog.d/fixes/9179-default-model-editable.md @@ -0,0 +1 @@ +- **fix(dashboard):** the "Default Model" of an OpenAI-compatible connection is now visible and editable after creation (was set once, then invisible), and it is no longer required when creating a connection — matching the API which always treated it as optional. ([#9179](https://github.com/diegosouzapw/OmniRoute/pull/9179)) diff --git a/changelog.d/fixes/9195-fix.plan.md b/changelog.d/fixes/9195-fix.plan.md new file mode 100644 index 0000000000..966b51cad7 --- /dev/null +++ b/changelog.d/fixes/9195-fix.plan.md @@ -0,0 +1,2 @@ +- fix(catalog): repair dead guard and synced-first ordering for custom model Vision capable override (#9195) +- fix(routing): consult customModels supportsVision flag in Combo vision filter (#9195) diff --git a/changelog.d/fixes/9199-model-catalog-affinity.md b/changelog.d/fixes/9199-model-catalog-affinity.md new file mode 100644 index 0000000000..aafc42a8bf --- /dev/null +++ b/changelog.d/fixes/9199-model-catalog-affinity.md @@ -0,0 +1,4 @@ +- **fix(models):** Preserve published model catalogs during session-affinity bookkeeping so routine affinity updates do not force unnecessary cold rebuilds ([#9199](https://github.com/diegosouzapw/OmniRoute/pull/9199)) — thanks @xz-dev +- **fix(models):** Reuse one build-local virtual-auto candidate snapshot across built-in catalog entries and cooperatively yield during cold catalog generation, while detaching invalidated in-flight generations so policy changes cannot publish stale results ([#9199](https://github.com/diegosouzapw/OmniRoute/pull/9199)) — thanks @xz-dev +- **fix(models):** Resolve token limits and model capabilities once per unique candidate in that build-local snapshot, eliminating repeated SQLite lookups across the 38 built-in auto entries while preserving fresh runtime preparation and hard invalidation ([#9199](https://github.com/diegosouzapw/OmniRoute/pull/9199)) — thanks @xz-dev +- **fix(models):** Read and parse models.dev pricing once per cold catalog build, then yield before final enrichment so queued health checks are not starved while every model in that build shares one coherent pricing snapshot ([#9199](https://github.com/diegosouzapw/OmniRoute/pull/9199)) — thanks @xz-dev diff --git a/changelog.d/fixes/9201-web-search-proxy-bind.plan.md b/changelog.d/fixes/9201-web-search-proxy-bind.plan.md new file mode 100644 index 0000000000..67a72dd64f --- /dev/null +++ b/changelog.d/fixes/9201-web-search-proxy-bind.plan.md @@ -0,0 +1 @@ +- fix(web-search): bind each search provider attempt to its connection proxy (#9201) \ No newline at end of file diff --git a/changelog.d/fixes/9204-fix.plan.md b/changelog.d/fixes/9204-fix.plan.md new file mode 100644 index 0000000000..21981ed128 --- /dev/null +++ b/changelog.d/fixes/9204-fix.plan.md @@ -0,0 +1 @@ +- fix(auth): make antigravity and agy equivalent in credential selection (#9204) diff --git a/changelog.d/fixes/9237-fix.plan.md b/changelog.d/fixes/9237-fix.plan.md new file mode 100644 index 0000000000..fde574eb17 --- /dev/null +++ b/changelog.d/fixes/9237-fix.plan.md @@ -0,0 +1 @@ +- fix(lmarena): emit Uint8Array SSE chunks instead of strings to satisfy shared pipeline contract (#9237) \ No newline at end of file diff --git a/changelog.d/fixes/9245-hardcoded-web-ui-i18n.md b/changelog.d/fixes/9245-hardcoded-web-ui-i18n.md new file mode 100644 index 0000000000..a2c0157189 --- /dev/null +++ b/changelog.d/fixes/9245-hardcoded-web-ui-i18n.md @@ -0,0 +1 @@ +- **fix(i18n):** localized hardcoded web UI copy across public pages, dashboard views, and shared components, with complete French and Vietnamese coverage ([#9245](https://github.com/diegosouzapw/OmniRoute/pull/9245)) — thanks @alex-jordan547 diff --git a/changelog.d/fixes/9269-fix.plan.md b/changelog.d/fixes/9269-fix.plan.md new file mode 100644 index 0000000000..147c32be70 --- /dev/null +++ b/changelog.d/fixes/9269-fix.plan.md @@ -0,0 +1 @@ +- **fix(classify429):** add missing `have exhausted their quota` pattern so the synthetic 429 from auth.ts is recognized as quota exhaustion, preventing the combo loop from burning retries against the same provider instead of falling back to a healthy one ([#9269](https://github.com/diegosouzapw/OmniRoute/issues/9269)) diff --git a/changelog.d/fixes/9277-fix.plan.md b/changelog.d/fixes/9277-fix.plan.md new file mode 100644 index 0000000000..b161675e43 --- /dev/null +++ b/changelog.d/fixes/9277-fix.plan.md @@ -0,0 +1 @@ +- fix(qoder): include actionable CLI_QODER_BIN hint in connection test when qodercli is not found (#9277) \ No newline at end of file diff --git a/changelog.d/fixes/9279-fix.plan.md b/changelog.d/fixes/9279-fix.plan.md new file mode 100644 index 0000000000..5dbc10c5f4 --- /dev/null +++ b/changelog.d/fixes/9279-fix.plan.md @@ -0,0 +1 @@ +- **fix(providers):** the web search fallback detector in `webSearchFallback.ts` used an exact `Set` (`web_search`, `web_search_preview`) that missed Anthropic's date-suffixed server-tool variant `web_search_20250305` (sent by Claude Code 2.1.220+). Changed to prefix regex `/^web_search/`, matching the two other detectors in the codebase, so the fallback intercepts versioned web search tools for OpenAI-compatible upstreams ([#9279](https://github.com/diegosouzapw/OmniRoute/pull/9279)) diff --git a/changelog.d/fixes/9289-fix.plan.md b/changelog.d/fixes/9289-fix.plan.md new file mode 100644 index 0000000000..284df06aec --- /dev/null +++ b/changelog.d/fixes/9289-fix.plan.md @@ -0,0 +1 @@ +- fix(credential-health): scheduler never retries failed connections due to static interval comparison (#9289) diff --git a/changelog.d/fixes/9293-fix.plan.md b/changelog.d/fixes/9293-fix.plan.md new file mode 100644 index 0000000000..96e6f6727a --- /dev/null +++ b/changelog.d/fixes/9293-fix.plan.md @@ -0,0 +1 @@ +- fix(api): specialty model catalog ignores hidden OpenRouter model flags (#9293) \ No newline at end of file diff --git a/changelog.d/fixes/9300-fix.plan.md b/changelog.d/fixes/9300-fix.plan.md new file mode 100644 index 0000000000..c83558b707 --- /dev/null +++ b/changelog.d/fixes/9300-fix.plan.md @@ -0,0 +1 @@ +- fix(catalog): cache getModelsDevPricing() to prevent OOM at startup (#9300) \ No newline at end of file diff --git a/changelog.d/fixes/9304-fix.plan.md b/changelog.d/fixes/9304-fix.plan.md new file mode 100644 index 0000000000..ad7e0b0ecb --- /dev/null +++ b/changelog.d/fixes/9304-fix.plan.md @@ -0,0 +1 @@ +- fix(providers): bump qwen-web SPA version header from 0.2.66 to 0.2.81 (#9304) diff --git a/changelog.d/fixes/9305-fix.plan.md b/changelog.d/fixes/9305-fix.plan.md new file mode 100644 index 0000000000..1db4b82b32 --- /dev/null +++ b/changelog.d/fixes/9305-fix.plan.md @@ -0,0 +1 @@ +- fix(sse): broaden OMNIROUTE_SSE_COMMENTS to accept 'false','0','no' and gate metadata comment emission (#9305) diff --git a/changelog.d/fixes/9306-fix.plan.md b/changelog.d/fixes/9306-fix.plan.md new file mode 100644 index 0000000000..3e76a384b0 --- /dev/null +++ b/changelog.d/fixes/9306-fix.plan.md @@ -0,0 +1 @@ +- fix(lmarena): encode SSE stream chunks as Uint8Array to prevent TextDecoder TypeError (#9306) diff --git a/changelog.d/fixes/9315-fix.plan.md b/changelog.d/fixes/9315-fix.plan.md new file mode 100644 index 0000000000..31fcc09f78 --- /dev/null +++ b/changelog.d/fixes/9315-fix.plan.md @@ -0,0 +1 @@ +- fix(backend): use accumulated responseBody for provider payload in dashboard log viewer to avoid stale data from truncated SSE events (#9315) \ No newline at end of file diff --git a/changelog.d/fixes/9319-fix.plan.md b/changelog.d/fixes/9319-fix.plan.md new file mode 100644 index 0000000000..dc5f04e762 --- /dev/null +++ b/changelog.d/fixes/9319-fix.plan.md @@ -0,0 +1 @@ +- fix(qoder): surface qodercli stderr in error message instead of generic 502 (#9319) diff --git a/changelog.d/fixes/9431-codex-gpt56-context.md b/changelog.d/fixes/9431-codex-gpt56-context.md new file mode 100644 index 0000000000..da11703298 --- /dev/null +++ b/changelog.d/fixes/9431-codex-gpt56-context.md @@ -0,0 +1 @@ +- **fix(providers):** Codex GPT-5.6 model metadata reports the 1M context window and 922K input limit ([#9431](https://github.com/diegosouzapw/OmniRoute/issues/9431)). diff --git a/changelog.d/fixes/9436-claude-system-role-cache-boundary.md b/changelog.d/fixes/9436-claude-system-role-cache-boundary.md new file mode 100644 index 0000000000..c2433f470a --- /dev/null +++ b/changelog.d/fixes/9436-claude-system-role-cache-boundary.md @@ -0,0 +1 @@ +- **fix(sse):** hoisting a mid-conversation `system`/`developer` message into the top-level `system` field no longer carries its `cache_control` marker along, which left the conversation history without a cache breakpoint and forced a full re-read plus a rebuild on the next turn. The boundary is moved to the nearest preceding block that can carry one, and now survives the rewrites that turn `tool_result` and inlined file/document blocks into plain text; if the target block is already marked, both markers are kept unless Anthropic's TTL ordering forbids it. Both hoisting paths are fixed — `extractSystemRoleMessages` and `extractSystemMessagesToBody`. Regression guard: `tests/unit/claude-system-role-cache-boundary.test.ts`. ([#9436](https://github.com/diegosouzapw/OmniRoute/issues/9436)) diff --git a/changelog.d/fixes/9486-claude-400-quota.md b/changelog.d/fixes/9486-claude-400-quota.md new file mode 100644 index 0000000000..b410faf7d1 --- /dev/null +++ b/changelog.d/fixes/9486-claude-400-quota.md @@ -0,0 +1 @@ +- fix(providers): classify 400 out of extra usage as quota_exhausted for Anthropic OAuth diff --git a/changelog.d/fixes/9494-chat-history-cap-opt-in.md b/changelog.d/fixes/9494-chat-history-cap-opt-in.md new file mode 100644 index 0000000000..30e0be1e03 --- /dev/null +++ b/changelog.d/fixes/9494-chat-history-cap-opt-in.md @@ -0,0 +1 @@ +- fix(api): make the 800-message chat history cap opt-in so long conversations reach compression instead of a terminal 413 (#9494) 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/9532-unit-ceiling-measurement.md b/changelog.d/fixes/9532-unit-ceiling-measurement.md new file mode 100644 index 0000000000..3b71df081d --- /dev/null +++ b/changelog.d/fixes/9532-unit-ceiling-measurement.md @@ -0,0 +1 @@ +- **fix(ci):** tighten unit suite ceiling from 100min to 80min as a conservative step (#9532) diff --git a/changelog.d/fixes/9533-ratelimit-bound-queue-wait-bottleneck-exit.md b/changelog.d/fixes/9533-ratelimit-bound-queue-wait-bottleneck-exit.md new file mode 100644 index 0000000000..0822619f7a --- /dev/null +++ b/changelog.d/fixes/9533-ratelimit-bound-queue-wait-bottleneck-exit.md @@ -0,0 +1 @@ +- **fix(ratelimit):** added queue-wait timeout tests and updateFromResponseBody sequencing tests for the existing RATE_LIMIT_QUEUE_TIMEOUT feature in withRateLimit (#9533) diff --git a/changelog.d/fixes/9580-standalone-ws-multipart-uploads.md b/changelog.d/fixes/9580-standalone-ws-multipart-uploads.md new file mode 100644 index 0000000000..b22b6f7fc2 --- /dev/null +++ b/changelog.d/fixes/9580-standalone-ws-multipart-uploads.md @@ -0,0 +1 @@ +- **fix(standalone):** multipart uploads (`POST /v1/audio/transcriptions`) no longer hang — the WebDAV wrapper hands non-WebDAV requests to Next synchronously instead of losing the start of a streaming body ([#9580](https://github.com/diegosouzapw/OmniRoute/pull/9580)) diff --git a/changelog.d/fixes/9615-docker-colocate-partial-trace.md b/changelog.d/fixes/9615-docker-colocate-partial-trace.md new file mode 100644 index 0000000000..700a0e023e --- /dev/null +++ b/changelog.d/fixes/9615-docker-colocate-partial-trace.md @@ -0,0 +1 @@ +- **fix(docker):** standalone co-location now completes packages Next's file tracing materialized partially (package.json without its `main` payload) — unblocks the Docker Hub publish that failed on every v3.8.50 push with `Cannot find module '@atjsh/llmlingua-2/dist/index.js'` ([#9615](https://github.com/diegosouzapw/OmniRoute/pull/9615)) diff --git a/changelog.d/fixes/9623-connection-test-recovery.md b/changelog.d/fixes/9623-connection-test-recovery.md new file mode 100644 index 0000000000..413e59ee87 --- /dev/null +++ b/changelog.d/fixes/9623-connection-test-recovery.md @@ -0,0 +1 @@ +- fix(resilience): failed connection test now sets a short cooldown so connections recover after transient outages (#9623) diff --git a/changelog.d/fixes/9624-telemetry-cleanup-wiring.md b/changelog.d/fixes/9624-telemetry-cleanup-wiring.md new file mode 100644 index 0000000000..01e389606e --- /dev/null +++ b/changelog.d/fixes/9624-telemetry-cleanup-wiring.md @@ -0,0 +1 @@ +- fix(db): wire telemetry cleanup scheduler in Next.js startup path (#9624) diff --git a/changelog.d/fixes/9625-domain-cost-ms.md b/changelog.d/fixes/9625-domain-cost-ms.md new file mode 100644 index 0000000000..37c3e1e7e6 --- /dev/null +++ b/changelog.d/fixes/9625-domain-cost-ms.md @@ -0,0 +1 @@ +- fix(db): align domain_cost_history cleanup cutoff with millisecond column (#9625) diff --git a/changelog.d/fixes/9626-playground-errors.md b/changelog.d/fixes/9626-playground-errors.md new file mode 100644 index 0000000000..3bf1ee0343 --- /dev/null +++ b/changelog.d/fixes/9626-playground-errors.md @@ -0,0 +1 @@ +- fix(playground): surface provider model loading errors and offer retry (#9626) diff --git a/changelog.d/fixes/9630-combo-false-503.md b/changelog.d/fixes/9630-combo-false-503.md new file mode 100644 index 0000000000..5558818649 --- /dev/null +++ b/changelog.d/fixes/9630-combo-false-503.md @@ -0,0 +1 @@ +- fix(combo): distinguish pre-dispatch skips from genuine failures to prevent false 503 ALL_ACCOUNTS_INACTIVE (#9630) diff --git a/changelog.d/fixes/9633-npm-build-files.md b/changelog.d/fixes/9633-npm-build-files.md new file mode 100644 index 0000000000..c1dc5128da --- /dev/null +++ b/changelog.d/fixes/9633-npm-build-files.md @@ -0,0 +1 @@ +- fix(build): add build-next-isolated.mjs sibling imports to package.json files array 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/9713-claude-gemini-tool-casing.md b/changelog.d/fixes/9713-claude-gemini-tool-casing.md new file mode 100644 index 0000000000..f35d38de3c --- /dev/null +++ b/changelog.d/fixes/9713-claude-gemini-tool-casing.md @@ -0,0 +1 @@ +- **fix(translator):** restore TitleCase tool names on the Claude → Gemini/Antigravity request path so Claude Code no longer fails with `No such tool available: read` ([#9713](https://github.com/diegosouzapw/OmniRoute/issues/9713)) 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/9737-memory-id-route-backend.md b/changelog.d/fixes/9737-memory-id-route-backend.md new file mode 100644 index 0000000000..93c6e6f836 --- /dev/null +++ b/changelog.d/fixes/9737-memory-id-route-backend.md @@ -0,0 +1 @@ +- Fixed `GET`/`PUT`/`DELETE /api/memory/[id]` always failing with a 500 (`Primary backend "sqlite" not registered`) when the route was reached before any other memory endpoint in the same process. diff --git a/changelog.d/fixes/9737-route-body-validation.md b/changelog.d/fixes/9737-route-body-validation.md new file mode 100644 index 0000000000..3c9f16a39c --- /dev/null +++ b/changelog.d/fixes/9737-route-body-validation.md @@ -0,0 +1 @@ +- Replaced hand-rolled body type checks with Zod validation in the plugins marketplace install route and the three Dario admin routes, restoring the `t06:route-validation` gate (Hard Rule #7). diff --git a/changelog.d/fixes/9737-vietnamese-locale-parity.md b/changelog.d/fixes/9737-vietnamese-locale-parity.md new file mode 100644 index 0000000000..3b6b4a1465 --- /dev/null +++ b/changelog.d/fixes/9737-vietnamese-locale-parity.md @@ -0,0 +1 @@ +- Restore Vietnamese locale parity after the entity-normalization sync dropped Radar, provider, and mini-playground messages. diff --git a/changelog.d/fixes/9776-radar-entitlement-refresh.md b/changelog.d/fixes/9776-radar-entitlement-refresh.md new file mode 100644 index 0000000000..1b4e775629 --- /dev/null +++ b/changelog.d/fixes/9776-radar-entitlement-refresh.md @@ -0,0 +1 @@ +- **fix(radar):** refresh signed catalog/referral caches when supporter entitlement changes, preserve the one-time live-to-community downgrade, and test real provider connection IDs from the setup tour diff --git a/changelog.d/fixes/9783-namespace-identity-pivot.md b/changelog.d/fixes/9783-namespace-identity-pivot.md new file mode 100644 index 0000000000..93ba20fdac --- /dev/null +++ b/changelog.d/fixes/9783-namespace-identity-pivot.md @@ -0,0 +1 @@ +- **Translator**: keep the Responses namespace identity map across the hub-and-spoke pivot — namespace sub-tool calls routed to non-OpenAI targets (Kiro, Cursor) no longer come back flattened (`unsupported call: functions__exec` in Codex CLI) (#9783 — thanks @VXNCXNX) 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/9914-search-exa-contents.md b/changelog.d/fixes/9914-search-exa-contents.md new file mode 100644 index 0000000000..506fe9ebed --- /dev/null +++ b/changelog.d/fixes/9914-search-exa-contents.md @@ -0,0 +1 @@ +- fix(search): nest Exa contents options (text/highlights) for /search API (#9914) diff --git a/changelog.d/fixes/9927-encryption-log-identity.md b/changelog.d/fixes/9927-encryption-log-identity.md new file mode 100644 index 0000000000..8a9f57cb28 --- /dev/null +++ b/changelog.d/fixes/9927-encryption-log-identity.md @@ -0,0 +1 @@ +- fix(encryption): name failing credential + recovery path in decrypt errors, dedupe per connection (#9927) diff --git a/changelog.d/fixes/9931-codex-translated-tool-strict.md b/changelog.d/fixes/9931-codex-translated-tool-strict.md new file mode 100644 index 0000000000..f6cf3f79c5 --- /dev/null +++ b/changelog.d/fixes/9931-codex-translated-tool-strict.md @@ -0,0 +1 @@ +- **fix(executors):** preserve non-strict function-tool semantics when translating Chat Completions requests to Codex Responses, avoiding intermittent streamed failures without rewriting tool schemas or dropping branch-level descriptions and annotations. ([#9931](https://github.com/diegosouzapw/OmniRoute/pull/9931)) diff --git a/changelog.d/fixes/9934-migration-fresh-setup.md b/changelog.d/fixes/9934-migration-fresh-setup.md new file mode 100644 index 0000000000..b794540c58 --- /dev/null +++ b/changelog.d/fixes/9934-migration-fresh-setup.md @@ -0,0 +1 @@ +- fix(migrations): don't abort on fresh install with only the 001 seed (#9934) \ No newline at end of file diff --git a/changelog.d/fixes/9940-per-connection-virtual-admission-lanes.md b/changelog.d/fixes/9940-per-connection-virtual-admission-lanes.md new file mode 100644 index 0000000000..59f304f591 --- /dev/null +++ b/changelog.d/fixes/9940-per-connection-virtual-admission-lanes.md @@ -0,0 +1 @@ +- **fix(admission):** per-connection virtual admission lanes with idle TTL eviction — guards `expireEntry` null deref, adds `deleteLane()` for safe LRU eviction, and passes `sessionId` to byte-level admission (fixes #9654) diff --git a/changelog.d/fixes/9971-empty-choices-vps.md b/changelog.d/fixes/9971-empty-choices-vps.md new file mode 100644 index 0000000000..9e09aa3c9b --- /dev/null +++ b/changelog.d/fixes/9971-empty-choices-vps.md @@ -0,0 +1 @@ +- fix(chat): don't misclassify content-less thinking/redacted Claude bodies as empty_choices (#9971) \ No newline at end of file diff --git a/changelog.d/fixes/9981-image-error-normalization.md b/changelog.d/fixes/9981-image-error-normalization.md new file mode 100644 index 0000000000..022d808ce2 --- /dev/null +++ b/changelog.d/fixes/9981-image-error-normalization.md @@ -0,0 +1 @@ +- fix(images): normalize terminal upstream errors via OpenAI-standard type/code (#9981) diff --git a/changelog.d/fixes/9985-basereds-docs-size.md b/changelog.d/fixes/9985-basereds-docs-size.md new file mode 100644 index 0000000000..1f743f8333 --- /dev/null +++ b/changelog.d/fixes/9985-basereds-docs-size.md @@ -0,0 +1 @@ +- fix(quality): green release/v3.8.50 base-reds — sync 4 env vars into .env.example/ENVIRONMENT.md and freeze the new proxied-TLS proxyFetch helper in the file-size baseline (#9985) 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/changelog.d/maintenance/9023-remove-retired-github-models.md b/changelog.d/maintenance/9023-remove-retired-github-models.md new file mode 100644 index 0000000000..3a09c1e961 --- /dev/null +++ b/changelog.d/maintenance/9023-remove-retired-github-models.md @@ -0,0 +1 @@ +- **refactor(providers):** removed the retired GitHub Models provider and its catalog, discovery, embedding, free-tier, UI, and documentation surfaces; upgrades now run a durable, idempotent purge of its stored credentials, usage state, structured configuration, and call-log artifacts while preserving GitHub Copilot and live members of mixed configurations ([#9023](https://github.com/diegosouzapw/OmniRoute/pull/9023)) diff --git a/changelog.d/maintenance/9614-opencode-plugin-bare-key-suite.md b/changelog.d/maintenance/9614-opencode-plugin-bare-key-suite.md new file mode 100644 index 0000000000..c47643b915 --- /dev/null +++ b/changelog.d/maintenance/9614-opencode-plugin-bare-key-suite.md @@ -0,0 +1 @@ +- **test(cli):** OpenCode plugin suite realigned to the bare-key static-catalog contract from #9178/#9175 (21 tests were red on every opencode-plugin CI run; 287/287 after) ([#9614](https://github.com/diegosouzapw/OmniRoute/pull/9614)) diff --git a/changelog.d/maintenance/9738-deadcode-radar-referrals.md b/changelog.d/maintenance/9738-deadcode-radar-referrals.md new file mode 100644 index 0000000000..0d89158f49 --- /dev/null +++ b/changelog.d/maintenance/9738-deadcode-radar-referrals.md @@ -0,0 +1 @@ +- Removed the unused `RadarReferrals` type export left by the radar referral-links feature (#9697), returning the dead-code ratchet to its 227 baseline. diff --git a/changelog.d/maintenance/9839-v3850-quality-ratchets.md b/changelog.d/maintenance/9839-v3850-quality-ratchets.md new file mode 100644 index 0000000000..51bad864ad --- /dev/null +++ b/changelog.d/maintenance/9839-v3850-quality-ratchets.md @@ -0,0 +1 @@ +- Reconcile the final v3.8.50 bundle-size and file-size ratchets against the measured release tip, preserving exact direction-down ceilings and their source attribution. diff --git a/changelog.d/maintenance/9950-file-size-rebaseline-30pct.md b/changelog.d/maintenance/9950-file-size-rebaseline-30pct.md new file mode 100644 index 0000000000..acf3604770 --- /dev/null +++ b/changelog.d/maintenance/9950-file-size-rebaseline-30pct.md @@ -0,0 +1 @@ +- **chore(quality):** expand all file-size baselines by +30% ahead of v3.8.51 (authorized DRIFT rebaseline) to unblock the pre-release queue; no functionality changes. diff --git a/config/alibaba-free-tier-allowlist.json b/config/alibaba-free-tier-allowlist.json new file mode 100644 index 0000000000..9f71a4f9ba --- /dev/null +++ b/config/alibaba-free-tier-allowlist.json @@ -0,0 +1,82 @@ +{ + "asOf": "2026-07-28", + "validUntil": "2026-08-27", + "capable": [ + "deepseek-v3.2", + "deepseek-v4-pro", + "glm-5.2", + "qwen-flash", + "qwen-flash-2025-07-28", + "qwen-flash-character", + "qwen-max", + "qwen-mt-flash", + "qwen-mt-lite", + "qwen-mt-plus", + "qwen-mt-turbo", + "qwen-plus-2025-04-28", + "qwen-plus-2025-07-14", + "qwen-plus-2025-07-28", + "qwen-plus-2025-09-11", + "qwen-plus-character", + "qwen-plus-latest", + "qwen3-14b", + "qwen3-235b-a22b", + "qwen3-235b-a22b-instruct-2507", + "qwen3-235b-a22b-thinking-2507", + "qwen3-30b-a3b", + "qwen3-30b-a3b-instruct-2507", + "qwen3-30b-a3b-thinking-2507", + "qwen3-32b", + "qwen3-8b", + "qwen3-coder-30b-a3b-instruct", + "qwen3-coder-480b-a35b-instruct", + "qwen3-coder-flash", + "qwen3-coder-flash-2025-07-28", + "qwen3-coder-next", + "qwen3-coder-plus", + "qwen3-coder-plus-2025-07-22", + "qwen3-coder-plus-2025-09-23", + "qwen3-max", + "qwen3-max-2025-09-23", + "qwen3-max-2026-01-23", + "qwen3-max-preview", + "qwen3-next-80b-a3b-instruct", + "qwen3-next-80b-a3b-thinking", + "qwen3.5-122b-a10b", + "qwen3.5-27b", + "qwen3.5-397b-a17b", + "qwen3.5-flash", + "qwen3.5-flash-2026-02-23", + "qwen3.5-plus", + "qwen3.5-plus-2026-02-15", + "qwen3.5-plus-2026-04-20", + "qwen3.6-27b", + "qwen3.6-35b-a3b", + "qwen3.6-flash", + "qwen3.6-flash-2026-04-16", + "qwen3.6-max-preview", + "qwen3.6-plus", + "qwen3.6-plus-2026-04-02", + "qwen3.7-flash", + "qwen3.7-flash-2026-07-15", + "qwen3.7-max-2026-05-17", + "qwen3.7-max-2026-05-20", + "qwen3.7-max-2026-06-08", + "qwen3.7-max-preview", + "qwen3.7-plus-2026-05-26", + "qwq-plus" + ], + "noFreeTier": [ + "deepseek-v4-flash", + "glm-5.1", + "glm-5.2-fast-preview", + "kimi-k2.7-code", + "qwen-plus", + "qwen-plus-2025-01-25", + "qwen-plus-character-ja", + "qwen-turbo", + "qwen3.5-35b-a3b", + "qwen3.7-max", + "qwen3.7-plus" + ] +} diff --git a/config/quality/dependency-allowlist.json b/config/quality/dependency-allowlist.json index 6c7e2e3f45..f276fc45d2 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", @@ -96,6 +97,8 @@ "node-machine-id", "omniglyph", "open", + "opencode-ai", + "onnxruntime-node", "ora", "parse5", "pino", @@ -113,6 +116,7 @@ "recharts", "safe-regex", "selfsigned", + "sharp", "size-limit", "smol-toml", "socks", @@ -121,6 +125,8 @@ "tailwind-merge", "tailwindcss", "tls-client-node", + "turndown", + "turndown-plugin-gfm", "tsup", "tsx", "type-coverage", diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index d2f8e946ec..c89081a812 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -44,16 +44,21 @@ "count": 11 } }, - "open-sse/executors/vertex.ts": { + "open-sse/executors/tinycms.ts": { "@typescript-eslint/no-explicit-any": { "count": 3 } }, - "open-sse/handlers/chatCore.ts": { - "no-restricted-imports": { + "open-sse/executors/tinycmsSigner.ts": { + "@typescript-eslint/no-explicit-any": { "count": 1 } }, + "open-sse/executors/vertex.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, "open-sse/handlers/chatCore/codexFailover.ts": { "no-restricted-imports": { "count": 1 @@ -81,7 +86,7 @@ }, "open-sse/handlers/search.ts": { "@typescript-eslint/no-explicit-any": { - "count": 34 + "count": 33 } }, "open-sse/handlers/sseParser.ts": { @@ -1356,24 +1361,11 @@ "count": 1 } }, - "src/sse/handlers/chat.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "src/sse/handlers/chatHelpers.ts": { "no-restricted-imports": { "count": 1 } }, - "src/sse/services/auth.ts": { - "no-restricted-imports": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 1 - } - }, "src/sse/services/model.ts": { "no-restricted-imports": { "count": 2 @@ -1686,7 +1678,7 @@ }, "tests/unit/base-executor-sanitize-effort.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 48 + "count": 6 } }, "tests/unit/batch-deletion.test.ts": { @@ -2049,11 +2041,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 @@ -3352,4 +3339,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 5a5c4208e1..ba33c6aeb2 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,6 +1,26 @@ { - "_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_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)", + "_rebaseline_2026_08_09_9207_breaker_halfopen_recovery": "PR #9207 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2020 (+42 = recordProviderSuccess now also transitions the provider circuit breaker from HALF_OPEN to CLOSED when a request succeeds, so the breaker is not stuck half-open after repeated failures; the transition and its reset wiring grow the existing provider-success path, not extractable). Covered by tests/unit/provider-breaker-halfopen-recovery.test.ts.", + "_rebaseline_2026_08_09_9351_antigravity_switch_auth": "PR #9351 own growth during the 2026-08-09 rebase: open-sse/executors/antigravity.ts 1528->1536 (+8 = switchAuth threaded out of tryResolveRetryFromErrorBody into handleAntigravityRateLimit's short-retry guard, so a decide429 switch decision beats the 60s same-account sleep; cohesive at the existing resolve chokepoint, not extractable). Covered by tests/unit/antigravity-429-switch-auth.test.ts.", + + "_rebaseline_2026_08_09_9328_bottleneck_doexpire_rate_limit": "PR #9328 own growth during the 2026-08-09 rebase: open-sse/services/rateLimitManager.ts 1167->1221 (the Bottleneck doExpire capacity-leak monkey-patch plus its diagnostic branch and deterministic assertions live at the manager's existing wiring; monolithic patch, not extractable). Covered by tests/unit/bottleneck-doexpire-patch.test.ts.", + "_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_09_9342_network_error_guard": "PR #9342 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2008 (+30 = the isQueueTimeout short-circuit plus a per-provider network-error dedup window in recordProviderFailure, keeping one VPN blip from the same provider's combo targets counting once per target). Covered by tests/unit/breaker-network-error-guard.test.ts. (chat.ts stays base-red: upstream tip is already 1918 > frozen 1904, this PR only adds +12 on top; not re-bumped per the no-inherit-ratchet rule.)", "_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_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\u2019s 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\u2019s own mandatory pre-merge checklist, not yet addressed) \u2014 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() \u2014 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) \u2014 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 \u2014 not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).", + "_rebaseline_2026_07_22_8131_windowshide_cloudflared_spawn": "PR #8167 (Dingding-leo, fix/windows-hide-child-process, #8131) own growth: src/lib/cloudflaredTunnel.ts 934->935 (+1, irreducible call-site wiring \u2014 the single `windowsHide: true` option added to the existing cloudflared spawn() options object so no transient conhost.exe/cmd console window flashes open on Windows). Covered by the pre-merge-fix regression test tests/unit/windows-hide-child-process-spawns-8131.test.ts (added for the two additional spawn() sites the PR missed: ServiceSupervisor.ts, versionManager/processManager.ts) plus the windowsHide assertion added to tests/unit/services/installers/runNpm-shell-5379.test.ts (installers/utils.ts buildNpmExecOptions).", + "_rebaseline_2026_07_22_8006_adobe_firefly_media_provider": "PR #8006 (artickc, feat/adobe-firefly-media) own growth: adds Adobe Firefly as a media-only (image + video) provider \u2014 unofficial IMS/cookie-session bridge for firefly.adobe.com covering IMS cookie->access_token exchange, discovery-catalog fallback, credits/balance usage, and submit+poll dispatch for both image (nano-banana/gpt-image families) and video (Sora 2/Veo 3.1/Kling 3.0) generation, with 408-under-load retry handling. New leaf open-sse/services/adobeFireflyClient.ts frozen at 1958 (>>cap 800) \u2014 a single self-contained upstream client (mirrors the qoderCli.ts precedent for a new provider client that is legitimately large on day one: IMS auth, cookie/JWT normalization, payload builders for 2 media types x multiple model families, SSE-less submit/poll state machine, error sanitization); not extractable without scattering a single upstream integration across artificial module boundaries mid-PR. open-sse/config/imageRegistry.ts (existing, previously under cap) grows 800->821 (+21, the new adobe-firefly IMAGE_PROVIDERS entry + models list, additive registry data at the existing registry chokepoint). src/lib/usage/providerLimits.ts 1000->1003 (+3, adobe-firefly/firefly added to the existing apikey-usage-fetcher allowlist, irreducible call-site wiring mirroring the sibling #7994 PromptQL/HyperAgent entries in the same PR group). Covered by tests/unit/adobe-firefly.test.ts (35/35). Structural shrink tracked in #3501.", + "_rebaseline_2026_07_22_7994_hyperagent_web_provider": "PR #7994 (artickc, feat/hyperagent-web) own growth: adds HyperAgent (hyperagent.com) as a new unofficial web-cookie chat provider, reverse-engineered from live SPA captures (thread/session SSE flow, credits/usage endpoint). New leaf open-sse/executors/hyperagent.ts frozen at 937 (>cap 800) \u2014 single self-contained executor covering cookie auth, SSE parsing (text/session_start/session_end/done events), and a sticky thread/session cache for multi-turn continuity; not extractable without splitting the executor mid-request-flow (mirrors the sseParser.ts/muse-spark-web.ts precedent for new provider executors that exceed cap on day one). src/lib/usage/providerLimits.ts 1000->1003 (+3, irreducible call-site wiring adding hyperagent/ha to the existing USAGE_FETCHER_PROVIDERS-style allowlist at the chokepoint other web-cookie providers already extend). Covered by tests/unit/executor-hyperagent.test.ts (16/16). Structural shrink tracked in #3501.", + "_rebaseline_2026_08_08_9173_own_comment_growth": "PR #9173's own follow-up commit (f0a694051): tests/unit/combo-routing-engine.test.ts 3457->3464 (+7) is this PR's own growth — explanatory comment blocks added alongside the ALL_ACCOUNTS_INACTIVE->ALL_TARGETS_SKIPPED stale-assertion fix (matching the identical fix applied to #9619/#9006/#8909 the same day; upstream's own test was never updated when the recordedAttempts===0 pre-dispatch-skip branch shipped). Caught by CI's PR-mode check:file-size (--base-ref) after the fix commit; missed locally because check-file-size.mjs was not re-run after that specific edit. Also fixed this round: src/i18n/messages/vi.json was missing 4 keys (cursorSessionUnchanged, cursorAgentNudgeTitle/Body/Dismiss) that this PR's own pre-merge branch had translated — the original merge's `git checkout --theirs` resolution for the 7 conflicted locale files discarded them since upstream's vi.json (which has no cursor-token-renewal feature) never had them. Restored from this PR's pre-merge tip (a38003e30).", + "_rebaseline_2026_08_08_9173_vi_json_restore": "PR #9173's own follow-up commit: src/i18n/messages/vi.json was missing 4 keys (cursorSessionUnchanged, cursorAgentNudgeTitle/Body/Dismiss) that this PR's own pre-merge branch had translated — the original merge's `git checkout --theirs` resolution for the 7 conflicted locale files discarded them since upstream's vi.json (which has no cursor-token-renewal feature) never had them. Restored from this PR's pre-merge tip (a38003e30).", + "_rebaseline_2026_08_07_9173_reconcile_onto_tip": "PR #9173 (cursor-token-renewal) full reconcile-onto-tip merge with release/v3.8.50 (2026-08-07). base.ts 1619->1681 and chatCore.ts 5028->5031 grew further past the 2026-08-02 rebaseline below via already-merged, no-PR-branch-left commits unrelated to this PR's own Cursor renewal changes (measured directly on the merged tree, split(\"\\n\").length). Same merge also surfaced 11 file + 1 test-file violations shared with PR #9619's identical-base reconciliation the same day (open-sse/mcp-server/schemas/tools.ts 1505->1553, open-sse/mcp-server/server.ts 1411->1444, open-sse/services/accountFallback.ts 1972->1978, src/app/(dashboard)/dashboard/combos/page.tsx 4647->4703, EditConnectionModal.tsx 1316->1324, src/app/api/providers/[id]/models/route.ts 2250->2304, src/app/api/v1/models/catalog.ts 1549->1556, src/lib/db/core.ts 1637->1639, src/sse/handlers/chat.ts 1877->1878, tests/unit/translator-openai-to-gemini.test.ts 1619->1622) plus two more specific to this PR's own additive work compounding with inherited drift: src/lib/tokenHealthCheck.ts 1021->1101 (this PR's own +48 cursor-token-renewal refresh-health logic, per _rebaseline_2026_08_02_9242_token_health_transient's file, plus +32 independent upstream growth) and useProviderConnections.ts 986->1002 (this PR's own +12, plus +41 independent upstream growth — newly crosses the 1000 cap). Same root cause as every other entry in this chain: fast-gates PR->release does not run check:file-size. No offending branch left to fix.", + "_rebaseline_2026_08_02_agentrouter_ccbeta_regression_fix": "PR #9173 (cursor-token-renewal) own growth: open-sse/executors/base.ts 1578->1619 (+41). Fixes a real regression from the same two already-merged agentrouter commits documented in _rebaseline_2026_08_02_agentrouter_protocol_dispatch above — usesClaudeCodeProtocol() widened the native-Claude system-transform block (billing header, selectBetaFlags-derived anthropic-beta) to also run for CC-compatible relay connections. selectBetaFlags() has no visibility into a relay's own providerSpecificData.requestDefaults: for a relay with explicit requestDefaults configured (context1m/redactThinking/summarizeThinking), its Object.assign() silently discarded the relay's own correctly-computed headers (wiping an earlier CONTEXT_1M_BETA_HEADER append, force-including redact-thinking-2026-02-12 regardless of opt-in). For a 'vanilla' relay with no requestDefaults at all, the native treatment is pre-existing, intentional behavior (tests/unit/cc-compatible-provider.test.ts, v3.6.6) — the earlier version of this fix broke that case by excluding CC-relays unconditionally. The final gate is `this.provider === \"claude\" || usesCcWireImage(this.provider) || !hasCcRequestDefaults` (native treatment applies unless the relay has explicit requestDefaults), plus an unconditional post-pass that strips the redact-thinking beta unless the relay's own requestDefaults opted in. Covered by tests/unit/executor-default-base.test.ts ('uses CC-compatible connection defaults to append 1M beta'), tests/unit/cc-compatible-provider.test.ts (both SSE-forcing tests), and tests/unit/provider-request-failure-pipeline.test.ts ('keeps request beta headers and summarized thinking body') — all pre-existing, all independently re-verified passing together.", + "_rebaseline_2026_08_02_agentrouter_protocol_dispatch": "Reconcile-onto-tip drift surfaced by PR #9173 (cursor-token-renewal): two already-merged, no-PR-branch-left commits on release/v3.8.50 (564c204ef fix(agentrouter): support Claude and Codex protocols; ec150a006 fix(agentrouter): honor alternate protocol in chat pipeline) grew open-sse/executors/base.ts 1562->1578 (Claude/Codex protocol dispatch wiring in the agentrouter executor branch) and open-sse/handlers/chatCore.ts 5020->5028 + tests/unit/chatcore-translation-paths.test.ts 2769->2776 (alternate-protocol chat-pipeline routing + companion test coverage) past their frozen caps, unrelated to this PR's own Cursor renewal changes. Same pattern as the prior release-green rebaselines (fast-gates PR->release do not run check:file-size): no offending branch left to fix in-place. Real sizes per check-file-size.mjs's own split(\"\\n\").length metric.", + "_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).", "_rebaseline_2026_07_22_8131_windowshide_cloudflared_spawn": "PR #8167 (Dingding-leo, fix/windows-hide-child-process, #8131) own growth: src/lib/cloudflaredTunnel.ts 934->935 (+1, irreducible call-site wiring — the single `windowsHide: true` option added to the existing cloudflared spawn() options object so no transient conhost.exe/cmd console window flashes open on Windows). Covered by the pre-merge-fix regression test tests/unit/windows-hide-child-process-spawns-8131.test.ts (added for the two additional spawn() sites the PR missed: ServiceSupervisor.ts, versionManager/processManager.ts) plus the windowsHide assertion added to tests/unit/services/installers/runNpm-shell-5379.test.ts (installers/utils.ts buildNpmExecOptions).", @@ -14,7 +34,6 @@ "_rebaseline_2026_07_19_7546_ghe_copilot_route": "PR #7546 (GHE Copilot OAuth provider) own growth: oauth/[provider]/[action]/route.ts 960->963 (gate units, +3 = ghe-copilot device-code wiring at the existing multi-provider device-code branch — reading + HTTPS-validating the gheUrl search param (isValidGheUrl guards at both raw entry points, security-review hardening, 963->970), adding ghe-copilot to the no-PKCE provider set, and building the provider config override / threading gheUrl through poll->postExchange extraData). Mirrors the existing kiro/amazon-q startUrl override pattern right above it in the same branch; cohesive with the existing device-code dispatch chokepoint, not separately extractable without splitting a single provider-switch mid-branch. Frozen so can only shrink; structural shrink tracked in #3501.", "_rebaseline_2026_07_19_6846_nvidia_concurrency_gate": "Issue #6846 Phase 1 (nvidia NIM local RPM budget + per-model lockout + per-connection concurrency cap) own growth: open-sse/executors/default.ts 877->890 (+13 = the irreducible call-site wiring at DefaultExecutor.execute(), the only place nvidia requests dispatch through — the existing session-pool body was extracted verbatim into a new private executeWithSessionPool() so the outer execute() can wrap it in the nvidia concurrency-gate acquire/finally-release). All actual gating logic (semaphore key + cap resolution) lives in the new leaf open-sse/executors/default/nvidiaConcurrencyGate.ts (not frozen, well under cap). Covered by tests/unit/nvidia-quota-phase1.test.ts.", "_rebaseline_2026_07_18_v3849_provider_detail_wiring": "Merge campaign R2/R3 (2026-07-18): three authorized PRs each add irreducible call-site wiring to ProviderDetailPageClient.tsx — #7360 +5 (ProviderQuotaVisibilityToggle render, component extracted), #7419 +4 (NoAuthProviderControls wiring), #7062 +3 (Dahl provider hook) = 786->798. All three follow the extracted-component pattern (AgentrouterConsoleFields precedent); the frozen file only takes the wiring. Structural shrink tracked in #3501.", - "_rebaseline_2026_07_25_dario_upstream_proxy_selector": "PR #8523 (Dario embedded service): upstream-proxy mode selector replaces the binary CLIProxyAPI toggle with Native/CLIProxyAPI/Dario/Fallback + a fallback-backend picker. ProviderDetailPageClient.tsx 798->804 (+6, new hook fields threaded through to ConnectionsListPanel), ConnectionRow.tsx 942->958 (+16, the mode replacing a single pill button), useProviderConnections.ts 954->986 (+32, upstreamProxyMode/upstreamProxyFallbackBackend state + handleSetUpstreamProxyMode, handleToggleCliproxyapiMode kept as a thin backward-compat wrapper for the existing hook-shape test). All additive UI/state for the new modes — no unrelated refactor.", "_rebaseline_2026_07_18_pr7653_chat_tracker_import": "PR #7653 merge-interaction growth: release moved chat.ts to its 1796 cap while this PR adds the single side-effect import 'quotaTrackersBatch.ts' (line 130) — chat.ts IS the canonical quota-fetcher registration point (codex/bailian/deepseek/openrouter/opencode/generic all import+register there), so the +1 is irreducible call-site wiring. 1796->1797. Covered by tests/unit/{agentrouter,v0,freemodel}-quota-fetcher.test.ts.", "_rebaseline_2026_07_17_pr7653_agentrouter_console_fields": "PR #7653 own growth (missing acceptance criterion: the AgentRouter quota tracker (#6850) read providerSpecificData.consoleApiKey/newApiUserId but neither field had dashboard UI for provider agentrouter — consoleApiKey was gated to bailian-coding-plan only and newApiUserId had zero UI). AddApiKeyModal.tsx 961->967 (+6) and EditConnectionModal.tsx 1278->1286 (+8) = import + a single render call plus the newApiUserId formData init field. The actual Input rendering (both consoleApiKey reuse + the new newApiUserId field) was EXTRACTED into a new leaf src/app/(dashboard)/dashboard/providers/[id]/components/modals/AgentrouterConsoleFields.tsx (48 LOC, 2462 (+1, irreducible at the existing model-aware preflight chokepoint — the `provider === \"codex\"` check that forwards requestedModel into the connection arg is extended to also cover `openrouter`, one added boolean + a doc comment, offset to a single net line by dropping the now-redundant inline condition). Enforcement itself lives in open-sse/services/openrouterQuotaFetcher.ts (not frozen) and the dispatch-time record/correct hooks live in open-sse/executors/base.ts (not frozen). Covered by tests/unit/openrouter-free-window-wiring-6842.test.ts.", @@ -159,190 +178,13 @@ "_rebaseline_2026_06_20_1409_1294_models": "Re-baseline src/lib/db/models.ts 1184->1221: combined growth of sibling fixes #1409 (cascade-delete orphaned model aliases when a provider is removed) + #1294 (persist max_input_tokens/max_output_tokens on custom models), both adding CRUD at the existing models domain module. Cohesive db module; not extractable.", "_rebaseline_2026_06_20_4389_thinking_toolchoice": "Re-baseline base.ts 1387->1399 (#4389): tool_choice-forced thinking guard at the existing Claude wire-image injection chokepoint (effThinking gate avoids the Anthropic 400 when tool_choice forces a tool). Cohesive guard; structural shrink tracked in #3501.", "_rebaseline_2026_07_18_6979_codex_test": "PR #6979 own growth: executor-codex.test.ts 1340->1347 (+7 = generalized ensureThinkingBudget assertion added to the existing codex thinking-budget cases). antigravity-test bump 942->977 REVERTED here: #7408's test split dropped that file to 888, so this PR's +35 fits under the original 942 frozen cap.", + "_rebaseline_2026_07_24_8354_logs_timeline_sidebar": "PR #8354 (hartmark, feature/scrolling-log) own growth: src/shared/constants/sidebarVisibility/sections.ts 812->820 (+8, the single new logs-timeline SidebarItemDefinition entry added to LOGS_GROUP.items for the new /dashboard/logs/timeline scrolling request-timeline page). Irreducible data-literal wiring at the existing sidebar-sections chokepoint, same shape as every other item in the file; not extractable without an ad-hoc single-item exception to the file's otherwise-uniform multi-line item style.", + "_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, - "frozen": { - "_rebaseline_2026_07_02_5816_qoder": "PR #5816 (@AgentKiller45, qoder PAT via qodercli): qoderCli.ts 666->989, new-above-cap frozen (owner-approved baseline freeze). The growth is the legitimate PAT job-token exchange + quota parsing CLI transport (the pure-JS Cosy path 500'd on every PAT request); extracting the spawn/parse helpers now would just add indirection to a contributor PR mid-merge. Test frozen also raised for this PR's coverage growth: providers-page-utils.test.ts 1052->1092. Additionally clears an inherited base-red from the already-merged #5933 (codex json_schema->text.format): translator-openai-responses-req.test.ts 1097->1172 (+75 regression tests, no offending branch left). All remain frozen (cannot grow further); release captain's rebaseline-at-release supersedes.", - "open-sse/services/qoderCli.ts": 989, - "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", - "_rebaseline_2026_07_23_8266_alibaba_media": "#8266 (@backryun) own growth: imageRegistry.ts 821->979 (+158) — Alibaba-family media models (Qwen image/video, Bailian, Wan) added to the image/video registry. Registry model data, not extractable logic; frozen at new size.", - "open-sse/config/imageRegistry.ts": 979, - "open-sse/config/providerRegistry.ts": 4731, - "open-sse/executors/antigravity.ts": 1813, - "open-sse/executors/base.ts": 1540, - "open-sse/executors/chatgpt-web.ts": 3206, - "open-sse/executors/claude-web.ts": 1057, - "open-sse/executors/codex.ts": 1541, - "open-sse/executors/cursor.ts": 1577, - "open-sse/executors/deepseek-web.ts": 1148, - "_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_28_5237_impersonation_ua_refresh": "PR #5237 (refresh impersonation UAs): grok-web.ts 1871->1873 (+2), muse-spark-web.ts 1284->1302 (+18), perplexity-web.ts 1013->1032 (+19). Net semantic change in each file is a single User-Agent constant (Chrome 147->149 for grok/muse; perplexity kept at Firefox 148 to stay matched with the firefox_148 TLS profile — the contributor's 152 bump was reverted to avoid a UA-vs-JA3 mismatch, #2459). The growth is Prettier reflow that lint-staged unavoidably applies to these grandfathered long-line files the moment they are touched; not extractable. src/sse/services/auth.ts 2336->2401 in the same reconcile is #5222's antigravity-LRU-retry growth that merged via --admin without a baseline bump.", - "open-sse/executors/duckduckgo-web.ts": 925, - "open-sse/executors/grok-web.ts": 1873, - "open-sse/executors/hyperagent.ts": 937, - "open-sse/executors/muse-spark-web.ts": 1396, - "open-sse/executors/perplexity-web.ts": 1032, - "open-sse/handlers/audioSpeech.ts": 1061, - "open-sse/handlers/chatCore.ts": 5125, - "open-sse/handlers/imageGeneration.ts": 3777, - "open-sse/handlers/responseSanitizer.ts": 1139, - "open-sse/handlers/search.ts": 1546, - "open-sse/handlers/sseParser.ts": 830, - "open-sse/handlers/videoGeneration.ts": 1275, - "_rebaseline_2026_07_22_8010_codex_responses_engine": "PR #8010 (@JxnLexn) own growth: open-sse/mcp-server/schemas/tools.ts 1497->1505 (+8 = threading the new \"codex-responses\" literal into the compressionConfigureInput strategy/autoTriggerMode Zod enums and setCompressionEngineInput engine enum, mirroring the existing rtk/omniglyph enum entries; no new tool). open-sse/services/compression/strategySelector.ts 1043->1054 (+11 = one new `if (mode === \"codex-responses\")` dispatch branch in runCompression that delegates 100% to the new codexResponsesEngine.apply, mirroring the existing rtk single-mode dispatch, plus threading config.codexResponsesConfig.preserveToolNames into the shared adaptBodyForCompression call at the 3 existing call sites). src/lib/db/compression.ts (untracked, new-file cap 800) 794->845 (+51 = normalizeCodexResponsesConfig, mirroring the existing normalizeRtkConfig normalizer, plus registering \"codex-responses\" in the COMPRESSION_MODES/STACKED_PIPELINE_ENGINE_IDS/SINGLE_MODE_ENGINE sets and the getCompressionSettings load/save switch) — added to the baseline at its current size. All three are cohesive dispatch/normalizer wiring at existing chokepoints (mirroring the prior compression-mode rebaselines #6534/#6556), not extractable without hiding the mode-dispatch boundary. Covered by tests/unit/compression/codex-responses.test.ts (6) + omniglyph-registries.test.ts/types.test.ts (22, updated for the new mode).", - "_rebaseline_2026_07_22_8034_compression_exclusions_persistence": "#8034 (compression exclusions) own growth: src/lib/db/compression.ts 845->850 (+5 = threading the new compressionExclusions field through the existing getCompressionSettings/saveCompressionSettings load/save switch over the shared key_value compression namespace — no new table, no raw SQL). Mirrors the prior compression-field rebaselines (#8010 codex-responses normalizer at the same chokepoint); the load/save switch is a single dispatch boundary, not extractable without hiding it. Covered by the PR's 8 node:test + 3 vitest cases.", - "src/lib/db/compression.ts": 866, - "open-sse/mcp-server/schemas/tools.ts": 1505, - "open-sse/mcp-server/server.ts": 1555, - "open-sse/mcp-server/tools/advancedTools.ts": 1120, - "_rebaseline_2026_06_27_5193_antigravity_basered": "Base-red (pre-existing release drift, fast-gate PR->release skips check:file-size): accountFallback.ts 1773->1777 and src/app/api/providers/[id]/test/route.ts 924->940 were already over their frozen caps on release/v3.8.39 independent of any antigravity change. Owner chose to rebaseline (keep the documented issue-reference comments #1846/#1449/#347 etc.) rather than accept the contributor comment-stripping in #5200/#5198. Reverted #5200 to restore the comments; bumped these two frozen caps to the actual base sizes. No logic change.", - "_rebaseline_2026_07_22_8213_gemini_tpm_quota_cooldown_wait": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: open-sse/services/accountFallback.ts 1857->1932 on the merged tip (release 1892 incl #8050 +35, plus this PR own growth +40); measured against the PR own merge-base was 1857->1898 (+41 — the release tip separately carries an unrelated +34 from #8050's antigravity 404 model-not-found lockout scoping, which this PR's branch does not include and this entry does not cover). Own growth is the Gemini TPM-ceiling classification + cooldown-wait wiring feeding into the combo cooldown-wait state machine (rate-limit wedge recovery) introduced by this PR's commit series. Irreducible additions at the existing account-fallback/model-lockout chokepoint. Covered by the PR's own gemini-rate-limit-tracker and TPM-ceiling benchmark test additions.", - "_rebaseline_2026_07_23_8252_combo_400_advance": "#8252 (@RaviTharuma) own growth: accountFallback.ts 1932->1940 (+8) + combo.ts 3604->3630 (+26) — advance combo on model-scoped 400s wrapped as invalid/Bad-Request. Irreducible wiring at existing account-fallback + combo dispatch chokepoints. Covered by combo-model-scoped-400-advance.test.ts.", - "open-sse/services/accountFallback.ts": 1940, - "open-sse/services/adobeFireflyClient.ts": 1958, - "open-sse/services/batchProcessor.ts": 915, - "open-sse/services/browserBackedChat.ts": 850, - "open-sse/services/claudeCodeCompatible.ts": 1202, - "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", - "_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, 3190 (+10 = one new `else if (strategy === \"quota-share\")` dispatch branch in handleComboChat that delegates 100% to selectQuotaShareTarget + its log line, plus the import). All the new logic lives OUT of the god-file in two new leaves under open-sse/services/combo/: quotaShareInflight.ts (in-flight counter with TTL/lease, ~150 LOC 3225 (+35) = one new `else if (strategy === \"task-aware\")` dispatch branch delegating 100% to selectTaskAwareTarget + its imports/log lines. All scoring/classification logic lives OUT of the god-file in the new leaf open-sse/services/taskAwareRouting.ts (553 LOC 3604 (+56, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Fixes combo cooldown-wait state recording so a bogus 503 is no longer crystallized when the cooldown-wait vars reset every setTry, adds an OpenAI-format SSE error frame path for combo-exhausted rejections (capturing request body + attempted models), and gives an abandoned per-target dispatch its own timeout instead of leaking a permanent 'pending' dashboard entry. Irreducible additions at the existing handleComboChat dispatch/retry chokepoint (mirrors the prior quota-share/headroom/task-aware strategy-branch precedents already frozen in this file). Covered by the PR's own combo-config + Gemini TPM-ceiling benchmark test additions.", - "open-sse/services/combo.ts": 3630, - "_rebaseline_2026_06_26_fidelity_gate_extraction": "Milestone-B fidelity-gate wiring residual: bodyToText+gateAdvance extracted to fidelityGateStep.ts (889->854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).", - "_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop — a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (960 (+61 = the opt-in result-memoization branches in applyCompression/applyCompressionAsync — principal+determinism gate, makeMemoKey lookup/store with model+supportsVision folded into the key, recompute-with-memo-off). Default off (memoizeCompressionResults), so zero behavior change. The memo helpers live in the leaf resultMemo.ts ( 800 cap). It is the vendored GCF generic-profile decoder (spec v3.2 nested flattening plus the prototype-pollution / hasOwnProperty hardening added in this PR's Gemini review). Kept as one file faithful to upstream gcf-typescript so re-vendoring stays a clean copy rather than a re-split each cycle (sibling generic.ts/scalar.ts stay < cap; extraction would also fragment the file's frozen eslint no-explicit-any suppressions). Round-trip + prototype-pollution regression coverage in tests/unit/compression/headroom-smartcrusher.test.ts. Frozen: only shrinks from here.", - "open-sse/services/compression/engines/headroom/gcf/decode_generic.ts": 880, - "open-sse/services/rateLimitManager.ts": 1035, - "_rebaseline_2026_06_29_4038_cas_guard": "PR (#4038) own growth: tokenRefresh.ts 2103->2181 (+78 = the compare-and-swap guard on the refresh persist — runWithCasGuard/getActiveCasGuard AsyncLocalStorage pair mirroring runWithOnPersist, casGuardShouldSkipPersist that rereads the row right before persisting and skips the write when a concurrent writer already rotated the refresh_token past the one presented, plus getCasGuardStats counters). Fixes the sibling-rotation-revert → token-family-revocation storm. Gated behind an active guard (opt-in; no guard => byte-identical). Wiring lives at the two persist chokepoints inside getAccessToken; the comparison reuses wasRefreshTokenRotated from refreshSerializer. Not extractable without splitting the refresh hot path.", - "_rebaseline_2026_07_09_6126_clinepass_dual_auth": "PR #6126 (@hajilok, dual-auth ClinePass) own growth: tokenRefresh.ts 2181->2182 (+1 = a single `case \"clinepass\":` fallthrough label added to the existing `case \"cline\":` in _getAccessTokenInternal's provider switch, so clinepass token refresh dispatches to the already-shared refreshClineToken() instead of silently falling through to the generic OAuth refresh). Irreducible 1-line switch-case wiring at the existing chokepoint; the header-building logic for the same feature was extracted to a new leaf src/shared/utils/clineAuth.ts::buildClinepassHeaders() (well under cap) to avoid growing open-sse/executors/default.ts. Covered by tests/unit/clinepass-provider.test.ts.", - "_rebaseline_2026_07_09_6363_kiro_external_idp": "PR #6363 (@artickc, Kiro external IdP) own growth: tokenRefresh.ts 2182->2249 (+67 = the external_idp refresh branch inside refreshKiroToken — standard public-client OAuth2 refresh_token grant against the org IdP tokenEndpoint via buildExternalIdpRefreshParams/isExternalIdpAuthMethod from the new leaf open-sse/services/kiroExternalIdp.ts, with invalid_grant/invalid_client -> unrecoverable_refresh_error mapping). Cohesive addition at the existing refreshKiroToken chokepoint. Covered by tests/unit/kiro-external-idp.test.ts.", - "open-sse/services/tokenRefresh.ts": 2249, - "open-sse/services/usage.ts": 3454, - "open-sse/translator/request/openai-to-gemini.ts": 906, - "open-sse/translator/request/openai-to-kiro.ts": 912, - "_rebaseline_2026_07_22_8211_gemini_malformed_tool_choice": "PR #8211 (hartmark, fix/gemini-malformed-function-call-tool-choice) own growth: open-sse/translator/response/gemini-to-openai.ts 771->821 (+50, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds MALFORMED_FUNCTION_CALL/UNEXPECTED_TOOL_CALL handling inside geminiToOpenAIResponse(): synthesizes a `malformed_tool_call` tool_calls entry so finish_reason normalizes to the standard \"tool_calls\" instead of an unrecognized raw enum value that OpenAI-compatible clients (e.g. OpenClaw) silently ignore, and always synthesizes (rather than skipping when a real tool call already exists) so a malformed attempt alongside a real one in the same turn is not silently discarded. Irreducible cohesive addition at the existing candidate/finishReason translation chokepoint (mirrors the 9router#2462 raw-finish-reason precedent immediately below it in the same function). Covered by the PR's own tests/unit test additions for both the malformed-only and malformed-plus-real-call cases.", - "open-sse/translator/response/gemini-to-openai.ts": 821, - "_rebaseline_2026_07_22_7936_namespace_roundtrip": "#7936 (@RCrushMe, Responses-Chat namespace round-trip identity seam) own growth: open-sse/translator/response/openai-responses.ts 1092->1125 (+33) and open-sse/utils/stream.ts 2814->2869 (+55) — threading the namespace-identity seam through the Responses↔Chat translation + stream paths so tool-call namespaces survive the round-trip. Cohesive translation/stream wiring at existing chokepoints, frozen at new size.", - "_rebaseline_2026_07_22_8210_openrouter_midstream_error": "PR #8210 (hartmark, fix/openrouter-midstream-error-surfacing) own growth: open-sse/translator/response/openai-responses.ts 1137->1163 (+26) measured on the merged tip (release 1137 + this PR own growth). Adds a single new branch inside openaiToOpenAIResponsesResponse() that detects an OpenRouter-style mid-stream aggregator error (HTTP 200 SSE chunk with empty choices + a top-level error object) and surfaces it as state.upstreamError instead of silently falling through to the no-op/awaitingTrailingUsage path, which previously masked the failure as a false empty-success completion and skipped combo fallback. Irreducible call-site addition at the existing chunk-dispatch chokepoint (mirrors the Gemini-to-OpenAI translator's #4177 precedent for the same class of upstream error surfacing). Note: this baseline entry does NOT cover the separate pre-existing +11 drift already on the release tip from #8081/#8162 (1125->1136, unrelated reasoning-placeholder-stripping fix merged after this PR branched) — that drift belongs to the maintainer's rebaseline, not this PR.", - "open-sse/translator/response/openai-responses.ts": 1163, - "open-sse/utils/cursorAgentProtobuf.ts": 1521, - "_rebaseline_2026_07_23_8143_empty_catch_logging": "#8143 (@chirag127) own growth: open-sse/utils/stream.ts 2869->2887 (+18) — replacing empty catch blocks in the SSE stream subsystem with console.debug logging (Rule #6 silent-swallow fix, issues #8138-#8142). Cohesive logging additions at the existing catch chokepoints, not extractable; frozen at new size. Covered by tests/unit/stream-handler-catch-logging-8143.test.ts.", - "open-sse/utils/stream.ts": 2887, - "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385, - "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031, - "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3120, - "src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105, - "src/app/(dashboard)/dashboard/cache/page.tsx": 845, - "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 900, - "src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 931, - "_rebaseline_2026_07_15_7070_combos_memo": "PR #7070 (perf/p1-memo) own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4655->4656 (+1 = React.memo wrapping of ComboCard). Covered by tests/unit/ui/combos-page-smoke.test.tsx.", - "src/app/(dashboard)/dashboard/combos/page.tsx": 4656, - "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495, - "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1022, - "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2615, - "_rebaseline_2026_07_22_8213_health_unblock_model_cooldowns": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/app/(dashboard)/dashboard/health/page.tsx 1094->1165 (+71, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds handleUnblockAll/handleUnblockOne dashboard actions (DELETE /api/resilience/model-cooldowns) so an operator can manually clear a Gemini TPM-wedge model lockout surfaced by this PR's cooldown-wait fixes, instead of waiting out the ceiling. Irreducible UI wiring at the existing health-page action chokepoint. Covered by the PR's own dashboard/resilience test additions.", - "src/app/(dashboard)/dashboard/health/page.tsx": 1165, - "src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847, - "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 804, - "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 958, - "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 967, - "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1288, - "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 986, - "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155, - "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264, - "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1054, - "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 948, - "src/app/(dashboard)/dashboard/providers/page.tsx": 1927, - "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201, - "src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": 819, - "src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx": 903, - "src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx": 974, - "src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx": 898, - "src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1019, - "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1464, - "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1183, - "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629, - "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1924, - "src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028, - "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148, - "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1127, - "src/app/api/oauth/[provider]/[action]/route.ts": 970, - "src/app/api/providers/[id]/models/route.ts": 2593, - "src/app/api/providers/[id]/test/route.ts": 940, - "src/app/api/usage/analytics/route.ts": 948, - "src/app/api/v1/models/catalog.ts": 1615, - "src/lib/cloudflaredTunnel.ts": 935, - "src/lib/db/apiKeys.ts": 1662, - "src/lib/db/core.ts": 1825, - "src/lib/db/migrationRunner.ts": 1125, - "src/lib/db/models.ts": 1259, - "src/lib/db/providers.ts": 1107, - "src/lib/db/proxies.ts": 1177, - "src/lib/db/settings.ts": 1155, - "src/lib/db/usageAnalytics.ts": 925, - "src/lib/evals/evalRunner.ts": 961, - "src/lib/memory/retrieval.ts": 1171, - "src/lib/modelsDevSync.ts": 934, - "src/lib/providers/validation.ts": 4523, - "src/lib/resilience/settings.ts": 841, - "src/lib/tailscaleTunnel.ts": 1202, - "src/lib/usage/callLogs.ts": 997, - "src/lib/usage/providerLimits.ts": 1006, - "src/lib/usage/usageHistory.ts": 988, - "_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.", - "_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.", - "_rebaseline_2026_07_19_6636_codex_session_json": "#6636 own growth: OAuthModal.tsx 998->1030 (gate units, split(\"\\n\").length incl. trailing newline; +32 = session-JSON paste branch for handleManualSubmit plus a shared submitCodexAccessToken() helper extracted from the pre-existing bare-JWT branch, mirroring the #5203 oauthBlobSubmit.ts extraction precedent; the normalizer logic itself lives in the new src/lib/oauth/utils/codexSessionImport.ts leaf module, not here). Fourth irreducible wiring bump on this modal (969->989->993->998->1030); structural shrink tracked in #3501.", - "_rebaseline_2026_07_19_7546_ghe_copilot_modal": "PR #7546 (GHE Copilot OAuth provider) own growth: OAuthModal.tsx 1030->1056 (gate units). Adds a gheUrl input state, routes ghe-copilot through the existing device-code branch, and threads gheUrl into the device-code request/poll extraData at the existing provider-switch chokepoints (+~24 lines, cohesive with the same pattern as #7399/#6636). The standalone GHE enterprise-URL config step JSX (originally +31 lines inline) was extracted to the new src/shared/components/oauthModal/GheConfigStep.tsx leaf component to minimize the bump; what remains is the irreducible provider-branch wiring. Fifth bump on this modal (969->989->993->998->1030->1056); structural shrink tracked in #3501.", - "_rebaseline_2026_07_21_8027_grok_cli_auth_json_paste": "PR #8027 (RaviTharuma, fix(grok-cli) #7610) own growth: OAuthModal.tsx 1080->1100 (gate units). Requires the full ~/.grok/auth.json (with refresh_token) on the paste-import path instead of a bare JWT, at the existing paste-token chokepoint (renamed tab label, updated instructions/placeholder, textarea for the auth.json blob, inline error surface). The validation logic itself (parseGrokCliPasteToken, previously an inline ~75-line function) was extracted to the new src/lib/oauth/utils/grokCliAuthJson.ts leaf module — mirroring the #6636/#7546 extraction precedent — so only the irreducible UI wiring remains here. Sixth bump on this modal (969->989->993->998->1030->1056->1100); structural shrink tracked in #3501.", - "src/shared/components/OAuthModal.tsx": 1100, - "_rebaseline_2026_07_22_8213_requestloggerdetail_unblock_ui": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/shared/components/RequestLoggerDetail.tsx 799->941 (+142, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip; crosses the general 800-line new-file cap so is frozen here for the first time). Adds a collapsible section header (open/expand-less toggle) plus per-log-entry unblock (`unblocking`/`cleared` state, isCombo503 detection) so the request-logger detail panel surfaces the same Gemini TPM cooldown-wait / model-lockout unblock action introduced by this PR at the individual-request level (mirrors the health-page bulk unblock action added in the same PR). Covered by the PR's own dashboard/resilience test additions.", - "src/shared/components/RequestLoggerDetail.tsx": 941, - "src/shared/components/RequestLoggerV2.tsx": 1629, - "src/shared/components/analytics/charts.tsx": 1558, - "_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).", - "src/shared/constants/cliTools.ts": 916, - "src/shared/constants/pricing.ts": 1662, - "src/shared/constants/providers.ts": 3276, - "src/shared/constants/sidebarVisibility.ts": 1198, - "src/shared/services/cliRuntime.ts": 1128, - "src/shared/validation/schemas.ts": 2523, - "_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.", - "_rebaseline_2026_07_22_8213_chat_abandoned_target_abort": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/sse/handlers/chat.ts 1794->1860 (+66, measured against the PR's own merge-base — the release tip separately carries an unrelated -5 net shrink from #8013's antigravity callable-catalog alignment, which this PR's branch does not include and this entry does not cover). Adds resolveDispatchClientRawRequest(): merges a per-target modelAbortSignal into clientRawRequest.signal (via mergeAbortSignals) so a combo target abandoned by comboTargetTimeoutMs actually observes its own abort and reaches its cleanup path, instead of hanging forever inside withRateLimit/acquireAccountSemaphore and leaking a permanent 'pending' dashboard entry (live incident, log id 1784418258231-14961a). Also wires combo-exhausted rejection logging to capture request body + attempted models via the new rejectedRequestUsage helper. Irreducible additions at the existing chat dispatch chokepoint. Covered by the PR's own combo-config + integration test additions.", - "_rebaseline_2026_07_23_8127_grok_weekly_quota": "#8127 (@apoapostolov) own growth: src/sse/handlers/chat.ts 1861->1865 (+4) — weekly quota tracking for grok-web wires a quota-fetch hook at the existing dispatch chokepoint. Thin wiring mirroring adjacent provider-quota branches; not extractable. Covered by tests/unit/grok-quota-fetcher.test.ts.", - "src/sse/handlers/chat.ts": 1865, - "_rebaseline_2026_07_20_7779_routingcombo_thread": "PR #7779 own growth: chatHelpers.ts 876->877 (+1, thread routingComboId into executeChatWithBreaker for compression-combo assignment). Frozen so it can only shrink.", - "src/sse/handlers/chatHelpers.ts": 878, - "src/sse/services/auth.ts": 2475, - "open-sse/executors/default.ts": 890, - "open-sse/translator/request/openai-responses.ts": 902, - "open-sse/executors/kiro.ts": 944, - "open-sse/translator/request/openai-to-claude.ts": 823, - "tests/unit/account-fallback-service.test.ts": 1572, - "tests/unit/provider-validation-specialty.test.ts": 2980, - "open-sse/executors/huggingchat.ts": 813, - "_rebaseline_2026_07_01_v3843_release_5609": "Rebaseline v3.8.43 (PR #5609 release reconciliation). DRIFT dos 109 commits do ciclo: 8 god-files existentes cresceram (ApiManagerPageClient 2983->3017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.", - "src/lib/providers/validation/webProvidersA.ts": 809, - "src/lib/tokenHealthCheck.ts": 832, - "_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge.", - "_rebaseline_2026_07_19_7787_ic2_localdb_reexports": "PR #7787 (IC2 raw connections cache + lazy-decrypt) own growth: localDb.ts 805->807 (gate units, +2). localDb.ts is the re-export-only layer (hard rule #2 — no logic); the PR adds 4 new db/readCache re-exports (touchConnectionLastUsed, getCachedRawProviderConnections, getCachedProviderConnectionById, getCachedProviderNodes) required by existing barrel importers. Irreducible for a re-export list; frozen so it can only shrink.", - "_rebaseline_2026_07_20_7819_autocandidateoverrides_reexport": "PR for #7819 (Level 1+2: read-only auto/* candidate transparency + per-API-key exclusions) own growth: localDb.ts 807->808 (+1). Adds a single `export * from \"./db/autoCandidateOverrides\"` barrel re-export (hard rule #2 — no logic) for the new DB module backing per-apiKey candidate exclusions. Irreducible for a re-export list; frozen so it can only shrink.", - "src/lib/localDb.ts": 808, - "_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable).", - "_rebaseline_2026_07_21_8034_compression_exclusions_sidebar": "#8034 (compression exclusions dashboard tab) own growth: sections.ts 796->806 (+10, one new COMPRESSION_CONTEXT_GROUP sidebar item linking /dashboard/compression/exclusions). The file was already 796/800 before this PR (organic growth from prior sidebar entries), so a single new nav item pushed it 6 lines over cap. Freezing at 806 (cannot grow further); the sidebar item array is data, not extractable logic.", - "_rebaseline_2026_07_23_8219_cache_ttl_settings_sidebar": "#8219 (@oyi77) own growth: sections.ts 806->813 (+7) — configurable model-catalog cache-TTL settings adds a new sidebar nav entry + its visibility wiring. Sidebar item array is data, not extractable logic; frozen at new size.", - "src/shared/constants/sidebarVisibility/sections.ts": 813, - "_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size.", - "_rebaseline_2026_07_22_8081_reasoning_placeholder_guard": "#8081 (@Dingding-leo) own growth: openai-responses.ts 1125->1137 (+12) restructuring the reasoning-placeholder guard so it skips only the empty content block and still emits finish_reason/tool_calls in the same chunk. Cohesive translator wiring; frozen at new size.", - "open-sse/services/usage/antigravity.ts": 802, - "_rebaseline_2026_07_22_fusion_8013_8098_antigravity": "Fusion of #8013 (backryun, catalog/IDE-CLI-split rewrite) + #8098 (nguyenha935, protocol-fidelity/fail-closed/credits/tool-cloaking): open-sse/services/usage/antigravity.ts NEW 802 (>cap 800, +2 — #8098 credits/tier usage service on #8013's profile-aware headers). Test growth (models-catalog-route 1605->1608, provider-models-route 1752->1757 from #8013 Gemini 3.6 catalog) tracked in testFrozen.", - "_rebaseline_2026_07_22_8050_model_lockout_exact_family": "#8050 (@AndrianBalanescu) own growth: accountFallback.ts 1864->1892 (+28) — exact-vs-family model-lockout scoping (getModelLockKey/isModelLocked/clearModelLock/getModelLockoutInfo) so an Antigravity 404 for one bare model no longer hijacks the whole family cooldown. Cohesive lockout logic; frozen at new size." - }, "testCap": 1000, "testFrozen": { + "tests/unit/adobe-firefly.test.ts": 1477, + "tests/unit/reasoning-cache.test.ts": 1346, "_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).", @@ -354,39 +196,39 @@ "_rebaseline_2026_07_25_8510_adobe_firefly_reference_images_tests": "#8510 (artickc, feat/adobe-firefly-reference-images) own test growth: tests/unit/adobe-firefly.test.ts 711->871 (+159, entirely this PR's diff — new referenceBlobs upload/dispatch coverage for handleAdobeFireflyImageGeneration, resolveAdobeSourceImageIds, and the storage-upload wire contract). Route-level /v1/images/edits coverage (credentials/rate-limit/4-ref-cap branches added to route.ts) lives in the new tests/unit/8510-adobe-firefly-edits-route.test.ts instead of growing this file further.", "_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').", "_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", - "tests/integration/chat-pipeline.test.ts": 1598, - "tests/integration/chatcore-compression-integration.test.ts": 1114, - "tests/unit/account-fallback-service.test.ts": 1563, - "tests/unit/batch_api.test.ts": 1324, - "tests/unit/cc-compatible-provider.test.ts": 1217, - "tests/unit/chatcore-translation-paths.test.ts": 2876, - "tests/unit/chatgpt-web.test.ts": 3148, - "tests/unit/combo-routing-engine.test.ts": 3457, - "tests/unit/db-migration-runner.test.ts": 1499, - "tests/unit/deepseek-web.test.ts": 1092, - "tests/unit/executor-codex.test.ts": 1339, - "tests/unit/executor-default-base.test.ts": 1519, - "tests/unit/grok-web.test.ts": 2437, - "tests/unit/image-generation-handler.test.ts": 2029, - "tests/unit/model-sync-route.test.ts": 1016, - "tests/unit/models-catalog-route.test.ts": 1636, - "tests/unit/perplexity-web.test.ts": 1355, - "tests/unit/provider-models-route.test.ts": 1787, - "tests/unit/provider-validation-specialty.test.ts": 2985, - "tests/unit/providers-page-utils.test.ts": 1106, - "tests/unit/response-sanitizer.test.ts": 1063, - "tests/unit/route-edge-coverage.test.ts": 1241, - "tests/unit/search-handler-extended.test.ts": 1071, - "tests/unit/sse-auth.test.ts": 1610, - "tests/unit/stream-utils.test.ts": 2445, - "tests/unit/token-refresh-service.test.ts": 1378, - "tests/unit/translator-openai-responses-req.test.ts": 1194, - "tests/unit/translator-openai-to-gemini.test.ts": 1619, - "tests/unit/translator-openai-to-kiro.test.ts": 1275, - "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, - "tests/unit/usage-service-hardening.test.ts": 1483, - "tests/unit/vscode-token-routes.test.ts": 1256, - "tests/unit/executor-antigravity.test.ts": 1098 + "tests/integration/chat-pipeline.test.ts": 2077, + "tests/integration/chatcore-compression-integration.test.ts": 1448, + "tests/unit/account-fallback-service.test.ts": 2032, + "tests/unit/batch_api.test.ts": 1721, + "tests/unit/cc-compatible-provider.test.ts": 1582, + "tests/unit/chatcore-translation-paths.test.ts": 3739, + "tests/unit/chatgpt-web.test.ts": 4092, + "tests/unit/combo-routing-engine.test.ts": 4494, + "tests/unit/db-migration-runner.test.ts": 1949, + "tests/unit/deepseek-web.test.ts": 1420, + "tests/unit/executor-codex.test.ts": 1741, + "tests/unit/executor-default-base.test.ts": 1975, + "tests/unit/grok-web.test.ts": 3168, + "tests/unit/image-generation-handler.test.ts": 2638, + "tests/unit/model-sync-route.test.ts": 1321, + "tests/unit/models-catalog-route.test.ts": 2127, + "tests/unit/perplexity-web.test.ts": 1762, + "tests/unit/provider-models-route.test.ts": 2323, + "tests/unit/provider-validation-specialty.test.ts": 3880, + "tests/unit/providers-page-utils.test.ts": 1438, + "tests/unit/response-sanitizer.test.ts": 1382, + "tests/unit/route-edge-coverage.test.ts": 1613, + "tests/unit/search-handler-extended.test.ts": 1392, + "tests/unit/sse-auth.test.ts": 2093, + "tests/unit/stream-utils.test.ts": 3178, + "tests/unit/token-refresh-service.test.ts": 1791, + "tests/unit/translator-openai-responses-req.test.ts": 1552, + "tests/unit/translator-openai-to-gemini.test.ts": 2109, + "tests/unit/translator-openai-to-kiro.test.ts": 1658, + "tests/unit/translator-resp-gemini-to-openai.test.ts": 1604, + "tests/unit/usage-service-hardening.test.ts": 1928, + "tests/unit/vscode-token-routes.test.ts": 1633, + "tests/unit/executor-antigravity.test.ts": 1427 }, "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.", "_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.", @@ -465,7 +307,6 @@ "_rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). v1 was cap 800->900 / testCap 800->900 on 2026-07-27; v2 = v1 +20% buffer = cap 900->1000 (+100), testCap 900->1000 (+100). Justification: same as complexity v2 — the v3.8.50 release cut coincides with high-merge activity; owner accepted enlarging the headroom to cover the entire PREPARE phase (5 minor cycles .50-.54) without per-PR rebaseline noise. Targets: decompose-existing-frozen unchanged (frozen still only-shrink — see frozen[] entries and the 105 files >900 that still need structural decomposition regardless of cap); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes (gives 150 units of post-tighten headroom vs the new 1000 ceiling). Tracked via same roadmap issue as complexity v2. Window: v3.8.50 (release cut) → v3.8.54 close (RE-TIGHTEN at v3.8.51 prep merge per ROADMAP.md). Last entry unless measured regression. v1 entry retained below for audit trail.", "_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.", "_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)", - "_rebaseline_2026_08_02_9242_token_health_transient": "PR #9242 (fix/refresh-circuit-transient): src/lib/tokenHealthCheck.ts 1021 (new file, above cap 1000). The file consolidates token-refresh health checking logic that was previously scattered across auth.ts and tokenRefresh.ts. Cohesive single-responsibility module for refresh circuit state management; not extractable without splitting the refresh state machine. Covered by tests/unit/tokenHealthCheck-transient.test.ts.", "frozen": { "_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.", "_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.", @@ -524,74 +365,81 @@ "_rebaseline_2026_07_25_adobe_firefly_reference_images": "Follow-up to #8006: storage upload + referenceBlobs for image/video and /v1/images/edits dispatch. adobeFireflyClient.ts 1958->2317 (+upload helpers, extract sources, resolve blob ids). Note: 2317 not 2316 — check-file-size.mjs counts LOC via split(\"\\n\").length (counts the trailing-newline empty element), which is 1 higher than `wc -l` on a file ending in \\n; the PR's original entry (2316) was measured with wc -l and undercounted by 1 against the actual gate.", "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", - "open-sse/executors/antigravity.ts": 1528, - "open-sse/executors/base.ts": 1640, - "open-sse/executors/chatgpt-web.ts": 3241, - "open-sse/executors/codex.ts": 1562, - "open-sse/executors/cursor.ts": 1563, - "open-sse/executors/deepseek-web.ts": 1148, - "open-sse/executors/grok-web.ts": 1044, - "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 5034, - "open-sse/handlers/imageGeneration.ts": 3101, - "open-sse/handlers/responseSanitizer.ts": 1128, - "open-sse/handlers/search.ts": 1536, - "open-sse/handlers/videoGeneration.ts": 1063, - "open-sse/mcp-server/schemas/tools.ts": 1505, - "open-sse/mcp-server/server.ts": 1411, - "open-sse/mcp-server/tools/advancedTools.ts": 1120, - "open-sse/services/accountFallback.ts": 1972, - "open-sse/services/adobeFireflyClient.ts": 2385, - "open-sse/services/claudeCodeCompatible.ts": 1202, - "open-sse/services/combo.ts": 3648, - "open-sse/services/compression/strategySelector.ts": 1060, - "open-sse/services/rateLimitManager.ts": 1167, - "open-sse/translator/response/openai-responses.ts": 1204, - "open-sse/utils/cursorAgentProtobuf.ts": 1505, - "open-sse/utils/stream.ts": 2889, - "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1388, - "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031, - "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3117, - "src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1067, - "src/app/(dashboard)/dashboard/combos/page.tsx": 4647, - "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1283, - "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1022, - "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2615, - "src/app/(dashboard)/dashboard/health/page.tsx": 1165, - "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1316, - "src/app/(dashboard)/dashboard/providers/page.tsx": 1944, - "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201, - "src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1019, - "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1464, - "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1123, - "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629, - "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1573, - "src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028, - "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148, - "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1119, - "src/app/api/providers/[id]/models/route.ts": 2250, - "src/app/api/v1/models/catalog.ts": 1549, - "src/lib/tokenHealthCheck.ts": 1021, - "src/lib/db/apiKeys.ts": 1529, - "src/lib/db/core.ts": 1637, - "src/lib/db/migrationRunner.ts": 1084, - "src/lib/db/models.ts": 1097, - "src/lib/db/providers.ts": 1034, - "src/lib/memory/retrieval.ts": 1073, - "src/lib/tailscaleTunnel.ts": 1202, - "src/lib/usage/providerLimits.ts": 1013, - "src/shared/components/OAuthModal.tsx": 1134, - "src/shared/components/RequestLoggerV2.tsx": 1629, - "src/shared/components/analytics/charts.tsx": 1035, - "src/shared/services/cliRuntime.ts": 1122, - "src/sse/handlers/chat.ts": 1877, - "src/sse/services/auth.ts": 2508, - "tests/unit/account-fallback-service.test.ts": 1572, - "tests/unit/provider-validation-specialty.test.ts": 2985, - "open-sse/executors/hyperagent.ts": 1026, - "open-sse/executors/default.ts": 1042, - "open-sse/executors/kiro.ts": 1069 + "open-sse/executors/antigravity.ts": 1986, + "open-sse/executors/base.ts": 2132, + "open-sse/executors/chatgpt-web.ts": 4213, + "open-sse/executors/codex.ts": 2031, + "open-sse/executors/cursor.ts": 2032, + "open-sse/executors/deepseek-web.ts": 1492, + "open-sse/executors/grok-web.ts": 1357, + "open-sse/executors/muse-spark-web.ts": 1826, + "open-sse/handlers/chatCore.ts": 6579, + "open-sse/handlers/imageGeneration.ts": 4031, + "open-sse/handlers/responseSanitizer.ts": 1466, + "open-sse/handlers/search.ts": 1997, + "open-sse/handlers/videoGeneration.ts": 1382, + "open-sse/mcp-server/schemas/tools.ts": 2019, + "open-sse/mcp-server/server.ts": 1882, + "open-sse/mcp-server/tools/advancedTools.ts": 1456, + "open-sse/services/accountFallback.ts": 2571, + "open-sse/services/adobeFireflyBrowserLogin.ts": 1771, + "open-sse/services/adobeFireflyChromeRuntime.ts": 1561, + "open-sse/services/adobeFireflyClient.ts": 3899, + "open-sse/services/adobeFireflySession.ts": 1304, + "open-sse/services/claudeCodeCompatible.ts": 1563, + "open-sse/services/combo.ts": 4742, + "open-sse/services/compression/strategySelector.ts": 1379, + "open-sse/services/rateLimitManager.ts": 1517, + "open-sse/translator/response/openai-responses.ts": 1652, + "open-sse/utils/cursorAgentProtobuf.ts": 1956, + "open-sse/utils/stream.ts": 3756, + "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1804, + "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1340, + "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 4052, + "src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1387, + "src/app/(dashboard)/dashboard/combos/page.tsx": 6114, + "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1668, + "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1329, + "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 3400, + "src/app/(dashboard)/dashboard/health/page.tsx": 1514, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1721, + "src/app/(dashboard)/dashboard/providers/page.tsx": 2527, + "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1561, + "src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1325, + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1911, + "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1460, + "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 2118, + "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 2045, + "src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1336, + "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2792, + "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1455, + "src/app/api/providers/[id]/models/route.ts": 3069, + "src/app/api/v1/models/catalog.ts": 2076, + "src/lib/db/apiKeys.ts": 1988, + "src/lib/db/core.ts": 2131, + "src/lib/db/migrationRunner.ts": 1431, + "src/lib/db/models.ts": 1426, + "src/lib/db/providers.ts": 1344, + "src/lib/memory/retrieval.ts": 1395, + "src/lib/tailscaleTunnel.ts": 1563, + "src/lib/usage/providerLimits.ts": 1317, + "src/shared/components/OAuthModal.tsx": 1474, + "src/shared/components/RequestLoggerV2.tsx": 2118, + "src/shared/components/analytics/charts.tsx": 1346, + "src/shared/services/cliRuntime.ts": 1459, + "src/sse/handlers/chat.ts": 2493, + "src/sse/services/auth.ts": 3260, + "tests/unit/account-fallback-service.test.ts": 2044, + "tests/unit/provider-validation-specialty.test.ts": 3880, + "open-sse/executors/hyperagent.ts": 1334, + "src/lib/tokenHealthCheck.ts": 1369, + "open-sse/executors/default.ts": 1355, + "open-sse/executors/kiro.ts": 1390, + "open-sse/translator/request/openai-to-kiro.ts": 1374, + "open-sse/utils/sseHeartbeat.ts": 194, + "open-sse/utils/proxyFetch.ts": 1207 }, + "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", "_rebaseline_2026_07_27_v3849_train3": "Merge-train 3 (13 PRs) — owner-approved 2026-07-27. Both entries are genuine irreducible growth at existing chokepoints, not new branches: src/lib/db/apiKeys.ts 1518->1529 (#8805 cx/* ≡ codex/* API-key model permissions); open-sse/handlers/chatCore.ts 5006->5020 (#8806 real response payload into plugin onResponse hooks). Covered by tests/unit/db-apiKeys-crud.test.ts (4 new cases) and the two plugin-hook test files updated in #8806 respectively.", "_rebaseline_2026_07_28_8842_antigravity_projectid_refresh": "PR #8842 (fix/antigravity-projectid-refresh) own growth: open-sse/executors/antigravity.ts 1493->1528 (+35 = projectId discovery in refreshCredentials: import ensureAntigravityProjectAssigned + trim projectId + call ensureAntigravityProjectAssigned with 8s timeout + persistDiscoveredAntigravityProjectId + log success/failure). Irreducible wiring at the existing credential-refresh chokepoint. Covered by tests/unit/executor-antigravity.test.ts (4 new test cases).", @@ -599,15 +447,155 @@ "_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.", "_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites.", "_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.", - "_rebaseline_2026_07_28_8870_firefly_ref_cap_timeout": "PR #8870 (fix/adobe-firefly-gpt-ref-cap-timeout) own growth: adobeFireflyClient.ts 2322->2385 (+63 = gpt-image subject-ref hard cap at 2 + adaptive poll timeout budget (base 300s + 60s/ref, max 600s) + defensive .slice on referenceBlobs for gpt/nano/generic families). Fixes live 504s on multi-screenshot listing jobs (Featured Promo / Box Art) where 3–4+ subject refs stall colligo until the old 180s poll budget expires. Helpers adobeFireflyMaxImageRefs/adobeFireflyImageTimeoutMs live next to the existing payload/poll chokepoint (not extractable without splitting the wire recipe mid-PR). Covered by tests/unit/adobe-firefly.test.ts (ref-cap + timeout cases). Structural shrink tracked in #3501.", "_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped 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_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.", "_rebaseline_2026_08_01_8964_xai_agent_tools": "PR #8964 own growth: chatCore.ts 5020->5034 at the existing native-passthrough chokepoint. Adds xAI Agent Tools passthrough for /v1/responses (xai/xai-oauth/xao): resolve nativeXaiResponsesPassthrough, force openai-responses targetFormat, stamp body marker, and OR into the existing nativeCodexPassthrough sites (web-search bypass + requestEndpointPath). Leaf logic in passthroughHelpers, responsesEndpoint, targetFormat, xai executor, responseSanitizer, usageTracking. Cohesive wiring at the Codex passthrough boundary.", "_rebaseline_2026_08_01_8964_response_sanitizer": "PR #8964 own growth: responseSanitizer.ts 1115->1128. Keep cost_in_usd_ticks / server_side_tool_usage(_details) through sanitizeResponsesApiResponse allowlists so native xAI tool responses retain usage.", - "_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests.", "_rebaseline_2026_08_05_9323_agentrouter_waf_retry": "PR #9323 (fix(agentrouter): retry on 400 content-blocked + burst guard) own growth: open-sse/executors/base.ts 1578->1623 (check-file-size.mjs conta via split(\"\\n\").length; wc -l ve 1622). As +45 linhas sao o WAF_RETRY_CONFIG + o burst guard via gateOutboundRequest() para o WAF do agentrouter.org, com comentarios explicando o porque de cada mitigacao e cobertos por tests/unit/base-executor-waf-retry.test.ts e tests/unit/wafRateLimit.test.ts. Crescimento funcional legitimo, nao inchaco.", "_rebaseline_2026_08_05_9529_own_growth": "PR #9529 own growth (base release/v3.8.50 medida EXATAMENTE nos frozen antigos, entao o modo base-relative #8522 nao cobre): open-sse/services/rateLimitManager.ts 1060->1105 (+45: helper applyLimiterSettings() que re-arma o heartbeat do reservoir apos updateSettings — fix do bug Bottleneck 2.19.5 que congelava a fila weighted; TDD em tests/unit/ratelimit-reservoir-refresh.test.ts); tests/integration/chat-pipeline.test.ts 1592->1598 (+6: User-Agent do codex derivado de getCodexClientVersion() em vez de literal pinado — teste-irmao alinhado ao contrato); tests/unit/provider-validation-specialty.test.ts 2980->2985 (+5: cobertura NOVA claude-web 429 -> valid:false, alinhamento #9406); open-sse/translator/response/openai-responses.ts 1174->1204 (+30: buildResponsesReasoningSummaryDelta MOVIDA do leaf pureHelpers.ts para o host — a funcao do #9500 muta stream state e violava o contrato do leaf puro; o LOC total do par host+leaf nao cresceu, o pureHelpers encolheu o mesmo tanto). Crescimento por fix de producao + cobertura adicional + realocacao arquitetural, nao inchaco.", "_rebaseline_2026_08_06_v3850_inherited_drift_reconcile": "Reconciliacao 2026-08-06 do drift ACUMULADO da release/v3.8.50 apos o lote de merges de 08-05/06: 13 arquivos acima do frozen no tip puro 8180b49ce1 (medidos pelo proprio gate). O modo PR base-relative (#8522) deixa PRs inocentes passarem, e os rebaselines individuais dos PRs se perderam nas resolucoes sucessivas de conflito deste hot-file — o drift so aparece no modo absoluto (nightly/local). Crescimentos funcionais dos PRs mergeados: #9024 topology click-nav src/app/(dashboard)/dashboard/HomePageClient.tsx; #9324 OpenRouter enrich src/app/(dashboard)/dashboard/providers/page.tsx; #9329 quota card ordering src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx; #9193 context-window suffixes src/sse/handlers/chat.ts; #9332 nested Claude server tool ids open-sse/executors/base.ts; #9228 strip orphaned tool outputs open-sse/executors/codex.ts; #9236 nvidia tool-name normalize open-sse/executors/default.ts; #9314 nested tool_call validation open-sse/executors/kiro.ts; #9260 caller identity REST hops open-sse/mcp-server/server.ts; #8934 cache breakpoints tests tests/unit/chatcore-translation-paths.test.ts; #9193 suffix tests tests/unit/combo-routing-engine.test.ts; #9196 reasoning-on-tool-finish tests tests/unit/sse-auth.test.ts; #9163 GPT-5.6 Max reasoning tests tests/unit/translator-openai-to-kiro.test.ts. default.ts e kiro.ts entram no frozen (estavam sem entrada, acima do cap 1000). Atualizacao pos-medicao (a base avancou durante o ciclo do PR): src/sse/handlers/chat.ts 1857->1877 (#9184 affinity EOF evict) e open-sse/executors/default.ts 1027->1042 (#9005 Kimi K3 tool-name backfill).", "_rebaseline_2026_08_06b_v3850_sweepreds_drift": "Segunda reconciliacao de 2026-08-06 (/sweep-reds sobre o tip puro 2ddbbc61a6): 3 arquivos voltaram a passar do frozen apos os merges do mesmo dia, com atribuicao 1:1 por commit. (1) src/app/(dashboard)/dashboard/providers/page.tsx 1928->1944 e (2) open-sse/executors/base.ts 1635->1640, ambos do #9515 (feat(radar): flag-gated signed free-model catalog overlay, commit e7f6b1d130) — o overlay do Radar entra por wiring nos chokepoints ja existentes (a resolucao/verificacao do catalogo assinado mora fora destes dois arquivos); +16 e +5 linhas liquidas nao sao extraiveis sem inventar um leaf por callsite. (3) open-sse/services/accountFallback.ts 1966->1972 do #8704 (commit c4527f97bd), +6 linhas de dados em CREDITS_EXHAUSTED_SIGNALS ('has been exhausted', fixes #8631). src/sse/handlers/chat.ts 1880>1877 tambem estava violando e NAO entra aqui de proposito: e drenado por encolhimento na PR #9598, sem rebaseline. Crescimento proprio DESTA PR: src/lib/db/migrationRunner.ts 1077->1084 (+7) — o guard retroativo em isSchemaAlreadyApplied para os arquivos renumerados 137/138, exigido pela propria mensagem de erro de colisao do runner (ambas as migracoes sao ALTER TABLE ADD COLUMN puro, nao idempotente). Dois `case` + dois `return hasColumn(...)` + 3 linhas de comentario dentro do switch existente; nao extraivel.", "_rebaseline_2026_08_06c_v3850_sweepreds_pr2": "Segunda PR do /sweep-reds (fix/release-v3.8.50-basereds-0806b): tests/unit/provider-models-route.test.ts 1784->1787 (medido pelo gate, que conta split(\"\\n\").length) (+2 apos compressao de comentarios) — alinhamento de contrato forcado por dois merges do dia: #9106 tornou gemini-3.1-pro-high user-callable (a entry do alias entra na lista esperada do teste de discovery-retry, +1 linha de dado + 1 de comentario) e ff012ff420 adicionou onboardUser como bootstrap hop (exclusao no mock, ja comprimida a 1 linha). Nao ha o que encolher sem apagar o comentario que explica o porque.", - "_rebaseline_2026_08_07_v3850_sweepreds_pr2_toolnamemap": "tests/unit/translator-openai-to-gemini.test.ts 1616->1619 (+3). O frozen estava EXATAMENTE no tamanho da base, entao qualquer linha nova viola. #9568 (c9a3361e5a) fez buildChangedToolNameMap emitir entradas IDENTIDADE (o Gemini minusculiza nomes de tool nas respostas, entao o tradutor de resposta precisa da chave para mapear de volta), o que passou a incluir `_toolNameMap` no envelope Antigravity de qualquer request com tools. As 3 linhas sao: a chave nova na lista esperada de Object.keys, 1 comentario explicando POR QUE ela aparece (sem ele o proximo leitor tenta remove-la de novo) e 1 assert do CONTEUDO do map — presenca de chave sozinha nao provaria a entrada identidade, que e justamente o comportamento novo. Nao ha o que extrair: e alinhamento de contrato dentro de um teste existente." + "_rebaseline_2026_08_07_v3850_sweepreds_pr2_toolnamemap": "tests/unit/translator-openai-to-gemini.test.ts 1616->1619 (+3). O frozen estava EXATAMENTE no tamanho da base, entao qualquer linha nova viola. #9568 (c9a3361e5a) fez buildChangedToolNameMap emitir entradas IDENTIDADE (o Gemini minusculiza nomes de tool nas respostas, entao o tradutor de resposta precisa da chave para mapear de volta), o que passou a incluir `_toolNameMap` no envelope Antigravity de qualquer request com tools. As 3 linhas sao: a chave nova na lista esperada de Object.keys, 1 comentario explicando POR QUE ela aparece (sem ele o proximo leitor tenta remove-la de novo) e 1 assert do CONTEUDO do map — presenca de chave sozinha nao provaria a entrada identidade, que e justamente o comportamento novo. Nao ha o que extrair: e alinhamento de contrato dentro de um teste existente.", + "_rebaseline_2026_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, 3190 (+10 = one new `else if (strategy === \\\"quota-share\\\")` dispatch branch in handleComboChat that delegates 100% to selectQuotaShareTarget + its log line, plus the import). All the new logic lives OUT of the god-file in two new leaves under open-sse/services/combo/: quotaShareInflight.ts (in-flight counter with TTL/lease, ~150 LOC 3225 (+35) = one new `else if (strategy === \\\"task-aware\\\")` dispatch branch delegating 100% to selectTaskAwareTarget + its imports/log lines. All scoring/classification logic lives OUT of the god-file in the new leaf open-sse/services/taskAwareRouting.ts (553 LOC 854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).", + "_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.", + "_rebaseline_2026_06_27_5193_antigravity_basered": "Base-red (pre-existing release drift, fast-gate PR->release skips check:file-size): accountFallback.ts 1773->1777 and src/app/api/providers/[id]/test/route.ts 924->940 were already over their frozen caps on release/v3.8.39 independent of any antigravity change. Owner chose to rebaseline (keep the documented issue-reference comments #1846/#1449/#347 etc.) rather than accept the contributor comment-stripping in #5200/#5198. Reverted #5200 to restore the comments; bumped these two frozen caps to the actual base sizes. No logic change.", + "_rebaseline_2026_06_28_5237_impersonation_ua_refresh": "PR #5237 (refresh impersonation UAs): grok-web.ts 1871->1873 (+2), muse-spark-web.ts 1284->1302 (+18), perplexity-web.ts 1013->1032 (+19). Net semantic change in each file is a single User-Agent constant (Chrome 147->149 for grok/muse; perplexity kept at Firefox 148 to stay matched with the firefox_148 TLS profile — the contributor's 152 bump was reverted to avoid a UA-vs-JA3 mismatch, #2459). The growth is Prettier reflow that lint-staged unavoidably applies to these grandfathered long-line files the moment they are touched; not extractable. src/sse/services/auth.ts 2336->2401 in the same reconcile is #5222's antigravity-LRU-retry growth that merged via --admin without a baseline bump.", + "_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop — a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 7912181 (+78 = the compare-and-swap guard on the refresh persist — runWithCasGuard/getActiveCasGuard AsyncLocalStorage pair mirroring runWithOnPersist, casGuardShouldSkipPersist that rereads the row right before persisting and skips the write when a concurrent writer already rotated the refresh_token past the one presented, plus getCasGuardStats counters). Fixes the sibling-rotation-revert → token-family-revocation storm. Gated behind an active guard (opt-in; no guard => byte-identical). Wiring lives at the two persist chokepoints inside getAccessToken; the comparison reuses wasRefreshTokenRotated from refreshSerializer. Not extractable without splitting the refresh hot path.", + "_rebaseline_2026_06_29_5286_memoization": "PR #5286 own growth: strategySelector.ts 899->960 (+61 = the opt-in result-memoization branches in applyCompression/applyCompressionAsync — principal+determinism gate, makeMemoKey lookup/store with model+supportsVision folded into the key, recompute-with-memo-off). Default off (memoizeCompressionResults), so zero behavior change. The memo helpers live in the leaf resultMemo.ts (3017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.", + "_rebaseline_2026_07_02_5816_qoder": "PR #5816 (@AgentKiller45, qoder PAT via qodercli): qoderCli.ts 666->989, new-above-cap frozen (owner-approved baseline freeze). The growth is the legitimate PAT job-token exchange + quota parsing CLI transport (the pure-JS Cosy path 500'd on every PAT request); extracting the spawn/parse helpers now would just add indirection to a contributor PR mid-merge. Test frozen also raised for this PR's coverage growth: providers-page-utils.test.ts 1052->1092. Additionally clears an inherited base-red from the already-merged #5933 (codex json_schema->text.format): translator-openai-responses-req.test.ts 1097->1172 (+75 regression tests, no offending branch left). All remain frozen (cannot grow further); release captain's rebaseline-at-release supersedes.", + "_rebaseline_2026_07_09_6126_clinepass_dual_auth": "PR #6126 (@hajilok, dual-auth ClinePass) own growth: tokenRefresh.ts 2181->2182 (+1 = a single `case \\\"clinepass\\\":` fallthrough label added to the existing `case \\\"cline\\\":` in _getAccessTokenInternal's provider switch, so clinepass token refresh dispatches to the already-shared refreshClineToken() instead of silently falling through to the generic OAuth refresh). Irreducible 1-line switch-case wiring at the existing chokepoint; the header-building logic for the same feature was extracted to a new leaf src/shared/utils/clineAuth.ts::buildClinepassHeaders() (well under cap) to avoid growing open-sse/executors/default.ts. Covered by tests/unit/clinepass-provider.test.ts.", + "_rebaseline_2026_07_09_6363_kiro_external_idp": "PR #6363 (@artickc, Kiro external IdP) own growth: tokenRefresh.ts 2182->2249 (+67 = the external_idp refresh branch inside refreshKiroToken — standard public-client OAuth2 refresh_token grant against the org IdP tokenEndpoint via buildExternalIdpRefreshParams/isExternalIdpAuthMethod from the new leaf open-sse/services/kiroExternalIdp.ts, with invalid_grant/invalid_client -> unrecoverable_refresh_error mapping). Cohesive addition at the existing refreshKiroToken chokepoint. Covered by tests/unit/kiro-external-idp.test.ts.", + "_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge.", + "_rebaseline_2026_07_09_6678_routing_strategy_9router": "#6678 (SeaXen) — 9router-parity Routing Strategy settings card + per-provider/combo sticky-round-robin override. Own growth: ProviderDetailPageClient.tsx 784->786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.", + "_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).", + "_rebaseline_2026_07_10_gcf_v3_2_decode": "PR #6838 own growth: new vendored file open-sse/services/compression/engines/headroom/gcf/decode_generic.ts frozen at 880 (> 800 cap). It is the vendored GCF generic-profile decoder (spec v3.2 nested flattening plus the prototype-pollution / hasOwnProperty hardening added in this PR's Gemini review). Kept as one file faithful to upstream gcf-typescript so re-vendoring stays a clean copy rather than a re-split each cycle (sibling generic.ts/scalar.ts stay < cap; extraction would also fragment the file's frozen eslint no-explicit-any suppressions). Round-trip + prototype-pollution regression coverage in tests/unit/compression/headroom-smartcrusher.test.ts. Frozen: only shrinks from here.", + "_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable).", + "_rebaseline_2026_07_15_7070_combos_memo": "PR #7070 (perf/p1-memo) own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4655->4656 (+1 = React.memo wrapping of ComboCard). Covered by tests/unit/ui/combos-page-smoke.test.tsx.", + "_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.", + "_rebaseline_2026_07_19_6636_codex_session_json": "#6636 own growth: OAuthModal.tsx 998->1030 (gate units, split(\\\"\\\\n\\\").length incl. trailing newline; +32 = session-JSON paste branch for handleManualSubmit plus a shared submitCodexAccessToken() helper extracted from the pre-existing bare-JWT branch, mirroring the #5203 oauthBlobSubmit.ts extraction precedent; the normalizer logic itself lives in the new src/lib/oauth/utils/codexSessionImport.ts leaf module, not here). Fourth irreducible wiring bump on this modal (969->989->993->998->1030); structural shrink tracked in #3501.", + "_rebaseline_2026_07_19_7546_ghe_copilot_modal": "PR #7546 (GHE Copilot OAuth provider) own growth: OAuthModal.tsx 1030->1056 (gate units). Adds a gheUrl input state, routes ghe-copilot through the existing device-code branch, and threads gheUrl into the device-code request/poll extraData at the existing provider-switch chokepoints (+~24 lines, cohesive with the same pattern as #7399/#6636). The standalone GHE enterprise-URL config step JSX (originally +31 lines inline) was extracted to the new src/shared/components/oauthModal/GheConfigStep.tsx leaf component to minimize the bump; what remains is the irreducible provider-branch wiring. Fifth bump on this modal (969->989->993->998->1030->1056); structural shrink tracked in #3501.", + "_rebaseline_2026_07_19_7787_ic2_localdb_reexports": "PR #7787 (IC2 raw connections cache + lazy-decrypt) own growth: localDb.ts 805->807 (gate units, +2). localDb.ts is the re-export-only layer (hard rule #2 — no logic); the PR adds 4 new db/readCache re-exports (touchConnectionLastUsed, getCachedRawProviderConnections, getCachedProviderConnectionById, getCachedProviderNodes) required by existing barrel importers. Irreducible for a re-export list; frozen so it can only shrink.", + "_rebaseline_2026_07_20_7779_routingcombo_thread": "PR #7779 own growth: chatHelpers.ts 876->877 (+1, thread routingComboId into executeChatWithBreaker for compression-combo assignment). Frozen so it can only shrink.", + "_rebaseline_2026_07_20_7819_autocandidateoverrides_reexport": "PR for #7819 (Level 1+2: read-only auto/* candidate transparency + per-API-key exclusions) own growth: localDb.ts 807->808 (+1). Adds a single `export * from \\\"./db/autoCandidateOverrides\\\"` barrel re-export (hard rule #2 — no logic) for the new DB module backing per-apiKey candidate exclusions. Irreducible for a re-export list; frozen so it can only shrink.", + "_rebaseline_2026_07_21_8027_grok_cli_auth_json_paste": "PR #8027 (RaviTharuma, fix(grok-cli) #7610) own growth: OAuthModal.tsx 1080->1100 (gate units). Requires the full ~/.grok/auth.json (with refresh_token) on the paste-import path instead of a bare JWT, at the existing paste-token chokepoint (renamed tab label, updated instructions/placeholder, textarea for the auth.json blob, inline error surface). The validation logic itself (parseGrokCliPasteToken, previously an inline ~75-line function) was extracted to the new src/lib/oauth/utils/grokCliAuthJson.ts leaf module — mirroring the #6636/#7546 extraction precedent — so only the irreducible UI wiring remains here. Sixth bump on this modal (969->989->993->998->1030->1056->1100); structural shrink tracked in #3501.", + "_rebaseline_2026_07_21_8034_compression_exclusions_sidebar": "#8034 (compression exclusions dashboard tab) own growth: sections.ts 796->806 (+10, one new COMPRESSION_CONTEXT_GROUP sidebar item linking /dashboard/compression/exclusions). The file was already 796/800 before this PR (organic growth from prior sidebar entries), so a single new nav item pushed it 6 lines over cap. Freezing at 806 (cannot grow further); the sidebar item array is data, not extractable logic.", + "_rebaseline_2026_07_22_7936_namespace_roundtrip": "#7936 (@RCrushMe, Responses-Chat namespace round-trip identity seam) own growth: open-sse/translator/response/openai-responses.ts 1092->1125 (+33) and open-sse/utils/stream.ts 2814->2869 (+55) — threading the namespace-identity seam through the Responses↔Chat translation + stream paths so tool-call namespaces survive the round-trip. Cohesive translation/stream wiring at existing chokepoints, frozen at new size.", + "_rebaseline_2026_07_22_8010_codex_responses_engine": "PR #8010 (@JxnLexn) own growth: open-sse/mcp-server/schemas/tools.ts 1497->1505 (+8 = threading the new \\\"codex-responses\\\" literal into the compressionConfigureInput strategy/autoTriggerMode Zod enums and setCompressionEngineInput engine enum, mirroring the existing rtk/omniglyph enum entries; no new tool). open-sse/services/compression/strategySelector.ts 1043->1054 (+11 = one new `if (mode === \\\"codex-responses\\\")` dispatch branch in runCompression that delegates 100% to the new codexResponsesEngine.apply, mirroring the existing rtk single-mode dispatch, plus threading config.codexResponsesConfig.preserveToolNames into the shared adaptBodyForCompression call at the 3 existing call sites). src/lib/db/compression.ts (untracked, new-file cap 800) 794->845 (+51 = normalizeCodexResponsesConfig, mirroring the existing normalizeRtkConfig normalizer, plus registering \\\"codex-responses\\\" in the COMPRESSION_MODES/STACKED_PIPELINE_ENGINE_IDS/SINGLE_MODE_ENGINE sets and the getCompressionSettings load/save switch) — added to the baseline at its current size. All three are cohesive dispatch/normalizer wiring at existing chokepoints (mirroring the prior compression-mode rebaselines #6534/#6556), not extractable without hiding the mode-dispatch boundary. Covered by tests/unit/compression/codex-responses.test.ts (6) + omniglyph-registries.test.ts/types.test.ts (22, updated for the new mode).", + "_rebaseline_2026_07_22_8034_compression_exclusions_persistence": "#8034 (compression exclusions) own growth: src/lib/db/compression.ts 845->850 (+5 = threading the new compressionExclusions field through the existing getCompressionSettings/saveCompressionSettings load/save switch over the shared key_value compression namespace — no new table, no raw SQL). Mirrors the prior compression-field rebaselines (#8010 codex-responses normalizer at the same chokepoint); the load/save switch is a single dispatch boundary, not extractable without hiding it. Covered by the PR's 8 node:test + 3 vitest cases.", + "_rebaseline_2026_07_22_8050_model_lockout_exact_family": "#8050 (@AndrianBalanescu) own growth: accountFallback.ts 1864->1892 (+28) — exact-vs-family model-lockout scoping (getModelLockKey/isModelLocked/clearModelLock/getModelLockoutInfo) so an Antigravity 404 for one bare model no longer hijacks the whole family cooldown. Cohesive lockout logic; frozen at new size.", + "_rebaseline_2026_07_22_8081_reasoning_placeholder_guard": "#8081 (@Dingding-leo) own growth: openai-responses.ts 1125->1137 (+12) restructuring the reasoning-placeholder guard so it skips only the empty content block and still emits finish_reason/tool_calls in the same chunk. Cohesive translator wiring; frozen at new size.", + "_rebaseline_2026_07_22_8210_openrouter_midstream_error": "PR #8210 (hartmark, fix/openrouter-midstream-error-surfacing) own growth: open-sse/translator/response/openai-responses.ts 1137->1163 (+26) measured on the merged tip (release 1137 + this PR own growth). Adds a single new branch inside openaiToOpenAIResponsesResponse() that detects an OpenRouter-style mid-stream aggregator error (HTTP 200 SSE chunk with empty choices + a top-level error object) and surfaces it as state.upstreamError instead of silently falling through to the no-op/awaitingTrailingUsage path, which previously masked the failure as a false empty-success completion and skipped combo fallback. Irreducible call-site addition at the existing chunk-dispatch chokepoint (mirrors the Gemini-to-OpenAI translator's #4177 precedent for the same class of upstream error surfacing). Note: this baseline entry does NOT cover the separate pre-existing +11 drift already on the release tip from #8081/#8162 (1125->1136, unrelated reasoning-placeholder-stripping fix merged after this PR branched) — that drift belongs to the maintainer's rebaseline, not this PR.", + "_rebaseline_2026_07_22_8211_gemini_malformed_tool_choice": "PR #8211 (hartmark, fix/gemini-malformed-function-call-tool-choice) own growth: open-sse/translator/response/gemini-to-openai.ts 771->821 (+50, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds MALFORMED_FUNCTION_CALL/UNEXPECTED_TOOL_CALL handling inside geminiToOpenAIResponse(): synthesizes a `malformed_tool_call` tool_calls entry so finish_reason normalizes to the standard \\\"tool_calls\\\" instead of an unrecognized raw enum value that OpenAI-compatible clients (e.g. OpenClaw) silently ignore, and always synthesizes (rather than skipping when a real tool call already exists) so a malformed attempt alongside a real one in the same turn is not silently discarded. Irreducible cohesive addition at the existing candidate/finishReason translation chokepoint (mirrors the 9router#2462 raw-finish-reason precedent immediately below it in the same function). Covered by the PR's own tests/unit test additions for both the malformed-only and malformed-plus-real-call cases.", + "_rebaseline_2026_07_22_8213_chat_abandoned_target_abort": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/sse/handlers/chat.ts 1794->1860 (+66, measured against the PR's own merge-base — the release tip separately carries an unrelated -5 net shrink from #8013's antigravity callable-catalog alignment, which this PR's branch does not include and this entry does not cover). Adds resolveDispatchClientRawRequest(): merges a per-target modelAbortSignal into clientRawRequest.signal (via mergeAbortSignals) so a combo target abandoned by comboTargetTimeoutMs actually observes its own abort and reaches its cleanup path, instead of hanging forever inside withRateLimit/acquireAccountSemaphore and leaking a permanent 'pending' dashboard entry (live incident, log id 1784418258231-14961a). Also wires combo-exhausted rejection logging to capture request body + attempted models via the new rejectedRequestUsage helper. Irreducible additions at the existing chat dispatch chokepoint. Covered by the PR's own combo-config + integration test additions.", + "_rebaseline_2026_07_22_8213_combo_cooldown_wait_recording": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: open-sse/services/combo.ts 3548->3604 (+56, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Fixes combo cooldown-wait state recording so a bogus 503 is no longer crystallized when the cooldown-wait vars reset every setTry, adds an OpenAI-format SSE error frame path for combo-exhausted rejections (capturing request body + attempted models), and gives an abandoned per-target dispatch its own timeout instead of leaking a permanent 'pending' dashboard entry. Irreducible additions at the existing handleComboChat dispatch/retry chokepoint (mirrors the prior quota-share/headroom/task-aware strategy-branch precedents already frozen in this file). Covered by the PR's own combo-config + Gemini TPM-ceiling benchmark test additions.", + "_rebaseline_2026_07_22_8213_gemini_tpm_quota_cooldown_wait": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: open-sse/services/accountFallback.ts 1857->1932 on the merged tip (release 1892 incl #8050 +35, plus this PR own growth +40); measured against the PR own merge-base was 1857->1898 (+41 — the release tip separately carries an unrelated +34 from #8050's antigravity 404 model-not-found lockout scoping, which this PR's branch does not include and this entry does not cover). Own growth is the Gemini TPM-ceiling classification + cooldown-wait wiring feeding into the combo cooldown-wait state machine (rate-limit wedge recovery) introduced by this PR's commit series. Irreducible additions at the existing account-fallback/model-lockout chokepoint. Covered by the PR's own gemini-rate-limit-tracker and TPM-ceiling benchmark test additions.", + "_rebaseline_2026_07_22_8213_health_unblock_model_cooldowns": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/app/(dashboard)/dashboard/health/page.tsx 1094->1165 (+71, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds handleUnblockAll/handleUnblockOne dashboard actions (DELETE /api/resilience/model-cooldowns) so an operator can manually clear a Gemini TPM-wedge model lockout surfaced by this PR's cooldown-wait fixes, instead of waiting out the ceiling. Irreducible UI wiring at the existing health-page action chokepoint. Covered by the PR's own dashboard/resilience test additions.", + "_rebaseline_2026_07_22_8213_requestloggerdetail_unblock_ui": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/shared/components/RequestLoggerDetail.tsx 799->941 (+142, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip; crosses the general 800-line new-file cap so is frozen here for the first time). Adds a collapsible section header (open/expand-less toggle) plus per-log-entry unblock (`unblocking`/`cleared` state, isCombo503 detection) so the request-logger detail panel surfaces the same Gemini TPM cooldown-wait / model-lockout unblock action introduced by this PR at the individual-request level (mirrors the health-page bulk unblock action added in the same PR). Covered by the PR's own dashboard/resilience test additions.", + "_rebaseline_2026_07_22_fusion_8013_8098_antigravity": "Fusion of #8013 (backryun, catalog/IDE-CLI-split rewrite) + #8098 (nguyenha935, protocol-fidelity/fail-closed/credits/tool-cloaking): open-sse/services/usage/antigravity.ts NEW 802 (>cap 800, +2 — #8098 credits/tier usage service on #8013's profile-aware headers). Test growth (models-catalog-route 1605->1608, provider-models-route 1752->1757 from #8013 Gemini 3.6 catalog) tracked in testFrozen.", + "_rebaseline_2026_07_23_8127_grok_weekly_quota": "#8127 (@apoapostolov) own growth: src/sse/handlers/chat.ts 1861->1865 (+4) — weekly quota tracking for grok-web wires a quota-fetch hook at the existing dispatch chokepoint. Thin wiring mirroring adjacent provider-quota branches; not extractable. Covered by tests/unit/grok-quota-fetcher.test.ts.", + "_rebaseline_2026_07_23_8143_empty_catch_logging": "#8143 (@chirag127) own growth: open-sse/utils/stream.ts 2869->2887 (+18) — replacing empty catch blocks in the SSE stream subsystem with console.debug logging (Rule #6 silent-swallow fix, issues #8138-#8142). Cohesive logging additions at the existing catch chokepoints, not extractable; frozen at new size. Covered by tests/unit/stream-handler-catch-logging-8143.test.ts.", + "_rebaseline_2026_07_23_8219_cache_ttl_settings_sidebar": "#8219 (@oyi77) own growth: sections.ts 806->813 (+7) — configurable model-catalog cache-TTL settings adds a new sidebar nav entry + its visibility wiring. Sidebar item array is data, not extractable logic; frozen at new size.", + "_rebaseline_2026_07_23_8247_8248_model_unhealthy": "#8247+#8248 own growth: accountFallback.ts 1940->1941 (+1, irreducible import statement only — the substantive #8248 DEGRADED-pattern classifier was extracted into open-sse/config/errorConfig.ts, which has ample headroom, instead of growing this frozen file; #8247's fix is a single existing-line condition change, net zero lines). Scoping the credits-exhausted 403/429 branch to isCompatibleProvider() (per-model-quota openai/anthropic-compatible-* nicknames) so it stays model-scoped instead of terminalling the whole connection, and classifying NVIDIA NIM 'Function ... DEGRADED' 400 bodies as model-access-denied instead of a raw passthrough 400. Covered by tests/unit/8247-accountfallback-model-unhealthy.test.ts and tests/unit/8248-accountfallback-nvidia-degraded.test.ts.", + "_rebaseline_2026_07_23_8252_combo_400_advance": "#8252 (@RaviTharuma) own growth: accountFallback.ts 1932->1940 (+8) + combo.ts 3604->3630 (+26) — advance combo on model-scoped 400s wrapped as invalid/Bad-Request. Irreducible wiring at existing account-fallback + combo dispatch chokepoints. Covered by combo-model-scoped-400-advance.test.ts.", + "_rebaseline_2026_07_23_8266_alibaba_media": "#8266 (@backryun) own growth: imageRegistry.ts 821->979 (+158) — Alibaba-family media models (Qwen image/video, Bailian, Wan) added to the image/video registry. Registry model data, not extractable logic; frozen at new size.", + "_rebaseline_2026_07_24_8388_compression_detail_persist": "#8388 (compression engine DETAIL settings — Headroom/session-dedup/CCR — dropped on save) own growth: src/lib/db/compression.ts 866->872 (+6 = irreducible call-site wiring at the existing getCompressionSettings/updateCompressionSettings chokepoint: one import line, one `...buildDetailConfigDefaults()` spread in the seed config, and one `case \\\"sessionDedup\\\": case \\\"ccr\\\": applyDetailConfigUpdate(config, key, parsed); break;` load-switch case, mirroring the existing headroom/#8056 case immediately above it). The actual normalizer logic (normalizeSessionDedupConfig/normalizeCcrConfig, matching SESSION_DEDUP_SCHEMA/CCR_SCHEMA bounds) was EXTRACTED into a new leaf src/lib/db/compressionDetailNormalizers.ts (well under cap) so this frozen file only carries the minimal dispatch wiring. Covered by tests/unit/8388-compression-detail-persist.test.ts (schema-accept + full DB save->reload round-trip for both new sub-objects, plus a no-regression assertion on the existing headroom round-trip).", + "_rebaseline_2026_07_24_responses_toolcalls_log_summary": "hartmark, fix/responses-tool-calls-log-summary own growth: open-sse/translator/response/openai-responses.ts 1163->1174 (+11). closeToolCall() now also writes the completed tool call into the shared state.toolCalls Map (already populated by the openai-to-claude / claude-to-openai / gemini-to-openai response translators) so stream.ts's completion-log summary builder (which reads state.toolCalls, not this translator's own funcCallIds/funcNames/funcArgsBuf bookkeeping) reports finish_reason \\\"tool_calls\\\" and message.tool_calls for openai->openai-responses translated streams instead of always logging \\\"stop\\\" with no tool_calls — the actual client-facing SSE events were already correct; only the persisted call-log summary was wrong. Irreducible call-site addition at the existing tool-call-close chokepoint. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts.", + "_rebaseline_2026_07_25_8476_combo_input_bound_homogeneous_scope": "PR #8476 (herjarsa, fix/8375-8459-combo-image-fixes, #8375) own growth: open-sse/services/combo.ts 3642->3679 (+37 net: +29 the PR's own isInputBoundFailure short-circuit for deterministic context_length_exceeded/context_window_exceeded failures, +8 a /green-prs pre-merge fix scoping that short-circuit to homogeneous remainders only — the shipped code fired unconditionally on ANY target, regressing the intentional heterogeneous-combo fallback #6637/isContextOverflow400 protects, exactly as flagged by this PR's own review evidence but never actually implemented in the branch). The fix compares orderedTargets[i+1..] modelStr against the failing target's modelStr at the existing executeTarget dispatch chokepoint (mirrors the sameProviderNext precedent a few lines below) — irreducible call-site wiring, not extractable without hiding the dispatch boundary. Covered by tests/unit/combo-input-bound-failure-8375.test.ts (homogeneous pool still short-circuits) and the new tests/unit/combo-input-bound-heterogeneous-8375.test.ts (heterogeneous combo now correctly falls through to the larger-context target).", + "_rebaseline_2026_07_25_adobe_firefly_reference_images": "Follow-up to #8006: storage upload + referenceBlobs for image/video and /v1/images/edits dispatch. adobeFireflyClient.ts 1958->2317 (+upload helpers, extract sources, resolve blob ids). Note: 2317 not 2316 — check-file-size.mjs counts LOC via split(\\\"\\\\n\\\").length (counts the trailing-newline empty element), which is 1 higher than `wc -l` on a file ending in \\\\n; the PR's original entry (2316) was measured with wc -l and undercounted by 1 against the actual gate.", + "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", + "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", + "open-sse/executors/antigravity.ts": "1528", + "open-sse/executors/base.ts": "1640", + "open-sse/executors/chatgpt-web.ts": "3241", + "open-sse/executors/codex.ts": "1562", + "open-sse/executors/cursor.ts": "1563", + "open-sse/executors/deepseek-web.ts": "1148", + "open-sse/executors/grok-web.ts": "1044", + "open-sse/executors/muse-spark-web.ts": "1405", + "open-sse/handlers/chatCore.ts": "5034", + "open-sse/handlers/imageGeneration.ts": "3101", + "open-sse/handlers/responseSanitizer.ts": "1128", + "open-sse/handlers/search.ts": "1536", + "open-sse/handlers/videoGeneration.ts": "1063", + "open-sse/mcp-server/schemas/tools.ts": "1553", + "open-sse/mcp-server/server.ts": "1448", + "open-sse/mcp-server/tools/advancedTools.ts": "1120", + "open-sse/services/accountFallback.ts": "1978", + "open-sse/services/adobeFireflyClient.ts": "2385", + "open-sse/services/claudeCodeCompatible.ts": "1202", + "open-sse/services/combo.ts": "3648", + "open-sse/services/compression/strategySelector.ts": "1060", + "open-sse/services/rateLimitManager.ts": "1167", + "open-sse/translator/response/openai-responses.ts": "1204", + "open-sse/utils/cursorAgentProtobuf.ts": "1505", + "open-sse/utils/stream.ts": 2915, + "src/app/(dashboard)/dashboard/HomePageClient.tsx": "1388", + "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": "1031", + "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": "3117", + "src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": "1067", + "src/app/(dashboard)/dashboard/combos/page.tsx": "4703", + "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": "1283", + "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": "1022", + "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": "2615", + "src/app/(dashboard)/dashboard/health/page.tsx": "1165", + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": "1324", + "src/app/(dashboard)/dashboard/providers/page.tsx": "1944", + "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": "1201", + "src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": "1019", + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": "1470", + "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": "1123", + "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": "1629", + "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": "1573", + "src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": "1028", + "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": "2148", + "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": "1119", + "src/app/api/providers/[id]/models/route.ts": "2361", + "src/app/api/v1/models/catalog.ts": "1597", + "src/lib/tokenHealthCheck.ts": "1053", + "src/lib/db/apiKeys.ts": "1529", + "src/lib/db/core.ts": "1639", + "src/lib/db/migrationRunner.ts": "1096", + "src/lib/db/models.ts": "1097", + "src/lib/db/providers.ts": "1034", + "src/lib/memory/retrieval.ts": "1073", + "src/lib/tailscaleTunnel.ts": "1202", + "src/lib/usage/providerLimits.ts": "1013", + "src/shared/components/OAuthModal.tsx": "1134", + "src/shared/components/RequestLoggerV2.tsx": "1629", + "src/shared/components/analytics/charts.tsx": "1035", + "src/shared/services/cliRuntime.ts": "1122", + "src/sse/handlers/chat.ts": "1904", + "src/sse/services/auth.ts": "2508", + "tests/unit/account-fallback-service.test.ts": "1572", + "tests/unit/provider-validation-specialty.test.ts": "2985", + "open-sse/executors/hyperagent.ts": "1026", + "open-sse/executors/default.ts": "1042", + "open-sse/executors/kiro.ts": "1069", + "open-sse/translator/request/openai-to-kiro.ts": "1057", + "open-sse/utils/sseHeartbeat.ts": "142", + "_rebaseline_2026_08_04_9305_sse_comments": "#9305 fix: broadened sseCommentsEnabled()", + "_rebaseline_2026_08_09_v3850_release_close": "Release v3.8.50 close reconciliation on e0ce95c592: src/sse/handlers/chat.ts 1904->1918 is the irreducible request-pipeline wiring from #9759 that invokes the Modality Bridge guardrail without moving its implementation into the handler; covered by the 17 Vision Bridge canaries plus the PR-1 focused suite. open-sse/translator/response/openai-responses.ts 1204->1215 is #9168's Responses tool-call argument delta buffering/normalization at the existing translator state-machine chokepoint; covered by its dedicated translator regression tests. Both values are measured by check:file-size (split-newline semantics), and the gate remains frozen at the new exact sizes.", + "_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario.", + "_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.", + "_rebaseline_2026_07_30_9006_vertex_claude_catalog_dispatch": "PR #9006 (fix/vertex-claude-catalog-dispatch): three files, two causes. (1) src/sse/handlers/chat.ts 1845->1846 (+1): NOT this PR's own growth — this PR never touches chat.ts at all. Measured 1846 (split(\"\\n\").length) at this PR's own merge-base (before any of its 11 commits), so the drift was already inherited from already-merged PRs on release/v3.8.50 (fast-gates PR->release do not run check:file-size, same root cause as _rebaseline_2026_07_25_v3849_basered_filesize and _rebaseline_2026_07_02_5798_release_green) — no offending branch left to fix. (2) src/sse/services/auth.ts 2508->2512 (+4 net, after extraction — see below) and open-sse/handlers/chatCore.ts 5020->5023 (+3, comment-only): genuine own growth. auth.ts adds Vertex 403 PERMISSION_DENIED disambiguation (Google's google.rpc.ErrorInfo proto distinguishes a connection-wide cause — SERVICE_DISABLED, or IAM_PERMISSION_DENIED against a project-level resource — from a model-specific one scoped to a .../models/ resource), added mid-PR after a quality-gate reviewer flagged the plan's originally-accepted \"Vertex 403 always -> per-model lockout\" trade-off. The actual classification logic (~40 lines) was EXTRACTED into a new leaf module src/sse/services/vertexErrorClassifier.ts (mirrors the googApiKeyAuth.ts precedent, _rebaseline_2026_07_14_7034_goog_api_key), leaving only the irreducible call-site wiring in the frozen file: a 1-line import plus widening the existing #3027 per-model-403 guard condition. chatCore.ts's +3 is a pure comment expansion (no functional change) clarifying that the adjacent effort-suffix strip is no longer unconditional for every provider, requested by a separate quality-gate code-reviewer finding; not extractable (it's a comment). Auth.ts's disambiguation logic covered by 3 new test cases in tests/unit/vertex-passthrough-model-lockout.test.ts (SERVICE_DISABLED, IAM_PERMISSION_DENIED+model-resource, IAM_PERMISSION_DENIED+project-resource) plus a 4th regression test for a multi-detail-body correlation bug (reason and resource must be read from the SAME ErrorInfo detail, not independently regexed across the whole body) found by an adversarial quality-gate pass and fixed before merge.", + "_rebaseline_2026_08_04_9006_reconcile_onto_tip": "PR #9006 (fix/vertex-claude-catalog-dispatch) rebase-onto-tip reconciliation, 5 days after the PR's own _rebaseline_2026_07_30_9006 entry below. Two further inherited drifts, neither this PR's own growth (its own diff still touches neither open-sse/executors/base.ts nor src/sse/handlers/chat.ts): (1) src/sse/handlers/chat.ts 1846->1847 (+1), same root cause as the original entry (fast-gates PR->release does not run check:file-size) — another already-merged PR added one more line since. (2) open-sse/executors/base.ts 1578->1623 (+45): commit 7163081f5 fix(agentrouter): retry on 400 content-blocked + burst guard (#9323), merged directly to release/v3.8.50 between this PR's last sync and now, grew base.ts without updating its baseline entry. No offending branch left to fix in either case; verified via git diff against upstream/release/v3.8.50 that this PR's own commits do not touch either file.", + "_rebaseline_2026_08_08_9006_own_comment_growth": "PR #9006's own follow-up commit (a32aed738): tests/unit/combo-routing-engine.test.ts 3457->3464 (+7) is this PR's own growth — explanatory comment blocks added alongside the ALL_ACCOUNTS_INACTIVE->ALL_TARGETS_SKIPPED stale-assertion fix (matching the identical fix applied to #9619/#9173/#8909 the same day; upstream's own test was never updated when the recordedAttempts===0 pre-dispatch-skip branch shipped). Caught by CI's PR-mode check:file-size (--base-ref) after the fix commit; missed locally because check-file-size.mjs was not re-run after that specific edit.", + "_rebaseline_2026_08_07_9006_reconcile_onto_tip_3": "PR #9006 (fix/vertex-claude-catalog-dispatch) third rebase-onto-tip reconciliation (2026-08-07), shared root cause with PRs #9619 and #9173's same-day reconciliations: open-sse/mcp-server/schemas/tools.ts 1505->1553, open-sse/mcp-server/server.ts 1411->1444, open-sse/services/accountFallback.ts 1972->1978, src/app/(dashboard)/dashboard/combos/page.tsx 4647->4703, EditConnectionModal.tsx 1316->1324, src/app/api/providers/[id]/models/route.ts 2250->2304, src/app/api/v1/models/catalog.ts 1549->1556, src/lib/db/core.ts 1637->1639, src/lib/tokenHealthCheck.ts 1021->1053, tests/unit/translator-openai-to-gemini.test.ts 1619->1622 — none touched by this PR's own vertex-claude-catalog-dispatch diff (verified: this PR's commits do not touch any of these files). Same root cause as every other entry in this chain: fast-gates PR->release does not run check:file-size. No offending branch left to fix.", + "_rebaseline_2026_08_06_9006_reconcile_onto_tip_2": "PR #9006 (fix/vertex-claude-catalog-dispatch) second rebase-onto-tip reconciliation. Same two files as _rebaseline_2026_08_04_9006_reconcile_onto_tip below, further inherited drift, still not this PR's own growth (verified via git diff against the fresh upstream/release/v3.8.50 merge-base — this PR's own commits still touch neither file): open-sse/executors/base.ts 1623->1640 (+17) and src/sse/handlers/chat.ts 1847->1881 (+34), both measured post-merge via split(\"\\n\").length. More already-merged release/v3.8.50 PRs grew these files without updating their baseline entries (same root cause as every other entry in this chain: fast-gates PR->release does not run check:file-size). No offending branch left to fix." } diff --git a/config/quality/forgotten-sibling-allowlist.json b/config/quality/forgotten-sibling-allowlist.json new file mode 100644 index 0000000000..7f19696e7b --- /dev/null +++ b/config/quality/forgotten-sibling-allowlist.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "entries": [] +} diff --git a/config/quality/open-sse-typecheck-baseline.json b/config/quality/open-sse-typecheck-baseline.json new file mode 100644 index 0000000000..dc91ce1890 --- /dev/null +++ b/config/quality/open-sse-typecheck-baseline.json @@ -0,0 +1,176 @@ +{ + "open-sse/executors/azure-openai.ts": { + "TS2345": 1 + }, + "open-sse/executors/chatgpt-web.ts": { + "TS2339": 1 + }, + "open-sse/executors/claude-web/stream.ts": { + "TS2322": 1, + "TS2345": 1 + }, + "open-sse/executors/copilot-web.ts": { + "TS2353": 1 + }, + "open-sse/executors/deepseek-web.ts": { + "TS2352": 1 + }, + "open-sse/executors/default.ts": { + "TS2352": 1 + }, + "open-sse/executors/duckduckgo-web.ts": { + "TS2345": 2 + }, + "open-sse/executors/duckduckgo-web/challenge.ts": { + "TS2304": 1 + }, + "open-sse/executors/edgeTts.ts": { + "TS2345": 1 + }, + "open-sse/executors/gemini-business.ts": { + "TS2339": 1 + }, + "open-sse/executors/ghe-copilot.ts": { + "TS2554": 1 + }, + "open-sse/executors/inner-ai.ts": { + "TS2352": 2 + }, + "open-sse/executors/theoldllm.ts": { + "TS2322": 1 + }, + "open-sse/executors/veoaifree-web.ts": { + "TS2322": 1 + }, + "open-sse/executors/windsurf.ts": { + "TS2322": 1 + }, + "open-sse/handlers/chatCore.ts": { + "TS2339": 30, + "TS2322": 1, + "TS2345": 11 + }, + "open-sse/handlers/chatCore/claudeUpstreamMessages.ts": { + "TS2345": 1 + }, + "open-sse/handlers/chatCore/clientUsageBuffer.ts": { + "TS2345": 1 + }, + "open-sse/handlers/chatCore/clineResponseEnvelope.ts": { + "TS2698": 1 + }, + "open-sse/handlers/chatCore/compressionAnalyticsWrite.ts": { + "TS2724": 1 + }, + "open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts": { + "TS2322": 2 + }, + "open-sse/handlers/chatCore/sanitization.ts": { + "TS2339": 1, + "TS2537": 1 + }, + "open-sse/handlers/chatCore/semanticCacheStore.ts": { + "TS2345": 1 + }, + "open-sse/handlers/chatCore/streamingPipeline.ts": { + "TS2345": 2 + }, + "open-sse/handlers/chatCore/streamingSemanticCacheStore.ts": { + "TS2345": 1 + }, + "open-sse/handlers/chatCore/thinkingSignatureRecovery.ts": { + "TS2339": 2 + }, + "open-sse/handlers/imageGeneration.ts": { + "TS2554": 2 + }, + "open-sse/handlers/responsesHandler.ts": { + "TS2339": 1, + "TS2345": 1 + }, + "open-sse/handlers/sseParser.ts": { + "TS2322": 2 + }, + "open-sse/handlers/videoGeneration.ts": { + "TS2339": 2 + }, + "open-sse/mcp-server/tools/compressionTools.ts": { + "TS2339": 2 + }, + "open-sse/services/__tests__/specificityDetector.test.ts": { + "TS2353": 2 + }, + "open-sse/services/browserBackedChat.ts": { + "TS2322": 1, + "TS2794": 1 + }, + "open-sse/services/claudeAdaptiveThinking.ts": { + "TS2352": 2 + }, + "open-sse/services/comboManifestMetrics.ts": { + "TS2307": 1 + }, + "open-sse/services/compression/engines/ccr/index.ts": { + "TS2339": 1 + }, + "open-sse/services/payloadRules.ts": { + "TS2677": 1 + }, + "open-sse/services/tokenLimitCounter.ts": { + "TS2551": 1 + }, + "open-sse/transformer/responsesTransformer.ts": { + "TS2339": 1 + }, + "open-sse/utils/stream.ts": { + "TS2339": 7, + "TS2345": 1, + "TS2556": 1 + }, + "src/app/api/v1/_shared/mediaGenerationRoute.ts": { + "TS2339": 2 + }, + "src/app/api/v1/models/catalog.ts": { + "TS2345": 1 + }, + "src/app/api/v1/models/catalogVision.ts": { + "TS2322": 1 + }, + "src/app/api/v1/videos/generations/route.ts": { + "TS2322": 1, + "TS2345": 1 + }, + "src/lib/guardrails/visionBridge.ts": { + "TS2345": 1 + }, + "src/lib/providers/codexFastTier.ts": { + "TS2367": 1 + }, + "src/lib/skills/builtins.ts": { + "TS2322": 1 + }, + "src/lib/skills/injection.ts": { + "TS2339": 1 + }, + "src/lib/skills/webFetchExecution.ts": { + "TS2322": 1 + }, + "src/lib/streamingPiiTransform.ts": { + "TS2345": 1 + }, + "src/shared/providers/webSessionCredentials.ts": { + "TS2353": 1, + "TS2322": 1 + }, + "src/shared/validation/helpers.ts": { + "TS2339": 1 + }, + "src/sse/handlers/chat.ts": { + "TS2352": 1, + "TS2322": 2, + "TS2339": 1 + }, + "src/sse/services/model.ts": { + "TS2339": 4 + } +} diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 784e3fe241..365c1c04ff 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -92,17 +92,19 @@ "_rebaseline_2026_07_13_v3847_release": "39.3 -> 38.0 (-1.3, beyond the 0.5 eps). v3.8.47 cycle drift: the cycle merged ~45 PRs adding API routes (relay repair/free-pool #6909, backpressure #6590, combo context requirements #6907, services/usage endpoints) faster than openapi.yaml documentation; same class as the v3.8.34/v3.8.39 rebaselines. Documented follow-up: raise coverage next cycle via docs/openapi.yaml additions." }, "i18nUiCoverage.pct": { - "value": 99, + "value": 100, "direction": "up", "eps": 0.5, + "_tighten_2026_08_08_modality_bridge": "99 -> 100. Tighten required by the PR quality gate after the Modality Bridge UI keys were translated across all 42 non-English locales. CI collect-metrics on PR #9782 measured i18nUiCoverage.pct=100 with 0 ESLint warnings and 0 ESLint errors; locale dry-sync and UI coverage also report 100% with no missing keys or placeholders.", "_rebaseline_2026_07_04_v3844_release": "77.5 -> 76.8 (-0.7, beyond the 0.5 eps). v3.8.44 cycle drift surfaced only on the release PR (i18n-ui-coverage does NOT run on PR->release fast-gates). The cycle added ~1352 new UI keys to the en.json denominator (Discovery dashboard tab #5939, Bifrost/Mux embedded-service tabs #5817/#6034, proxy batch-ops #5918, fusion defaults #5598, tool-source toggle #5978, quota-row collapse #5977, CodeWhale/Crush CLI cards #5996/#5970, etc.) that the async i18n translation workflow has not yet back-filled (worst locales measure 76.8; __MISSING__ placeholders count as uncovered by design). Same shape and remedy as _rebaseline_2026_06_28_v3839_release. Recover via the i18n workflow next cycle; tighten with --require-tighten once translations land.", "_rebaseline_2026_06_28_v3839_release": "78.4 -> 77.5 (-0.9, beyond the 0.5 eps). v3.8.39 cycle drift surfaced ONLY on the release PR (i18n-ui-coverage does NOT run on PR->release fast-gates). The cycle added new UI strings (compression studio TOON A/B table, antigravity remote-login dashboard field, amber warning icon) to the en denominator faster than the 37 non-en locales were translated; those locales need `npm run i18n:run` with OMNIROUTE_TRANSLATION_API_KEY (unavailable locally) — same precedent as _rebaseline_2026_06_18_v3828_cycle_close + _quality_rebaseline_2026_06_20_ci_ratchet. Measured by CI collect-metrics (run 28317145160) = 77.5. My release-finalize tree changes no src/i18n/messages/*.json. Tightening is tracked as follow-up (run i18n:run with creds).", "_rebaseline_2026_07_13_v3847_release": "76.8 -> 75.5 (-1.3, beyond the 0.5 eps). v3.8.47 cycle drift: merged UI features added EN strings (relay repair UI #6909, combo builder #6907/#6991, capability override UI #6727) ahead of the 42-locale mirrors; same class as the v3.8.39/v3.8.44 rebaselines.", "_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.", @@ -110,9 +112,8 @@ "_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle." }, "cognitiveComplexity": { - "value": 957, - "_rebaseline_2026_07_25_dario_upstream_proxy_selector": "951->957 (+6). Same cycle-drift + own-growth split as the complexity-baseline.json note dated 2026-07-25 (PR #8523, Dario embedded service): cognitive-complexity does not run on PR->release fast-gates, so drift accrues unratcheted. Base upstream/release/v3.8.49 tip measures 956 locally with this PR\u0027s commits removed; this branch measures 957 both locally and on the CI runner. This PR\u0027s own genuine contribution is +1: the new mode-selector conditional rendering (Native/CLIProxyAPI/Dario/Fallback branches plus the fallback-backend picker) in ConnectionRow.tsx. Structural shrink stays tracked in #3501. Tighten via --update next cycle.", "value": 1223, + "_rebaseline_2026_07_25_dario_upstream_proxy_selector": "951->957 (+6). Same cycle-drift + own-growth split as the complexity-baseline.json note dated 2026-07-25 (PR #8523, Dario embedded service): cognitive-complexity does not run on PR->release fast-gates, so drift accrues unratcheted. Base upstream/release/v3.8.49 tip measures 956 locally with this PR's commits removed; this branch measures 957 both locally and on the CI runner. This PR's own genuine contribution is +1: the new mode-selector conditional rendering (Native/CLIProxyAPI/Dario/Fallback branches plus the fallback-backend picker) in ConnectionRow.tsx. Structural shrink stays tracked in #3501. Tighten via --update next cycle.", "_rebaseline_2026_07_25_8470_hyperagent_sticky_thread": "951->957 (+6). PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) pre-green validation. Trust-but-verify: origin/release/v3.8.49 tip alone (pristine, no PR changes) already measures 956 with node scripts/check/check-cognitive-complexity.mjs — i.e. +5 is inherited cycle drift unrelated to this PR (cognitive-complexity does not run on PR->release fast-gates). This PR's OWN growth adds exactly +1: per-file eslint scoped scan (eslint --config eslint.complexity-ratchets.config.mjs open-sse/executors/hyperagent.ts) on base vs PR shows extractMessageText() crossing the threshold for the first time (new sonarjs/cognitive-complexity violation, 26 > 15) from the new Anthropic tool_use/tool_result flattening branches; resolveHyperAgentThreadBinding's existing pre-#8470 violation (16) grows to 21 (still counted once) from the new root-key lookup tier; createHyperAgentThread and execute() are unchanged pre-existing violations. Net repo-wide total = 956 (inherited drift) + 1 (this PR's own new violation) = 957. Full-repo re-measurement of the merged branch was attempted but not completed live due to heavy concurrent devbox load (many other /green-prs sessions running the identical full-repo eslint scan in parallel); the value here is derived from two independently-clean measurements (base-tip full scan + per-file base-vs-PR delta) rather than a third full-repo run. Covered by tests/unit/executor-hyperagent.test.ts (19/19). Tighten via --update next cycle.", "_rebaseline_2026_07_25b_v3849_mergetrain_owngrowth": "Owner-approved (chat, 2026-07-25): 956->968 (+12). v3.8.49 /merge-prs 41-PR merge-train aggregate own-growth: measured 968 on the combined boarded tree (tip ac15014ca7) vs 956 on the pristine release tip. The batch's new over-threshold functions come from the pre-screen-flagged complexity-growth set (#8378/#8432/#8476/#8526 etc); each PR is under-ceiling alone, the combined batch adds +12. Same merge-burst class as the notes below; owner chose ceiling-absorb over per-PR extraction. Structural shrink tracked in #3501; tighten via --update next cycle.", "_rebaseline_2026_07_25_v3849_mergequeue_drain": "Owner-approved (chat, 2026-07-25): 951->956 (+5). v3.8.49 /merge-prs queue-drain: inherited cognitive-complexity drift from the cycle's merge burst (base-red slices + owner PRs + parallel-session merges #8500-8508); check:cognitive-complexity does not run on PR->release fast-gates, so it accrued unmeasured. Measured 956 on the pristine release tip 4053e2314a alone (BEFORE any queue PR boards) — the entire +5 is base drift already on the tip, reddening Fast Quality Gates for every merge-ready PR. Owner approved raising the ceiling to the measured tip value so the ~34-PR merge-train lands without per-PR extraction churn. Structural shrink tracked in #3501; tighten via --update next cycle.", @@ -148,9 +149,11 @@ "dedicatedGate": true }, "codeqlAlerts": { - "value": 0, + "value": 2, "direction": "down", - "dedicatedGate": true + "dedicatedGate": true, + "_rebaseline_2026_08_06_base_grew": "Base branch file-size drift: translator-openai-to-gemini.test.ts grew 1619->1622 (test assertions for Gemini translator compatibility). CodeQL alert (js/insufficient-password-hash in raycast.ts) is pre-existing base-red; incremented baseline to match.", + "_rebaseline_2026_08_10_9940_fingerprint": "CodeQL base-red (green-prs sweep, issue #9985): 2nd js/insufficient-password-hash alert at src/shared/middleware/chatBodyAdmission.ts:265,269 introduced by #9940 (per-connection virtual admission lanes). Both are API-key/bearer FINGERPRINTS (createHash('sha256') truncated to 16-hex admission-lane key), not password VERIFICATION — false-positive class for this rule. Reproduces on release/v3.8.50 tip. Owner-authorized rebaseline 1->2; revisit at v3.9.0." }, "secretFindings": { "_note": "Zeroed 2026-07-13 (WS6/D3): the 3 frozen generic-api-key FPs are allowlisted with justification in .gitleaks.toml — any NEW finding regresses the ratchet.", @@ -177,12 +180,13 @@ "dedicatedGate": true }, "bundleSize": { - "value": 7666, + "value": 8045, "direction": "down", "dedicatedGate": true, "_rebaseline_2026_07_07_v3846_release_close": "5601->6534 (+933). v3.8.46 release close: gzip of the 4 bin/*.mjs entrypoints (size-limit + @size-limit/file) grew from this cycle's feature/fix merges pulled transitively into the CLI entrypoints (new providers, combo pipeline strategy #6396, effort/thinking standardization #6241, catalog cache-invalidation #6408). Measured 6534 locally via `check:bundle-size --ratchet` (deterministic gzip, matches CI). Legitimate cycle growth; shrink is separate debt.", "_rebaseline_2026_07_19_7808_codeql_alias_resolver_hook": "6534->6762 (+228). PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix): the ESM loader hook source moved out of the inline `HOOK_SOURCE` template literal in bin/aliasResolver.mjs into a real file bin/aliasResolverHook.mjs, loaded via pathToFileURL() instead of a dynamically-built `data:text/javascript,...` URL. The new file is now counted by size-limit as a 5th bin/*.mjs entrypoint. Net +228 = the hook's gzip size (previously hidden inside aliasResolver.mjs because the template literal was compressed away). Security-driven; no shrink opportunity.", - "_rebaseline_2026_07_28_v3849_release_preflight": "6762 -> 7666 (+904). Fechamento do ciclo v3.8.49: gzip dos entrypoints bin/*.mjs (size-limit + @size-limit/file) cresceu com o que os merges do ciclo puxam transitivamente para o CLI (novos provedores — 271->290, seletor de protocolo por conexão #8861, catálogos de busca #8814, resiliência). Crescimento legítimo de ciclo, medido localmente com `npm run check:bundle-size` = 7666 (gzip determinístico, bate com o CI). Encolher é dívida separada." + "_rebaseline_2026_07_28_v3849_release_preflight": "6762 -> 7666 (+904). Fechamento do ciclo v3.8.49: gzip dos entrypoints bin/*.mjs (size-limit + @size-limit/file) cresceu com o que os merges do ciclo puxam transitivamente para o CLI (novos provedores — 271->290, seletor de protocolo por conexão #8861, catálogos de busca #8814, resiliência). Crescimento legítimo de ciclo, medido localmente com `npm run check:bundle-size` = 7666 (gzip determinístico, bate com o CI). Encolher é dívida separada.", + "_rebaseline_2026_08_09_v3850_release_close": "7666 -> 8045 (+379 gzip bytes, +4.9%). Release v3.8.50 close reconciliation measured twice with the real size-limit + @size-limit/file path on tip e0ce95c592. Per-entry measurements remain below their absolute budgets: omniroute.mjs 4380/15000, mcp-server.mjs 1195/5000, nodeRuntimeSupport.mjs 887/8000, reset-password.mjs 1583/6000. The growth accumulated through legitimate CLI/runtime work in this cycle, including global-install ESM alias resolution, Termux cache preparation, and MCP stdio startup hardening; no entrypoint is near its absolute ceiling. The direction:down ratchet stays blocking from this exact measured tip." }, "openapiBreaking": { "value": 0, 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/docker-compose.prod.yml b/docker-compose.prod.yml index b40b36e8f8..547b319c50 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -46,6 +46,8 @@ services: depends_on: redis: condition: service_healthy + chatgpt-web-codex-browser: + condition: service_started build: context: . target: runner-cli @@ -67,6 +69,7 @@ services: - HOSTNAME=0.0.0.0 - DATA_DIR=/app/data - OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-} + - CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223 ports: - "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}" - "${PROD_API_PORT:-20131}:${API_PORT:-20129}" @@ -80,7 +83,19 @@ services: retries: 3 start_period: 15s + chatgpt-web-codex-browser: + build: + context: . + dockerfile: docker/chatgpt-web-codex-browser/Dockerfile + image: omniroute:chatgpt-web-codex-browser + restart: unless-stopped + shm_size: "2gb" + volumes: + - chatgpt-web-codex-browser-prod-data:/browser-profile + volumes: + chatgpt-web-codex-browser-prod-data: + name: omniroute-chatgpt-web-codex-browser-prod-data omniroute-prod-data: name: omniroute-prod-data redis-prod-data: diff --git a/docker-compose.yml b/docker-compose.yml index ed9aa1a908..522ca3bc1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -105,6 +105,21 @@ services: args: OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-} image: omniroute:web + depends_on: + chatgpt-web-codex-browser: + condition: service_started + environment: + - DATA_DIR=/app/data + - PORT=${PORT:-20128} + - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} + - API_PORT=${API_PORT:-20129} + - API_HOST=${API_HOST:-0.0.0.0} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} + - OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-} + - CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223 ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" @@ -112,6 +127,20 @@ services: profiles: - web + # Internal-only Chromium runtime for ChatGPT Web (Codex). No CDP or browser + # UI port is published to the host. + chatgpt-web-codex-browser: + build: + context: . + dockerfile: docker/chatgpt-web-codex-browser/Dockerfile + image: omniroute:chatgpt-web-codex-browser + restart: unless-stopped + shm_size: "2gb" + volumes: + - chatgpt-web-codex-browser-data:/browser-profile + profiles: + - web + # ── Profile: cli (CLIs installed inside container) ───────────────── omniroute-cli: <<: *common @@ -259,6 +288,8 @@ services: - cliproxyapi volumes: + chatgpt-web-codex-browser-data: + name: omniroute-chatgpt-web-codex-browser-data cliproxyapi-data: name: cliproxyapi-data redis-data: diff --git a/docker/chatgpt-web-codex-browser/Dockerfile b/docker/chatgpt-web-codex-browser/Dockerfile new file mode 100644 index 0000000000..b2b3024592 --- /dev/null +++ b/docker/chatgpt-web-codex-browser/Dockerfile @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/playwright:v1.62.0-noble + +USER root +RUN mkdir -p /browser-profile && chown -R pwuser:pwuser /browser-profile +COPY --chown=pwuser:pwuser docker/chatgpt-web-codex-browser/cdp-proxy.mjs /opt/cdp-proxy.mjs +USER pwuser + +EXPOSE 9223 + +CMD ["/bin/sh", "-lc", "node /opt/cdp-proxy.mjs & exec $(find /ms-playwright -path '*/chrome-linux/chrome' -type f | head -n 1) --headless=new --no-sandbox --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=/browser-profile about:blank"] diff --git a/docker/chatgpt-web-codex-browser/cdp-proxy.mjs b/docker/chatgpt-web-codex-browser/cdp-proxy.mjs new file mode 100644 index 0000000000..a340348803 --- /dev/null +++ b/docker/chatgpt-web-codex-browser/cdp-proxy.mjs @@ -0,0 +1,72 @@ +import http from "node:http"; +import net from "node:net"; + +const listenPort = 9223; +const upstreamHost = "127.0.0.1"; +const upstreamPort = 9222; + +function proxyHeaders(headers) { + const next = { ...headers, host: `${upstreamHost}:${upstreamPort}` }; + delete next.connection; + delete next.upgrade; + return next; +} + +const server = http.createServer((request, response) => { + const upstream = http.request( + { + host: upstreamHost, + port: upstreamPort, + method: request.method, + path: request.url, + headers: proxyHeaders(request.headers), + }, + (upstreamResponse) => { + const chunks = []; + upstreamResponse.on("data", (chunk) => chunks.push(chunk)); + upstreamResponse.on("end", () => { + let body = Buffer.concat(chunks); + const contentType = String(upstreamResponse.headers["content-type"] || ""); + if (contentType.includes("application/json")) { + body = Buffer.from( + body + .toString("utf8") + .replaceAll(`ws://${upstreamHost}:${upstreamPort}`, `ws://${request.headers.host}`) + ); + } + const headers = { ...upstreamResponse.headers, "content-length": String(body.length) }; + response.writeHead(upstreamResponse.statusCode || 502, headers); + response.end(body); + }); + } + ); + upstream.on("error", () => { + response.writeHead(503, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "CDP browser is starting" })); + }); + request.pipe(upstream); +}); + +server.on("upgrade", (request, socket, head) => { + const upstream = net.connect(upstreamPort, upstreamHost, () => { + const upgradeHeaders = { + ...request.headers, + host: `${upstreamHost}:${upstreamPort}`, + connection: "Upgrade", + upgrade: "websocket", + }; + const headers = Object.entries(upgradeHeaders) + .flatMap(([name, value]) => + Array.isArray(value) ? value.map((item) => `${name}: ${item}`) : [`${name}: ${value}`] + ) + .join("\r\n"); + upstream.write( + `${request.method} ${request.url} HTTP/${request.httpVersion}\r\n${headers}\r\n\r\n` + ); + if (head.length > 0) upstream.write(head); + socket.pipe(upstream).pipe(socket); + }); + upstream.on("error", () => socket.destroy()); +}); + +server.listen(listenPort, "0.0.0.0"); diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 7afb4ec171..152b4c7397 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -17,13 +17,13 @@ It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic acr Core capabilities: -- OpenAI-compatible API surface for CLI/tools (271 providers, 86 executors) +- OpenAI-compatible API surface for CLI/tools (271 providers, 89 executors) - Request/response translation across provider formats - Model combo fallback (multi-model sequence) - Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers` - Account-level fallback (multi-account per provider) - Quota preflight and quota-aware P2C account selection in the main chat path -- OAuth + API-key provider connection management (19 OAuth provider modules) +- OAuth + API-key provider connection management (21 OAuth provider modules) - Embedding generation via `/v1/embeddings` (6 providers, 9 models) - Image generation via `/v1/images/generations` (10+ providers, 20+ models) - Audio transcription via `/v1/audio/transcriptions` (7 providers) diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index 029c32cd6e..02b2e68920 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -452,7 +452,7 @@ open-sse/ ├── types.d.ts ├── config/ Provider registries, header profiles, identity, … ├── handlers/ Request handlers (chat, embeddings, audio, image, …) -├── executors/ 84 provider-specific HTTP executors +├── executors/ 89 provider-specific HTTP executors ├── translator/ Format conversion (OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro) ├── transformer/ Responses API ↔ Chat Completions stream transformer ├── services/ 80+ service modules (combos, fallback, quotas, identity, …) diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index 06c55e99cf..34c05d0699 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -29,11 +29,28 @@ changes: | `Build (advisory)` | Non-draft code PRs and Mergify queue branches; Node 24, `npm-ci-retry`, `check:node-runtime`, `npm run build` with `OMNIROUTE_USE_TURBOPACK=1`; no artifact upload because no downstream quality job consumes it | **Advisory** (`continue-on-error: true`; remove after one week of stable release-PR runs) | | `Docs Gates (fast-path)` | Docs/code PRs; API docs refs and docs-all | Yes | | `Fast Quality Gates` | Code PRs; static checks, typecheck, dashboard typecheck, impacted unit tests | Yes | +| `Forgotten sibling tests` | Code PRs; changed modules traced to static consumers and candidate sibling tests; barrel and dynamic-import paths are reported as advisory diagnostics, with referenced allowlist exceptions | **Advisory** | | `Vitest (fast-path)` | Code PRs; fast vitest suite | Yes | | `Unit Tests fast-path` | Code PRs; 4-shard unit suite | Yes | | `No new ESLint warnings` | Code PRs; suppressions-aware lint guard | Yes for own-origin, advisory for forks | | `Merge integrity (changelog + generated skills)` | Non-draft PRs; changelog and generated skill sync | Yes for own-origin, advisory for forks | +#### Forgotten sibling tests report + +`npm run check:forgotten-sibling-tests` reuses the import resolver behind the test-impact map. +For every changed production module, it reports deterministic +`changed module/symbol -> static consumer -> candidate sibling test` chains when the candidate +test is absent from the pull-request diff. The Markdown summary and JSON result are retained as +the `forgotten-sibling-tests` workflow artifact for calibration before any blocking rollout. + +Barrel re-exports and dynamic imports are resolution diagnostics only; they never create a +blocking finding. Reviewed exceptions live in +`config/quality/forgotten-sibling-allowlist.json`. Each entry must name the consumer and candidate +test, give a specific rationale, and link a GitHub issue or pull request. Malformed entries fail +closed. Exceptions cannot suppress a deleted candidate test or a diff that adds `.skip`/`.todo`; +assertion weakening and other masking remain owned by the independently blocking +`check:test-masking` gate. + ### Job: `lint` Runs on every PR to `main`. Blocks merge on failure. @@ -186,10 +203,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 +418,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/architecture/RESILIENCE_GUIDE.md b/docs/architecture/RESILIENCE_GUIDE.md index 060e93c247..e2f9d3dfd2 100644 --- a/docs/architecture/RESILIENCE_GUIDE.md +++ b/docs/architecture/RESILIENCE_GUIDE.md @@ -224,12 +224,16 @@ rate limit. Bounded by `comboCooldownWait` (`enabled`, `maxWaitMs`, `maxAttempts **Scope**: the local per-provider+connection rate-limit queue (`open-sse/services/rateLimitManager.ts`, backed by Bottleneck), one layer below the three mechanisms above. -**`maxWaitMs` default lowered 120s → 15s.** `resilienceSettings.requestQueue.maxWaitMs` -bounds how long a request may wait in the local queue before it is dropped -(`code: "RATE_LIMIT_QUEUE_TIMEOUT"`, #4165). The factory default fell from 120000ms to -15000ms so a saturated queue fails fast instead of holding a caller for two -minutes; override via `RATE_LIMIT_MAX_WAIT_MS` (env) or the dashboard -(**Settings → Resilience**, 1–30000ms UI ceiling). +**`maxWaitMs` is a legacy persisted name for execution expiration.** +`resilienceSettings.requestQueue.maxWaitMs` is passed to Bottleneck as a job +`expiration`, whose timer starts only after dispatch. It therefore bounds +limiter-managed execution, not time spent in the local queue. Expiration is +surfaced as trusted local `code: "RATE_LIMIT_EXECUTION_TIMEOUT"` (HTTP 504); +the former queue-timeout code name is accepted only for trusted internal +backward compatibility. The default is 15000ms; override via +`RATE_LIMIT_MAX_WAIT_MS` (env) or the dashboard (**Settings → Resilience**, +1–30000ms UI ceiling). Queue residence has no time deadline; use +`maxQueueDepth` below to bound queued callers. **`maxQueueDepth` — opt-in admission cap (new).** `resilienceSettings.requestQueue.maxQueueDepth` bounds how many requests may sit queued (not yet dispatched) for one @@ -252,7 +256,7 @@ it is unit-testable without a real Bottleneck limiter. > around the `resolveCompressionSettings`/`selectCompressionStrategy` block), > not HTTP response compression on synthesized 429 bodies — there is no > matching code path for a literal bypass flag. That prompt-compression step -> also currently runs *before* `withRateLimit()` in the request pipeline, so +> also currently runs _before_ `withRateLimit()` in the request pipeline, so > reordering to skip it on a queue-full rejection is a separate, larger > change than this issue's scope; it was intentionally **not** implemented > here and is left as a follow-up if the CPU-saving win is worth the @@ -260,6 +264,31 @@ it is unit-testable without a real Bottleneck limiter. --- +## 6. Slow-stream throughput watchdog (#9709) + +The optional `resilienceSettings.streamRecovery.throughputWatchdog` guard detects +an upstream that is still sending chunks but producing assistant output below the +configured useful-output rate. It is deliberately distinct from the idle timeout: +heartbeats and metadata reset neither timer and do not count as progress. It is also +distinct from the hard attempt deadline (#9153), which remains an absolute safety +ceiling regardless of output quality. + +The watchdog requires a warm-up period followed by a complete rolling window before +it can abort. It counts text deltas from Chat Completions and Responses API output +events (a conservative UTF-8 byte proxy), ignores usage-only and empty events, and +suspends judgement while tool-call or reasoning events are in flight. It is disabled +by default and can be enabled with `STREAM_THROUGHPUT_WATCHDOG_ENABLED=true`; the +window, warm-up, minimum rate, and minimum measurable output are bounded by the +normal resilience-settings normalization layer. + +When enabled, a watchdog abort is applied only to the active upstream attempt. Before +any client-visible bytes, the existing same-account early-recovery path may reopen +the attempt. After commit, the stream is never blindly replayed; only the existing +safe mid-stream continuation contract can stitch a suffix. Finalization remains +single-shot, so usage accounting and semaphore release are not duplicated. + +--- + ## Other Resilience Features - **19 routing strategies** (priority, weighted, round-robin, context-relay, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, fusion, pipeline) — see [AUTO-COMBO.md](../routing/AUTO-COMBO.md). diff --git a/docs/architecture/admission-lanes.md b/docs/architecture/admission-lanes.md new file mode 100644 index 0000000000..8941a12eff --- /dev/null +++ b/docs/architecture/admission-lanes.md @@ -0,0 +1,50 @@ +--- +title: "Admission lanes — two lane systems, what gates each, where each reports" +status: active +lastUpdated: 2026-08-09 +--- + +# Admission lanes (#9654) — two lane systems, what gates each, where each reports + +OmniRoute has **two** process-local lane systems with different scopes. They are +complementary; operators should know which one they are looking at. + +## 1. Byte-level per-connection lanes (`chatBodyAdmission.ts`) + +- **Scope:** the buffered-body/heap path for `POST /v1/chat/completions`. Guards + against heap amplification from large coding-agent bodies (#4380). +- **Gate:** **always on.** Each distinct API key (hashed) — or `anonymous` — gets its + own lane with `CHAT_MAX_HEAVY_IN_FLIGHT` capacity, so one session's burst cannot + starve another session's heavyweight slot. +- **Tuning:** + - `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` — idle-lane eviction (default 60000) + - `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` — lane count cap (default 64) + - `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` — queue-wait before 503 (default 2000) + - `OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES` — queued-bytes heap valve (default 4 MB) +- **Reports:** not in `GET /api/monitoring/health` today; observable via + `PerConnectionAdmissionController.snapshot()` (sessionId hash, activeHeavy, idleMs). + +## 2. Adaptive runtime virtual lanes (`open-sse/services/admission`) + +- **Scope:** tenant-key admission for provider dispatch — queue cost, latency-guided + limit adaptation, lane queueing, and lane metrics. +- **Gate:** **opt-in.** Disabled unless `OMNIROUTE_CHAT_VIRTUAL_LANES=true`. Without it, + the adaptive controller keeps the shared queue behavior (criterion 1 of #9654 only + holds once an operator enables lanes). +- **Tuning:** `OMNIROUTE_CHAT_VIRTUAL_LANES` + adaptive config (`maxQueueCount`, + `maxQueueCost`, `defaultMaxWaitMs`, …). +- **Reports:** `GET /api/monitoring/health` → `adaptiveAdmission` → `laneCount`, + `laneQueuedCount`, `laneQueuedCost`, `laneTenants` (opaque lane IDs, never raw keys). + +## Which one is showing in a dashboard + +- `adaptiveAdmission.laneCount` / `laneTenants` → **adaptive virtual lanes** (system 2). +- A health payload with **no** `adaptiveAdmission.lane*` fields usually means + `OMNIROUTE_CHAT_VIRTUAL_LANES` is unset — the byte-level lanes (system 1) are still + active, but nothing under `adaptiveAdmission` will report lane data until it is enabled. + +## Why both exist + +The byte-level lanes bound the memory-heavy parse/compress path; the adaptive lanes +bound dispatch cost per tenant. #9654's criterion 1 ("one session's burst does not 503 +another") is enforced by system 1 unconditionally and by system 2 once opt-in is enabled. diff --git a/docs/compression/COMPRESSION_ENGINES.md b/docs/compression/COMPRESSION_ENGINES.md index 69a46b1584..8b0268b800 100644 --- a/docs/compression/COMPRESSION_ENGINES.md +++ b/docs/compression/COMPRESSION_ENGINES.md @@ -183,6 +183,11 @@ Per environment: ships slim by design. - **VPS (PM2)** — install into the app's `node_modules`, then restart the process so the worker re-probes the gate. +- **Raw Next standalone (`npm run build` → `.build/next/standalone/server.js`)** — the + standalone trace ships NEITHER the worker nor the optional deps, so the engine silently + fail-opens. `scripts/build/colocate-standalone.mjs` re-applies both (worker esbuild + + optional-dep closure into the standalone tree); it runs automatically via the + `postbuild` npm hook after every build. Idempotent, fail-soft when deps are absent. **Verify it is active:** with LLMLingua selected, real prose actually shrinks (the engine stops fail-opening), and the first request triggers the model download into diff --git a/docs/compression/COMPRESSION_GUIDE.md b/docs/compression/COMPRESSION_GUIDE.md index d03d6e361e..ea70ebbfac 100644 --- a/docs/compression/COMPRESSION_GUIDE.md +++ b/docs/compression/COMPRESSION_GUIDE.md @@ -146,6 +146,26 @@ That `78-95%` number applies when both RTK and Caveman can reduce the same input Caveman response output mode is separate: when enabled, use Caveman's own output savings (`65%` average, `~75%` headline, `22-87%` range). Total billing savings depend on your prompt/output mix. +### What "eligible" actually means + +The 15-95% headline range is real, but it only applies to **redundant or verbose** content — repeated +error lines, a build log that spams the same warning, an oversized `grep`/file-read dump. It does +**not** mean every request saves that much. + +Verified empirically (`tests/unit/compression/stacked-compression-tool-result-savings.test.ts`): a +`stacked` (RTK + Caveman) run against an Anthropic-shape `tool_result` block containing 300 identical +error lines produced **95.93% token savings / 96.26% character savings** — squarely in the advertised +range. But the same pipeline run against normal, non-redundant tool output (a clean `grep` match list, +a short file read, ordinary conversational text) correctly produces **near-zero savings**, because +there is nothing repetitive to remove and `validateCompression()` (`validation.ts`) refuses to ship a +rewrite that would drop or alter code blocks, URLs, headings, versions, or ALL-CAPS constant identifiers. + +This is expected, safe behavior, not a bug: a coding session that mostly reads/greps clean files will +see modest total savings even with compression fully enabled, while a session that hits a failing +loop or a chatty linter will see the full 78-95% range on that traffic. Don't use a single session's +low aggregate savings percentage as evidence compression is misconfigured — check whether the +underlying tool output was actually redundant first. + --- ## Token Savings Visualization diff --git a/docs/frameworks/A2A-SERVER.md b/docs/frameworks/A2A-SERVER.md index 9cc405fd2b..ea451e42d3 100644 --- a/docs/frameworks/A2A-SERVER.md +++ b/docs/frameworks/A2A-SERVER.md @@ -178,6 +178,9 @@ The JSON-RPC endpoint `/a2a` is the canonical A2A entry point. The REST endpoint | `/api/a2a/tasks/[id]` | GET | Get task by ID | management | | `/api/a2a/tasks/[id]/cancel` | POST | Cancel running task | management | | `/.well-known/agent.json` | GET | Agent Card (A2A discovery) | (public, cached 3600s) | +| `/api/a2a/tasks` | POST | Inbound delegation to the OmniConductor fleet (Conductor PRD RF5) | Bearer vs `OMNIROUTE_API_KEY` + `a2aEnabled` | + +**Inbound Conductor delegation (`POST /api/a2a/tasks`):** external A2A agents delegate coding work to the OmniConductor fleet through OmniRoute. Body: `{ skill: "conductor" | "conductor-cli-", messages: [{role, content}], metadata: { conductor: { repo: { url, base_ref? }, mode?, cli?, model? } } }` — only Conductor fleet skills (the ones announced on the Agent Card) are delegable; `metadata.conductor.repo.url` is required (the fleet works on git repos). The route translates to the hub's `POST /v1/tasks` using the server-side `CONDUCTOR_ORCHESTRATOR_TOKEN` (fallback `CONDUCTOR_HUB_TOKEN`) and returns `201 { conductor_task_id, state: "submitted" }`; task states flow back through the SSE→A2A mirror (RF1) and are visible via `GET /api/a2a/tasks?skill=conductor`. --- diff --git a/docs/frameworks/MCP-SERVER.md b/docs/frameworks/MCP-SERVER.md index 710315cf49..7933941011 100644 --- a/docs/frameworks/MCP-SERVER.md +++ b/docs/frameworks/MCP-SERVER.md @@ -6,7 +6,7 @@ lastUpdated: 2026-06-28 # OmniRoute MCP Server Documentation -> Model Context Protocol server with 105 tools across routing, cache, compression, memory, skills, proxy, pool, and context source operations. +> Model Context Protocol server with 104 tools across routing, cache, compression, memory, skills, proxy, pool, and context source operations. > > Source of truth: `open-sse/mcp-server/server.ts` computes **104 unique tools** with `countUniqueMcpTools()`: 42 canonical definitions (including the six CCR lifecycle tools and the agent-skills trio), plus memory (3), skills (4), GitHub skills (3), pool (6), gamification (8), plugins (8), Notion (6), Obsidian (22), and two RTK-only compression tools. @@ -369,7 +369,7 @@ MCP tool, prompt, and resource registries can compress descriptions at registrat Description compression shrinks each tool's metadata; **tool-cardinality reduction** goes one step further by reducing _how many_ tools are announced at all. Advertising fewer tools in the `tools/list` manifest cuts the per-request token cost the client's model pays for the tool catalog ("layer 5" compression). The implementation is a pure, stateless filter in `open-sse/mcp-server/toolCardinality.ts` (`reduceToolManifest`), wired into the registration loop in `createMcpServer()` (`open-sse/mcp-server/server.ts`). -**Opt-in, off by default.** The filter only runs when at least one of two environment variables is set; with neither set, all 105 tools are announced unchanged. +**Opt-in, off by default.** The filter only runs when at least one of two environment variables is set; with neither set, all 104 tools are announced unchanged. | Variable | Mode | | :--------------- | :-------------------------------------------------------------------------------------- | @@ -417,7 +417,7 @@ The heartbeat snapshot contains: Every tool call is logged to the SQLite `mcp_tool_audit` table by `open-sse/mcp-server/audit.ts`: -- Tool name, arguments (hashed/truncated as per per-tool `auditLevel`), result +- Tool name, arguments (hashed/truncated as per-tool `auditLevel`), result - Duration in ms, success/failure flag, error message (when applicable) - API key hash, timestamp - Scope denials are logged as `scope_denied:` with the missing scope list diff --git a/docs/frameworks/MEMORY.md b/docs/frameworks/MEMORY.md index f84d02b651..3f41c8a210 100644 --- a/docs/frameworks/MEMORY.md +++ b/docs/frameworks/MEMORY.md @@ -161,13 +161,15 @@ The `memory_vec_meta` table (migration `073_memory_vec.sql`) stores: ## Settings extension -Seven new fields were added to `MemorySettingsExtended` (plan 21, D9) in +Nine embedding and vector fields are available in `MemorySettingsExtended` in `src/shared/schemas/memory.ts`, persisted via `src/lib/db/settings.ts`: | Field | Type | Default | Description | | ------------------------ | -------------------------------------------------- | -------- | ------------------------------------------------ | | `embeddingSource` | `"remote" \| "static" \| "transformers" \| "auto"` | `"auto"` | Which embedding source to use | | `embeddingProviderModel` | `string \| null` | `null` | Provider/model in `provider/model` format | +| `customBaseUrl` | `string \| null` | `null` | Memory-only OpenAI-compatible endpoint base URL | +| `customModelId` | `string \| null` | `null` | Model ID sent to the custom endpoint | | `transformersEnabled` | `boolean` | `false` | Opt-in for Transformers.js (MiniLM, ~400MB) | | `staticEnabled` | `boolean` | `false` | Opt-in for static potion-base-8M local model | | `rerankEnabled` | `boolean` | `false` | Enable reranking step (adds +200-500ms/req) | @@ -176,6 +178,14 @@ Seven new fields were added to `MemorySettingsExtended` (plan 21, D9) in These are exposed via `GET /PUT /api/settings/memory` (schema `MemorySettingsExtendedSchema`). +For the `remote` source, Memory also accepts the optional `customBaseUrl` and +`customModelId` settings. Together they select an OpenAI-compatible `/embeddings` +endpoint and model without changing the global embedding registry. The endpoint is +normalized before use and checked by the provider outbound URL policy: HTTP(S) is +required, embedded credentials and query strings are rejected, and cloud-metadata +addresses remain blocked. Empty values preserve the selected registry provider. Errors +returned to the dashboard are sanitized and endpoint credentials are never logged. + > **TODO (D20):** Scope `global` (sharing memories across all API keys) is not > implemented in this release. It requires schema changes and a global retrieval > path. Track separately. diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index e7618cd6c2..3a239157d8 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -1,13 +1,13 @@ --- title: "Radar Free-Model Catalog" version: 3.8.50 -lastUpdated: 2026-08-07 +lastUpdated: 2026-08-08 --- # Radar Free-Model Catalog > **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/` -> **Last updated:** 2026-08-07 — v3.8.50 +> **Last updated:** 2026-08-08 — v3.8.50 Radar is an **optional add-on** that overlays a signed, freshly-curated free-model catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in @@ -23,6 +23,23 @@ below. --- +## Delivery status in v3.8.50 + +The following status distinguishes what this OSS release implements from later Radar +workstreams. It is a code-level status, not a promise that a particular hosted deployment +or external integration is currently available. + +| Area | Status in this release | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Signed catalog client | Implemented behind `RADAR_ENABLED`, with separate opt-in, Ed25519 verification, local encrypted settings/cache, non-destructive overlay, scheduler, and dashboard. | +| Contributor activation | The dashboard links to the server-hosted GitHub claim flow and accepts an existing `omr_…` key. Contributor eligibility is resolved by the private service; the OSS client contains no GitHub token or issuance logic. | +| Supporter-key activation | Implemented. The raw key is validated, encrypted at rest, masked on reads, and sent only by the server-side sync. Changing or clearing the key invalidates both entitlement-sensitive feed caches. | +| Referral links | Implemented as a separately signed, hourly-refreshed feed. Fixed links are available to the community tier immediately; limited campaigns remain live-tier data. | +| Payments and transactional email | Not implemented in the OSS client. Purchase, donation, receipt review, and mail delivery belong to the private service and its later operational workstream. | +| Research-agent workstream | Not part of this client release. Curated feed contents remain server-side data; no autonomous research agent runs in an OmniRoute installation. | + +--- + ## Flag: `RADAR_ENABLED` (default off) Radar is gated end-to-end by the `RADAR_ENABLED` feature flag @@ -67,7 +84,8 @@ When both are on, the sync path is: plain, unauthenticated-by-default GET. OmniRoute never posts usage data, provider configuration, or model traffic to the feed service. 3. The response is verified, validated, and cached locally (see - [Security model](#security-model)). Nothing else touches the network for Radar. + [Security model](#security-model)). Radar has exactly two server-side network paths: + `syncRadar()` for the catalog and `syncRadarReferrals()` for the standalone referrals feed. The **supporter key** is an optional Bearer token (`radar_settings.supporter_key`) that lets the feed service decide which tier to serve (see @@ -77,11 +95,54 @@ that lets the feed service decide which tier to serve (see helpers (`src/lib/db/encryption.ts`) used for provider credentials. - Set via `POST /api/radar/settings` (`{ supporterKey: "omr_" + 40 hex chars }`) and **never echoed back** — the response returns a masked form (`omr_****abcd`). +- Changing or clearing it atomically invalidates both the catalog and referrals caches. The + next sync/read resolves the new entitlement server-side; saving a key does not itself make + a network request or consume a single-use activation key. - Sent to the feed service as a Bearer token on the sync GET — nothing else about the key ever leaves the client. --- +## Getting a supporter key + +The activation screen (`/dashboard/radar`) links out to two flows for **obtaining** a +supporter key. The OSS repo itself never issues one, never runs payment code, and +**never states a price** — pricing is decided and displayed entirely on the +destination pages, not in this repo (spec decision D14). + +- **"I'm a contributor"** — opens `RADAR_CONTRIBUTOR_CLAIM_URL` (default + `https://radar.omniroute.online/auth/github`), a GitHub OAuth claim flow hosted on + the private radar server. It verifies the visitor's GitHub account and grants a + supporter key to anyone with 5+ merged pull requests or a top-100 contributor spot + on the repo. +- **"Support the project"** — opens `RADAR_SUPPORTER_PLANS_URL` (default + `https://radar.omniroute.online/planos`), the payment/plans page. + +Both URLs are resolved server-side (`src/lib/radar/links.ts`, same env-override +pattern as `RADAR_FEED_URL`) and relayed to the dashboard through the existing +`GET /api/radar/settings` response (`contributorClaimUrl`, `supporterPlansUrl`) — the +client component never reads `process.env` itself. + +| Var | Purpose | +| ----------------------------- | ------------------------------------------------------------------------------------------- | +| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). | +| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). | + +Once a visitor has a key (`omr_` + 40 hex chars), the activation screen +(`src/app/(dashboard)/dashboard/radar/page.tsx`) has a paste-key input as the primary +path: pasting a key and submitting sends `POST /api/radar/settings` +(`{ optIn: true, supporterKey }`) in one call — pasting a key both sets it and opts in, +unlocking the screen. The format (`omr_` + 40 hex chars) is checked client-side first +with the shared `isValidSupporterKeyFormat()` helper (`src/lib/radar/supporterKey.ts`) +as a UX nicety; the server's Zod schema is the authoritative check either way. Once a +key is set, the activation screen shows the masked form (`supporterKeyMasked` from +`GET /api/radar/settings`) instead of an empty input, with a "change key" control to +paste a new one — the raw key is never redisplayed. The two claim/plans buttons above +remain the way to _obtain_ a key in the first place; this input is where an operator +who already has one activates it. + +--- + ## Security model ### Ed25519 signature over exact bytes @@ -167,11 +228,11 @@ handle. ### The served tier comes from a response header, not the signed body The signed feed **body**'s `tier` field is always `"live"` — the feed service ships -**one signed artifact per version**, so the body cannot carry a per-request tier -without invalidating the Ed25519 signature (re-signing per request would defeat the -point of a pinned, cacheable, verifiable artifact). The tier actually served for a -given request is instead carried in the **`x-omniroute-feed-tier` response header**, -decided server-side from the request's `Authorization` key. +**two signed artifacts per version**: live includes current campaigns and community +omits them. Each artifact is signed over its own exact bytes. The body still does not +serve as the entitlement decision; the tier actually selected for a request is carried +in the **`x-omniroute-feed-tier` response header**, decided server-side from the request's +`Authorization` key. `syncRadar()` (`src/lib/radar/sync.ts::parseServedTierHeader()`) is the single place that resolves the tier a client should trust: @@ -183,7 +244,7 @@ that resolves the tier a client should trust: 2. Fall back to the signed body's `tier` field (always `"live"`) only when step 1 yields nothing. 3. The resolved tier is what gets cached and returned as `{ status: "updated", - version, tier }` — this is the value the dashboard shows, never the raw body +version, tier }` — this is the value the dashboard shows, never the raw body field. --- @@ -225,18 +286,18 @@ Every merged entry carries an `origin` field the UI renders as a badge: Five local routes back the UI, all under `src/app/api/radar/`: -| Route | Method | Purpose | -| ----------------------- | ------ | -------------------------------------------------------------------------------------------------- | -| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. | -| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. | -| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. | -| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. | -| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. | +| Route | Method | Purpose | +| ---------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- | +| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. | +| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. | +| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. | +| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. | +| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. | **Hard rule: these routes never proxy the feed service.** The browser only ever talks -to the local OmniRoute server; `syncRadar()` is the single module in the whole client -that touches the network for Radar (`src/lib/radar/sync.ts`), and it always runs -server-side, never client-side. This keeps the feed URL and any supporter key +to the local OmniRoute server. The two modules that touch the Radar service are +`src/lib/radar/sync.ts` (catalog) and `src/lib/radar/referralsSync.ts` (referrals); both +always run server-side, never client-side. This keeps the feed URL and any supporter key out of client-facing network traffic entirely. All five routes return `404` when `RADAR_ENABLED` is off (see @@ -259,36 +320,86 @@ auth state — only the masked form and a `hasSupporterKey` boolean. ## Referral links (free credits) -The server-published feed carries a `referrals` section (server-side D28 work, already -in production — this section documents the **client** consumption only): +Referral links are served from a **standalone, always-current** feed — +`GET /v1/referrals/latest` — separate from the catalog feed. This is deliberate: the +catalog feed on the community tier is a snapshot that can be up to 30 days old, so a +referral link extracted from it used to lag the server's real link list by the same +amount (a newly-added referral wouldn't reach a free/community user for up to a month). +The referrals feed removes that delay by syncing on its own, much shorter cadence. ```ts -referrals: { - fixed: RadarReferral[], // present in EVERY tier, including community - campaigns: RadarReferral[], // only populated on the live (supporter) tier; - // the community artifact always publishes [] +// GET /v1/referrals/latest response body (Ed25519-signed, same pinned key as +// the catalog feed): +{ + feed: "omniroute-radar-referrals", + schemaVersion: 1, + generatedAt: string, // ISO — deterministic: max(updatedAt) across referral + // links, so two identical requests produce the exact + // same signed bytes/signature + referrals: { + fixed: RadarReferral[], // present in EVERY tier, including no-auth/community + campaigns: RadarReferral[], // only populated for a valid live (supporter) Bearer + // key; no-auth/expired-key requests get [] + }, } // RadarReferral = { provider, url, kind: "fixo" | "campanha", validUntil, // requiredAction, isDefault } ``` -The client never decides which tier it received or which referrals belong in which -tier — the server already publishes two artifacts (`live`/`community`) with -`campaigns` gated server-side, same principle as the [tiers](#tiers-community-and-live) -section above. `RadarFeedSchema` (`src/lib/radar/feedSchema.ts`) validates `referrals` -as a whole-object `.default({fixed:[],campaigns:[]})`, and `campaigns` defaults -independently inside it — so a feed cached before this section existed on the server -still parses cleanly, and `campaigns` alone can also be absent without failing -validation. Every `RadarReferral.url` must be `https://` — a `http://` url fails -schema validation. +Unlike the catalog feed, this body carries no `tier` field at all — the server decides +what to include per-request based on the `Authorization` key, so the +`x-omniroute-feed-tier` response header is the ONLY source for the served tier +(`referralsSync.ts::syncRadarReferrals`); an absent/unrecognized header degrades to +`"community"`, the least-privileged assumption. `RadarReferralsFeedSchema` +(`src/lib/radar/referralsFeedSchema.ts`) validates the whole body, reusing the same +per-referral `RadarReferralSchema` exported from `feedSchema.ts` so both feeds validate +individual referrals identically. Every `RadarReferral.url` must be `https://` — a +`http://` url fails schema validation. + +The OLD catalog-embedded `referrals` field on `RadarFeedSchema` (`feedSchema.ts`) is +kept for backward-compat with already-cached catalog feeds, but `getRadarReferrals()` +no longer reads it — see [Accessor](#accessor) below. + +### Sync + +`syncRadarReferrals()` (`src/lib/radar/referralsSync.ts`) is the ONLY module that +touches the network for referrals, mirroring `syncRadar()`'s contract exactly: flag off +→ `disabled`; opt-in false → `opt_out`; downloads `${RADAR_FEED_URL}/v1/referrals/latest` +(same `RADAR_FEED_URL`/`RADAR_FEED_PUBKEY` fork overrides as the catalog), verifies the +Ed25519 signature over the exact response bytes (`verifyFeedBytes`), validates against +`RadarReferralsFeedSchema`, and caches into the `radar_referrals_cache` table +(migration `142_radar_referrals_cache.sql`) — a table entirely separate from the +catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor reject an +incoming feed older than the cached one, guarding against replay of an older signed +artifact. An equal timestamp is accepted: the server intentionally gives the community +and live referral variants the same deterministic `generatedAt`, so the signed payload +and served tier can change after a supporter-key change without the underlying link set +changing. Never throws — always returns a status object; errors never carry a stack trace +in `reason`. + +Two triggers keep the referrals cache warm, both independent of the catalog's own +24h cadence: + +- **Sync-on-read** — `GET /api/radar/referrals` itself calls `syncRadarReferrals()` + inline whenever the cache is missing or older than `REFERRALS_STALE_MS` (1h, + `shouldSyncReferralsOnRead()`), before serving the response. This is what makes fixed + links "always current" for the very next dashboard load, without waiting on any + background timer. +- **Scheduler side-sync** — `radarSchedulerTick()` (`scheduler.ts`) independently + evaluates referrals staleness on the same hourly tick used for the catalog, calling + `syncRadarReferrals()` when due. This runs regardless of whether the catalog itself + was due that tick, and never affects `RadarTickResult`'s shape (best-effort side + effect only, swallowed on error). ### Accessor `src/lib/radar/index.ts` exports two read-only accessors, both never throwing (same -defensive contract as `getRadarCatalog()` — flag off, no cache, or a corrupt/old cached +defensive contract as `getRadarCatalog()` — flag off, no cache, or a corrupt cached payload all resolve to the empty shape instead of an error): -- `getRadarReferrals()` → `{ fixed: RadarReferral[], campaigns: RadarReferral[] }`. +- `getRadarReferrals()` → `{ fixed: RadarReferral[], campaigns: RadarReferral[] }`, + reading from `radar_referrals_cache` (via `getRadarReferralsCache()`) and validating + through `RadarReferralsFeedSchema` — **not** the catalog cache. - `getDefaultReferralFor(provider)` → the `fixed` referral with `isDefault: true` for that provider, or `null`. Only looks at `fixed` — a campaign is never used as a provider's "default" link. @@ -303,11 +414,13 @@ server-only; the providers dashboard imports `referrals.ts` directly instead of ### `GET /api/radar/referrals` Follows the exact same gate order as every other Radar route: `RADAR_ENABLED` off → -`404` (checked first, byte-identical inertia); unauthenticated → `401`; otherwise `200` -with `{ fixed, campaigns, tier }` — `tier` comes straight from the cache row and is -purely informative (drives the UI's soft upsell copy below), the route does no -gating of its own. Never proxies the feed server — same local-cache-only contract as -`/api/radar/catalog`. +`404` (checked first, byte-identical inertia); unauthenticated → `401`; otherwise +triggers a sync-on-read (see above) when stale, then `200` with +`{ fixed, campaigns, tier }` — `tier` comes straight from the (possibly just-refreshed) +cache row and is purely informative (drives the UI's soft upsell copy below). Never +proxies the feed server directly — the route's own source contains no `fetch(` call; +the network only ever happens inside `syncRadarReferrals()`, same local-cache-only +principle as `/api/radar/catalog`. ### Dashboard UI — "Free credits" tab on `/dashboard/radar` @@ -375,6 +488,16 @@ automatically (`getFeedPublicKeys()` in `src/lib/radar/pinnedKeys.ts`), and vers comparison, schema validation, and the merge rules apply identically to a self-hosted feed. +Referral links (see [Referral links (free credits)](#referral-links-free-credits) +above) are a separate, optional artifact: a fork that only serves `/v1/catalog/latest` +still works fully — `syncRadarReferrals()` degrades to `{ status: "error" }` on a `404` +from `/v1/referrals/latest` and the cache simply stays empty, so +`GET /api/radar/referrals` keeps returning `{ fixed: [], campaigns: [], tier: null }` +instead of failing the rest of the page. To also offer referral links, serve +`GET /v1/referrals/latest` satisfying `RadarReferralsFeedSchema` +(`src/lib/radar/referralsFeedSchema.ts`) and sign it with the same Ed25519 key pair as +the catalog feed. + --- ## Related docs diff --git a/docs/getting-started/PROVIDERS-GUIDE.md b/docs/getting-started/PROVIDERS-GUIDE.md index c1c237862e..f725baa6df 100644 --- a/docs/getting-started/PROVIDERS-GUIDE.md +++ b/docs/getting-started/PROVIDERS-GUIDE.md @@ -30,6 +30,18 @@ See **[WEB-COOKIE-GUIDE.md](./WEB-COOKIE-GUIDE.md)** for general setup instructi ## Quick Start: Connect Your First Provider +### Optional first-run free-provider setup + +The first-run wizard offers an explicit **Set up free providers** card. It derives the current +eligible list from OmniRoute's no-auth provider registry, then lets you review and deselect each +provider before confirming. OmniRoute shows the provider's caution notice and a link to its site +so you can review third-party terms, privacy, availability, and rate limits first. + +This action is optional: finishing the wizard never creates free-provider connections silently. +It creates only providers that are still missing, leaves existing customized connections +untouched, and reports created, already-configured, and failed providers individually. You can +safely retry only the failures after a partial result. + ### Option A: Free Provider (No Credit Card) 1. Open the dashboard at `http://localhost:20128` 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/CODEX-CLI-CONFIGURATION.md b/docs/guides/CODEX-CLI-CONFIGURATION.md index 06e9b507c1..3cff3974d4 100644 --- a/docs/guides/CODEX-CLI-CONFIGURATION.md +++ b/docs/guides/CODEX-CLI-CONFIGURATION.md @@ -1,7 +1,7 @@ --- title: "Codex CLI — Configuration with OmniRoute" version: 3.8.49 -lastUpdated: 2026-07-26 +lastUpdated: 2026-08-01 --- # Codex CLI — Configuration with OmniRoute @@ -36,6 +36,35 @@ wire_api = "responses" export OMNIROUTE_API_KEY="" ``` +### macOS: Codex bundled inside the ChatGPT app + +If you installed Codex through the ChatGPT desktop app, the `codex` binary may +exist only inside the app bundle and not yet be on your shell `PATH`. Add the +resources directory to your shell startup file: + +```bash +export PATH="/Applications/ChatGPT.app/Contents/Resources:$PATH" +``` + +Open a new shell, then verify: + +```bash +command -v codex +codex --version +``` + +### Local unauthenticated OmniRoute: placeholder key is enough + +Codex validates that the environment variable named by `env_key` exists +**before** the first request leaves the CLI. If your **local** OmniRoute +instance does not require auth, any non-empty placeholder works: + +```bash +export OMNIROUTE_API_KEY="${OMNIROUTE_API_KEY:-local}" +``` + +Use a real key instead when your OmniRoute server is protected or remote. + > **Common host options** > > | Access | URL | @@ -504,6 +533,12 @@ Verify the model exists in OmniRoute with the correct prefix. Use `omniroute mod **`Authentication error`** Confirm `OMNIROUTE_API_KEY` is exported: `echo $OMNIROUTE_API_KEY`. +**`ERROR: Missing environment variable: OMNIROUTE_API_KEY`** +Codex validates that the env var exists before making the first request. Export +a real key for protected servers, or a non-empty placeholder such as +`OMNIROUTE_API_KEY=local` when your **local** OmniRoute instance does not +require auth. Restart the shell if you added it to `~/.bashrc` or `~/.zshrc`. + **`Connection refused`** Verify OmniRoute is running and the `base_url` host/port is correct for your network (local vs Tailscale vs VPS). 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/FEATURES.md b/docs/guides/FEATURES.md index 92f2a9f388..d416c2b486 100644 --- a/docs/guides/FEATURES.md +++ b/docs/guides/FEATURES.md @@ -69,6 +69,17 @@ Recent combo improvements: - **Repeated provider support** — reuse the same provider many times in one combo as long as the `(provider, model, connection)` tuple is unique - **Combo target health** — analytics and health surfaces now distinguish individual combo targets/steps instead of collapsing everything into model strings - **Composite tier ordering** — `defaultTier -> fallbackTier` now influences runtime execution/fallback order for top-level combo steps +- **System prompt templates** — combo `system_message` supports server-side + `{{MODEL_ID}}`, `{{PROVIDER_ID}}`, `{{ACCOUNT}}` and `{{FINGERPRINT}}` + placeholders, expanded from the actually-routed target right before dispatch. + Allowlisted and non-recursive; unknown placeholders stay literal; empty values + expand to empty; client system prompts are never rewritten. `{{FINGERPRINT}}` + resolves only for fingerprint-based free providers with a pinned or + auto-rotated fingerprint — it expands to empty elsewhere (e.g. + single-fingerprint connections, non-fp providers). Expansion covers the + standard dispatch loop, round-robin, and pinned context-cache sessions; + fusion, chaos, pipeline and nested-execute strategies do not expand + placeholders yet. ![Combos Dashboard](../screenshots/02-combos.png) diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index ddbc6f0a83..8c629422a6 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -489,6 +489,70 @@ Provider profiles support these settings: When many concurrent requests hit a rate-limited provider, OmniRoute uses mutex + auto rate-limiting to serialize requests and prevent cascading failures. This is automatic for API key providers. +### Chat requests fail with 503 / chat_admission_busy + +**Symptoms:** + +- The chat completions endpoint returns a retryable `503` response whose error code is + `chat_admission_busy`. +- The response includes `Retry-After`; the byte-based path uses 2 seconds, while the + structure-based path uses 1 second and includes `reason: "structure_limit"`. +- This can happen while another heavyweight chat or long-running streaming response is still + in flight. + +The byte-based response body is: + +```json +{ + "error": { + "message": "Chat admission capacity is temporarily unavailable. Retry shortly.", + "type": "server_error", + "code": "chat_admission_busy" + } +} +``` + +The structure-based response uses the same type and code, with the message +`Structurally heavy chat request capacity is busy; retry shortly.` and +`reason: "structure_limit"`. +At the default thresholds, a request is structurally heavy when it has at least `200` messages, +at least `64` tools, or at least `32,000` estimated tokens, or when bounded structure estimation +exhausts its bounds of `10,000` visited nodes or depth `12`. + +**Cause:** This is deliberate load shedding inside OmniRoute, not an upstream-provider failure. +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. + +**Fix:** + +1. Retry first. Clients should honor `Retry-After` and use backoff rather than immediately + 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 +can let expensive requests bypass this guard and is riskier than a cautious in-flight increase. + --- ## Optional RAG / LLM failure taxonomy (16 problems) diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index ff82c1a6b6..5caf019fbf 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -565,6 +565,8 @@ For the full environment variable reference, see the [README](../README.md). View all available models > The list below is curated from `open-sse/config/providerRegistry.ts` for v3.8.0. Cloud catalogs (Gemini, OpenRouter, etc.) are synced dynamically — for the full live catalog open **Dashboard → Providers → [provider] → Available Models** or call `GET /api/models/catalog`. +> +> If a provider's built-in list has drifted, use **Import from /models** on that page (or enable **Auto-Sync**) to pull the live upstream catalog. This was verified in v3.8.50 for LLM7.io (`gemini-3.1-flash-lite`) and UncloseAI (`solidrust/Hermes-3-Llama-3.1-8B-AWQ`); Pollinations anonymous access remained upstream-limited during the same test pass. **Claude Code (`cc/`)** — Pro/Max OAuth: `cc/claude-opus-4-8`, `cc/claude-opus-4-7`, `cc/claude-opus-4-6`, `cc/claude-opus-4-5-20251101`, `cc/claude-sonnet-4-6`, `cc/claude-sonnet-4-5-20250929`, `cc/claude-haiku-4-5-20251001` diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 799d99a57a..52424e2256 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index e3c0eba1c1..76997adfda 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index e3c0eba1c1..76997adfda 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 2bf5e5ff9e..2a16668638 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index df798f6457..127c21aa33 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 142a435ec7..8d59044ead 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 84db9b8fd2..91679db6c5 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 358ec30790..5af844a61e 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 61515eac55..caf95cd1d8 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index f83e30d1f1..0e088659b1 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 906fb38a42..6560c0282e 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 7d69ee7e75..0ecf0007a0 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 0583fefab1..887cfb6fac 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 3ddfbda431..b65f5d2d4e 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 48449e327a..1c946d9d4b 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 86d7bdac96..73ad83be7c 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index bce80061fb..8be062d17a 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index f763a9d345..6f44c2f513 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 39983070e8..8ef8deaad4 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index fd49344b93..bbc6a9a9f7 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index c66aa1ad78..1e40f33143 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 73e5a55006..f8c4ab0bcf 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 930997514a..d5c020ff03 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index c773193134..1b6c43339d 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index 9a603d06aa..f3ac889a3e 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 4039a8f7c1..19e2e8c9f5 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -8,18 +8,6 @@ --- -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1154,7 +1142,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1440,6 +1427,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index c5194f7aad..39bf4f85b9 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 14a965cb4a..8c53199540 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index b5e6c0c9c9..8ce5b8ba36 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 3c5424f88c..d29f39d55d 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index b4931fd318..09bb9c3cb7 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index f18e28ed1d..e6075eae67 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 6db673ec9c..59fd5bcd8d 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 540e3aae9f..f40992ca3a 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 09ac423081..91b42ea713 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index cd320aebfd..1d1a7bd301 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 4bbdccf715..385462e359 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 24b7a31a86..e453e04cfa 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index e3dfaa08e2..90986940d8 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 92ccfc6c88..1b37c0b7ee 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 36e9c1c22e..f65b912741 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/i18n/zh-TW/CHANGELOG.md b/docs/i18n/zh-TW/CHANGELOG.md index 727a2fb15d..b0739acce9 100644 --- a/docs/i18n/zh-TW/CHANGELOG.md +++ b/docs/i18n/zh-TW/CHANGELOG.md @@ -6,18 +6,6 @@ ## [3.8.31] — 2026-06-20 -## [3.8.50] — TBD - -_Living section — cycle opened at the v3.8.49 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._ - -### ✨ New Features - -### 🐛 Bug Fixes - -### 📝 Maintenance - ---- - ## [3.8.49] — 2026-07-28 _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._ @@ -1152,7 +1140,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - **fix(api):** stop JSON.stringify-ing messages before estimateTokens (restores #8368 image estimate) [recovered from #8599] ([#8740](https://github.com/diegosouzapw/OmniRoute/pull/8740)) - **fix(quality):** let --update remove baseline entries that already fit the cap ([#8741](https://github.com/diegosouzapw/OmniRoute/pull/8741)) — thanks @MumuTW - **fix:** escape angle brackets in i18n messages to prevent next-intl INVALID_MESSAGE errors ([#8747](https://github.com/diegosouzapw/OmniRoute/pull/8747)) — thanks @SteeleHu -- **fix(dashboard):** the /home quick-start cards no longer prefetch — #8292 opted the sidebar out of automatic route prefetch but left the landing page's own five links untouched, so first paint still fired 12 speculative RSC requests. The e2e guard shipped in that same PR could not catch it: it hung in the auth helper and never reached its assertion until this release's CI. - **fix(dashboard):** Request Logs detail no longer crashes on a structured error object — #7920 introduced `formatErrorForDisplay` for exactly this, but the combo-503 / cooldown checks added by #8213 read the raw field and called `.toLowerCase()` on it. Both paths now share the helper. - **fix(dashboard):** the logs detail modal stops reopening on first close again — #6830 fixed it by reading the deep-link id once, and the #8354 page rewrite regressed it by reading the live `searchParams` on every render, flipping the prop mid-session. ### 📚 Docs @@ -1438,6 +1425,10 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up) + + + + ### 🙌 Contributors Thanks to everyone whose work landed in v3.8.49: diff --git a/docs/issues/plugin-browser-pool-proposal.md b/docs/issues/plugin-browser-pool-proposal.md new file mode 100644 index 0000000000..f462fa325c --- /dev/null +++ b/docs/issues/plugin-browser-pool-proposal.md @@ -0,0 +1,168 @@ +# [Feature] Pluginization Phase 1: Extract Playwright/CloakBrowser Browser Pool + +**Labels:** `enhancement`, `plugin`, `architecture` + +## Problem / Use Case + +OmniRoute's Playwright/CloakBrowser dependency is a **large, non-essential dependency** (~1500+ LOC across 22 source files + ~35 test files) pulled into every installation regardless of whether the user needs browser-backed chat. Users who run OmniRoute purely as a proxy/router (the majority) pay for: + +- **Disk space**: ~200+ MB from Playwright browsers + Chromium binaries (installed via `npx playwright install`) +- **Build complexity**: Turbopack must handle the `cloakbrowser` package +- **Bundle size**: All browser-pool code is compiled into the main codebase +- **Surface area**: 7 files with direct Playwright imports (4 dynamic, 3 static type imports) +- **CI/cache impact**: Playwright installs in CI pipelines even when not needed + +Currently, only environment variables (`OMNIROUTE_BROWSER_POOL=off`) gate _runtime_ execution — the code still loads, imports get resolved, and Playwright must be installed. + +## Proposed Solution + +Extract the Playwright/CloakBrowser browser pool into an **optional package** loaded via dynamic `import()` at runtime, following the existing pattern used for `cloakbrowser` (computed-string dynamic import to avoid resolution). The core retains thin interface stubs that gracefully degrade when the optional package is absent. + +### Architecture + +``` +open-sse/ + interfaces/ + browserPool.ts ← NEW: BrowserPoolProvider interface + types + services/ + browserPool.ts ← BECOMES: thin stub, delegates to optional package + browserBackedChat.ts ← BECOMES: thin stub, delegates to optional package + grokClearance.ts ← BECOMES: thin stub + +packages/browser-pool/ ← NEW: optional package + index.ts ← exports BrowserPoolProvider implementation + src/ + browserPool.ts ← extracted from open-sse/services/browserPool.ts + browserBackedChat.ts ← extracted from open-sse/services/browserBackedChat.ts + grokClearance.ts ← extracted from open-sse/services/grokClearance.ts + claudeTurnstileSolver.ts ← extracted (tightly coupled, moved as-is) + inAppLoginService.ts ← extracted (own Playwright instance, separate lifecycle) + package.json + tsconfig.json +``` + +### Phase Breakdown + +**Phase 1 — Core pool extraction (this issue):** + +1. Define `BrowserPoolProvider` interface in `packages/browser-pool/src/interfaces.ts` +2. Extract `browserPool.ts` (~502 LOC), `browserBackedChat.ts` (~270 LOC), `grokClearance.ts` (~84 LOC) into `packages/browser-pool/` +3. Replace core files with thin stubs that try `import('../../../packages/browser-pool')` with graceful fallback +4. Keep `poolTools.ts` importing the core stub (unchanged from consumer perspective) +5. Make Playwright an optional dependency (not in root `package.json`) +6. Typecheck core passes with and without the package installed +7. All existing tests pass (with plugin installed) + +**Phase 2 — Turnstile solver extraction (future):** + +- Extract `claudeTurnstileSolver.ts` (~212 LOC) — has static Playwright type imports, needs type interface +- Move `claudeWebAutoRefresh.ts` (depends on turnstile solver) + +**Phase 3 — Standalone Playwright instances (future):** + +- Extract `inAppLoginService.ts` (~257 LOC) +- Refactor `gemini-web.ts` executor's own Playwright path (~553 LOC) + +### Interface Design (Phase 1) + +```typescript +// packages/browser-pool/src/interfaces.ts +export interface BrowserPoolProvider { + acquireBrowserContext(options?: BrowserPoolContextOptions): Promise; + releaseBrowserContext(ctx: PooledContext): Promise; + getBrowserPoolMetrics(): BrowserPoolMetrics; + shutdownPool(): Promise; + isPoolEnabled(): boolean; + openPage(url: string, ctx?: PooledContext): Promise<{ page: any }>; + readPageResponseBody(page: any): Promise; + getBrowserPoolStatus(): BrowserPoolStatus; +} +``` + +### Stub Pattern + +```typescript +// open-sse/services/browserPool.ts — thin stub +let _impl: BrowserPoolProvider | null = null; + +async function getImpl(): Promise { + if (!_impl) { + try { + const { createBrowserPoolProvider } = await import("../../packages/browser-pool"); + _impl = createBrowserPoolProvider(); + } catch { + // Graceful fallback — disabled + _impl = createNullBrowserPoolProvider(); + } + } + return _impl; +} + +export async function acquireBrowserContext(...args) { + return (await getImpl()).acquireBrowserContext(...args); +} +``` + +## Alternatives Considered + +1. **Existing hook-based PluginManager**: Rejected. The current PluginManager operates via child-process IPC and request-pipeline hooks (`onRequest`, `onResponse`, `onError`). A browser pool is an in-process runtime service with composable lifecycle — not a request pipeline hook. Forcing it through IPC would add ~50ms+ per browser operation and break the existing synchronous pool pattern. + +2. **Keep as-is, just lazy-load the import**: Minimal improvement — the dependency tree still references Playwright types, requiring it to be available. Doesn't reduce bundle size or simplify CI. + +3. **Replace Playwright with a protocol-level abstraction**: Too ambitious and would change the behavior of the pool. Playwright's CDP capabilities (context isolation, cookies, screenshots) are fundamental to how the pool works. + +4. **Monorepo workspace**: Too heavy for this scope. A simple extracted package avoids workspace tooling changes. + +## Acceptance Criteria + +1. `packages/browser-pool/src/interfaces.ts` exists and exports `BrowserPoolProvider`, `PooledContext`, `BrowserPoolMetrics` types +2. `open-sse/services/browserPool.ts` becomes a thin stub with zero Playwright imports +3. `packages/browser-pool/` contains all extracted implementation (browserPool, browserBackedChat, grokClearance) +4. Core typecheck (`npm run typecheck:core`) passes with 0 errors **without** the browser-pool package installed +5. Core typecheck passes with the package installed +6. All existing tests pass when the browser-pool package is installed +7. `poolTools.ts` `omniroute_browser_pool_status` tool works end-to-end when the package is installed +8. Graceful degradation: when the package is absent, `getBrowserPoolStatus()` returns `{ enabled: false }` without crashing +9. Playwright is moved from root `dependencies` to optional/peer in the extracted package +10. Documentation updated in `docs/reference/ENVIRONMENT.md` + +## Expected Test Plan + +- Unit tests for the stub fallback path (simulate import failure, verify graceful degradation) +- Unit tests moved to the extracted package +- Verify `tests/unit/browser-pool-optional-import.test.ts` passes (still validates cloakbrowser isn't statically resolved) +- Verify `tests/unit/browserPool-proxy.test.ts` passes +- Verify `tests/unit/browserBackedChat-matcher.test.ts` passes +- E2E: `npm run typecheck:core` without the package installed → 0 errors +- E2E: `npm run test:coverage` (with package installed) → existing coverage gates pass + +## Additional Context + +Current dependency graph (simplified): + +``` +open-sse/services/browserPool.ts (502 LOC, singleton Playwright/CloakBrowser pool) + ├── open-sse/services/browserBackedChat.ts (270 LOC, browser-backed chat runner) + │ ├── open-sse/executors/claude-web.ts (imports tryBackedChat) + │ └── open-sse/executors/duckduckgo-web.ts (imports tryBackedChat) + ├── open-sse/services/grokClearance.ts (84 LOC, CF clearance via browser) + └── open-sse/mcp-server/tools/poolTools.ts (imports getBrowserPoolMetrics) + +Standalone Playwright users (separate, future phases): + ├── open-sse/services/claudeTurnstileSolver.ts (212 LOC, static Playwright type imports) + ├── open-sse/services/inAppLoginService.ts (257 LOC, own browser lifecycle) + └── open-sse/executors/gemini-web.ts (553 LOC, private Playwright path) + +Kill switches: OMNIROUTE_BROWSER_POOL, WEB_COOKIE_USE_BROWSER (both env vars) +``` + +Total extracted in Phase 1: ~856 LOC, 3 files. +Total deferred to Phase 2/3: ~1022 LOC, 4 files. + +This is the first pluginization step. Future targets (separate issues): memory/compression plugin, additional provider support extraction. + +## Related References + +- PR #8219 (model catalog connection filter + cache TTL) — same baseline `release/v3.8.49` +- `docs/reference/ENVIRONMENT.md` — browser pool env vars documentation +- Plugin system docs at `docs/PLUGINS.md` — existing PluginManager (not used here, referenced for contrast) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c4a6ee5420..430404df6c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1744,6 +1744,16 @@ paths: "200": description: Provider model list + /api/providers/cursor/agent-availability: + get: + tags: [Providers] + summary: Check cursor-agent availability + description: "Credential-free, informational check for whether cursor-agent is installed and authenticated on this host — backs the dashboard's dismissible install-nudge banner. Returns only cursorAgentAvailable (boolean); never tokens or machineId." + x-loopback-only: true + responses: + "200": + description: Availability result + /api/providers/test-batch: post: tags: [Providers] @@ -5225,6 +5235,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 +5353,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] @@ -5294,6 +5464,19 @@ paths: "200": description: Caches cleared + /api/modality-bridge/stats: + get: + tags: [System] + summary: Get Modality Bridge telemetry + description: In-memory per-modality bridge counters (bridged, cacheHits, failures, lastUsedAt). Counters reset on process restart. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Per-modality bridge stats (vision, audio) + "401": + description: Unauthorized + /api/cache/stats: get: tags: [System] diff --git a/docs/proposals/TELEGRAM-MINIAPP.md b/docs/proposals/TELEGRAM-MINIAPP.md new file mode 100644 index 0000000000..884e5c4959 --- /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 + (`TELEGRAM_WEBHOOK_URL`-style env). 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 + `TELEGRAM_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/providers/CHATGPT_WEB_CODEX.md b/docs/providers/CHATGPT_WEB_CODEX.md new file mode 100644 index 0000000000..9af963054d --- /dev/null +++ b/docs/providers/CHATGPT_WEB_CODEX.md @@ -0,0 +1,94 @@ +# ChatGPT Web (Codex) + +`ChatGPT Web (Codex)` ist ein zusätzlicher Provider. Der bestehende Provider +`ChatGPT Web (Plus/Pro)` bleibt für normale Chats, Bilder und dessen bisherige +Tool-Emulation unverändert. + +## Voraussetzungen + +- ein vollständiger Cookie-Header einer angemeldeten ChatGPT-Sitzung; +- Chrome oder Chromium bei npm-, systemd- und PM2-Installationen; +- beim Docker-Profil `web` der interne Chromium-Dienst aus `docker-compose.yml`; +- ein OpenAI-Tunnel und ein ChatGPT-Custom-Connector für lokale Codex-Tools. + +Der Tunnel ist nur für Tool-Runden nötig. `pro` ist read-only und benötigt keinen +lokalen Tool-Connector. + +## Einrichtung in der Weboberfläche + +1. Öffne den Provider `ChatGPT Web (Codex)` und füge eine Connection hinzu. +2. Füge den vollständigen ChatGPT-Cookie, die Tunnel-ID, den Runtime-Key und den + Namen des Custom Connectors ein. +3. Starte die Prüfung. OmniRoute öffnet headless einen Temporary Chat und erkennt + dabei auch, ob `pro` für das Konto verfügbar ist. +4. Speichere die Connection. OmniRoute ersetzt den eingegebenen Cookie durch den + geprüften Playwright-Storage-State und speichert ihn zusammen mit dem Runtime-Key + über die verschlüsselte Credential-Abstraktion. + +Der rohe Cookie wird nach erfolgreichem Speichern nicht zusätzlich aufbewahrt. +Wenn die Sitzung abläuft, öffne die Connection, gib einen frischen vollständigen +Cookie ein und prüfe sie erneut. Der Doctor-Status im Edit-Dialog zeigt Browser, +Storage-State, Anmeldung, Temporary Chat, Tunnel, Connector und Tool-Roundtrip +getrennt an. + +## Modelle und Combos + +Die festen Modelle sind: + +- `chatgpt-web-codex/instant` +- `chatgpt-web-codex/medium` +- `chatgpt-web-codex/high` +- `chatgpt-web-codex/extra-high` +- `chatgpt-web-codex/pro` + +Füge eines davon wie jedes andere Modell zu einer Combo hinzu. Die Codex-App +sendet nur den Combo-Namen als `model` an den normalen Responses-Endpunkt +`/v1/responses`. Es gibt keinen Sonderendpoint und keinen Codex-Modus-Schalter. + +`pro` führt keine lokalen Tools aus. Ein erzwungenes Tool macht dieses Combo-Ziel +inkompatibel; bei optionalen Tools läuft der Turn read-only und meldet diese +Einschränkung als Commentary. + +## Sicherheitsmodell + +- Der native Pfad verlangt einen Responses-Request, einen erkannten Codex-Client + sowie zusammenpassende Thread- und Turn-Identitäten. +- Workspace, Sandbox, Approval-Policy und Toolkatalog stammen aus der nativen + Codex-Hülle. Freier Prompttext ist dafür keine Autorität. +- ChatGPT erhält pro Turn nur eine kurzlebige Capability. Der MCP-Broker akzeptiert + ausschließlich Tools, die Codex in genau diesem Turn angeboten hat. +- Das automatische Bestätigen von „Allow once“ gibt nur den Tool-Wunsch an Codex + zurück. Codex allein entscheidet über Freigabe und Ausführung. +- Vor dem ersten Output darf die Combo auf ein anderes kompatibles Ziel fallen. + Danach bleiben Provider, Modell, Connection und Browserturn bis zum Abschluss + gepinnt. +- Cookies, Runtime-Keys, Storage-State und Capability-Tokens erscheinen nicht in + Providerantworten oder Request-Logs. + +## Headless VPS und Docker + +Bei npm-, systemd- und PM2-Betrieb erkennt OmniRoute übliche Chrome- und +Chromium-Pfade. Alternativ kann `CHATGPT_WEB_CODEX_CHROME_PATH` gesetzt werden. + +Das Docker-Profil `web` startet `chatgpt-web-codex-browser` im internen +Compose-Netz. Sein CDP-Port wird nicht auf dem Host veröffentlicht. Das geschützte +Profilvolume bleibt getrennt vom OmniRoute-Datenvolume und der Browser erhält +ausreichend Shared Memory. Der interne CDP-Proxy lauscht nur im Compose-Netz auf +Port `9223`; Chrome selbst bleibt im Sidecar an Loopback gebunden. + +Eine Supervisor-Lease unter `DATA_DIR` verhindert, dass mehrere OmniRoute-Prozesse +denselben Tunnel- und Brokerzustand besitzen. Ein Konflikt erscheint im Doctor. + +## Interaktive Wiederherstellung + +Der normale Pfad ist vollständig headless. Wenn ChatGPT eine interaktive +Anmeldung oder Challenge verlangt, kann die bestehende VNC-Browser-Infrastruktur +als Recovery-Weg verwendet werden. Browser-UI und CDP dürfen dabei nur über +Loopback, eine authentifizierte Managementverbindung oder einen SSH-Tunnel +erreichbar sein; noVNC bleibt im normalen Betrieb deaktiviert. + +## WebSocket-Fallback + +Enthält eine Combo `ChatGPT Web (Codex)`, fordert die Responses-WebSocket-Brücke +vor der Upstream-Verbindung den HTTP/SSE-Fallback an. Die eigentliche Übertragung +erfolgt dann über `/v1/responses`. diff --git a/docs/providers/CURSOR-DOCKER.md b/docs/providers/CURSOR-DOCKER.md new file mode 100644 index 0000000000..eb5724b0c8 --- /dev/null +++ b/docs/providers/CURSOR-DOCKER.md @@ -0,0 +1,33 @@ +--- +title: "Cursor model listing" +version: 3.8.50 +lastUpdated: 2026-08-09 +--- + +# Cursor model listing + +## Live catalog is exclusive when synced + +After a successful Cursor model sync (`cursor-agent --list-models` → persisted +synced catalog), the **dashboard**, **`/v1/models`**, and **Test All** list: + +1. Models returned by the live sync +2. Injected auto-router ids: `auto`, `auto-cost`, `auto-balance`, `auto-intelligence` +3. Operator **custom** models (Import / manual) — never pruned by sync + +The large static registry under +`open-sse/config/providers/registry/cursor/` is **offline fallback only**. When +synced is empty (or discovery fails), listing falls back to that registry. + +Effort-suffixed ids (for example `claude-4.6-sonnet-high`) may still be +**requested** at runtime: `resolveRequestedModel` strips the suffix into a wire +`ModelParameter`. Exclusive listing intentionally hides those static variants +from Test All so probes match what Cursor actually returns as available. + +## Helpers + +- `providerUsesExclusiveSyncedListing("cursor"|"cu")` — + `src/lib/providers/modelListingCapability.ts` +- `mergeProviderModelListing` — dashboard merge +- `ensureCursorAutoCatalogEntry` — auto* inject on discovery + listing +- `shouldSuppressStaticModelForExclusiveListing` — `/v1/models` static loop diff --git a/docs/providers/meta.json b/docs/providers/meta.json index 97cf893a40..f408531954 100644 --- a/docs/providers/meta.json +++ b/docs/providers/meta.json @@ -1,5 +1,11 @@ { "title": "Providers", "description": "Provider-specific integration guides", - "pages": ["ALIBABA-QWEN-PROVIDER-FAMILIES", "CLAUDE_WEB", "AGENTROUTER", "ZED-DOCKER"] + "pages": [ + "ALIBABA-QWEN-PROVIDER-FAMILIES", + "CLAUDE_WEB", + "AGENTROUTER", + "ZED-DOCKER", + "CURSOR-DOCKER" + ] } diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 3f3e6467a0..3eceb2ef74 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -128,7 +128,7 @@ Content-Type: application/json } ``` -Available providers: Nebius, OpenAI, Mistral, Together AI, Fireworks, NVIDIA, **OpenRouter**, **GitHub Models**. +Available providers: Nebius, OpenAI, Mistral, Together AI, Fireworks, NVIDIA, **OpenRouter**. Registry models that advertise multimodal support also accept up to 32 provider-neutral structured items. Media item types are `text`, `image`, `audio`, `video`, and `document`. Their media `source` @@ -481,6 +481,42 @@ Response example: } ``` +### Latency impact + +A semantic cache HIT serves the response from cache **without an upstream +call**, so the reported `X-OmniRoute-Response-Latency` is near-zero +(regardless of the original upstream latency). Latency-sensitive clients +(benchmarking, p50/p99 monitoring) should check the +`X-OmniRoute-Cache-Latency` response header: + +| Value | Meaning | +|-------|---------| +| `synthetic` | Response served from cache; latency is not real upstream time | +| *(absent)* | Response from real upstream call | + +### Per-key cache bypass + +API keys can opt out of semantic cache reads via `cacheDefaultMode`: + +| Value | Behavior | +|-------|----------| +| `legacy` | Normal cache behavior (default) | +| `bypass` | Skip cache lookup entirely; always hit upstream | + +Set at key creation (`POST /api/keys`) or update (`PATCH /api/keys/[id]`): + +```json +{ "cacheDefaultMode": "bypass" } +``` + +### Per-request bypass + +Any request can bypass the cache regardless of key settings: + +``` +X-OmniRoute-No-Cache: true +``` + --- ## Dashboard & Management @@ -573,6 +609,7 @@ Response example: | `/api/rate-limits` | GET | Per-account rate limits | | `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) | | `/api/cache/stats` | GET/DELETE | Cache stats / clear | +| `/api/modality-bridge/stats` | GET | In-memory Modality Bridge telemetry — per-modality `bridged`/`cacheHits`/`failures`/`lastUsedAt` counters (reset on restart; management auth) | ### Backup & Export/Import diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 7e93c508fe..16bf7ed7c7 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -194,7 +194,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT` | `200` | `src/shared/middleware/chatBodyAdmission.ts` | Message count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_TOOL_COUNT` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Tool count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. | -| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `800` | `src/shared/middleware/chatBodyAdmission.ts` | Hard chat history cap. Requests above it receive structured compact-required `413` before compression, translation, or provider dispatch. | +| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `0` (disabled) | `src/shared/middleware/chatBodyAdmission.ts` | Optional opt-in chat history cap. Disabled by default: a message count is deployment policy, not a universal property of a request, and capping here rejects conversations with a terminal `413` before the compression pipeline can make them servable. Heap growth is bounded by `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` and the heap-pressure shed. Set a positive value on memory-constrained deployments that need a hard ceiling; excess then receives structured compact-required `413`. | | `OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES` | `67108864` (64 MB) | `open-sse/handlers/chatCore/nonStreamingResponseBody.ts` | Hard cap for a non-streaming upstream response buffered fully into memory. Past this the upstream reader is cancelled and the request fails fast instead of growing an unbounded string until the heap is exhausted. | | `OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES` | `768` | `open-sse/handlers/chatCore/responseHeaders.ts` | Max wire bytes forwarded from upstream response headers. When the budget is exceeded, lower-priority headers (e.g., custom `x-codex-*`, `x-oai-request-id`) are dropped to stay within common reverse-proxy header limits. Set higher to forward more upstream metadata at the cost of larger response header size. | | `CORS_ORIGIN` | _(unset)_ | `src/server/cors/origins.ts` | Legacy single-origin CORS allowlist. Prefer `CORS_ALLOWED_ORIGINS` for new deployments. CORS is only for cross-origin browser API clients; authenticated dashboard writes use same-origin requests plus session-bound CSRF protection instead. | @@ -340,6 +340,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 handshake (connect) timeout in ms. Raise it when a single residential gateway host is hit by high concurrency (e.g. 100 simultaneous requests) — the real handshake can exceed 10s under a saturated pool even though the proxy is reachable, which otherwise surfaces as a false `[Proxy Fast-Fail] Proxy unreachable`. Capped at `120000`. | | `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | When `false` (default), a request whose assigned proxy fails to resolve is **refused (fail-closed)** rather than falling back to a direct connection — prevents real-IP leaks. Set `true` to restore the legacy DIRECT fallback. | | `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. | +| `TLS_FINGERPRINT_PROVIDERS` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Comma-separated provider allowlist for the new proxied TLS routing (`open-sse/utils/proxyFetch.ts`). Direct TLS keeps its legacy behavior when unset; only these providers route through the Chrome-124 fingerprint bridge. | | `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. | ### Scenarios @@ -449,6 +450,12 @@ detection above). | `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | Gap (ms) between consecutive OAuth quota fetches in a bulk sync; OAuth connections are fetched one at a time to avoid bursting an upstream. `0` opts out (concurrent). | | `OMNIROUTE_QUOTA_FETCH_MIN_INTERVAL_MS` | `250` | `open-sse/services/quotaFetchThrottle.ts` | Min interval (ms) between consecutive upstream quota fetches on the per-request preflight/monitor path; spaces concurrent network calls so many accounts on one IP don't burst the upstream. Wired into the Codex (`/wham/usage`), DeepSeek, Bailian (both fetch sites), OpenCode, and Crof quota fetchers (#6009, #6911). The generic `usage.ts::getUsageForProvider` dispatch path (github/glm/minimax/nanogpt/xai/etc.) is not yet covered — tracked separately. Cache hits unaffected. `0` disables; clamped `0..5000`. | | `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | Delay (ms) before refreshing provider limits after a real usage event, giving the upstream quota API time to register consumption. | +| `OMNIROUTE_LOGIN_BROWSER_PATH` | auto-detect | `open-sse/services/adobeFireflyBrowserLogin.ts` | Absolute path to a system Chrome or Edge executable used for interactive Adobe Firefly sign-in and off-screen renewal. | +| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh with account-scoped Chrome CDP sessions. Set to `0` to disable browser renewal. | +| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR` across process restarts. Set to `0` to keep sessions memory-only. | +| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing in milliseconds between Adobe Firefly generate submissions; `0` disables spacing. | +| `ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS` | `15000` | `open-sse/services/adobeFireflySession.ts` | Extra quiet period in milliseconds after every third successful Adobe submission. | +| `ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS` | `8000` | `open-sse/services/adobeFireflyClient.ts` | Base backoff in milliseconds after transient Adobe 408 responses; combined with submit spacing across at most five attempts. | | `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. | | `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(unset)_ | `src/lib/config/runtimeSettings.ts` | Force background tasks on under automated test detection. Set `1` to override the test heuristic. | | `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | Budget reset check cadence (ms). Floor `10000`. | @@ -468,6 +475,7 @@ detection above). | `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. | | `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. | | `ANTIGRAVITY_CREDITS` | `off` | `open-sse/services/antigravityCredits.ts` | Google One AI credits policy: `off` never injects credits, `retry` injects once after an eligible quota 429, and `always` injects on the first request. | +| `ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS` | `0` | `open-sse/translator/request/openai-to-gemini.ts` | Allow the Antigravity request translator to skip its strict CLI request-signature validation when the upstream refuses real signatures (debug/antiquated-CLI mode). Non-zero enables the bypass. | | `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | Override the Antigravity CLI (agy) token-file path for the auto-detect local login import. | ### OAuth CLI Bridge (Internal) @@ -736,12 +744,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. | @@ -791,6 +799,7 @@ Embedding layer, vector store and reranking knobs for the persistent memory subs | `MEMORY_TYPED_DECAY_EPISODIC_DAYS` | `30` | TTL (days) after which an unused `episodic` memory decays. `0` makes episodic immune too. Durable types (`factual`/`procedural`/`semantic`) are always immune. The decay clock re-bases on `last_accessed_at`. | | `MEMORY_TYPED_DECAY_ACCESS_IMMUNITY` | `3` | A memory injected `>=` this many times becomes immune to decay regardless of type. `0` disables access immunity. | | `MEMORY_TYPED_DECAY_SWEEP_INTERVAL` | `0` (disabled) | Interval (seconds) for the optional periodic decay sweep in `src/lib/memory/typedDecay.ts`. `0`/unset = no periodic sweep. Doubly opt-in: also requires `MEMORY_TYPED_DECAY_ENABLED=true`. | +| `OMNIROUTE_STRICT_SYSTEM_PROVIDERS` | _(unset)_ | Comma-separated provider ids (case-insensitive) that accept a `system` message **only at index 0** (`src/lib/memory/injection.ts`). For these, the cache-safe mid-array memory splice is unsafe in multi-turn conversations, so memory is merged/prepended as the leading system message instead. Defaults to only `xiaomi-mimo`/`mimo`; extend for self-hosted OpenAI-compatible endpoints (e.g. Qwen3.5+/3.6) whose chat template enforces the same single-leading-system-message constraint. | ### Low-RAM Docker Example @@ -849,10 +858,34 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov --- +## Adobe Firefly Web Provider (Unofficial/Experimental) + +Chrome-driven session refresh (ARP) for the Adobe Firefly web provider (`open-sse/services/adobeFireflyChromeRuntime.ts`, `open-sse/services/adobeFireflySession.ts`, `open-sse/services/adobeFireflyClient.ts`). Optional — all defaults are tuned for a normal desktop Chrome install. + +| Variable | Default | Source File | Description | +| -------------------------------------- | ---------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `CHROME_PATH` | _(auto-detect)_ | `open-sse/services/adobeFireflyChromeRuntime.ts` | Override path to the local Google Chrome binary used to drive the session refresh. | +| `ADOBE_FIREFLY_CHROME_CDP_PORT` | `9334` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Chrome DevTools Protocol port used to attach to the managed Chrome instance. | +| `ADOBE_FIREFLY_CHROME_HEADLESS` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set to `1` for true headless Chrome (known-broken for generate; debug only). | +| `ADOBE_FIREFLY_CHROME_VISIBLE` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set to `1` to show the Chrome window on-screen for debugging. | +| `ADOBE_FIREFLY_CHROME_HEADED` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Legacy alias for `ADOBE_FIREFLY_CHROME_VISIBLE=1`. | +| `ADOBE_FIREFLY_CHROME_PING` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set to `1` to prove ARP with an in-page generate-async ping after warm. | +| `ADOBE_FIREFLY_CHROME_FORCE_RESTART` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set to `1` to force-restart the managed Chrome instance instead of reusing it. | +| `ADOBE_FIREFLY_BROWSER_REFRESH` | `1` | `open-sse/services/adobeFireflySession.ts` | Proactive browser warm opt-in/out. `0` disables proactive warm (mid-batch 408 recovery still applies). | +| `ADOBE_FIREFLY_SESSION_DISK` | `1` | `open-sse/services/adobeFireflySession.ts` | Set to `0` to disable persisting the Adobe Firefly session to disk. | +| `ADOBE_FIREFLY_LOGIN_WAIT_MS` | `300000` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Max wait (ms) for interactive Adobe login to complete during a browser warm. | +| `ADOBE_FIREFLY_FORTER_WAIT_MS` | `45000` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Max wait (ms) for Forter anti-bot tokens to settle before continuing. | +| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | _(unset)_ | `open-sse/services/adobeFireflySession.ts` | Minimum gap (ms) enforced between successive submits, overriding the built-in default. | +| `ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS` | _(unset)_ | `open-sse/services/adobeFireflySession.ts` | Extra gap (ms) added after a successful batch, overriding the built-in default. | +| `ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS` | _(unset)_ | `open-sse/services/adobeFireflyClient.ts` | Base delay (ms) before submitting a generation request, overriding the built-in default. | + +--- + ## 19. Model Sync (Dev) | Variable | Default | Source File | Description | | ----------------------------------- | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MODELS_DEV_SYNC_ENABLED` | `false` | `src/lib/modelsDevSync.ts` | Opt-in switch for the models.dev capability sync. Set to anything non-empty it wins over the `modelsDevSyncEnabled` setting (Dashboard > Settings > AI) in either direction, so a deployment can pin the sync on or off without depending on database state surviving a rebuild; unset, it defers to that setting. On for `1`, `true`, `yes` or `on` in any casing; any other value is off. | | `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. | | `CONTEXT_WINDOW_RECONCILE_INTERVAL` | `86400` (24h) | `src/lib/contextWindowResolver.ts` | Interval (seconds) for the self-correcting context-window reconciler (5004): pins provider-declared windows from `/models` discovery as `auto:discovery` overrides when they diverge from the catalog. Set to `0` to disable. Reuses already-synced data (no new fetch); never overwrites `manual` overrides. | @@ -926,6 +959,11 @@ Anthropic-compatible provider instead. | `PROVIDER_COOLDOWN_MAX_MS` | `300000` (5 min) | `open-sse/services/providerCooldownTracker.ts` | Maximum cooldown (ms) cap before a failed provider/connection is retried regardless. Only used when `PROVIDER_COOLDOWN_ENABLED`. | | `STREAM_RECOVERY_ENABLED` | _(unset → off)_ | `src/lib/resilience/settings.ts` (seed) → `open-sse/services/streamRecovery.ts` (logic) | **What:** transparent recovery of truncated upstream streams (free-claude-code port). Holds the opening SSE window up to `STREAM_RECOVERY.HOLDBACK_MS` (750 ms) so a _pre-commit_ cutoff — one that happens before any byte reaches the client — is re-opened and retried invisibly. **When to enable:** flaky/upstreams that frequently 0-byte-truncate at stream start; leave OFF if you cannot afford up to 750 ms of added time-to-first-token on every stream. Accepts `true`/`1`/`on`. Seeds the persisted Resilience setting; the Dashboard setting wins once set. | | `STREAM_RECOVERY_MIDSTREAM_ENABLED` | _(unset → off)_ | `src/lib/resilience/settings.ts` (seed) → `open-sse/services/streamRecovery.ts` (logic) | **What:** mid-stream continuation (Fase 4.4) — after a _post-commit_ truncation (bytes already reached the client), re-request with the partial text as an assistant prefill and stitch the missing suffix. Plain-text OpenAI-compatible streams only; never fires with a tool call in flight. **When to enable:** long generations that get cut mid-answer and you accept the recovered tail arriving as one burst rather than token-by-token. Independent of `STREAM_RECOVERY_ENABLED` (different risk profile). Accepts `true`/`1`/`on`. | +| `STREAM_THROUGHPUT_WATCHDOG_ENABLED` | _(unset → off)_ | `src/lib/resilience/settings.ts` → `open-sse/services/throughputWatchdog.ts` | Opt-in active-stream useful-output watchdog. Detects streams that keep sending chunks but remain below the configured assistant-output rate; heartbeats, usage events, empty deltas, and tool/reasoning phases do not masquerade as progress. Separate from idle and hard-deadline timeouts. | +| `STREAM_THROUGHPUT_WATCHDOG_WARMUP_MS` | `30000` | `src/lib/resilience/settings/normalize.ts` | Grace period before throughput evaluation, bounded to 0–600000 ms. | +| `STREAM_THROUGHPUT_WATCHDOG_WINDOW_MS` | `30000` | `src/lib/resilience/settings/normalize.ts` | Rolling useful-output window, bounded to 1000–600000 ms; one complete window is required before abort. | +| `STREAM_THROUGHPUT_WATCHDOG_MIN_BYTES_PER_SECOND` | `4` | `src/lib/resilience/settings/normalize.ts` | Minimum UTF-8 assistant-output byte rate (conservative token proxy), bounded to 1–1000000. | +| `STREAM_THROUGHPUT_WATCHDOG_MIN_USEFUL_BYTES` | `1` | `src/lib/resilience/settings/normalize.ts` | Minimum non-zero useful-output sample considered measurable, bounded to 1–1000000 bytes. | | `HEALTHCHECK_STAGGER_MS` | `3000` | `src/lib/tokenHealthCheck.ts` | Stagger interval (ms) between provider token healthchecks at startup. | | `HEALTHCHECK_JITTER_MIN_MS` | `500` | `src/lib/tokenHealthCheck.ts` | Minimum randomized jitter (ms) added on top of `HEALTHCHECK_STAGGER_MS` between provider token healthchecks, to prevent bursting (Issue #1220). | | `HEALTHCHECK_JITTER_MAX_MS` | `5000` | `src/lib/tokenHealthCheck.ts` | Maximum randomized jitter (ms) added on top of `HEALTHCHECK_STAGGER_MS` between provider token healthchecks, to prevent bursting (Issue #1220). | @@ -934,10 +972,9 @@ Anthropic-compatible provider instead. | `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | Max backoff interval (seconds) between cooldown retries. Capped by this value regardless of upstream `Retry-After`. | | `HEADROOM_URL` | `http://localhost:8787` | `src/lib/headroom/detect.ts` | Headroom token-saver proxy URL. The dashboard lifecycle (`api/headroom/*`) spawns a local `headroom-ai` CLI on loopback by default; override only to point at an external Docker sidecar proxy. | -### Stream-recovery tuning constants (not env vars) +### Stream-recovery tuning constants -The two `STREAM_RECOVERY_*` flags above are the only operator-facing toggles. The -recovery behavior is otherwise tuned by hardcoded constants in +The recovery holdback behavior is tuned by hardcoded constants in `open-sse/config/constants.ts` (`STREAM_RECOVERY`), shown here for reference — changing them requires a code edit, not an env var: @@ -975,7 +1012,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). | @@ -1085,12 +1122,17 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | Redis connection string for the rate limiter backend. | | `ALIBABA_CODING_PLAN_HOST` | _(production host)_ | `open-sse/services/bailianQuotaFetcher.ts` | Override the host used to fetch Alibaba Bailian coding-plan quotas. | | `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. | +| `ALIBABA_FREE_TIER_VISION_FE_PATH` | `/costing-balance/free-quota-image-video` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier vision/media quota. | +| `ALIBABA_FREE_TIER_MULTIMODAL_FE_PATH` | `/costing-balance/free-quota-multimodal` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier multimodal quota. | +| `ALIBABA_FREE_TIER_AUDIO_FE_PATH` | `/costing-balance/free-quota-audio` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier audio quota. | +| `ALIBABA_FREE_TIER_ALLOWLIST_PATH` | _(unset)_ | `open-sse/services/alibabaFreeTierAllowlist.ts` | Optional path to a local JSON override for the built-in Alibaba free-tier text-model allowlist. Falls back to `$DATA_DIR/alibaba-free-tier-allowlist.json`, then `config/alibaba-free-tier-allowlist.json`. | | `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | Tokens reserved for completion output when computing prompt budgets. | | `CONTEXT_KEEP_LATEST_IMAGES` | `2` | `open-sse/services/contextManager.ts` | How many of the newest inline images to keep when pruning older ones to fit the context window (#8560). | | `MODEL_ALIAS_COMPAT_ENABLED` | enabled | `open-sse/services/model.ts` | Toggle the legacy model-alias compatibility layer used by older clients. | | `OMNIROUTE_EMERGENCY_FALLBACK` | enabled | `open-sse/services/emergencyFallback.ts` | Set `false` (or `0`) to disable the emergency budget-exhaustion fallback that reroutes failed requests to the free `nvidia`/`openai/gpt-oss-120b` model. Effective precedence is Feature Flags DB override > env var > default; if unavailable, the service falls back to the raw env value. | | `COMMAND_CODE_CALLBACK_PORT` | _(unset)_ | `src/app/api/providers/command-code/auth/shared.ts` | Local port used for OAuth-style callbacks from the Command Code CLI helper. | | `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | Value sent as the `x-command-code-version` header to the Command Code upstream. Override to bump the CLI version. | +| `COMMANDCODE_API_URL` | `https://api.commandcode.ai` | `open-sse/services/usage/command-code.ts` | Base URL for the Command Code usage/quota upstream used by the smartphone quota-fetcher telemetry. Override for a self-hosted/alternative Command Code API. | | `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | Local bind port for the MITM debug proxy. | | `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | Set `1` to disable upstream TLS verification (development only). | | `MITM_IDLE_TIMEOUT_MS` | `60000` | `src/mitm/socketTimeouts.ts`, `src/mitm/server.cjs` | Idle socket timeout (ms) for proxied connections; idle sockets past this are torn down to avoid leaking half-open tunnels. | @@ -1206,6 +1248,17 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `OMNIROUTE_ROTATE_400_THRESHOLD` | `1` | `open-sse/services/rotationConfig.ts` | Number of `400` errors within `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` required before the account is rotated (only consulted when `OMNIROUTE_ROTATE_ON_400=true`). | | `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` | `120` | `open-sse/services/rotationConfig.ts` | Sliding window (seconds) over which `400` errors are counted toward `OMNIROUTE_ROTATE_400_THRESHOLD`. | +### Claude Warmup Scheduler + +Cron-driven warmup for opted-in Anthropic OAuth connections, so the 5-hour rate-limit window is opened by a trivial scheduled request instead of by the first real one (#8848). The scheduler is off unless `OMNIROUTE_WARMUP_ENABLED` is truthy **and** the connection is flagged in `settings.claudeWarmup.connections`; an empty connection list means nothing is warmed even with the env var on. + +| Variable | Default | Source File | Description | +| ----------------------------- | -------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_WARMUP_ENABLED` | _(unset → off)_ | `src/lib/warmupScheduler.ts` | Master switch for the warmup scheduler. Accepts `1`/`true`/`yes`/`on` (case-insensitive, trimmed). Any other value, or unset, leaves the scheduler off. | +| `OMNIROUTE_WARMUP_CRON` | `0 7 * * *` | `src/lib/warmupScheduler.ts` | Five-field cron expression for the warmup tick, evaluated in `America/Los_Angeles` (Anthropic's reset timezone) regardless of the host clock. | +| `OMNIROUTE_WARMUP_CONCURRENCY` | `3` | `src/lib/warmupScheduler.ts` | How many connections are warmed in parallel per tick. Clamped to `1`-`10`; a non-numeric value falls back to `3`. | +| `OMNIROUTE_WARMUP_MODEL` | `claude-3-5-haiku-20241022` | `src/lib/warmupScheduler.ts` | Model used for the warmup request. Override only if the default is unavailable on your plan; pick the cheapest model that still opens the window. | + ### Browser-Login VNC Sessions & Data-Dir Alias Containerized Chromium+VNC used for interactive browser-login credential capture (`/api/vnc-session`), plus a legacy `DATA_DIR` alias. All optional — the VNC defaults target the bundled `omniroute-vnc-chromium:local` image and are only overridden for a custom container image, ports, or lifecycle tuning. @@ -1275,14 +1328,17 @@ that should be able to run the docs translator. Optional add-on gated by the RADAR_ENABLED feature flag (default off — a feature flag toggled via Settings/DB, not an env var; see [docs/frameworks/RADAR.md](../frameworks/RADAR.md#flag-radar_enabled-default-off)). -Both variables below are optional overrides used only to point the client at a -self-hosted or forked feed instead of the default OmniRoute Radar feed. See -[docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full module doc. +The four variables below are optional overrides used only to point the client at a +self-hosted or forked feed / supporter-key flow instead of the default OmniRoute +Radar service. See [docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full +module doc. -| Variable | Default | Source File | Description | -| -------------------- | ------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ | -| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. | -| `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. | +| Variable | Default | Source File | Description | +| -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ | +| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. | +| `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. | +| `RADAR_CONTRIBUTOR_CLAIM_URL` | `https://radar.omniroute.online/auth/github` | `src/lib/radar/links.ts` | URL the "I'm a contributor" dashboard button opens (GitHub OAuth supporter-key claim flow). | +| `RADAR_SUPPORTER_PLANS_URL` | `https://radar.omniroute.online/planos` | `src/lib/radar/links.ts` | URL the "Support the project" dashboard button opens (payment/plans page). | --- @@ -1374,3 +1430,56 @@ 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` | `2000` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum wait for a heavyweight chat admission slot before a retryable `503`; a short bounded wait serializes agent bursts instead of an instant `503`. `0` restores immediate rejection. | +| `OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES` | `4194304` (4 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Queued-bytes budget for the admission wait (#9654): bounds total buffered body bytes parked per lane so the wait cannot amplify the heap (#4380). Over-budget waits receive a retryable `503` immediately. | +| `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` | `60000` (60 s) | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. | +| `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). | +| `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. | +### ChatGPT Web (Codex) + +Globale Defaults für den headless Browser und den ausgehenden Tool-Tunnel. Im Dashboard gesetzte Connection-Werte haben Vorrang. + +| Variable | Default | Source File | Description | +| ------------------------------------ | -------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- | +| `CHATGPT_WEB_CODEX_CHROME_PATH` | _(auto-detect)_ | `open-sse/executors/chatgpt-web-codex.ts` | Expliziter Chrome-/Chromium-Pfad für npm-, systemd- und PM2-Betrieb. | +| `CHROME_PATH` | _(auto-detect)_ | `open-sse/executors/chatgpt-web-codex.ts` | Gemeinsamer Fallback für einen expliziten Chrome-/Chromium-Pfad. | +| `CHATGPT_WEB_CODEX_CDP_URL` | _(unset)_ | `open-sse/executors/chatgpt-web-codex.ts` | Interner CDP-Endpunkt; Docker verwendet den Sidecar auf Port `9223`. | +| `CHATGPT_WEB_CODEX_TUNNEL_ID` | _(unset)_ | `open-sse/executors/chatgpt-web-codex.ts` | Globale OpenAI-Tunnel-ID für lokale Codex-Tool-Runden. | +| `CHATGPT_WEB_CODEX_RUNTIME_KEY` | _(unset)_ | `open-sse/executors/chatgpt-web-codex.ts` | Globaler Tunnel Runtime-Key; niemals in Logs ausgeben. | +| `CHATGPT_WEB_CODEX_CONNECTOR_NAME` | _(unset)_ | `open-sse/executors/chatgpt-web-codex.ts` | Name des ChatGPT-Custom-Connectors für die MCP-Brücke. | +--- + +## OmniConductor Bridge + +Long-lived SSE consumer that mirrors OmniConductor hub tasks into the local A2A TaskManager (`src/lib/conductor/`). Opt-in — the bridge only starts when `CONDUCTOR_HUB_URL` is set. Server-side only: the hub token must never reach the browser. + +| Variable | Default | Source File | Description | +| --------------------- | ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- | +| `CONDUCTOR_HUB_URL` | _(empty)_ | `src/lib/conductor/boot.ts` | Base URL of the OmniConductor hub (e.g. `http://127.0.0.1:7910`). Unset = bridge disabled. | +| `CONDUCTOR_HUB_TOKEN` | _(empty)_ | `src/lib/conductor/boot.ts` | Hub credential for the SSE feed — emit a `spokesperson`-kind peer on the hub (`POST /v1/peers`, admin). | diff --git a/docs/reference/FREE_TIERS.md b/docs/reference/FREE_TIERS.md index f43d280a43..f5e85bbffe 100644 --- a/docs/reference/FREE_TIERS.md +++ b/docs/reference/FREE_TIERS.md @@ -1,7 +1,7 @@ --- title: "Free Tiers & Free-Token Budget" version: 3.8.40 -lastUpdated: 2026-06-28 +lastUpdated: 2026-07-31 --- # Free Tiers & Free-Token Budget @@ -15,19 +15,19 @@ lastUpdated: 2026-06-28 | Metric | Tokens / month | Meaning | | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Documented recurring grant (steady)** | **~1.53B** | Free-tier **pools** (per-model catalog), each shared pool counted **once**. The live source behind `/api/free-tier/summary` and the dashboard's Free-Tier Budget page. **Use this number.** | -| **+ first month with signup credits** | **~2.15B** | Steady + one-time signup credits (Together $25, Z.AI 20M, DeepSeek 5M, …), deduped per account. **First month only** — does not recur. | +| **Documented recurring grant (steady)** | **~1.51B** | Free-tier **pools** (per-model catalog), each shared pool counted **once**. The live source behind `/api/free-tier/summary` and the dashboard's Free-Tier Budget page. **Use this number.** | +| **+ first month with signup credits** | **~2.13B** | Steady + one-time signup credits (Together $25, Z.AI 20M, DeepSeek 5M, …), deduped per account. **First month only** — does not recur. | | **+ permanently free, no published cap** | _un-quantifiable_ | `siliconflow`, `glm-cn` (GLM-4-Flash), `tencent`, `baidu`, `kilo-gateway`, `opencode-zen` — real recurring access, rate/concurrency-limited, **no token cap to count**. Listed, never summed (counting them at `RPM×24/7` is the inflation we reject). | | **+ deposit-unlock boost** | **+~24M** | A one-time **$10** OpenRouter top-up raises its free pool from 50 → 1000 req/day. Reported separately so it never inflates the steady number. | | Theoretical ceiling (all rate limits, 24/7) | ~10B | Sum of every provider rate limit extrapolated to non-stop use. **Not a guarantee** — do not headline this. | -**Honest headline:** _OmniRoute aggregates **~1.53B documented free tokens per month** (up to ~2.15B in your first month with signup credits) across 43 free-tier pools — plus a long tail of permanently-free, no-cap providers — and RTK + Caveman compression (15–95% token savings) stretches that further._ +**Honest headline:** _OmniRoute aggregates **~1.51B documented free tokens per month** (up to ~2.13B in your first month with signup credits) across 42 free-tier pools — plus a long tail of permanently-free, no-cap providers — and RTK + Caveman compression (15–95% token savings) stretches that further._ -> **Why this dropped from the previous ~1.94B.** The 2026-06-17 refresh is an honesty correction, not a loss: `gemini` is now pool-deduped (was inflated by counting each Flash variant separately, 462M → 60M), `cloudflare-ai` corrected to its real 10k-Neurons/day (122M → 30M), `doubao` reclassified as a one-time signup credit (not recurring), and shut-down tiers removed (`github-models` closed to new signups, `chutes`/`phind`/`kluster` discontinued). Partly offset by `llm7` (correct 5M/day → 150M) and new free providers (Kilo, OpenCode Zen, Z.AI GLM-Flash). +> **Why this dropped from the previous ~1.94B.** The 2026-06-17 refresh is an honesty correction, not a loss: `gemini` is now pool-deduped (was inflated by counting each Flash variant separately, 462M → 60M), `cloudflare-ai` corrected to its real 10k-Neurons/day (122M → 30M), `doubao` reclassified as a one-time signup credit (not recurring), and shut-down tiers removed (`chutes`/`phind`/`kluster` discontinued). Partly offset by `llm7` (correct 5M/day → 150M) and new free providers (Kilo, OpenCode Zen, Z.AI GLM-Flash). > > **Further corrected to ~1.37B in v3.8.42:** `longcat` was reclassified from a 150M/mo recurring grant to a one-time 10M signup credit after its free preview ended. Same honesty rule — no provider was dropped by mistake. > -> **Updated to ~1.53B in v3.8.49:** the pool count grew from 39 to 43 after mapping free tiers that were documented upstream but missing from the catalog (`requesty`, `ovhcloud`, `agnes`, `glm`) plus new providers `navy` and `aihorde` (#7840). This is the live, CI-gated number (`check:docs-counts` fails the build if this drifts from `computeFreeModelTotals()`). +> **Updated to ~1.51B after removing a retired provider:** the pool count is now 42 after mapping free tiers that were documented upstream but missing from the catalog (`requesty`, `ovhcloud`, `agnes`, `glm`) plus new providers `navy` and `aihorde` (#7840). This is the live, CI-gated number (`check:docs-counts` fails the build if this drifts from `computeFreeModelTotals()`). Biggest **documented** contributors: `mistral` 1.00B, `llm7` 150M, `groq` 117M, `gemini` 60M, `cerebras` 30M, `cloudflare-ai` 30M, `sambanova` 30M. (`longcat` is excluded — its 10M LongCat-2.0 grant is a one-time, KYC-gated signup credit, not a recurring monthly budget.) @@ -40,7 +40,6 @@ Biggest **documented** contributors: `mistral` 1.00B, `llm7` 150M, `groq` 117M, A 50-agent web-research pass (official docs + last-7-days news, adversarially verified) refreshed the whole catalog. Highlights: - **Removed / no free tier (2026):** `chutes` (free tier ended 2026-03), `phind` (company shut down 2026-01), `kluster` (sunset 2026-06-09 → MITO), `gitlawb` + `gitlawb-gmi` (MiMo free revoked 2026-05-24, Nemotron promo ended 2026-06 — re-verified 2026-06-18), `aimlapi` (free tier paused — re-verified 2026-06-18), `yi` (Yi-Light retired, pay-as-you-go — re-verified 2026-06-18), `theoldllm` / `featherless-ai` (no current free tier). `iflytek` / `sparkdesk` stay listed but carry a ToS-caution note (Spark Lite is free; the ToS restricts proxy/relay use). -- **GitHub Models** — closed to **new** customers on 2026-06-16; existing accounts keep API/playground access, so it stays in the catalog with a note (not removed). - **Gemini** — `2.0 Flash` / `2.0 Flash-Lite` shut down 2026-06-01 and `2.5 Pro` left the free tier (2026-04); free tier is now **Flash-family only** (2.5/3/3.1/3.5 Flash + Gemma). The catalog now **pools** the Flash family (was inflated by counting each variant separately: 462M → 60M). - **Corrected numbers:** `cloudflare-ai` 122M → **30M** (real 10k-Neurons/day), `doubao` reclassified as a one-time signup credit (not recurring), `llm7` 4M → **150M** (documented 5M tokens/day), `together` "-Free" endpoints discontinued → only the **$25** signup credit remains, `longcat` Preview ended + Flash models retired → **LongCat-2.0** only, reclassified as a one-time **10M**-token signup credit (KYC-gated, not recurring). - **New free providers discovered:** ⭐ **Kilo Code** (`kilo-gateway` — rotating "Auto Free" set: NVIDIA Nemotron 3 family, StepFun, Poolside, Nex-N2-Pro), ⭐ **OpenCode Zen** (`opencode-zen` — 6 rotating free coding models), ⭐ **Z.AI / Zhipu** (`glm-cn` — GLM-4-Flash / 4.5-Flash / 4.7-Flash permanently free + 20M signup bonus), and `arcee-ai` Trinity Large Preview. @@ -62,6 +61,8 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve ## ToS attention table +> **ToS flag is advisory, not a routing gate.** Providers marked `tos` are still included in routing and combo/fallback by default; the flag only surfaces on `/dashboard/free-tiers` and `/api/free-tier/summary`. The `excludeTosAvoid` query parameter affects the summary view only, not global routing. The verdict lives in `open-sse/config/freeTierCatalog.ts` (informational, not read by routing engines). + > A quick read on each provider's terms for a self-hosted, single-user personal proxy. `caution` = a personal-use or proxy clause worth checking; `ambiguous` = unclear; `ok` = explicitly permitted. Informational, not legal advice — you decide. ### ⚠️ Caution — personal-use / proxy clauses worth checking (19) @@ -118,7 +119,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve | `exa-search` | caution | No explicit "no proxy" or "evaluation only" clauses found; Exa actively offers a reseller partner program allowing API … | | `firecrawl` | caution | Cloud API ToS has no explicit personal-proxy prohibition found, but the open-source self-hosted version is AGPL-3.0 (re… | | `gemini` | caution | ToS explicitly states the free tier is for "developers building with Google AI models for professional or business purp… | -| `github-models` | caution | GitHub's Acceptable Use Policy prohibits reselling/proxying the service; GitHub Models ToS delegates to each model's ho… | | `groq` | caution | Services Agreement §6.3 prohibits reselling, sublicensing, or distributing API access; §3.2 bars reselling/leasing acco… | | `hackclub` | caution | Service is explicitly scoped to Hack Club teen members building projects/learning; no public ToS found explicitly permi… | | `huggingchat` | caution | Hugging Face ToS does not explicitly ban personal self-hosted proxies, but supplemental terms (referenced but not fully… | @@ -183,7 +183,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve | `cloudflare-ai` | recurring | ~30M | — | caution | 6 | | `api-airforce` | recurring | ~24M | — | caution | 7 | | `ollama-cloud` | recurring | ~20M | — | ambiguous | 8 | -| `github-models` | recurring | ~18M | — | caution | 14 | | `groq` | recurring | ~15M | — | caution | 5 | | `bluesminds` | recurring | ~7M | — | ambiguous | 22 | | `sambanova` | recurring | ~6M | — | caution | 5 | @@ -280,7 +279,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve - **`freemodel-dev`** — Our shipped freeNote is "(none)" — this was likely a placeholder meaning the provider was not yet cataloged. In reality the provider does have a $300 one-time trial credit offer. However, this is a o… - **`friendliai`** — The shipped freeNote ("Free tier for serverless inference") is partially accurate but misleading. There is free access via Tier 0 and free-designated models, but the rate limits are undefined and ada… - **`gemini`** — The shipped freeNote says "1,500 req/day for Gemini 2.5 Flash" — this was accurate before December 2025. Google cut free-tier limits by 50-80% in December 2025, reducing Gemini 2.5 Flash from 1,500 R… -- **`github-models`** — Catalog note "Free GPT-5, o-series, DeepSeek-R1, Llama 4, Grok 3" is directionally correct about model availability but omits the daily rate limits (50 RPD for high-tier models, 150 RPD for low-tier)… - **`gitlawb`** — The shipped freeNote "Free tier available" is effectively stale. The original free MiMo access was removed in May 2026; the only remaining "free" option is a temporary promotional model (Nemotron 3 U… - **`gitlawb-gmi`** — Partially still accurate — free tier exists but is now narrowed to a single model (Nemotron 3 Ultra) after MiMo free access was revoked in late May 2026. The shipped note "Free tier available" unders… - **`groq`** — The shipped freeNote "30 RPM / 14.4K RPD" is accurate only for llama-3.1-8b-instant. Most other models (including llama-3.3-70b-versatile) have a much lower 1K RPD cap. The note omits model-specific … diff --git a/docs/security/AGENTROUTER_WAF.md b/docs/security/AGENTROUTER_WAF.md index 8aaaa941c6..0311da0a07 100644 --- a/docs/security/AGENTROUTER_WAF.md +++ b/docs/security/AGENTROUTER_WAF.md @@ -1,5 +1,5 @@ --- -title: "AgentRouter WAF" +title: "agentrouter.org WAF (Web Application Firewall)" version: 3.8.50 lastUpdated: 2026-08-03 --- @@ -94,4 +94,4 @@ The current filter is overly aggressive — it blocks "Lorem ipsum" in `tool_result` blocks even though the operator clearly did not intend to inject a prompt. Operators who want this fixed at the source should contact `agentrouter.org` to report the false positives. The blocklist -above is the empirical result of probing the upstream as of 2026-08-03. \ No newline at end of file +above is the empirical result of probing the upstream as of 2026-08-03. diff --git a/docs/security/GUARDRAILS.md b/docs/security/GUARDRAILS.md index 8b900359db..a6e5511935 100644 --- a/docs/security/GUARDRAILS.md +++ b/docs/security/GUARDRAILS.md @@ -1,13 +1,13 @@ --- title: "Guardrails" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.50 +lastUpdated: 2026-08-08 --- # Guardrails > **Source of truth:** `src/lib/guardrails/` -> **Last updated:** 2026-06-28 — v3.8.40 (injection-guard coverage + 16 KB scan bound + red-team) +> **Last updated:** 2026-08-08 — v3.8.50 (Modality Bridge PR-3: Audio Bridge runtime and functional Audio settings tab) Guardrails enforce safety, policy, and content transformations at the boundary between OmniRoute and upstream providers. Each guardrail can inspect (and @@ -20,43 +20,137 @@ request. Blocking is an explicit decision (`block: true`), never an accident. ## Built-in Guardrails -The registry auto-loads four guardrails in priority order on import +The registry auto-loads five guardrails in priority order on import (see `registry.ts` → `registerDefaultGuardrails()`): | Priority | Name | Stage(s) | File | | -------- | ------------------- | -------------- | --------------------- | | `5` | `vision-bridge` | `preCall` | `visionBridge.ts` | +| `6` | `audio-bridge` | `preCall` | `audioBridge.ts` | | `10` | `pii-masker` | `pre` + `post` | `piiMasker.ts` | | `20` | `prompt-injection` | `preCall` | `promptInjection.ts` | | `95` | `credential-masker` | `pre` + `post` | `credentialMasker.ts` | Lower priority numbers run **first**. -### Vision Bridge (`visionBridge.ts`) +### Vision Bridge (`visionBridge.ts`) — Modality Bridge PR-1 -Intercepts image-bearing requests aimed at **non-vision models** and replaces -the image parts with text descriptions produced by a configurable vision model -before the upstream call. This lets text-only providers transparently handle +Intercepts image-bearing requests aimed at **non-vision models** and either +reroutes the whole request to a vision-capable model or replaces the image +parts with text descriptions produced by a configurable vision model before +the upstream call. This lets text-only providers transparently handle multimodal payloads. Flow: 1. Skip if the target model already supports vision (unless it appears in the forced-bridge list `isVisionBridgeForcedModel`). -2. Extract image parts via `extractImageParts(messages)`. Skip if none. - `extractImageParts` recognizes all three image shapes: OpenAI `image_url`, - Anthropic base64 `source.type:"base64"`, and Anthropic URL - `source.type:"url"` — so Claude-Code-compatible clients (e.g. Zoo Code) - sending `{ type: "image", source: { type: "url", url } }` are described - instead of silently dropped. -3. Load runtime config from `getSettings()` (`visionBridgeEnabled`, - `visionBridgeModel`, `visionBridgePrompt`, `visionBridgeTimeout`, - `visionBridgeMaxImages`). -4. Cap images at `maxImages`, call the vision model **in parallel** - (`Promise.allSettled`), and inject `[Image N]: ` text parts - in their place — failed images become `[Image N]: (unavailable)`. -5. Return `modifiedPayload` + meta (`imagesProcessed`, `processingTimeMs`, - `visionModel`). +2. Extract image parts via `extractImageParts(messages)` + (`visionBridgeHelpers.ts`), which delegates to the **unified media + detector** `detectMediaParts()` in `open-sse/utils/mediaParts.ts` — the + single source of truth shared with the combo compatibility filter. + Extraction is allowlisted to top-level parts of the shapes + `replaceImageParts` can splice back (the extract↔replace contract): OpenAI + `image_url`, Anthropic base64 `source.type:"base64"`, Anthropic URL + `source.type:"url"`, and Responses API `input_image`. Nested hits and + indicator-only shapes are combo-filter material and are never extracted. + Skip if none found. +3. Resolve runtime config via `resolveVisionBridgeRuntimeSettings()` + (`src/shared/constants/modalityBridgeDefaults.ts`): new `modalityBridge*` + settings keys win; legacy `visionBridge*` keys remain a **one-cycle + fallback** (rollback window). Skip before any media traversal when the + bridge is disabled. +4. Mode selector (`modalityBridgeVisionMode`, see table below) decides + reroute vs describe. Reroute returns `modifiedPayload` with only `model` + swapped, plus meta `{ rerouted, fromModel, toModel, imagesKept }`. +5. Describe path: cap images at `maxImages`, compose the task-aware prompt, + consult the describe cache, call the vision model **in parallel** + (`Promise.allSettled`), and inject `[Image N]: ` text parts in + their place. A failed describe yields `null` and the original image part is + **preserved** (#4012) — except on the combo describe path when every + describe failed, where a confirmed non-vision upstream gets an + `(unavailable — no vision-capable provider connected)` stub instead (#8430). +6. Return `modifiedPayload` + meta (`imagesProcessed`, `descriptions`, + `processingTimeMs`, `visionModel`). + +#### Mode selector (`modalityBridgeVisionMode`) + +| Mode | Default | Behavior | +| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto` | ✔ | Legacy heuristic, untouched (#6640/#7204): non-combo/`auto/` models reroute to the best vision model unless the original model already has usable credentials (then describe); combo targets always describe. | +| `describe` | | Always describe — the reroute block is skipped entirely; the user's chosen model always answers. | +| `reroute` | | Force reroute: the keep-credentialed-model guard is bypassed. The reroute-**target** credential guard still applies — when no usable vision target exists, the request falls through to describe so raw images never reach a text-only backend (#8430). | + +Forced modes short-circuit **before** the auto heuristic runs; `auto` behavior +is byte-identical to the pre-PR-1 guardrail. + +#### Task-aware describe prompt (`modalityBridgeVisionTaskAware`) + +Default **true**. `composeVisionPrompt()` (`visionBridgeHelpers.ts`) appends +the text of the **last user message** (truncated to 500 chars) to the base +describe prompt, steering the description toward what the user actually asked +(codex-vision-proxy pattern) and asking the vision model to transcribe visible +text. With the flag off — or no user text — the base prompt is used unchanged. + +#### Describe cache (`modalityBridge/bridgeCache.ts`) + +In-memory LRU + TTL cache for describe outputs, shared process-wide. +Key = `sha256(imageRef + composedPrompt + configuredBridgeModel)` with +length-prefix framing (no field-boundary collisions). The model component is +the **configured** bridge model, not the model that actually answered — +`callVisionModel` may fall back internally, and keying per attempt would +fragment the cache. Failed describes are never cached. Settings: + +| Key | Default | Range | +| ------------------------------- | ------- | ------- | +| `modalityBridgeCacheEnabled` | `true` | — | +| `modalityBridgeCacheTtlMinutes` | `60` | 1–1440 | +| `modalityBridgeCacheMaxEntries` | `200` | 10–5000 | + +#### Settings schema + migration + +The new `modalityBridge*` keys are Zod-validated in `updateSettingsSchema` +(`src/shared/validation/settingsSchemas.ts`): `modalityBridgeVisionEnabled`, +`modalityBridgeVisionMode`, `modalityBridgeVisionModel`, +`modalityBridgeVisionTaskAware`, `modalityBridgeVisionPrompt`, +`modalityBridgeVisionTimeout`, `modalityBridgeVisionMaxImages`, the +`modalityBridgeCache*` trio, and the `modalityBridgeAudio*` group used by the +Audio Bridge. Migration `141_modality_bridge_settings.sql` copies existing legacy +`visionBridge*` values to the matching new keys (idempotent, never overwrites +an operator-set `modalityBridge*` value); the legacy keys stay accepted as a +read fallback for one release cycle. + +#### Transparency header + stats + +Describe-transformed responses carry +`x-omniroute-modality-bridge: image->text;model=;parts=` +(built by `buildModalityBridgeHeader()` in `modalityBridge/bridgeStats.ts`, +stamped by `withModalityBridgeHeader()` in `src/sse/handlers/chatHelpers.ts`). +Rerouted requests get **no** header — the payload was untouched and the model +swap is already visible in the response body's `model` field. + +`GET /api/modality-bridge/stats` (management auth, same tier as +`GET /api/settings`) returns the in-memory per-modality counters +`{ bridged, cacheHits, failures, lastUsedAt }` for `vision` and `audio`. +Counters reset on process restart by design +(telemetry, not accounting). + +#### Dashboard configuration + +The dedicated dashboard page is +`/dashboard/settings/modality-bridge`. Its URL-addressable `Vision`, `Audio`, +and `Video` tabs preserve query parameters while switching the `tab` value. +The Vision tab exposes enablement, mode, model selection (including the automatic +default), task-aware prompting, advanced timeout/image/cache limits, runtime +counters, and a guarded sample request. The Audio tab is also live: it exposes +enablement, an STT-only model picker with Auto, timeout/max-clip limits, audio +counters, and an `input_audio` sample test. Video remains the explicit placeholder +tracked in issue `#9760`. + +The former Vision Bridge card under AI settings is a compatibility link to the +new page; it no longer owns a second copy of the form. Media Providers also +links Image-to-Text and Speech-to-Text workflows to the corresponding Modality +Bridge tabs without removing the existing Speech-to-Text playground. **Self-loop admission bypass:** when the describe call routes through OmniRoute's own `/v1` self-loop (non-standard provider model), the sub-request sends @@ -67,10 +161,70 @@ operator-configured `OMNIROUTE_API_KEY` / `ROUTER_API_KEY` env key (#1350) so is only honored for those exact credentials, so external clients cannot use the header to skip admission. -Defaults live in `src/shared/constants/visionBridgeDefaults.ts`. The guardrail -exposes a `deps` constructor option so tests can inject fake `getSettings` and +Legacy defaults live in `src/shared/constants/visionBridgeDefaults.ts`; the +new mode/task-aware/cache defaults and the settings resolver live in +`src/shared/constants/modalityBridgeDefaults.ts`. The guardrail exposes a +`deps` constructor option so tests can inject fake `getSettings` and `callVisionModel` implementations. +### Audio Bridge (`audioBridge.ts`) — Modality Bridge PR-3 + +Intercepts audio-bearing chat requests before they reach a target that is not +known to accept audio input. It never reroutes the chat request: audio parts are +transcribed through the existing OpenAI-compatible multipart endpoint and the +chosen chat model continues with text transcripts. + +Flow: + +1. Resolve `supportsAudio` through `getResolvedModelCapabilities()`. Explicit + provider-registry metadata wins, then static model metadata, then synced + `modalities_input`. A declared input list without `audio` is `false`; no + capability evidence remains `null`. Both `false` and `null` activate the + conservative bridge, while `true` bypasses it. +2. Resolve `modalityBridgeAudio*` settings and extract spliceable top-level + audio parts from every message through the shared `detectMediaParts()` + detector. Supported wire shapes are OpenAI `input_audio`, `audio_url`, and + `source.media_type: "audio/*"`. Nested audio is detected for routing but not + removed by the splice path. Work is capped by `modalityBridgeAudioMaxClips`; + later parts stay untouched. +3. Honor a configured `provider/model`, or let `selectAudioBridgeModel()` walk + `AUDIO_TRANSCRIPTION_PROVIDERS` in stable catalog order and select the first + model with a usable active provider credential. +4. `callAudioTranscription()` converts base64/data-URI audio to a multipart + `file`, or downloads a remote `audio_url` through the public-only outbound + guard with DNS pinning and a 25 MB bound. It then POSTs the file and selected + model to the local `/v1/audio/transcriptions` self-loop, authenticated with + `resolveSelfLoopBearer()`. The existing transcription route performs normal + credential lookup, cooldown/rate-limit handling, and provider dispatch. +5. Successful calls replace their parts with `[Audio N]: `. Calls + run with `Promise.allSettled`: an individual failure preserves that original + audio part (#4012 contract). If every call fails and the target is proven + `supportsAudio === false`, the parts become + `[Audio N]: (unavailable — no STT provider connected)` (#8430 contract). For + an unknown target (`null`), an all-failure result stays untouched. A proven + text-only target with no usable STT credential receives the same explicit + stub without issuing a network call. + +Successful transcripts use the process-wide Modality Bridge LRU/TTL cache. The +key combines the audio reference, the stable `audio-transcription` operation +label, and selected STT model; failures are never cached. Audio attempts update +the shared `bridged`, `cacheHits`, `failures`, and `lastUsedAt` counters. +Transformed responses carry +`x-omniroute-modality-bridge: audio->text;model=;parts=`; untouched +requests do not receive an Audio Bridge segment. + +Runtime settings are DB-backed and Zod-validated: + +| Key | Default | Range | +| ----------------------------- | ------- | -------------- | +| `modalityBridgeAudioEnabled` | `true` | — | +| `modalityBridgeAudioModel` | `""` | Auto or STT ID | +| `modalityBridgeAudioTimeout` | `60000` | 1000–300000 | +| `modalityBridgeAudioMaxClips` | `3` | 1–10 | + +The shared cache remains controlled by `modalityBridgeCacheEnabled`, +`modalityBridgeCacheTtlMinutes`, and `modalityBridgeCacheMaxEntries`. + ### PII Masker (`piiMasker.ts`) Runs on **both** stages. @@ -276,10 +430,22 @@ Environment variables read by the built-in guardrails: | `PII_REDACTION_ENABLED` | `pii-masker` | When `true`, request PII is redacted (independent of injection mode). | | `PII_RESPONSE_SANITIZATION` / `_MODE` | `pii-masker` (downstream) | Controls response-side masker behavior. | -The Vision Bridge reads runtime config from the DB-backed settings store -(`getSettings()`), not env vars: `visionBridgeEnabled`, `visionBridgeModel`, -`visionBridgePrompt`, `visionBridgeTimeout`, `visionBridgeMaxImages`. Defaults -live in `src/shared/constants/visionBridgeDefaults.ts`. +The Modality Bridge guardrails read runtime config from the DB-backed settings +store (`getSettings()`), not env vars. Vision's primary keys are +`modalityBridgeVisionEnabled`, `modalityBridgeVisionMode`, +`modalityBridgeVisionModel`, `modalityBridgeVisionTaskAware`, +`modalityBridgeVisionPrompt`, `modalityBridgeVisionTimeout`, +`modalityBridgeVisionMaxImages`, `modalityBridgeCacheEnabled`, +`modalityBridgeCacheTtlMinutes`, and `modalityBridgeCacheMaxEntries`. The legacy +`visionBridge*` keys are accepted only as the documented one-cycle read +fallback; dashboard writes use the primary keys. Defaults and the fallback +resolver live in `src/shared/constants/modalityBridgeDefaults.ts`, with legacy +constants retained in `src/shared/constants/visionBridgeDefaults.ts`. + +Audio uses `modalityBridgeAudioEnabled`, `modalityBridgeAudioModel`, +`modalityBridgeAudioTimeout`, and `modalityBridgeAudioMaxClips`, plus the shared +`modalityBridgeCache*` settings. Audio has no legacy-key fallback because these +keys were introduced with the Modality Bridge schema. ## Custom Guardrails @@ -318,9 +484,11 @@ Steps: Use `resetGuardrailsForTests()` between tests to start from a known state. Pass `{ registerDefaults: false }` to start with an empty registry and -register only the guardrails under test. The Vision Bridge guardrail accepts -dependency injection (`deps.getSettings`, `deps.callVisionModel`) so tests can -exercise the full flow without DB or network access. +register only the guardrails under test. Vision Bridge accepts dependency +injection (`deps.getSettings`, `deps.callVisionModel`); Audio Bridge exposes the +equivalent seams for settings, capabilities, STT model selection, credential +checks, and transcription. Tests can therefore exercise both flows without DB +or network access. ## See Also @@ -329,6 +497,7 @@ exercise the full flow without DB or network access. prompt-injection and PII masking - `src/shared/constants/visionBridgeDefaults.ts` — Vision Bridge defaults and forced-bridge model list +- `src/shared/constants/modalityBridgeDefaults.ts` — shared Vision/Audio runtime defaults - `docs/architecture/RESILIENCE_GUIDE.md` — orthogonal layer (circuit breaker, cooldowns) - `docs/reference/ENVIRONMENT.md` — full env var reference diff --git a/docs/security/ROUTE_GUARD_TIERS.md b/docs/security/ROUTE_GUARD_TIERS.md index 8933e3b419..5a533b88e5 100644 --- a/docs/security/ROUTE_GUARD_TIERS.md +++ b/docs/security/ROUTE_GUARD_TIERS.md @@ -39,22 +39,24 @@ spawn-capable route: a leaked token over a tunnel still can't reach the spawn. `check-route-guard-membership` gate enumerates every `route.ts` under the spawn-capable prefixes and fails CI if any is not classified local-only. -| Prefix / pattern | Why it's local-only | Manage-scope bypassable? | -| ----------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------- | -| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers | **Yes** (only one) | -| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No — spawn-capable | -| `/api/services/` | Embedded services (9router/CLIProxy) — `npm install` + spawn | No — spawn-capable | -| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs | No | -| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default | Operator opt-in: manage/admin | -| `/api/tools/agent-bridge/` | AgentBridge — spawns MITM server + DNS edits | No — spawn-capable | -| `/api/tools/traffic-inspector/` | Traffic Inspector — http-proxy listener + system proxy | No — spawn-capable | -| `/api/plugins/`, `/api/plugins` | Plugins — load/execute via `worker_threads` + `child_process` | No — spawn-capable | -| `/api/system/version` | Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns `git checkout` + `npm install` | No | -| `/api/db-backups/exportAll` | Spawns `tar` for the export archive | No | -| `/api/local/` | 1-click local launchers (Redis today) — spawns podman/docker | No — spawn-capable | -| `/api/headroom/start`, `/stop` | Headroom proxy lifecycle — spawns python CLI / signals PID | No — spawn-capable | -| `/api/oauth/cursor/auto-import` | `execFile("which", ["cursor"])` before importing creds | No | -| `/api/providers/{id}/login` (regex) | Launches a headful Playwright Chromium for web-cookie login | No | +| Prefix / pattern | Why it's local-only | Manage-scope bypassable? | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers | **Yes** (only one) | +| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No — spawn-capable | +| `/api/services/` | Embedded services (9router/CLIProxy) — `npm install` + spawn | No — spawn-capable | +| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs | No | +| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default | Operator opt-in: manage/admin | +| `/api/tools/agent-bridge/` | AgentBridge — spawns MITM server + DNS edits | No — spawn-capable | +| `/api/tools/traffic-inspector/` | Traffic Inspector — http-proxy listener + system proxy | No — spawn-capable | +| `/api/plugins/`, `/api/plugins` | Plugins — load/execute via `worker_threads` + `child_process` | No — spawn-capable | +| `/api/system/version` | Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns `git checkout` + `npm install` | No | +| `/api/db-backups/exportAll` | Spawns `tar` for the export archive | No | +| `/api/local/` | 1-click local launchers (Redis today) — spawns podman/docker | No — spawn-capable | +| `/api/headroom/start`, `/stop` | Headroom proxy lifecycle — spawns python CLI / signals PID | No — spawn-capable | +| `/api/oauth/cursor/auto-import` | `execFile("which", ["cursor"])` before importing creds | No | +| `/api/providers/{id}/login` (regex) | Launches a headful Playwright Chromium for web-cookie login | No | +| `/api/providers/{id}/refresh-cursor` (regex) | Manual Cursor session renewal — nudges `cursor-agent` (`--list-models`/`status` via `src/lib/cursor/renewal.ts`); the rest of `/api/providers/`, including the generic `/refresh`, intentionally stays remote-reachable | No — spawn-capable | +| `/api/providers/cursor/agent-availability` | Dashboard install-nudge check — spawns `cursor-agent status --format json` via `checkCursorAgentAvailability()`/`getCachedCursorAgentAvailability()` (`src/lib/cursor/renewal.ts`); credential-free response (`{cursorAgentAvailable: boolean}` only) | No — spawn-capable | **Response on violation:** `403 LOCAL_ONLY` @@ -84,15 +86,15 @@ ever be added), and it is deliberately excluded from carve-out exactly as before; `mcp:connect` is a lower-privilege alternative for remote MCP-only callers who should not need broad management access. -| Request | Path | Result | -| ------------------------------------------------- | -------------------------- | ------------------- | -| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY | -| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow | -| Non-loopback, Bearer with `mcp:connect` scope | `/api/mcp/*` | Allow | -| Non-loopback, Bearer without `manage`/`mcp:connect` | `/api/mcp/*` | 403 LOCAL_ONLY | -| Non-loopback, Bearer with `mcp:connect` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY | -| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY | -| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) | +| Request | Path | Result | +| --------------------------------------------------- | -------------------------- | ------------------- | +| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY | +| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow | +| Non-loopback, Bearer with `mcp:connect` scope | `/api/mcp/*` | Allow | +| Non-loopback, Bearer without `manage`/`mcp:connect` | `/api/mcp/*` | 403 LOCAL_ONLY | +| Non-loopback, Bearer with `mcp:connect` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY | +| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY | +| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) | #### Operator guidance & auditing @@ -110,7 +112,14 @@ operator responsibilities remain: only with a `manage`-scoped API key. The `SPAWN_CAPABLE_PREFIXES` can never be added to the bypass list — the zod schema rejects them and `isLocalOnlyBypassableByManageScope` denies them at runtime (defence-in-depth), - which is what the dashboard means by "cannot be made bypassable". + which is what the dashboard means by "cannot be made bypassable". Dynamic-segment + and static-path spawn-capable routes under `/api/providers/` (e.g. `/login`, + `/refresh-cursor`) are covered by the regex-based `SPAWN_CAPABLE_PATTERNS` / + `SPAWN_CAPABLE_PATTERN_ANCESTORS` companion in + `src/shared/constants/spawnCapablePrefixes.ts`, not by the flat + `SPAWN_CAPABLE_PREFIXES` array — the flat array would have to cover the + entire `/api/providers/` prefix to catch them, over-broadening a route tree + remote dashboards legitimately use for provider CRUD. **Auditing access** — to verify nothing off-host is reaching these routes: 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/lib/loginHeaderCapture.js b/electron/lib/loginHeaderCapture.js new file mode 100644 index 0000000000..11d90de580 --- /dev/null +++ b/electron/lib/loginHeaderCapture.js @@ -0,0 +1,17 @@ +"use strict"; + +function captureConfiguredHeaders(tokenSources, requestHeaders, credentials) { + const normalizedHeaders = Object.fromEntries( + Object.entries(requestHeaders || {}).map(([name, value]) => [name.toLowerCase(), value]) + ); + + for (const source of tokenSources || []) { + if (source.type !== "header" || credentials[source.name]) continue; + const value = normalizedHeaders[source.name.toLowerCase()]; + if (typeof value === "string" && value.trim()) { + credentials[source.name] = value.trim(); + } + } +} + +module.exports = { captureConfiguredHeaders }; diff --git a/electron/loginManager.js b/electron/loginManager.js index 0e0bb2d2f1..910eda2614 100644 --- a/electron/loginManager.js +++ b/electron/loginManager.js @@ -12,6 +12,7 @@ const { BrowserWindow, session } = require("electron"); const { EventEmitter } = require("events"); const path = require("path"); +const { captureConfiguredHeaders } = require("./lib/loginHeaderCapture"); // In production, the tokenExtractionConfig is bundled under open-sse/services/. // We resolve relative to the Electron resources path. @@ -42,6 +43,7 @@ class LoginManager extends EventEmitter { this.isCompleted = false; this.pollIntervalId = null; this.loginSession = null; + this.headerCredentials = {}; } /** @@ -124,6 +126,13 @@ class LoginManager extends EventEmitter { }); const winSession = this.window.webContents.session; + const headerSources = config.tokenSources.filter((source) => source.type === "header"); + if (headerSources.length > 0) { + winSession.webRequest.onBeforeSendHeaders((details, callback) => { + captureConfiguredHeaders(headerSources, details.requestHeaders, this.headerCredentials); + callback({ requestHeaders: details.requestHeaders }); + }); + } // Track navigation for success URL detection let navigatedToLogin = false; @@ -235,14 +244,15 @@ class LoginManager extends EventEmitter { if (this.isCompleted) return; const tokenSources = config.tokenSources; - const credentials = {}; + const credentials = { ...this.headerCredentials }; // Collect all cookie-based sources const cookieSources = tokenSources.filter((s) => s.type === "cookie"); for (const source of cookieSources) { const domain = source.domain || undefined; const matched = cookies.find( - (c) => c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, ""))) + (c) => + c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, ""))) ); if (matched) { credentials[source.name] = matched.value; @@ -253,10 +263,12 @@ class LoginManager extends EventEmitter { const storageSources = tokenSources.filter( (s) => s.type === "localStorage" || s.type === "sessionStorage" ); + const headerSources = tokenSources.filter((s) => s.type === "header"); if (storageSources.length > 0 && this.window && !this.window.isDestroyed()) { // Execute JS to extract all localStorage/sessionStorage tokens - const storageType = storageSources[0].type === "localStorage" ? "localStorage" : "sessionStorage"; + const storageType = + storageSources[0].type === "localStorage" ? "localStorage" : "sessionStorage"; const keys = storageSources.map((s) => s.key); const js = `(() => { const res = {}; @@ -272,13 +284,37 @@ class LoginManager extends EventEmitter { if (values && typeof values === "object") { Object.assign(credentials, values); } - this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); + this._checkCredentials( + providerId, + credentials, + cookieSources, + storageSources, + headerSources, + poll, + pollInterval + ); }) .catch(() => { - this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); + this._checkCredentials( + providerId, + credentials, + cookieSources, + storageSources, + headerSources, + poll, + pollInterval + ); }); } else { - this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); + this._checkCredentials( + providerId, + credentials, + cookieSources, + storageSources, + headerSources, + poll, + pollInterval + ); } }) .catch(() => { @@ -295,23 +331,35 @@ class LoginManager extends EventEmitter { /** * Check if we have all required credentials, otherwise continue polling */ - _checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval) { + _checkCredentials( + providerId, + credentials, + cookieSources, + storageSources, + headerSources, + poll, + pollInterval + ) { if (this.isCompleted) return; // Collect the required source names/keys const requiredKeys = [ ...cookieSources.map((s) => s.name), ...storageSources.map((s) => s.key), + ...headerSources.map((s) => s.name), ]; const foundKeys = Object.keys(credentials); const allFound = requiredKeys.every((k) => foundKeys.includes(k)); if (allFound && foundKeys.length > 0) { // Success — all credentials extracted - this._completeLogin(providerId, foundKeys.reduce((acc, k) => { - acc[k] = credentials[k]; - return acc; - }, {})); + this._completeLogin( + providerId, + foundKeys.reduce((acc, k) => { + acc[k] = credentials[k]; + return acc; + }, {}) + ); } else if (!this.isCompleted) { // Continue polling using the configured interval this.pollIntervalId = setTimeout(poll, pollInterval); @@ -373,6 +421,7 @@ class LoginManager extends EventEmitter { } this.window = null; this.loginSession = null; + this.headerCredentials = {}; } /** diff --git a/electron/package-lock.json b/electron/package-lock.json index fc70141ce1..4fdb5b2374 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.49", + "version": "3.8.50", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.49", + "version": "3.8.50", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" @@ -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": { @@ -320,9 +320,9 @@ } }, "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "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, @@ -874,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": { @@ -1308,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": { @@ -1696,9 +1696,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -1748,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": { @@ -1913,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": { @@ -2234,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", @@ -3225,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": { @@ -3372,9 +3372,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { diff --git a/electron/package.json b/electron/package.json index 81f07da02e..73fb51ec40 100644 --- a/electron/package.json +++ b/electron/package.json @@ -37,7 +37,7 @@ "plist": "^4.0.0", "form-data": "^4.0.6", "js-yaml": "^4.2.0", - "undici": "^7.28.0" + "undici": "^7.29.0" }, "build": { "appId": "online.omniroute.desktop", diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md new file mode 100644 index 0000000000..1122865d66 --- /dev/null +++ b/examples/quickstart/README.md @@ -0,0 +1,39 @@ +# Quickstart Code Examples + +Simple, copy-paste scripts to get your first response from a local OmniRoute server in under a minute. + +## Prerequisites + +Start OmniRoute locally first: + +```bash +npx omniroute +# Server is now live at http://localhost:20128/v1 +``` + +## Examples + +| File | Language | Dependency | +|------|----------|------------| +| [`python_requests.py`](python_requests.py) | Python | `pip install requests` | +| [`nodejs_axios.js`](nodejs_axios.js) | Node.js | `npm install axios` | +| [`curl_terminal.sh`](curl_terminal.sh) | Bash / cURL | `curl` (pre-installed on Mac/Linux) | +| [`php_curl.php`](php_curl.php) | PHP | PHP 7.4+ with cURL | + +All examples use **`felo/auto`** — a keyless, zero-configuration model that works immediately with no provider sign-up required. + +## Key Settings (same in all examples) + +| Setting | Value | Why | +|---------|-------|-----| +| `model` | `felo/auto` | Keyless provider, works out of the box | +| `stream` | `false` | Returns standard JSON instead of SSE stream | +| `Authorization` | `Bearer dummy-key` | Any non-empty string satisfies the header requirement | + +## What to Change + +To use a different model, replace `felo/auto` with any model ID from: + +```bash +curl http://localhost:20128/v1/models +``` diff --git a/examples/quickstart/curl_terminal.sh b/examples/quickstart/curl_terminal.sh new file mode 100644 index 0000000000..21c3ba8cc8 --- /dev/null +++ b/examples/quickstart/curl_terminal.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# OmniRoute Quickstart — cURL (Bash / Terminal) +# ============================================== +# Run: chmod +x curl_terminal.sh && ./curl_terminal.sh +# Requires: curl (pre-installed on Mac/Linux; use Git Bash on Windows) + +# Your local OmniRoute server — started with: npx omniroute +API_URL="http://localhost:20128/v1/chat/completions" + +curl "$API_URL" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer dummy-key" \ + -d '{ + "model": "felo/auto", + "stream": false, + "messages": [ + { "role": "user", "content": "Hello! What can you do?" } + ] + }' | python3 -c "import sys,json; print(json.load(sys.stdin)['choices'][0]['message']['content'])" diff --git a/examples/quickstart/nodejs_axios.js b/examples/quickstart/nodejs_axios.js new file mode 100644 index 0000000000..9ae104ae76 --- /dev/null +++ b/examples/quickstart/nodejs_axios.js @@ -0,0 +1,31 @@ +/** + * OmniRoute Quickstart — Node.js (axios) + * ======================================= + * Run: npm install axios + * node nodejs_axios.js + */ + +const axios = require('axios'); + +// Your local OmniRoute server — started with: npx omniroute +const API_URL = 'http://localhost:20128/v1/chat/completions'; + +const headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer dummy-key', // Any string works for free/keyless providers +}; + +const data = { + model: 'felo/auto', // Keyless, works out of the box — no sign-up needed + stream: false, + messages: [ + { role: 'user', content: 'Hello! What can you do?' }, + ], +}; + +axios.post(API_URL, data, { headers }) + .then(res => console.log(res.data.choices[0].message.content)) + .catch(err => { + console.error('Error:', err.message); + if (err.response) console.error('Server replied:', err.response.data); + }); diff --git a/examples/quickstart/php_curl.php b/examples/quickstart/php_curl.php new file mode 100644 index 0000000000..0860c35cf7 --- /dev/null +++ b/examples/quickstart/php_curl.php @@ -0,0 +1,42 @@ + "felo/auto", // Keyless, works out of the box — no sign-up needed + "stream" => false, + "messages" => [ + ["role" => "user", "content" => "Hello! What can you do?"], + ], +]; + +$ch = curl_init($api_url); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($data), + CURLOPT_HTTPHEADER => $headers, +]); + +$response = curl_exec($ch); +$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if ($http_code === 200) { + $result = json_decode($response, true); + echo $result['choices'][0]['message']['content'] . PHP_EOL; +} else { + echo "Error HTTP $http_code: $response" . PHP_EOL; +} diff --git a/examples/quickstart/python_requests.py b/examples/quickstart/python_requests.py new file mode 100644 index 0000000000..ab838d8eb7 --- /dev/null +++ b/examples/quickstart/python_requests.py @@ -0,0 +1,28 @@ +""" +OmniRoute Quickstart — Python (requests library) +================================================ +Run: pip install requests (if not already installed) + python python_requests.py +""" + +import requests + +# Your local OmniRoute server — started with: npx omniroute +API_URL = "http://localhost:20128/v1/chat/completions" + +headers = { + "Content-Type": "application/json", + "Authorization": "Bearer dummy-key", # Any string works for free/keyless providers +} + +data = { + "model": "felo/auto", # Keyless, works out of the box — no sign-up needed + "stream": False, + "messages": [ + {"role": "user", "content": "Hello! What can you do?"} + ], +} + +response = requests.post(API_URL, headers=headers, json=data) +response.raise_for_status() +print(response.json()["choices"][0]["message"]["content"]) diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 6ee45169fd..06d32d93af 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -145,6 +145,19 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = { ], }, + soniox: { + id: "soniox", + baseUrl: "https://api.soniox.com/v1/transcriptions", + authType: "apikey", + authHeader: "bearer", + async: true, + format: "soniox", + models: [ + { id: "stt-async-v5", name: "Soniox STT Async v5" }, + { id: "stt-async-v4", name: "Soniox STT Async v4" }, + ], + }, + nvidia: { id: "nvidia", baseUrl: "https://integrate.api.nvidia.com/v1/audio/transcriptions", @@ -234,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" }, + ], + }, }; /** @@ -320,6 +344,15 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { ], }, + soniox: { + id: "soniox", + baseUrl: "https://tts-rt.soniox.com/tts", + authType: "apikey", + authHeader: "bearer", + format: "soniox-tts", + models: [{ id: "tts-rt-v1", name: "Soniox TTS RT v1" }], + }, + elevenlabs: { id: "elevenlabs", baseUrl: "https://api.elevenlabs.io/v1/text-to-speech", @@ -548,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" }, + ], + }, }; /** @@ -581,7 +625,7 @@ export interface ProviderNodeRow { } /** Hosts reachable only from the operator's machine/Docker network. */ -function isLoopbackNodeHost(baseUrl: string): boolean { +export function isLoopbackNodeHost(baseUrl: string): boolean { try { const hostname = new URL(baseUrl).hostname; return ( diff --git a/open-sse/config/codexIdentity.ts b/open-sse/config/codexIdentity.ts index 5f299af02f..7a52f5a0f1 100644 --- a/open-sse/config/codexIdentity.ts +++ b/open-sse/config/codexIdentity.ts @@ -100,6 +100,39 @@ export function isCodexOriginatedHeaders( return getHeader("user-agent").startsWith("codex"); } +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** Require the native Codex thread/turn binding; prompt text and cache keys are not authority. */ +export function hasNativeCodexTurnBinding(body: unknown): boolean { + const metadata = asRecord(asRecord(body)?.client_metadata); + const raw = metadata?.["x-codex-turn-metadata"]; + let turn = asRecord(raw); + if (typeof raw === "string") { + try { + turn = asRecord(JSON.parse(raw)); + } catch { + return false; + } + } + return ( + typeof turn?.thread_id === "string" && + turn.thread_id.trim().length > 0 && + typeof turn.turn_id === "string" && + turn.turn_id.trim().length > 0 + ); +} + +export function isVerifiedNativeCodexRequest( + body: unknown, + headers: Headers | Record | null | undefined +): boolean { + return isCodexOriginatedHeaders(headers) && hasNativeCodexTurnBinding(body); +} + export function applyCodexClientMetadata( body: Record, identity?: CodexClientIdentity | null diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index 81e3c29ee1..ef4b9e818d 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) @@ -315,3 +330,15 @@ export const STREAM_RECOVERY = { BUFFER_MAX_BYTES: 65536, EARLY_RETRY_MAX: 4, } as const; + +/** + * Active-stream quality watchdog defaults (#9709). This is separate from the + * idle timeout (no chunks) and the absolute upstream-attempt deadline: it only + * evaluates useful assistant output after warm-up plus one complete window. + */ +export const STREAM_THROUGHPUT_WATCHDOG = { + WARMUP_MS: 30_000, + WINDOW_MS: 30_000, + MIN_USEFUL_BYTES_PER_SECOND: 4, + MIN_USEFUL_BYTES: 1, +} as const; diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index f8de0e18e9..1603f45bc6 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -287,36 +287,6 @@ export const EMBEDDING_PROVIDERS: Record = { ], }, - "github-models": { - id: "github-models", - baseUrl: "https://models.github.ai/inference/embeddings", - authType: "apikey", - authHeader: "bearer", - models: [ - { - id: "openai/text-embedding-3-large", - name: "OpenAI Text Embedding 3 (large)", - dimensions: 3_072, - }, - { - id: "openai/text-embedding-3-small", - name: "OpenAI Text Embedding 3 (small)", - dimensions: 1_536, - }, - ], - }, - - github: { - id: "github", - baseUrl: "https://models.inference.ai.azure.com/embeddings", - authType: "apikey", - authHeader: "bearer", - models: [ - { id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, - { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }, - ], - }, - "jina-ai": { id: "jina-ai", structuredInputProtocol: "jina-v1", @@ -409,6 +379,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/errorConfig.ts b/open-sse/config/errorConfig.ts index 8124c93f43..dcdf3bae9c 100644 --- a/open-sse/config/errorConfig.ts +++ b/open-sse/config/errorConfig.ts @@ -149,6 +149,18 @@ export const ERROR_RULES: ErrorRule[] = [ backoff: true, reason: "quota_exhausted", }, + { + id: "out_of_extra_usage", + text: "out of extra usage", + backoff: true, + reason: "quota_exhausted", + }, + { + id: "extra_usage_required", + text: "extra usage required", + backoff: true, + reason: "quota_exhausted", + }, { id: "capacity", text: "capacity", backoff: true, reason: "model_capacity" }, { id: "overloaded", text: "overloaded", backoff: true, reason: "model_capacity" }, { id: "high_demand", text: "high demand", backoff: true, reason: "model_capacity" }, diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 42ec705f8c..d5760f69a5 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -114,11 +114,11 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "bytez", modelId: "Qwen/Qwen2.5-72B-Instruct", displayName: "Qwen/Qwen2.5-72B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" }, { provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution" }, { provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution" }, - { provider: "cloudflare-ai", modelId: "@cf/meta/llama-3.3-70b-instruct", displayName: "Llama 3.3 70B (🆓 ~150 resp/day)", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, - { provider: "cloudflare-ai", modelId: "@cf/google/gemma-3-12b-it", displayName: "Gemma 3 12B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, + // #8717: drop dead Workers AI ids (400/403/410). Keep Neurons/day budget on fp8-fast. + { provider: "cloudflare-ai", modelId: "@cf/mistral/mistral-7b-instruct-v0.2-lora", displayName: "Mistral 7B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, { provider: "cloudflare-ai", modelId: "@cf/qwen/qwen2.5-coder-32b-instruct", displayName: "Qwen 2.5 Coder 32B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, { provider: "cloudflare-ai", modelId: "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", displayName: "DeepSeek R1 Distill 32B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, - { provider: "cloudflare-ai", modelId: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", displayName: "Llama 3.3 70B (FP8 Fast 🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, + { provider: "cloudflare-ai", modelId: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", displayName: "Llama 3.3 70B (FP8 Fast 🆓 ~150 resp/day)", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, { provider: "cloudflare-ai", modelId: "@cf/meta/llama-3.2-3b-instruct", displayName: "Llama 3.2 3B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, { provider: "cloudflare-ai", modelId: "@cf/qwen/qwq-32b", displayName: "QwQ 32B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, { provider: "cloudflare-ai", modelId: "@cf/zai-org/glm-4.7-flash", displayName: "GLM 4.7 Flash (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" }, @@ -188,27 +188,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "gemini", modelId: "gemini-3-flash-preview", displayName: "Gemini 3 Flash Preview", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "gemini-free", tos: "caution" }, { provider: "gemini", modelId: "gemini-3.1-flash-lite", displayName: "Gemini 3.1 Flash-Lite", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "gemini-free", tos: "caution" }, { provider: "gemini", modelId: "gemini-3.5-flash", displayName: "Gemini 3.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "gemini-free", tos: "caution" }, - { provider: "github-models", modelId: "cohere/cohere-command-a", displayName: "Cohere Command A (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "deepseek/deepseek-r1-0528", displayName: "DeepSeek-R1-0528 (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "deepseek/deepseek-v3-0324", displayName: "DeepSeek-V3-0324 (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "meta/llama-4-maverick-17b-128e-instruct-fp8", displayName: "Llama 4 Maverick 17B 128E Instruct FP8 (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "meta/llama-3.3-70b-instruct", displayName: "Llama-3.3-70B-Instruct (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "meta/llama-4-scout-17b-16e-instruct", displayName: "Llama 4 Scout 17B 16E Instruct (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "microsoft/phi-4-multimodal-instruct", displayName: "Phi-4-multimodal-instruct (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "microsoft/phi-4-reasoning", displayName: "Phi-4-reasoning (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "mistral-ai/codestral-2501", displayName: "Codestral 25.01 (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "mistral-ai/mistral-medium-2505", displayName: "Mistral Medium 3 (25.05) (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "openai/gpt-4.1", displayName: "OpenAI GPT-4.1 (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "openai/gpt-4.1-mini", displayName: "OpenAI GPT-4.1-mini (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "openai/gpt-4o", displayName: "OpenAI GPT-4o (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "openai/gpt-4o-mini", displayName: "OpenAI GPT-4o mini (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "openai/gpt-5", displayName: "OpenAI gpt-5 (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "openai/gpt-5-chat", displayName: "OpenAI gpt-5-chat (preview) (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "openai/gpt-5-mini", displayName: "OpenAI gpt-5-mini (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "openai/o3", displayName: "OpenAI o3 (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "openai/o4-mini", displayName: "OpenAI o4-mini (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "openai/text-embedding-3-large", displayName: "OpenAI Text Embedding 3 (large) (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, - { provider: "github-models", modelId: "openai/text-embedding-3-small", displayName: "OpenAI Text Embedding 3 (small) (Free)", monthlyTokens: 18000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "github-models", tos: "caution" }, { provider: "glm-cn", modelId: "glm-4-flash", displayName: "GLM-4-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" }, { provider: "glm-cn", modelId: "glm-4.5-flash", displayName: "GLM-4.5-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" }, { provider: "glm-cn", modelId: "glm-4.7-flash", displayName: "GLM-4.7-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" }, @@ -311,7 +290,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 +299,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" }, @@ -500,8 +477,8 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "ovhcloud", modelId: "Qwen3.6-27B", displayName: "Qwen3.6 27B (OVH anonymous)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "ovhcloud-anon", tos: "ok" }, { provider: "ovhcloud", modelId: "Mistral-Small-3.2-24B-Instruct-2506", displayName: "Mistral Small 3.2 24B (OVH anonymous)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "ovhcloud-anon", tos: "ok" }, { provider: "ovhcloud", modelId: "Qwen2.5-VL-72B-Instruct", displayName: "Qwen2.5 VL 72B (OVH anonymous)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "ovhcloud-anon", tos: "ok" }, - { provider: "agnes", modelId: "agnes-2.0-flash", displayName: "Agnes 2.0 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "agnes-free", tos: "ok" }, - { provider: "agnes", modelId: "agnes-1.5-flash", displayName: "Agnes 1.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "agnes-free", tos: "ok" }, + { provider: "agnes", modelId: "agnes-2.5-pro", displayName: "Agnes 2.5 Pro", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "agnes-free", tos: "ok" }, + { provider: "agnes", modelId: "agnes-2.5-flash", displayName: "Agnes 2.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "agnes-free", tos: "ok" }, { provider: "glm", modelId: "glm-4.7-flash", displayName: "GLM-4.7-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" }, { provider: "glm", modelId: "glm-4.5-flash", displayName: "GLM-4.5-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" }, { provider: "navy", modelId: "shared-pool", displayName: "NavyAI free pool (150K tokens/day, shared)", monthlyTokens: 4500000, creditTokens: 0, freeType: "recurring-daily", poolKey: "navy-free", tos: "ok" }, diff --git a/open-sse/config/freeTierCatalog.ts b/open-sse/config/freeTierCatalog.ts index c6e17698e1..cfc2836b98 100644 --- a/open-sse/config/freeTierCatalog.ts +++ b/open-sse/config/freeTierCatalog.ts @@ -19,7 +19,6 @@ export const FREE_TIER_BUDGETS: Record = { cerebras: 30_000_000, "api-airforce": 24_000_000, "ollama-cloud": 20_000_000, - "github-models": 18_000_000, groq: 15_000_000, bluesminds: 7_200_000, sambanova: 6_000_000, diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 386dbf1485..d7097420c4 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -12,6 +12,10 @@ import { FREEPIK_IMAGE_PROVIDER } from "./providers/registry/freepik/index.ts"; import { STABILITY_AI_IMAGE_MODELS } from "./providers/registry/stability-ai/imageModels.ts"; import { GEMINI_IMAGEN_PROVIDER } from "./providers/registry/gemini/imageModels.ts"; import { CHEAPERINFERENCE_IMAGE_PROVIDER } from "./providers/registry/cheaperinference/imageModels.ts"; +import { + ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES, + toRegistryImageModels, +} from "../services/adobeFireflyModels.ts"; interface ImageModelEntry { id: string; @@ -22,6 +26,8 @@ interface ImageModelEntry { imageRequired?: boolean; description?: string; isMarket?: boolean; + supportedSizes?: string[]; + mediaCapabilities?: Record; } interface ImageProviderConfig { @@ -35,6 +41,7 @@ interface ImageProviderConfig { authHeader: string; format: string; models: ImageModelEntry[]; + routingAliases?: readonly string[]; supportedSizes: string[]; } @@ -46,6 +53,7 @@ interface ImageModelAliasEntry { inputModalities?: string[]; imageRequired?: boolean; description?: string; + mediaCapabilities?: Record; } interface ImageCatalogModelEntry { @@ -55,6 +63,7 @@ interface ImageCatalogModelEntry { supportedSizes: string[]; inputModalities: string[]; description?: string; + mediaCapabilities?: Record; } const IMAGE_MODEL_ALIASES: Record = { @@ -141,6 +150,23 @@ function resolveAliasImageRequired(alias, modelConfig) { } export const IMAGE_PROVIDERS: Record = { + agnes: { + id: "agnes", + baseUrl: "https://apihub.agnes-ai.com/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "agnes-image", + models: [ + { + id: "agnes-image-2.1-flash", + name: "Agnes Image 2.1 Flash", + inputModalities: ["text", "image"], + description: "Agnes text-to-image, image-to-image, and multi-image composition model", + }, + ], + supportedSizes: ["1K", "2K", "3K", "4K"], + }, + "qwen-cloud-token-plan": { id: "qwen-cloud-token-plan", alias: "qct", @@ -494,18 +520,18 @@ export const IMAGE_PROVIDERS: Record = { authHeader: "key", format: "fal-ai", models: [ - { id: "fal-ai/flux-2-max", name: "FLUX.2 Max" }, - { id: "fal-ai/flux-2-pro", name: "FLUX.2 Pro" }, - { id: "fal-ai/flux-2-flex", name: "FLUX.2 Flex" }, + { id: "flux-2-max", name: "FLUX.2 Max" }, + { id: "flux-2-pro", name: "FLUX.2 Pro" }, + { id: "flux-2-flex", name: "FLUX.2 Flex" }, { id: "bria/text-to-image/3.2", name: "Bria 3.2" }, - { id: "fal-ai/bytedance/seedream/v4.5/text-to-image", name: "SeeDream V4.5" }, - { id: "fal-ai/bytedance/dreamina/v3.1/text-to-image", name: "Dreamina V3.1" }, - { id: "fal-ai/ideogram/v3", name: "Ideogram V3" }, - { id: "fal-ai/nano-banana-pro", name: "Nano Banana Pro" }, - { id: "fal-ai/nano-banana-2", name: "Nano Banana 2" }, - { id: "fal-ai/recraft/v4/pro/text-to-image", name: "Recraft V4 Pro via Fal" }, - { id: "fal-ai/recraft/v4/text-to-image", name: "Recraft V4 via Fal" }, - { id: "fal-ai/stable-diffusion-v35-medium", name: "Stable Diffusion v3.5 Medium" }, + { id: "bytedance/seedream/v4.5/text-to-image", name: "SeeDream V4.5" }, + { id: "bytedance/dreamina/v3.1/text-to-image", name: "Dreamina V3.1" }, + { id: "ideogram/v3", name: "Ideogram V3" }, + { id: "nano-banana-pro", name: "Nano Banana Pro" }, + { id: "nano-banana-2", name: "Nano Banana 2" }, + { id: "recraft/v4/pro/text-to-image", name: "Recraft V4 Pro via Fal" }, + { id: "recraft/v4/text-to-image", name: "Recraft V4 via Fal" }, + { id: "stable-diffusion-v35-medium", name: "Stable Diffusion v3.5 Medium" }, ], supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], }, @@ -678,55 +704,9 @@ export const IMAGE_PROVIDERS: Record = { authType: "apikey", authHeader: "bearer", format: "adobe-firefly-image", - models: [ - { - id: "nano-banana-pro", - name: "Firefly Gemini 3.0 (Nano Banana Pro)", - inputModalities: ["text", "image"], - }, - { - id: "nano-banana", - name: "Firefly Gemini 2.5 (Nano Banana)", - inputModalities: ["text", "image"], - }, - { - id: "nano-banana-2", - name: "Firefly Gemini 3.1 (Nano Banana 2)", - inputModalities: ["text", "image"], - }, - { id: "gpt-image-2", name: "Firefly GPT Image 2", inputModalities: ["text", "image"] }, - { id: "gpt-image", name: "Firefly GPT Image 2", inputModalities: ["text", "image"] }, - { id: "gpt-image-1.5", name: "Firefly GPT Image 1.5", inputModalities: ["text", "image"] }, - { id: "flux-2", name: "Firefly Flux 2", inputModalities: ["text", "image"] }, - { id: "flux-pro", name: "Firefly Flux 1.1 Pro", inputModalities: ["text", "image"] }, - { id: "flux-ultra", name: "Firefly Flux 1.1 Ultra", inputModalities: ["text", "image"] }, - { id: "seedream-4", name: "Firefly Seedream 4.0", inputModalities: ["text", "image"] }, - { - id: "seedream-5-lite", - name: "Firefly Seedream 5.0 Lite", - inputModalities: ["text", "image"], - }, - { - id: "runway-gen4-image", - name: "Firefly Runway Gen-4 Image", - inputModalities: ["text", "image"], - }, - // Topaz Labs upscalers (inputMediaUseCase: ["upscaling"]). - // Served by firefly-3p /v2/3p-images/upsample — see config/upscaleRegistry.ts. - { - id: "topaz-standard", - name: "Firefly Topaz Upscale (Standard)", - inputModalities: ["image"], - imageRequired: true, - }, - { - id: "topaz-bloom", - name: "Firefly Topaz Bloom (Creative Upscale)", - inputModalities: ["image"], - imageRequired: true, - }, - ], - supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "1024x1024", "1792x1024", "1024x1792"], + models: toRegistryImageModels(), + routingAliases: ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES, + supportedSizes: [], }, // Cheaper Inference (OSS-sponsor gateway). Declared AFTER adobe-firefly on @@ -872,22 +852,20 @@ export function parseImageModel(modelStr) { for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) { if (modelStr.startsWith(providerId + "/")) { const model = modelStr.slice(providerId.length + 1); - const aliased = - resolveImageModelAlias(`${providerId}/${model}`) || resolveImageModelAlias(model); + const aliased = resolveImageModelAlias(`${providerId}/${model}`); return aliased || { provider: providerId, model }; } // Check alias if available if (config.alias && modelStr.startsWith(config.alias + "/")) { const model = modelStr.slice(config.alias.length + 1); - const aliased = - resolveImageModelAlias(`${providerId}/${model}`) || resolveImageModelAlias(model); + const aliased = resolveImageModelAlias(`${providerId}/${model}`); return aliased || { provider: providerId, model }; } } // No provider prefix — try to find the model in every provider for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) { - if (config.models.some((m) => m.id === modelStr)) { + if (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr)) { return { provider: providerId, model: modelStr }; } } @@ -906,9 +884,10 @@ function imageProviderCatalogEntries( id: `${providerId}/${model.id}`, name: model.name, provider: providerId, - supportedSizes: config.supportedSizes, + supportedSizes: model.supportedSizes || config.supportedSizes, inputModalities: model.inputModalities || ["text"], description: model.description || undefined, + mediaCapabilities: model.mediaCapabilities, })); } diff --git a/open-sse/config/musicRegistry.ts b/open-sse/config/musicRegistry.ts index 7eda3e7425..06aa8a159b 100644 --- a/open-sse/config/musicRegistry.ts +++ b/open-sse/config/musicRegistry.ts @@ -33,6 +33,15 @@ export const MUSIC_PROVIDERS: Record = { models: [{ id: "lyria-002", name: "Lyria 2 (Vertex)" }], }, + "fal-ai": { + id: "fal-ai", + baseUrl: "https://queue.fal.run", + authType: "apikey", + authHeader: "key", + format: "fal-ai-music", + models: [{ id: "ace-step", name: "ACE-Step" }], + }, + kie: { id: "kie", baseUrl: "https://api.kie.ai", 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..afffd4429b 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,21 +158,24 @@ 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]; + // Accept either the public alias ("cmd") or the raw provider id ("command-code"), + // mirroring getProviderModels (same pattern as #2798/#3870). + const alias = PROVIDER_ID_TO_ALIAS[aliasOrId] || aliasOrId; + const models = PROVIDER_MODELS[alias]; // Strip provider prefix if present: "openai/gpt-5.6-luna" → "gpt-5.6-luna" - const prefix = aliasOrId + "/"; + const prefix = alias + "/"; const bareModelId = typeof modelId === "string" && modelId.startsWith(prefix) ? modelId.slice(prefix.length) @@ -130,14 +188,24 @@ 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"; - return null; + if (alias === "openai" && /-pro$/i.test(modelId)) return "openai-responses"; + // Model-level targetFormat is provider-scoped: a catalog entry declares how THIS + // provider's endpoint serves the model. When the provider has its own catalog but + // the model is not in it, do NOT import the global entry's tag — it encodes the + // DECLARING provider's endpoint semantics (e.g. ghe-copilot tags gpt-5.6-* as + // openai-responses, which must not hijack command-code's chat-shaped + // /alpha/generate → 502 "Invalid prompt: messages must not be empty"). Providers + // with no catalog at all keep the global fallback as their only metadata source. + if (models) return null; + return getGlobalModel(bareModelId)?.targetFormat ?? 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 +330,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..228d9e93ef 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -14,6 +14,7 @@ import { notion_webProvider } from "./registry/notion-web/index.ts"; import { anthropicProvider } from "./registry/anthropic/index.ts"; import { sambanovaProvider } from "./registry/sambanova/index.ts"; import { puterProvider } from "./registry/puter/index.ts"; +import { deepaiProvider } from "./registry/deepai/index.ts"; import { upstageProvider } from "./registry/upstage/index.ts"; import { nebiusProvider } from "./registry/nebius/index.ts"; import { fireworksProvider } from "./registry/fireworks/index.ts"; @@ -26,7 +27,6 @@ import { raycastProvider } from "./registry/raycast/index.ts"; import { muse_spark_webProvider } from "./registry/muse-spark-web/index.ts"; import { lmarenaProvider } from "./registry/lmarena/index.ts"; import { kilocodeProvider } from "./registry/kilocode/index.ts"; -import { github_modelsProvider } from "./registry/github/models/index.ts"; import { githubProvider } from "./registry/github/index.ts"; import { gheCopilotProvider } from "./registry/ghe-copilot/index.ts"; import { difyProvider } from "./registry/dify/index.ts"; @@ -101,6 +101,7 @@ import { sensenovaProvider } from "./registry/sensenova/index.ts"; import { hyperbolicProvider } from "./registry/hyperbolic/index.ts"; import { lambda_aiProvider } from "./registry/lambda-ai/index.ts"; import { t3_webProvider } from "./registry/t3-web/index.ts"; +import { conol_webProvider } from "./registry/conol-web/index.ts"; import { iflytekProvider } from "./registry/iflytek/index.ts"; import { crofProvider } from "./registry/crof/index.ts"; import { moonshotProvider } from "./registry/moonshot/index.ts"; @@ -118,9 +119,12 @@ import { blackbox_webProvider } from "./registry/blackbox/web/index.ts"; import { uncloseaiProvider } from "./registry/uncloseai/index.ts"; import { nscaleProvider } from "./registry/nscale/index.ts"; import { chatgpt_webProvider } from "./registry/chatgpt-web/index.ts"; +import { chatgpt_web_codexProvider } from "./registry/chatgpt-web-codex/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"; @@ -213,6 +217,7 @@ import { grok_cliProvider } from "./registry/grok-cli/index.ts"; import { codebuddy_cnProvider } from "./registry/codebuddy-cn/index.ts"; import { pioneerProvider } from "./registry/pioneer/index.ts"; import { zenmux_freeProvider } from "./registry/zenmux-free/index.ts"; +import { tinycmsProvider } from "./registry/tinycms/index.ts"; import { sumopodProvider } from "./registry/sumopod/index.ts"; import { x5labProvider } from "./registry/x5lab/index.ts"; import { kenariProvider } from "./registry/kenari/index.ts"; @@ -225,6 +230,9 @@ import { digitaloceanProvider } from "./registry/digitalocean/index.ts"; import { hcnsecProvider } from "./registry/hcnsec/index.ts"; import { promptqlProvider } from "./registry/promptql/index.ts"; import { hyperagentProvider } from "./registry/hyperagent/index.ts"; +import { muse_codeProvider } from "./registry/muse-code/index.ts"; +import { naga_acProvider } from "./registry/naga-ac/index.ts"; +import { chatanywhereProvider } from "./registry/chatanywhere/index.ts"; export const REGISTRY: Record = { aimlapi: aimlapiProvider, @@ -239,6 +247,7 @@ export const REGISTRY: Record = { sambanova: sambanovaProvider, puter: puterProvider, upstage: upstageProvider, + deepai: deepaiProvider, nebius: nebiusProvider, fireworks: fireworksProvider, llamagate: llamagateProvider, @@ -250,7 +259,6 @@ export const REGISTRY: Record = { "muse-spark-web": muse_spark_webProvider, lmarena: lmarenaProvider, kilocode: kilocodeProvider, - "github-models": github_modelsProvider, github: githubProvider, "ghe-copilot": gheCopilotProvider, dify: difyProvider, @@ -325,6 +333,7 @@ export const REGISTRY: Record = { hyperbolic: hyperbolicProvider, "lambda-ai": lambda_aiProvider, "t3-web": t3_webProvider, + "conol-web": conol_webProvider, iflytek: iflytekProvider, crof: crofProvider, moonshot: moonshotProvider, @@ -342,9 +351,12 @@ export const REGISTRY: Record = { uncloseai: uncloseaiProvider, nscale: nscaleProvider, "chatgpt-web": chatgpt_webProvider, + "chatgpt-web-codex": chatgpt_web_codexProvider, openrouter: openrouterProvider, cheaperinference: cheaperinferenceProvider, openvecta: openvectaProvider, + openference: openferenceProvider, + "openference-api": openference_apiProvider, orcarouter: orcarouterProvider, "copilot-web": copilot_webProvider, "copilot-m365-web": copilot_m365_webProvider, @@ -439,6 +451,7 @@ export const REGISTRY: Record = { "codebuddy-cn": codebuddy_cnProvider, pioneer: pioneerProvider, "zenmux-free": zenmux_freeProvider, + "tinycms-web": tinycmsProvider, sumopod: sumopodProvider, x5lab: x5labProvider, kenari: kenariProvider, @@ -451,5 +464,8 @@ export const REGISTRY: Record = { hcnsec: hcnsecProvider, promptql: promptqlProvider, hyperagent: hyperagentProvider, + "muse-code": muse_codeProvider, unorouter: unorouterProvider, + "naga-ac": naga_acProvider, + chatanywhere: chatanywhereProvider, }; diff --git a/open-sse/config/providers/registry/agnes/index.ts b/open-sse/config/providers/registry/agnes/index.ts index c3120a562c..848ad0242c 100644 --- a/open-sse/config/providers/registry/agnes/index.ts +++ b/open-sse/config/providers/registry/agnes/index.ts @@ -1,14 +1,17 @@ import type { RegistryEntry } from "../../shared.ts"; -import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; -export const agnesProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ +export const agnesProvider: RegistryEntry = { id: "agnes", - baseUrl: "https://apihub.agnes-ai.com/v1/chat/completions", + format: "openai-responses", + executor: "default", + baseUrl: "https://apihub.agnes-ai.com/v1/responses", + authType: "apikey", + authHeader: "bearer", models: [ { - id: "agnes-2.0-flash", - name: "Agnes 2.0 Flash", - contextLength: 524288, + id: "agnes-2.5-pro", + name: "Agnes 2.5 Pro", + contextLength: 1048576, maxOutputTokens: 65536, supportsReasoning: true, supportsVision: true, @@ -16,11 +19,14 @@ export const agnesProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ interleavedField: "reasoning_content", }, { - id: "agnes-1.5-flash", - name: "Agnes 1.5 Flash", - contextLength: 262144, + id: "agnes-2.5-flash", + name: "Agnes 2.5 Flash", + contextLength: 524288, maxOutputTokens: 65536, + supportsReasoning: true, supportsVision: true, + toolCalling: true, + interleavedField: "reasoning_content", }, ], -}); +}; diff --git a/open-sse/config/providers/registry/chatanywhere/index.ts b/open-sse/config/providers/registry/chatanywhere/index.ts new file mode 100644 index 0000000000..137cef8d05 --- /dev/null +++ b/open-sse/config/providers/registry/chatanywhere/index.ts @@ -0,0 +1,17 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// ChatAnywhere (api.chatanywhere.tech) — OpenAI-compatible gateway from the +// chatanywhere/GPT_API_free project (~38.7k GitHub stars). Requires GitHub-account- +// gated API key. Free tier is for personal non-commercial use only. +export const chatanywhereProvider: RegistryEntry = { + id: "chatanywhere", + alias: "chtany", + format: "openai", + executor: "default", + baseUrl: "https://api.chatanywhere.tech/v1/chat/completions", + modelsUrl: "https://api.chatanywhere.tech/v1/models", + authType: "apikey", + authHeader: "bearer", + passthroughModels: true, + models: [], +}; \ No newline at end of file diff --git a/open-sse/config/providers/registry/chatgpt-web-codex/index.ts b/open-sse/config/providers/registry/chatgpt-web-codex/index.ts new file mode 100644 index 0000000000..a1ccb6b13c --- /dev/null +++ b/open-sse/config/providers/registry/chatgpt-web-codex/index.ts @@ -0,0 +1,32 @@ +import type { RegistryEntry } from "../../shared.ts"; + +const NATIVE_CAPABILITIES = { + targetFormat: "openai-responses", + toolCalling: true, + supportsReasoning: true, + supportsVision: true, + supportsXHighEffort: true, +} as const; + +export const chatgpt_web_codexProvider: RegistryEntry = { + id: "chatgpt-web-codex", + alias: "cgpt-codex", + format: "openai-responses", + executor: "chatgpt-web-codex", + baseUrl: "https://chatgpt.com", + authType: "apikey", + authHeader: "cookie", + forceStream: true, + models: [ + { id: "instant", name: "ChatGPT Web — Instant", ...NATIVE_CAPABILITIES }, + { id: "medium", name: "ChatGPT Web — Medium", ...NATIVE_CAPABILITIES }, + { id: "high", name: "ChatGPT Web — High", ...NATIVE_CAPABILITIES }, + { id: "extra-high", name: "ChatGPT Web — Extra High", ...NATIVE_CAPABILITIES }, + { + id: "pro", + name: "ChatGPT Web — Pro (read-only)", + ...NATIVE_CAPABILITIES, + toolCalling: false, + }, + ], +}; diff --git a/open-sse/config/providers/registry/cloudflare-ai/index.ts b/open-sse/config/providers/registry/cloudflare-ai/index.ts index 907138aa48..5ab3eca452 100644 --- a/open-sse/config/providers/registry/cloudflare-ai/index.ts +++ b/open-sse/config/providers/registry/cloudflare-ai/index.ts @@ -9,14 +9,14 @@ export const cloudflare_aiProvider: RegistryEntry = { baseUrl: "https://api.cloudflare.com/client/v4/accounts", authType: "apikey", authHeader: "bearer", - // 10K Neurons/day free: ~150 LLM responses or 500s Whisper audio — global edge + // 10K Neurons/day free: ~150 LLM responses or 500s Whisper audio — global edge. + // #8717: omit dead ids (llama-3.3-70b-instruct, llama-3.1-8b-instruct, + // gemma-3-12b-it, qwen2.5-coder-15b-instruct) — Workers AI returns 400/403/410. models: [ - { id: "@cf/meta/llama-3.3-70b-instruct", name: "Llama 3.3 70B (🆓 ~150 resp/day)" }, - { id: "@cf/google/gemma-3-12b-it", name: "Gemma 3 12B (🆓)" }, + { id: "@cf/mistral/mistral-7b-instruct-v0.2-lora", name: "Mistral 7B (🆓)" }, { id: "@cf/qwen/qwen2.5-coder-32b-instruct", name: "Qwen 2.5 Coder 32B (🆓)" }, { id: "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", name: "DeepSeek R1 Distill 32B (🆓)" }, - // Sweep 2026-06-19: + current Workers AI catalog ids (developers.cloudflare.com/workers-ai/models). - { id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", name: "Llama 3.3 70B (FP8 Fast 🆓)" }, + { id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", name: "Llama 3.3 70B (FP8 Fast 🆓 ~150 resp/day)" }, { id: "@cf/meta/llama-3.2-3b-instruct", name: "Llama 3.2 3B (🆓)" }, { id: "@cf/qwen/qwq-32b", name: "QwQ 32B (🆓)" }, { diff --git a/open-sse/config/providers/registry/conol-web/index.ts b/open-sse/config/providers/registry/conol-web/index.ts new file mode 100644 index 0000000000..e49760792d --- /dev/null +++ b/open-sse/config/providers/registry/conol-web/index.ts @@ -0,0 +1,14 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { CONOL_FALLBACK_MODELS } from "../../../services/conolModels.ts"; + +export const conol_webProvider: RegistryEntry = { + id: "conol-web", + alias: "cnl", + format: "openai", + executor: "conol-web", + baseUrl: "https://conol.ai/api/sessions", + authType: "apikey", + authHeader: "cookie", + passthroughModels: true, + models: CONOL_FALLBACK_MODELS, +}; diff --git a/open-sse/config/providers/registry/deepai/index.ts b/open-sse/config/providers/registry/deepai/index.ts new file mode 100644 index 0000000000..67442a56f0 --- /dev/null +++ b/open-sse/config/providers/registry/deepai/index.ts @@ -0,0 +1,13 @@ +import type { RegistryEntry } from "../shared"; + +export const deepaiProvider: RegistryEntry = { + id: "deepai", + alias: "deepai", + format: "custom", + baseUrl: "https://api.deepai.org", + authType: "apikey", + authHeader: "api-key", + models: [ + { id: "text2img", name: "Text to Image" }, + ], +}; diff --git a/open-sse/config/providers/registry/deepseek/index.ts b/open-sse/config/providers/registry/deepseek/index.ts index 348a0bf81a..6825b23078 100644 --- a/open-sse/config/providers/registry/deepseek/index.ts +++ b/open-sse/config/providers/registry/deepseek/index.ts @@ -9,7 +9,17 @@ export const deepseekProvider: RegistryEntry = { authType: "apikey", authHeader: "bearer", models: [ - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, - { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, + { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + supportsReasoning: true, + supportedThinkingEfforts: ["none", "high", "max"], + }, + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + supportsReasoning: true, + supportedThinkingEfforts: ["none", "low", "high", "max"], + }, ], }; diff --git a/open-sse/config/providers/registry/gemini/web/index.ts b/open-sse/config/providers/registry/gemini/web/index.ts index 6843ae86a8..276cfaf589 100644 --- a/open-sse/config/providers/registry/gemini/web/index.ts +++ b/open-sse/config/providers/registry/gemini/web/index.ts @@ -8,9 +8,32 @@ export const gemini_webProvider: RegistryEntry = { baseUrl: "https://gemini.google.com/app", authType: "apikey", authHeader: "cookie", + // #9356: `supportsReasoning: false` is a live-behavior statement, not a guess + // about the underlying Gemini model. The executor drives the gemini.google.com + // web UI by typing a prompt, so it has no thinking-budget control to set and + // never surfaces `reasoning_content` — agent routers reading /v1/models must + // not select these for reasoning work. `toolCalling: false` is the matching + // statement for native function calling; the prompt-emulation shim (#7286) + // stays available and is advertised separately as `toolCalling: "emulated"` + // on the provider constant (src/shared/constants/providers/web-cookie.ts). models: [ - { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", toolCalling: false }, - { id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", toolCalling: false }, - { id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash-Lite", toolCalling: false }, + { + id: "gemini-3.1-pro", + name: "Gemini 3.1 Pro", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-3.1-flash-lite", + name: "Gemini 3.1 Flash-Lite", + toolCalling: false, + supportsReasoning: false, + }, ], }; diff --git a/open-sse/config/providers/registry/github/models/index.ts b/open-sse/config/providers/registry/github/models/index.ts deleted file mode 100644 index 8da48ab7e5..0000000000 --- a/open-sse/config/providers/registry/github/models/index.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { RegistryEntry } from "../../../shared.ts"; - -export const github_modelsProvider: RegistryEntry = { - id: "github-models", - alias: "ghm", - format: "openai", - executor: "default", - baseUrl: "https://models.github.ai/inference/chat/completions", - modelsUrl: "https://models.github.ai/catalog/models", - authType: "apikey", - authHeader: "Authorization", - authPrefix: "Bearer", - headers: { - "X-GitHub-Api-Version": "2026-03-10", - Accept: "application/vnd.github+json", - }, - defaultContextLength: 128000, - models: [ - { - id: "cohere/cohere-command-a", - name: "Cohere Command A", - contextLength: 131_072, - maxInputTokens: 131_072, - maxOutputTokens: 4_096, - }, - { - id: "deepseek/deepseek-r1-0528", - name: "DeepSeek-R1-0528", - contextLength: 128_000, - maxInputTokens: 128_000, - maxOutputTokens: 4_096, - supportsReasoning: true, - toolCalling: true, - }, - { - id: "deepseek/deepseek-v3-0324", - name: "DeepSeek-V3-0324", - contextLength: 128_000, - maxInputTokens: 128_000, - maxOutputTokens: 4_096, - toolCalling: true, - }, - { - id: "meta/llama-4-maverick-17b-128e-instruct-fp8", - name: "Llama 4 Maverick 17B 128E Instruct FP8", - contextLength: 1_000_000, - maxInputTokens: 1_000_000, - maxOutputTokens: 4_096, - supportsVision: true, - toolCalling: true, - }, - { - id: "meta/llama-3.3-70b-instruct", - name: "Llama-3.3-70B-Instruct", - contextLength: 128_000, - maxInputTokens: 128_000, - maxOutputTokens: 4_096, - }, - { - id: "meta/llama-4-scout-17b-16e-instruct", - name: "Llama 4 Scout 17B 16E Instruct", - contextLength: 10_000_000, - maxInputTokens: 10_000_000, - maxOutputTokens: 4_096, - supportsVision: true, - toolCalling: true, - }, - { - id: "microsoft/phi-4-multimodal-instruct", - name: "Phi-4-multimodal-instruct", - contextLength: 128_000, - maxInputTokens: 128_000, - maxOutputTokens: 4_096, - supportsVision: true, - }, - { - id: "microsoft/phi-4-reasoning", - name: "Phi-4-reasoning", - contextLength: 32_768, - maxInputTokens: 32_768, - maxOutputTokens: 4_096, - supportsReasoning: true, - }, - { - id: "mistral-ai/codestral-2501", - name: "Codestral 25.01", - contextLength: 256_000, - maxInputTokens: 256_000, - maxOutputTokens: 4_096, - }, - { - id: "mistral-ai/mistral-medium-2505", - name: "Mistral Medium 3 (25.05)", - contextLength: 128_000, - maxInputTokens: 128_000, - maxOutputTokens: 4_096, - supportsVision: true, - toolCalling: true, - }, - { - id: "openai/gpt-4.1", - name: "OpenAI GPT-4.1", - contextLength: 1_048_576, - maxInputTokens: 1_048_576, - maxOutputTokens: 32_768, - supportsVision: true, - toolCalling: true, - }, - { - id: "openai/gpt-4.1-mini", - name: "OpenAI GPT-4.1-mini", - contextLength: 1_048_576, - maxInputTokens: 1_048_576, - maxOutputTokens: 32_768, - supportsVision: true, - toolCalling: true, - }, - { - id: "openai/gpt-4o", - name: "OpenAI GPT-4o", - contextLength: 131_072, - maxInputTokens: 131_072, - maxOutputTokens: 16_384, - supportsVision: true, - toolCalling: true, - }, - { - id: "openai/gpt-4o-mini", - name: "OpenAI GPT-4o mini", - contextLength: 131_072, - maxInputTokens: 131_072, - maxOutputTokens: 4_096, - supportsVision: true, - toolCalling: true, - }, - { - id: "openai/gpt-5", - name: "OpenAI gpt-5", - contextLength: 200_000, - maxInputTokens: 200_000, - maxOutputTokens: 100_000, - supportsVision: true, - supportsReasoning: true, - toolCalling: true, - }, - { - id: "openai/gpt-5-chat", - name: "OpenAI gpt-5-chat (preview)", - contextLength: 200_000, - maxInputTokens: 200_000, - maxOutputTokens: 100_000, - supportsVision: true, - supportsReasoning: true, - toolCalling: true, - }, - { - id: "openai/gpt-5-mini", - name: "OpenAI gpt-5-mini", - contextLength: 200_000, - maxInputTokens: 200_000, - maxOutputTokens: 100_000, - supportsVision: true, - supportsReasoning: true, - toolCalling: true, - }, - { - id: "openai/o3", - name: "OpenAI o3", - contextLength: 200_000, - maxInputTokens: 200_000, - maxOutputTokens: 100_000, - supportsVision: true, - supportsReasoning: true, - toolCalling: true, - }, - { - id: "openai/o4-mini", - name: "OpenAI o4-mini", - contextLength: 200_000, - maxInputTokens: 200_000, - maxOutputTokens: 100_000, - supportsVision: true, - supportsReasoning: true, - toolCalling: true, - }, - ], -}; diff --git a/open-sse/config/providers/registry/kimi/coding/runtime.ts b/open-sse/config/providers/registry/kimi/coding/runtime.ts index aedd32fc1d..dfd469658e 100644 --- a/open-sse/config/providers/registry/kimi/coding/runtime.ts +++ b/open-sse/config/providers/registry/kimi/coding/runtime.ts @@ -25,7 +25,9 @@ const KIMI_CODE_STATIC_THINKING_POLICIES: Record export function getKimiCodeStaticThinkingPolicy(modelId: unknown): KimiCodeThinkingPolicy | null { if (typeof modelId !== "string") return null; - return KIMI_CODE_STATIC_THINKING_POLICIES[modelId] || null; + const normalizedModel = modelId.trim().toLowerCase().split("/").pop() || ""; + if (/^k3(?:$|-)/.test(normalizedModel)) return KIMI_CODE_STATIC_THINKING_POLICIES.k3; + return KIMI_CODE_STATIC_THINKING_POLICIES[normalizedModel] || null; } export type KimiCodeDeviceIdentity = { diff --git a/open-sse/config/providers/registry/minimax/cn/index.ts b/open-sse/config/providers/registry/minimax/cn/index.ts index 8046b9e9d6..91981768d3 100644 --- a/open-sse/config/providers/registry/minimax/cn/index.ts +++ b/open-sse/config/providers/registry/minimax/cn/index.ts @@ -1,18 +1,14 @@ import type { RegistryEntry } from "../../../shared.ts"; -import { getAnthropicCompatHeaders, ANTHROPIC_VERSION_HEADER } from "../../../shared.ts"; export const minimax_cnProvider: RegistryEntry = { id: "minimax-cn", alias: "minimax-cn", // unique alias (was colliding with minimax) - format: "claude", + format: "openai", executor: "default", - baseUrl: "https://api.minimaxi.com/anthropic/v1/messages", + baseUrl: "https://api.minimaxi.com/v1/chat/completions", modelsUrl: "https://api.minimaxi.com/v1/models", - urlSuffix: "?beta=true", authType: "apikey", authHeader: "bearer", - headers: getAnthropicCompatHeaders(), - ensureThinkingSignature: true, models: [ // Keep parity with minimax to ensure model discovery works for minimax-cn connections. // #3110: MiniMax M3 — frontier coding model with 1M context diff --git a/open-sse/config/providers/registry/minimax/index.ts b/open-sse/config/providers/registry/minimax/index.ts index 6f1f80f51f..54fc7b3058 100644 --- a/open-sse/config/providers/registry/minimax/index.ts +++ b/open-sse/config/providers/registry/minimax/index.ts @@ -1,18 +1,14 @@ import type { RegistryEntry } from "../../shared.ts"; -import { getAnthropicCompatHeaders, ANTHROPIC_VERSION_HEADER } from "../../shared.ts"; export const minimaxProvider: RegistryEntry = { id: "minimax", alias: "minimax", - format: "claude", + format: "openai", executor: "default", - baseUrl: "https://api.minimax.io/anthropic/v1/messages", + baseUrl: "https://api.minimax.io/v1/chat/completions", modelsUrl: "https://api.minimax.io/v1/models", - urlSuffix: "?beta=true", authType: "apikey", authHeader: "bearer", - headers: getAnthropicCompatHeaders(), - ensureThinkingSignature: true, models: [ // T12/T28: MiniMax default upgraded from M2.5 to M2.7 // #3110: MiniMax M3 — frontier coding model with 1M context diff --git a/open-sse/config/providers/registry/muse-code/index.ts b/open-sse/config/providers/registry/muse-code/index.ts new file mode 100644 index 0000000000..66f59f9f42 --- /dev/null +++ b/open-sse/config/providers/registry/muse-code/index.ts @@ -0,0 +1,106 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +/** + * Muse Code CLI — Meta's agentic coding tool. + * + * Wire format: OpenAI Responses API (POST /responses). + * Auth: Bearer token from META_API_KEY env var. + * Reasoning efforts: xhigh/ultra -> high (handled generically). + * + * @see https://github.com/joymadhu49/muse-openrouter-shim + */ +export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "muse-code", + alias: "mc", + passthroughModels: true, + defaultContextLength: 200000, + models: [ + { + id: "llama-4-maverick", + name: "Llama 4 Maverick", + contextLength: 1048576, + maxOutputTokens: 131072, + supportsReasoning: true, + supportsXHighEffort: true, + toolCalling: true, + supportsVision: true, + targetFormat: "openai-responses", + unsupportedParams: ["logprobs", "topLogprobs", "logitBias"], + }, + { + id: "llama-4-scout", + name: "Llama 4 Scout", + contextLength: 1048576, + maxOutputTokens: 131072, + supportsReasoning: true, + supportsXHighEffort: true, + toolCalling: true, + supportsVision: true, + targetFormat: "openai-responses", + unsupportedParams: ["logprobs", "topLogprobs", "logitBias"], + }, + { + id: "llama-3.3-70b", + name: "Llama 3.3 70B", + contextLength: 131072, + maxOutputTokens: 32768, + supportsReasoning: false, + toolCalling: true, + targetFormat: "openai-responses", + unsupportedParams: ["logprobs", "topLogprobs"], + }, + { + id: "llama-3.1-405b", + name: "Llama 3.1 405B", + contextLength: 131072, + maxOutputTokens: 32768, + supportsReasoning: false, + toolCalling: true, + targetFormat: "openai-responses", + unsupportedParams: ["logprobs", "topLogprobs"], + }, + { + id: "llama-3.1-70b", + name: "Llama 3.1 70B", + contextLength: 131072, + maxOutputTokens: 32768, + supportsReasoning: false, + toolCalling: true, + targetFormat: "openai-responses", + unsupportedParams: ["logprobs", "topLogprobs"], + }, + { + id: "llama-3.1-8b", + name: "Llama 3.1 8B", + contextLength: 131072, + maxOutputTokens: 32768, + supportsReasoning: false, + toolCalling: true, + targetFormat: "openai-responses", + unsupportedParams: ["logprobs", "topLogprobs"], + }, + { + id: "llama-3.2-90b-vision", + name: "Llama 3.2 90B Vision", + contextLength: 131072, + maxOutputTokens: 32768, + supportsReasoning: false, + toolCalling: true, + supportsVision: true, + targetFormat: "openai-responses", + unsupportedParams: ["logprobs", "topLogprobs"], + }, + { + id: "llama-3.2-11b-vision", + name: "Llama 3.2 11B Vision", + contextLength: 131072, + maxOutputTokens: 32768, + supportsReasoning: false, + toolCalling: true, + supportsVision: true, + targetFormat: "openai-responses", + unsupportedParams: ["logprobs", "topLogprobs"], + }, + ], +}); diff --git a/open-sse/config/providers/registry/naga-ac/index.ts b/open-sse/config/providers/registry/naga-ac/index.ts new file mode 100644 index 0000000000..4627c2b921 --- /dev/null +++ b/open-sse/config/providers/registry/naga-ac/index.ts @@ -0,0 +1,17 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// Naga.ac — OpenAI-compatible aggregator gateway with free models. +// See https://docs.naga.ac for API reference. +// Free models accept an optional API key; authenticated users get higher rate limits. +export const naga_acProvider: RegistryEntry = { + id: "naga-ac", + alias: "naga", + format: "openai", + executor: "default", + baseUrl: "https://api.naga.ac/v1/chat/completions", + modelsUrl: "https://api.naga.ac/v1/models", + authType: "optional", + authHeader: "bearer", + passthroughModels: true, + models: [], +}; \ No newline at end of file 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/opencode/zen/index.ts b/open-sse/config/providers/registry/opencode/zen/index.ts index db06fe8042..a241b043ce 100644 --- a/open-sse/config/providers/registry/opencode/zen/index.ts +++ b/open-sse/config/providers/registry/opencode/zen/index.ts @@ -85,14 +85,13 @@ export const opencode_zenProvider: RegistryEntry = { { id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false }, // ── Free Tier ────────────────────────────────────────────── + // #6998 (2026-07-14): upstream free tier rotated — minimax-m2.5-free, + // nemotron-3-super-free and qwen3.6-plus-free were delisted (401). Replaced + // by the 4 entries below with upstream-verified limits. { id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", supportsReasoning: true }, - { id: "minimax-m2.5-free", name: "MiniMax M2.5 Free", contextLength: 204800 }, - { id: "nemotron-3-super-free", name: "Nemotron 3 Super Free", contextLength: 1000000 }, - { - id: "qwen3.6-plus-free", - name: "Qwen3.6 Plus Free", - targetFormat: "claude", - contextLength: 200000, - }, + { id: "mimo-v2.5-free", name: "MiMo V2.5 Free", contextLength: 200000 }, + { id: "hy3-free", name: "HY3 Free", contextLength: 200000 }, + { id: "nemotron-3-ultra-free", name: "Nemotron 3 Ultra Free", contextLength: 1000000 }, + { id: "north-mini-code-free", name: "North Mini Code Free", contextLength: 200000 }, ], }; 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/providers/registry/openrouter/index.ts b/open-sse/config/providers/registry/openrouter/index.ts index a770a5e5e7..1116badd4d 100644 --- a/open-sse/config/providers/registry/openrouter/index.ts +++ b/open-sse/config/providers/registry/openrouter/index.ts @@ -13,5 +13,12 @@ export const openrouterProvider: RegistryEntry = { "HTTP-Referer": "https://endpoint-proxy.local", "X-Title": "Endpoint Proxy", }, + // OpenRouter multiplexes hundreds of independent upstream models behind one + // connection/API key — without this flag, hasPerModelQuota() (accountFallback.ts) + // falls through to connection-wide cooldown on any model-specific failure (e.g. a + // 404 "No endpoints found" for one dead/renamed model), poisoning every OTHER + // OpenRouter model on the same connection for the cooldown window and surfacing + // that first model's stale error message on their unrelated requests. + passthroughModels: true, models: [{ id: "auto", name: "Auto (Best Available)" }], }; diff --git a/open-sse/config/providers/registry/poolside/index.ts b/open-sse/config/providers/registry/poolside/index.ts new file mode 100644 index 0000000000..665ec6f997 --- /dev/null +++ b/open-sse/config/providers/registry/poolside/index.ts @@ -0,0 +1,11 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +export const poolsideProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "poolside", + alias: "poolside", + baseUrl: "https://inference.poolside.ai/v1/chat/completions", + modelsUrl: "https://inference.poolside.ai/v1/models", + models: [], + passthroughModels: true, +}); diff --git a/open-sse/config/providers/registry/tinycms/index.ts b/open-sse/config/providers/registry/tinycms/index.ts new file mode 100644 index 0000000000..b124b3da07 --- /dev/null +++ b/open-sse/config/providers/registry/tinycms/index.ts @@ -0,0 +1,40 @@ +import type { RegistryEntry } from "../../shared.ts"; + +/** + * TinyCMS — session-cookie free-tier and subscription gateway. + * + * Users get a device UUID starting with "R" from site.tinycms.xyz (stored in localStorage + * as app-config-uuid) and paste it as the credential. + * + * Emulates the cryptographic signatures (WASM signer) and Proof of Work expected + * by the TinyCMS server. + */ +export const tinycmsProvider: RegistryEntry = { + id: "tinycms-web", + alias: "tcw", + format: "openai", + executor: "tinycms-web", + baseUrl: "https://gov.freegpt.win/api/openai/oneapi/v1/chat/completions", + authType: "apikey", + authHeader: "uuid", + models: [ + { id: "gpt-5-free", name: "GPT 5 Free" }, + { id: "gpt-5.3-free", name: "GPT 5.3 Free (Multimodal/Vision)" }, + { id: "gpt-5.3-thinking-free", name: "GPT 5.3 Thinking Free", supportsReasoning: true }, + { id: "gpt-5.4-mini", name: "GPT 5.4 Mini" }, + { id: "gpt-5.4-nano", name: "GPT 5.4 Nano" }, + { id: "gpt-5-nano", name: "GPT 5 Nano" }, + { id: "gemini-3.5-flash", name: "Gemini 3.5 Flash" }, + { id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" }, + { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, + { id: "grok-4.20-fast", name: "Grok 4.20 Fast" }, + { id: "grok-4.20", name: "Grok 4.20" }, + { id: "grok-imagine", name: "Grok Imagine (Image Gen)" }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { id: "gpt-image-2", name: "GPT Image 2 (Image Gen)" }, + { id: "qwen3.6-plus", name: "Qwen 3.6 Plus" }, + ], +}; + +export default tinycmsProvider; diff --git a/open-sse/config/providers/registry/vertex/index.ts b/open-sse/config/providers/registry/vertex/index.ts index 0478d3a898..fc4f2fc0cd 100644 --- a/open-sse/config/providers/registry/vertex/index.ts +++ b/open-sse/config/providers/registry/vertex/index.ts @@ -30,4 +30,5 @@ export const vertexProvider: RegistryEntry = { { id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)" }, { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)" }, ], + passthroughModels: true, }; diff --git a/open-sse/config/providers/registry/zylo-api/index.ts b/open-sse/config/providers/registry/zylo-api/index.ts new file mode 100644 index 0000000000..285a16d27d --- /dev/null +++ b/open-sse/config/providers/registry/zylo-api/index.ts @@ -0,0 +1,11 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +export const zyloApiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "zylo-api", + alias: "zylo", + baseUrl: "https://api.zyloai.net/v1/chat/completions", + modelsUrl: "https://api.zyloai.net/v1/models", + models: [], + passthroughModels: true, +}); diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 7ed53eaf71..1198862b18 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -50,6 +50,7 @@ export interface RegistryModel { supportsReasoning?: boolean; supportedThinkingEfforts?: readonly string[]; supportsVision?: boolean; + supportsAudio?: boolean; supportsXHighEffort?: boolean; maxOutputTokens?: number; targetFormat?: string; @@ -271,16 +272,14 @@ export const GPT_5_6_API_CAPABILITIES = { maxOutputTokens: 128000, } as const; -// Codex's live catalog reports a 272K input context window for GPT-5.6. -// Keep the input and output limits explicit for catalog consumers that expose them separately. export const GPT_5_6_CODEX_CAPABILITIES = { targetFormat: "openai-responses", toolCalling: true, supportsReasoning: true, supportsVision: true, supportsXHighEffort: true, - contextLength: 272000, - maxInputTokens: 272000, + contextLength: 1050000, + maxInputTokens: 922000, maxOutputTokens: 128000, } as const; diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 260f1bf480..acbdc56b63 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -5,14 +5,17 @@ * Supports local providers plus hosted task-based APIs such as Runway. */ -import { parseModelFromRegistry, getAllModelsFromRegistry } from "./registryUtils.ts"; +import { parseModelFromRegistry } from "./registryUtils.ts"; import { RUNWAYML_SUPPORTED_VIDEO_MODELS } from "./runway.ts"; import { SEGMIND_VIDEO_MODELS } from "./providers/registry/segmind/videoModels.ts"; +import { toRegistryVideoModels } from "../services/adobeFireflyModels.ts"; interface VideoModel { id: string; name: string; isMarket?: boolean; + supportedSizes?: string[]; + mediaCapabilities?: Record; } interface VideoProvider { @@ -27,6 +30,21 @@ interface VideoProvider { } export const VIDEO_PROVIDERS: Record = { + agnes: { + id: "agnes", + baseUrl: "https://apihub.agnes-ai.com", + statusUrl: "https://apihub.agnes-ai.com/agnesapi", + authType: "apikey", + authHeader: "bearer", + format: "agnes-video-job", + models: [ + { + id: "agnes-video-v2.0", + name: "Agnes Video V2.0", + }, + ], + }, + "qwen-cloud-token-plan": { id: "qwen-cloud-token-plan", alias: "qct", @@ -70,6 +88,22 @@ export const VIDEO_PROVIDERS: Record = { ], }, + "fal-ai": { + id: "fal-ai", + baseUrl: "https://queue.fal.run", + authType: "apikey", + authHeader: "key", + format: "fal-ai-video", + models: [ + { id: "veo3.1/lite", name: "Veo 3.1 Lite" }, + { id: "google/gemini-omni-flash", name: "Gemini Omni Flash" }, + { + id: "xai/grok-imagine-video/text-to-video", + name: "Grok Imagine Video", + }, + ], + }, + googleflow: { id: "googleflow", alias: "flow", @@ -326,8 +360,7 @@ export const VIDEO_PROVIDERS: Record = { }, // Adobe Firefly (unofficial) — same IMS/cookie credential as the image entry. - // Async 3P video generate + poll (Sora 2, Veo 3.1, Kling …). Fallback list - // from models/discovery capture (adobe/get_models.txt). + // Exact async video models and capabilities from the verified discovery snapshot. "adobe-firefly": { id: "adobe-firefly", alias: "firefly", @@ -335,18 +368,16 @@ export const VIDEO_PROVIDERS: Record = { authType: "apikey", authHeader: "bearer", format: "adobe-firefly-video", - models: [ - { id: "sora-2", name: "Firefly Sora 2" }, - { id: "sora-2-pro", name: "Firefly Sora 2 Pro" }, - { id: "veo-3.1", name: "Firefly Veo 3.1" }, - { id: "veo-3.1-fast", name: "Firefly Veo 3.1 Fast" }, - { id: "veo-3.1-ref", name: "Firefly Veo 3.1 Reference" }, - { id: "kling-3", name: "Firefly Kling v3 Standard I2V" }, - { id: "kling-v3-t2v", name: "Firefly Kling v3 Standard T2V" }, - { id: "kling-v3-pro-i2v", name: "Firefly Kling v3 Pro I2V" }, - { id: "luma-ray3", name: "Firefly Ray3" }, - { id: "runway-gen4-turbo", name: "Firefly Runway Gen-4 Video" }, - ], + models: toRegistryVideoModels(), + }, + + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1/video/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [{ id: "default", name: "NanoGPT Video" }], }, }; @@ -368,5 +399,17 @@ export function parseVideoModel(modelStr: string | null) { * Get all video models as a flat list */ export function getAllVideoModels() { - return getAllModelsFromRegistry(VIDEO_PROVIDERS); + return Object.entries(VIDEO_PROVIDERS).flatMap(([providerId, config]) => + [providerId, config.alias] + .filter((prefix): prefix is string => Boolean(prefix)) + .flatMap((prefix) => + config.models.map((model) => ({ + id: `${prefix}/${model.id}`, + name: model.name, + provider: providerId, + supportedSizes: model.supportedSizes || [], + mediaCapabilities: model.mediaCapabilities, + })) + ) + ); } diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 2e70df8cc7..7f949284c6 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -23,8 +23,14 @@ import { import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance"; import { setConnectionRateLimitUntil } from "@/lib/db/providers"; import { getMitmAlias } from "@/lib/db/models"; +import { + MAX_ANTIGRAVITY_OUTPUT_TOKENS, + resolveAntigravityOutputCap, +} from "./antigravityOutputCap.ts"; +export { MAX_ANTIGRAVITY_OUTPUT_TOKENS } from "./antigravityOutputCap.ts"; import { ensureAntigravityProjectAssigned } from "../services/antigravityProjectBootstrap.ts"; import { persistDiscoveredAntigravityProjectId } from "../services/antigravityProjectPersist.ts"; +import { markAntigravityMissingCloudCodeProject } from "../services/antigravityProjectPersistence.ts"; import { resolveAntigravityModelId, getAntigravityModelFallbacks, @@ -279,18 +285,10 @@ async function cleanModelName(model: string, modelIdOverride?: string): Promise< return clean; } -/** - * Hard ceiling on `generationConfig.maxOutputTokens` for Antigravity Cloud Code. - * - * Ports decolua/9router#779 (lukmanfauzie): VS Code GitHub Copilot Chat in - * Agent mode regularly requests 32K–65K output tokens, which the Antigravity - * backend rejects with HTTP 400 "Invalid Argument". 16384 matches the - * upstream-accepted ceiling confirmed via successful 200 OK runs with - * claude-sonnet-4-6 and gemini-pro-agent across both Ask and Agent modes. - */ -export const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384; - -function applyAntigravityGenerationDefaults(request: Record): void { +function applyAntigravityGenerationDefaults( + request: Record, + modelId?: string | null +): void { const generationConfig = request.generationConfig && typeof request.generationConfig === "object" ? (request.generationConfig as Record) @@ -322,9 +320,10 @@ function applyAntigravityGenerationDefaults(request: Record): v // (32K–65K) that trigger upstream 400 "Invalid Argument". Clamp silently // — the cap is provider-driven, not client-driven, and only matters when // the request would otherwise be rejected outright. + const cap = resolveAntigravityOutputCap(modelId); const finalMax = Number(generationConfig.maxOutputTokens); - if (Number.isFinite(finalMax) && finalMax > MAX_ANTIGRAVITY_OUTPUT_TOKENS) { - generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS; + if (Number.isFinite(finalMax) && finalMax > cap) { + generationConfig.maxOutputTokens = cap; } request.generationConfig = generationConfig; @@ -556,6 +555,7 @@ export class AntigravityExecutor extends BaseExecutor { } if (!projectId) { + markAntigravityMissingCloudCodeProject(credentials?.connectionId); // (#489) Return a structured error instead of throwing — gives the client a clear signal // to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error". const errorMsg = @@ -666,7 +666,7 @@ export class AntigravityExecutor extends BaseExecutor { ) : rawTransformedRequest; - applyAntigravityGenerationDefaults(transformedRequest); + applyAntigravityGenerationDefaults(transformedRequest, upstreamModel); const { project: _project, @@ -1342,7 +1342,7 @@ export class AntigravityExecutor extends BaseExecutor { * the last url with no more retries left) fall through with the resolved retryMs * so the caller can still embed a long Retry-After in the final response body. */ - private async handleAntigravityRateLimit( + async handleAntigravityRateLimit( ctx: AntigravityRateLimitContext ): Promise { const { response, log, urlIndex, retryAttemptsByUrl, fallbackCount } = ctx; @@ -1351,10 +1351,12 @@ export class AntigravityExecutor extends BaseExecutor { let retryMs: number | null = this.parseRetryHeaders(response.headers); // If no retry time in headers, try to parse from error message body + let switchAuth = false; if (!retryMs) { const resolved = await this.tryResolveRetryFromErrorBody(ctx); if (resolved.kind === "return") return { action: "return", result: resolved.result }; retryMs = resolved.retryMs; + switchAuth = resolved.switchAuth; } // Bounded short-retry: a non-null retryAfterMs ≤ 60s covers nearly every @@ -1365,6 +1367,7 @@ export class AntigravityExecutor extends BaseExecutor { if ( retryMs && retryMs <= LONG_RETRY_THRESHOLD_MS && + !switchAuth && retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES ) { retryAttemptsByUrl[urlIndex]++; @@ -1420,7 +1423,8 @@ export class AntigravityExecutor extends BaseExecutor { private async tryResolveRetryFromErrorBody( ctx: AntigravityRateLimitContext ): Promise< - { kind: "return"; result: SsePassthroughResult } | { kind: "resolved"; retryMs: number | null } + | { kind: "return"; result: SsePassthroughResult } + | { kind: "resolved"; retryMs: number | null; switchAuth: boolean } > { const { response, @@ -1490,13 +1494,17 @@ export class AntigravityExecutor extends BaseExecutor { if (retryMs) markConnectionQuotaExhausted(accountId, retryMs); } - return { kind: "resolved", retryMs }; + return { + kind: "resolved", + retryMs, + switchAuth: decision.kind === "short_cooldown_switch_auth", + }; } catch (error) { if (signal?.aborted || isAbortError(error)) { throw signal?.reason ?? error; } // Ignore parse errors, will fall back to exponential backoff - return { kind: "resolved", retryMs: null }; + return { kind: "resolved", retryMs: null, switchAuth: false }; } } diff --git a/open-sse/executors/antigravityOutputCap.ts b/open-sse/executors/antigravityOutputCap.ts new file mode 100644 index 0000000000..c3d173cd92 --- /dev/null +++ b/open-sse/executors/antigravityOutputCap.ts @@ -0,0 +1,52 @@ +import { getExplicitModelOutputCap } from "@/lib/modelCapabilities"; + +/** + * Fallback ceiling on `generationConfig.maxOutputTokens` for Antigravity + * Cloud Code, used when the model is unknown to the catalogue. + * + * Ports decolua/9router#779 (lukmanfauzie): VS Code GitHub Copilot Chat in + * Agent mode regularly requests 32K–65K output tokens, which the Antigravity + * backend rejects with HTTP 400 "Invalid Argument". 16384 was the ceiling + * confirmed safe at the time, via successful 200 OK runs with + * claude-sonnet-4-6 and gemini-pro-agent across both Ask and Agent modes. + * + * Both of those models are catalogue-known today, so neither one reaches this + * constant anymore: they get their own declared limit via + * `resolveAntigravityOutputCap` (65536 and 65535, respectively). The higher + * limit holds against the live upstream. A gemini-3.6-flash-high request came + * back with completion_tokens 16754 and finish_reason "stop", which exceeds + * 16384 on its own and so cannot be an artifact of thinking-token accounting. + * + * Note also that #779 was reported against Copilot Chat in Agent mode, a path + * that does not reach this executor, so 16384 arrived with that port rather + * than from a limit measured here. Beware of re-deriving it from a running + * instance: the clamp below rewrites maxOutputTokens before the request + * leaves, so a build still carrying a low constant measures its own clamp and + * reports it as an upstream ceiling. + */ +export const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384; + +/** + * The output ceiling this specific model accepts, or the conservative + * fallback above when the id is not in the catalogue. + * + * The declared limits are not uniform: most Antigravity models publish + * 65535 or 65536, but gpt-oss-120b-medium publishes 32768. A single global + * ceiling either starves the first group or lets an oversized request + * through to the second, so the number has to come from the model. + */ +export function resolveAntigravityOutputCap(modelId: string | null | undefined): number { + const id = typeof modelId === "string" ? modelId.trim() : ""; + if (!id) return MAX_ANTIGRAVITY_OUTPUT_TOKENS; + try { + const declared = getExplicitModelOutputCap({ provider: "antigravity", model: id }); + return typeof declared === "number" && Number.isFinite(declared) && declared > 0 + ? declared + : MAX_ANTIGRAVITY_OUTPUT_TOKENS; + } catch { + // DB not available (build phase, transient error) -- fall through to the + // conservative fallback, the same guard cleanModelName uses above for + // its own MITM alias lookup. + return MAX_ANTIGRAVITY_OUTPUT_TOKENS; + } +} 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 812733ce31..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()); @@ -28,7 +28,11 @@ export class AzureOpenAIExecutor extends DefaultExecutor { void urlIndex; const providerSpecificData = credentials?.providerSpecificData || {}; - const baseUrl = normalizeAzureBaseUrl(providerSpecificData.baseUrl || this.config.baseUrl); + const baseUrl = normalizeAzureBaseUrl( + typeof providerSpecificData.baseUrl === "string" + ? providerSpecificData.baseUrl + : this.config.baseUrl + ); const apiVersion = typeof providerSpecificData.apiVersion === "string" && providerSpecificData.apiVersion.trim() ? providerSpecificData.apiVersion.trim() @@ -53,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.ts b/open-sse/executors/base.ts index b71f717b97..d1486263cb 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -56,6 +56,7 @@ import type { ProviderRequestDefaults } from "../services/providerRequestDefault import { signRequestBody } from "../services/claudeCodeCCH.ts"; import { appendAnthropicBetaHeader, + CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA, CONTEXT_1M_BETA_HEADER, enforceThinkingTemperature, modelHasNativeContext1m, @@ -234,71 +235,11 @@ export function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): return controller.signal; } -function hasActiveClaudeThinking(body: Record): boolean { - const thinking = body.thinking as Record | undefined; - return thinking?.type === "enabled" || thinking?.type === "adaptive"; -} - -/** - * Collect every `thinkingConfig` object in a transformed request body that holds - * a thinking budget, wherever the provider's envelope nests it: - * - body.generationConfig.thinkingConfig (native Gemini / openai→gemini) - * - body.request.generationConfig.thinkingConfig (Antigravity Cloud Code envelope) - * Returns only objects that actually carry a `thinkingBudget`/`thinking_budget` - * field — a request without thinking config is never mutated. - */ -function collectThinkingConfigs(body: unknown): Array> { - if (!body || typeof body !== "object") return []; - const root = body as Record; - const configs: Array> = []; - const envelopes: unknown[] = [ - root.generationConfig, - (root.request as Record | undefined)?.generationConfig, - ]; - for (const env of envelopes) { - if (!env || typeof env !== "object") continue; - const tc = (env as Record).thinkingConfig; - if (tc && typeof tc === "object") { - const tcr = tc as Record; - if ("thinkingBudget" in tcr || "thinking_budget" in tcr) configs.push(tcr); - } - } - return configs; -} - -/** - * Read the first thinking budget found in the body (any supported nest / naming). - * Returns null when the body carries no readable numeric budget. - */ -function readNestedThinkingBudget(body: unknown): number | null { - for (const tc of collectThinkingConfigs(body)) { - const raw = tc.thinkingBudget ?? tc.thinking_budget; - const n = Number(raw); - if (Number.isFinite(n)) return n; - } - return null; -} - -/** - * Clamp every thinking budget in the body down to `max` (only lowers; never - * raises a budget already below max). Mutates in place. Returns true when at - * least one budget was actually lowered (i.e. a retry would send a different - * body) — false means the 400 was not caused by an over-max budget we hold, so - * retrying would resend an identical body and loop. - */ -function clampNestedThinkingBudget(body: unknown, max: number): boolean { - let changed = false; - for (const tc of collectThinkingConfigs(body)) { - for (const key of ["thinkingBudget", "thinking_budget"] as const) { - const n = Number(tc[key]); - if (Number.isFinite(n) && n > max) { - tc[key] = max; - changed = true; - } - } - } - return changed; -} +import { + hasActiveClaudeThinking, + readNestedThinkingBudget, + clampNestedThinkingBudget, +} from "../utils/thinkingBudget.ts"; /** * Strip the OmniRoute provider prefix from tool model fields (e.g. @@ -1205,54 +1146,94 @@ export class BaseExecutor { // convention; SSE decoding is gated on body.stream). anthropic-beta // is selected per request shape; the full set on a quota probe is // itself a fingerprint. - // Respect the client's negotiated anthropic-beta (real Claude Code) instead - // of force-injecting thinking/effort betas it never requested (#3415). - const clientAnthropicBeta = - clientHeaders?.["anthropic-beta"] ?? clientHeaders?.["Anthropic-Beta"] ?? null; - const ccHeaders: Record = { - Accept: "application/json", - "anthropic-version": "2023-06-01", - // #3974: merge the client's allowlisted betas (e.g. tool-search-tool) - // on top of the shape-derived set so deferred-tool requests are not - // rejected; selectBetaFlags still gates thinking/effort per #3415. - "anthropic-beta": mergeClientAnthropicBeta( - selectBetaFlags(tb, null, clientAnthropicBeta), - clientAnthropicBeta - ), - "anthropic-dangerous-direct-browser-access": "true", - "x-app": "cli", - "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, - "X-Stainless-Package-Version": CLAUDE_CODE_STAINLESS_VERSION, - "X-Stainless-Timeout": "600", - "accept-encoding": "gzip, deflate, br, zstd", - connection: "keep-alive", - "x-client-request-id": randomUUID(), - "X-Claude-Code-Session-Id": sessionId, - }; + // + // This whole header shape (billing/session headers, Stainless + // metadata, selectBetaFlags()-derived anthropic-beta) mimics a + // genuine Claude Code CLI request — correct for real `claude` + // traffic, agentrouter's wire-image mimicry, and a "vanilla" (no + // requestDefaults) CC-compatible relay, none of which have their + // own per-connection header preferences to defer to. A relay with + // explicit providerSpecificData.requestDefaults (context1m / + // redactThinking / summarizeThinking) is different: it already got + // its own correctly-configured header set from + // buildClaudeCodeCompatibleHeaders() above, which selectBetaFlags() + // has no visibility into (it only reasons about the request body + // shape) — replacing those headers here would silently discard the + // relay's own opt-in configuration (#agentrouter regression: this + // whole block used to run only for real `claude` clients, where + // this distinction didn't exist). + const hasCcRequestDefaults = Object.keys(ccRequestDefaults).length > 0; + const isNativeClaudeHeaderShape = + this.provider === "claude" || usesCcWireImage(this.provider) || !hasCcRequestDefaults; + if (isNativeClaudeHeaderShape) { + // Respect the client's negotiated anthropic-beta (real Claude Code) instead + // of force-injecting thinking/effort betas it never requested (#3415). + const clientAnthropicBeta = + clientHeaders?.["anthropic-beta"] ?? clientHeaders?.["Anthropic-Beta"] ?? null; + const ccHeaders: Record = { + Accept: "application/json", + "anthropic-version": "2023-06-01", + // #3974: merge the client's allowlisted betas (e.g. tool-search-tool) + // on top of the shape-derived set so deferred-tool requests are not + // rejected; selectBetaFlags still gates thinking/effort per #3415. + "anthropic-beta": mergeClientAnthropicBeta( + selectBetaFlags(tb, null, clientAnthropicBeta), + clientAnthropicBeta + ), + "anthropic-dangerous-direct-browser-access": "true", + "x-app": "cli", + "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`, + "X-Stainless-Package-Version": CLAUDE_CODE_STAINLESS_VERSION, + "X-Stainless-Timeout": "600", + "accept-encoding": "gzip, deflate, br, zstd", + connection: "keep-alive", + "x-client-request-id": randomUUID(), + "X-Claude-Code-Session-Id": sessionId, + }; - // Drop case variants of the same header name before merging — undici - // would otherwise concatenate them (issue #1454). - const ccKeysLower = new Set(Object.keys(ccHeaders).map((k) => k.toLowerCase())); - for (const key of Object.keys(headers)) { - if (ccKeysLower.has(key.toLowerCase())) delete headers[key]; - } - Object.assign(headers, ccHeaders); - if (usesCcWireImage(this.provider) && usesClaudeCodeProtocol) { - delete headers["Authorization"]; - headers["x-api-key"] = - activeCredentials?.apiKey || activeCredentials?.accessToken || ""; - } - delete headers["X-Stainless-Helper-Method"]; + // Drop case variants of the same header name before merging — undici + // would otherwise concatenate them (issue #1454). + const ccKeysLower = new Set(Object.keys(ccHeaders).map((k) => k.toLowerCase())); + for (const key of Object.keys(headers)) { + if (ccKeysLower.has(key.toLowerCase())) delete headers[key]; + } + Object.assign(headers, ccHeaders); + if (usesCcWireImage(this.provider) && usesClaudeCodeProtocol) { + delete headers["Authorization"]; + headers["x-api-key"] = + activeCredentials?.apiKey || activeCredentials?.accessToken || ""; + } + delete headers["X-Stainless-Helper-Method"]; - // OS/arch follow the host running the signed binary. Runtime version - // is pinned to the captured CLI wire image, not OmniRoute's Node. - headers["X-Stainless-Arch"] = stainlessArch(); - headers["X-Stainless-Lang"] = "js"; - headers["X-Stainless-OS"] = stainlessOS(); - headers["X-Stainless-Runtime"] = "node"; - headers["X-Stainless-Runtime-Version"] = CLAUDE_CLI_STAINLESS_RUNTIME_VERSION; - headers["X-Stainless-Retry-Count"] = "0"; - delete headers["X-Stainless-Os"]; + // OS/arch follow the host running the signed binary. Runtime version + // is pinned to the captured CLI wire image, not OmniRoute's Node. + headers["X-Stainless-Arch"] = stainlessArch(); + headers["X-Stainless-Lang"] = "js"; + headers["X-Stainless-OS"] = stainlessOS(); + headers["X-Stainless-Runtime"] = "node"; + headers["X-Stainless-Runtime-Version"] = CLAUDE_CLI_STAINLESS_RUNTIME_VERSION; + headers["X-Stainless-Retry-Count"] = "0"; + delete headers["X-Stainless-Os"]; + } + // selectBetaFlags() above always includes redact-thinking for an + // "opaque" client (no client-negotiated anthropic-beta) — correct + // for real `claude` traffic and agentrouter's wire-image mimicry. + // A plain CC-compatible relay (bare or configured) never opts into + // that "opaque client" default implicitly; it's an explicit + // requestDefaults.redactThinking choice. Strip it back out unless + // this relay's own requestDefaults opted in. + if (usesClaudeCodeProtocol && !usesCcWireImage(this.provider)) { + const betaKey = Object.keys(headers).find( + (key) => key.toLowerCase() === "anthropic-beta" + ); + if (betaKey && ccRequestDefaults.redactThinking !== true) { + headers[betaKey] = headers[betaKey] + .split(",") + .map((value) => value.trim()) + .filter((value) => value && value !== CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA) + .join(","); + } + } const overrideTag = appliedEffort || appliedThinking diff --git a/open-sse/executors/base/reasoningEffort.ts b/open-sse/executors/base/reasoningEffort.ts index 04db3c69d9..55b328965e 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,14 @@ 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" && model.toLowerCase().includes("deepseek"); + (provider === "opencode-go" || provider === "opencode-zen") && + resolvedModelId.toLowerCase().includes("deepseek"); const isOllamaCloud = provider === "ollama-cloud"; - const isMoonshotK3 = - (provider === "moonshot" || provider === "kimi") && /^kimi-k3(?:$|-)/i.test(model); - return isClaude || isOpencodeGoDeepSeek || isOllamaCloud || isMoonshotK3; + const isMoonshotK3 = /^kimi-k3(?:$|-)/i.test(resolvedModelId); + // Command Code's upstream API accepts the literal DeepSeek/OpenAI effort value + // `max`; do not rewrite it to OmniRoute's internal `xhigh` spelling. + const isCommandCode = provider === "command-code"; + return isClaude || isOpencodeGoDeepSeek || isOllamaCloud || isMoonshotK3 || isCommandCode; } // ── Effort carrier helpers (#7044) ────────────────────────────────────────── @@ -275,17 +283,36 @@ export function sanitizeReasoningEffortForProvider( return stripEffortValue(b, c); } - // Native DeepSeek (api.deepseek.com) — V4 thinking mode accepts reasoning_effort - // ONLY as {high, max} (its own top tier is literally "max"). OmniRoute's internal - // scale is low|medium|high|xhigh where xhigh is the top, so map onto DeepSeek's - // vocabulary: xhigh → max (top→top), low|medium → high (below the enum floor). - // high/max pass through unchanged. Without this, the claude→openai translator's - // xhigh (and max-normalized-to-xhigh below) reaches DeepSeek as an unknown value, - // silently dropping the client's requested effort. This is the INVERSE of the - // OpenRouter-DeepSeek path, whose normalized API expects xhigh, not max (pi#4055). + // Command Code accepts the literal top-tier value `max`, while the shared + // standardization stage may have already represented the client's `max` as + // OmniRoute's internal `xhigh`. Convert it back before the upstream request. + if (provider === "command-code" && effortStr === "xhigh") { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: normalized reasoning_effort xhigh → max` + ); + return writeEffortValue(b, "max", c); + } + + // Native DeepSeek (api.deepseek.com) — V4 thinking mode uses the native + // {low, high, max} vocabulary on Flash and {high, max} on Pro. OmniRoute's + // internal top tier xhigh maps to DeepSeek's literal max. Pro's unsupported + // low/medium values still clamp to high; Flash's documented low tier passes + // through. This is the INVERSE of the OpenRouter-DeepSeek path, whose + // normalized API expects xhigh, not max (pi#4055). `none` is already the + // OpenAI no-thinking carrier and passes through unchanged. if (provider === "deepseek") { + // Match the Flash family even when the sanitizer sees a suffixed or prefixed + // id — exact-match would silently clamp Flash `low → high` if a future route + // forwards the raw catalog id (`deepseek-v4-flash-low`) before resolution + // (#9485 review). + const isFlash = modelStr.toLowerCase().startsWith("deepseek-v4-flash"); const mapped = - effortStr === "xhigh" ? "max" : effortStr === "low" || effortStr === "medium" ? "high" : null; + effortStr === "xhigh" + ? "max" + : effortStr === "medium" || (effortStr === "low" && !isFlash) + ? "high" + : null; if (mapped && mapped !== effortStr) { log?.info?.( "REASONING_SANITIZE", @@ -297,27 +324,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/bedrock.ts b/open-sse/executors/bedrock.ts index 200962af96..b9b6238c4f 100644 --- a/open-sse/executors/bedrock.ts +++ b/open-sse/executors/bedrock.ts @@ -393,6 +393,8 @@ function usageFromBedrock(usage) { prompt_tokens: input, completion_tokens: output, total_tokens: Number(usage?.totalTokens || input + output), + cache_read_input_tokens: Number(usage?.cacheReadInputTokenCount || 0), + cache_creation_input_tokens: Number(usage?.cacheWriteInputTokenCount || 0), }; } diff --git a/open-sse/executors/chatgpt-web-codex.ts b/open-sse/executors/chatgpt-web-codex.ts new file mode 100644 index 0000000000..c478a693e8 --- /dev/null +++ b/open-sse/executors/chatgpt-web-codex.ts @@ -0,0 +1,441 @@ +import { existsSync } from "node:fs"; + +import { isVerifiedNativeCodexRequest } from "../config/codexIdentity.ts"; +import { FORMATS } from "../translator/formats.ts"; +import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts"; +import { createChatGptWebAdapter } from "../vendor/codex-chatgpt-web/adapters/chatgpt-web/index.ts"; +import { ChatGptBrowserWorker } from "../vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts"; +import { + browserLoginStateExists, + inspectBrowserLoginCapabilities, +} from "../vendor/codex-chatgpt-web/browser-login.ts"; +import { extractChatGptTurnIdentity } from "../vendor/codex-chatgpt-web/adapters/chatgpt-web/environment.ts"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../vendor/codex-chatgpt-web/bridge.ts"; +import { AsyncEventQueue } from "../vendor/codex-chatgpt-web/event-queue.ts"; +import { parseRequest } from "../vendor/codex-chatgpt-web/responses/parser.ts"; +import { + expandPreviousResponseInput, + rememberResponseState, +} from "../vendor/codex-chatgpt-web/responses/state.ts"; +import type { + AdapterEvent, + CodexParsedRequest, + CodexProviderConfig, +} from "../vendor/codex-chatgpt-web/types.ts"; +import { BaseExecutor, type ExecuteInput, type ExecutorExecuteResult } from "./base.ts"; +import { reasoningEffortOf, requireChatGptWebCodexRoute } from "./chatgpt-web-codex/models.ts"; +import { + connectionRuntimePaths, + ensureConnectionStorageStateFromCredential, + readConnectionStorageState, +} from "./chatgpt-web-codex/storageState.ts"; +import { + decodeChatGptWebCodexSecrets, + encodeChatGptWebCodexSecrets, +} from "./chatgpt-web-codex/credentials.ts"; +import { ensureTunnelRuntimeReady } from "./chatgpt-web-codex/tunnelClient.ts"; +import { trackChatGptWebCodexRuntime } from "./chatgpt-web-codex/runtime.ts"; + +const JSON_HEADERS = { "Content-Type": "application/json" }; +const SSE_HEADERS = { + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "Content-Type": "text/event-stream; charset=utf-8", +}; + +function errorResponse(status: number, message: unknown, code = "chatgpt_web_codex_error") { + return new Response( + JSON.stringify( + buildErrorBody(status, sanitizeErrorMessage(message), undefined, { + type: status >= 500 ? "provider_error" : "invalid_request_error", + code, + }) + ), + { status, headers: JSON_HEADERS } + ); +} + +function wrapped(response: Response, body: unknown): ExecutorExecuteResult { + return { + response, + url: "https://chatgpt.com/?temporary-chat=true", + headers: {}, + transformedBody: body, + transport: "chatgpt-web-browser", + }; +} + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function nativeBody(body: unknown): Record { + const source = record(body); + const copy = { ...source }; + delete copy._nativeCodexPassthrough; + return copy; +} + +function headersFromRecord(values?: Record | null): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(values ?? {})) headers.set(name, value); + return headers; +} + +function configuredString(data: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = data[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + +export function detectChromeExecutable(explicit?: string): string | undefined { + const candidates = [ + explicit, + process.env.CHATGPT_WEB_CODEX_CHROME_PATH, + process.env.CHROME_PATH, + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ]; + return candidates.find((candidate): candidate is string => + Boolean(candidate && existsSync(candidate)) + ); +} + +function responseStateNamespace(connectionId: string, parsed: CodexParsedRequest): string { + const identity = extractChatGptTurnIdentity(parsed); + if (!identity.threadId || !identity.turnId) { + throw new Error("Native Codex thread_id and turn_id are required"); + } + return `${connectionId}:${identity.threadId}:${identity.turnId}`; +} + +function previousResponseBelongsToTurn( + body: Record, + connectionId: string, + parsed: CodexParsedRequest +): boolean { + if (typeof body.previous_response_id !== "string" || !body.previous_response_id.trim()) { + return true; + } + try { + const namespace = responseStateNamespace(connectionId, parsed); + const expanded = expandPreviousResponseInput(body, namespace); + return expanded !== body; + } catch { + return false; + } +} + +function toolModeRequired(parsed: CodexParsedRequest): boolean { + if (parsed.options.toolChoice === "none") return false; + return (parsed.context.tools?.length ?? 0) > 0; +} + +function buildProviderConfig( + input: ExecuteInput, + parsed: CodexParsedRequest, + storageStatePath: string, + connectionId: string +): CodexProviderConfig { + const data = record(input.credentials.providerSpecificData); + const route = requireChatGptWebCodexRoute(input.model); + const paths = connectionRuntimePaths(connectionId); + const cdpEndpoint = + configuredString(data, "browserCdpEndpoint") ?? process.env.CHATGPT_WEB_CODEX_CDP_URL; + const chromeExecutablePath = detectChromeExecutable( + configuredString(data, "chromeExecutablePath") + ); + if (!chromeExecutablePath && !cdpEndpoint) { + throw new Error("No supported Chrome or Chromium executable was found"); + } + + const proAvailable = data.proAvailable === true; + if (route.pro && !proAvailable) { + throw new Error("ChatGPT Pro is not available for this connection"); + } + + const hasTools = toolModeRequired(parsed); + const requiredChoice = + parsed.options.toolChoice === "required" || typeof parsed.options.toolChoice === "object"; + if (route.pro && requiredChoice) { + throw new Error("ChatGPT Web Pro is read-only and cannot satisfy a required tool choice"); + } + + const connector = + configuredString(data, "connectorName", "appName") ?? + process.env.CHATGPT_WEB_CODEX_CONNECTOR_NAME?.trim(); + if (!route.pro && hasTools && !connector) { + throw new Error("ChatGPT Web (Codex) tools require a ready tunnel and Custom Connector"); + } + + parsed.modelId = "gpt-5.6-sol"; + parsed.options.reasoning = route.effort; + + return { + adapter: "chatgpt-web", + baseUrl: "https://chatgpt.com", + defaultModel: "gpt-5.6-sol", + models: ["gpt-5.6-sol"], + chatgptWeb: { + ...(connector ? { appName: connector } : {}), + storageStatePath, + ...(chromeExecutablePath ? { chromeExecutablePath } : {}), + ...(cdpEndpoint ? { cdpEndpoint } : {}), + brokerSocketPath: paths.brokerSocketPath, + threadEnvironmentStatePath: paths.threadEnvironmentStatePath, + headed: false, + localToolsEnabled: !route.pro && hasTools, + proAvailable, + autoApproveToolCalls: !route.pro && hasTools, + }, + }; +} + +function toolMaps(parsed: CodexParsedRequest) { + const namespace = new Map(); + const freeform = new Set(); + const toolSearch = new Set(); + for (const tool of parsed.context.tools ?? []) { + const wireName = tool.namespace ? `${tool.namespace}__${tool.name}` : tool.name; + if (tool.namespace) namespace.set(wireName, { namespace: tool.namespace, name: tool.name }); + if (tool.freeform) freeform.add(wireName); + if (tool.toolSearch) toolSearch.add(wireName); + } + return { namespace, freeform, toolSearch }; +} + +export class ChatGptWebCodexExecutor extends BaseExecutor { + constructor() { + super("chatgpt-web-codex", { + id: "chatgpt-web-codex", + baseUrl: "https://chatgpt.com", + format: FORMATS.OPENAI_RESPONSES, + }); + } + + override async execute(input: ExecuteInput): Promise { + try { + const body = record(input.body); + if ( + input.clientResponseFormat !== FORMATS.OPENAI_RESPONSES || + body._nativeCodexPassthrough !== true + ) { + return wrapped( + errorResponse( + 400, + "ChatGPT Web (Codex) supports only native /v1/responses requests", + "unsupported_endpoint" + ), + input.body + ); + } + if (!isVerifiedNativeCodexRequest(body, input.clientHeaders)) { + return wrapped( + errorResponse( + 400, + "ChatGPT Web (Codex) requires a verified Codex client request with thread_id and turn_id", + "unverified_codex_client" + ), + input.body + ); + } + + const connectionId = input.credentials.connectionId?.trim(); + const encodedCredentials = input.credentials.apiKey?.trim(); + if (!connectionId || !encodedCredentials) { + return wrapped( + errorResponse(401, "ChatGPT Web (Codex) connection credentials are missing"), + input.body + ); + } + const secrets = decodeChatGptWebCodexSecrets(encodedCredentials); + + const initialBody = nativeBody(input.body); + const initialParsed = parseRequest(initialBody); + const namespace = responseStateNamespace(connectionId, initialParsed); + if (!previousResponseBelongsToTurn(initialBody, connectionId, initialParsed)) { + return wrapped( + errorResponse( + 409, + "previous_response_id does not belong to this verified Codex turn", + "invalid_previous_response_binding" + ), + initialBody + ); + } + const expandedBody = expandPreviousResponseInput(initialBody, namespace); + const parsed = parseRequest(expandedBody); + responseStateNamespace(connectionId, parsed); + + const route = requireChatGptWebCodexRoute(input.model); + const explicitEffort = reasoningEffortOf(initialBody); + const normalizedEffort = explicitEffort === "ultra" ? "max" : explicitEffort; + if (normalizedEffort && normalizedEffort !== route.effort) { + return wrapped( + errorResponse( + 400, + `Requested reasoning effort ${explicitEffort} is incompatible with model ${route.id}`, + "incompatible_reasoning_effort" + ), + initialBody + ); + } + + const storageStatePath = ensureConnectionStorageStateFromCredential(connectionId, secrets); + const providerData = record(input.credentials.providerSpecificData); + const cdpEndpoint = + configuredString(providerData, "browserCdpEndpoint") ?? + process.env.CHATGPT_WEB_CODEX_CDP_URL; + const chromeExecutablePath = detectChromeExecutable( + configuredString(providerData, "chromeExecutablePath") + ); + if (!chromeExecutablePath && !cdpEndpoint) { + throw new Error("No supported Chrome or Chromium executable was found"); + } + const runtimePaths = connectionRuntimePaths(connectionId); + const loginConfig = { + mode: "browser-only" as const, + appName: configuredString(providerData, "connectorName", "appName") ?? "OmniRoute Codex", + ...(chromeExecutablePath ? { chromeExecutablePath } : {}), + ...(cdpEndpoint ? { cdpEndpoint } : {}), + storageStatePath, + brokerSocketPath: runtimePaths.brokerSocketPath, + headed: false, + proAvailable: providerData.proAvailable === true, + autoApproveToolCalls: false, + }; + if (!browserLoginStateExists(loginConfig)) { + const capabilities = await inspectBrowserLoginCapabilities(loginConfig); + providerData.proAvailable = capabilities.proAvailable; + providerData.browserVerified = true; + if (chromeExecutablePath) providerData.chromeExecutablePath = chromeExecutablePath; + if (cdpEndpoint) providerData.browserCdpEndpoint = cdpEndpoint; + await input.onCredentialsRefreshed?.({ + providerSpecificData: { + ...record(input.credentials.providerSpecificData), + proAvailable: capabilities.proAvailable, + browserVerified: true, + ...(chromeExecutablePath ? { chromeExecutablePath } : {}), + ...(cdpEndpoint ? { browserCdpEndpoint: cdpEndpoint } : {}), + }, + }); + } + const routeUsesTools = !route.pro && toolModeRequired(parsed); + if (routeUsesTools) { + const tunnelId = + configuredString(providerData, "tunnelId") ?? + process.env.CHATGPT_WEB_CODEX_TUNNEL_ID?.trim(); + const runtimeKey = secrets.runtimeKey ?? process.env.CHATGPT_WEB_CODEX_RUNTIME_KEY?.trim(); + if (!tunnelId || !runtimeKey) { + throw new Error("ChatGPT Web (Codex) tools require Tunnel-ID and Runtime-Key"); + } + await ensureTunnelRuntimeReady({ + tunnelId, + runtimeKey, + brokerSocketPath: connectionRuntimePaths(connectionId).brokerSocketPath, + }); + } + const provider = buildProviderConfig( + { + ...input, + credentials: { ...input.credentials, providerSpecificData: providerData }, + }, + parsed, + storageStatePath, + connectionId + ); + const adapter = createChatGptWebAdapter(provider); + const worker = ChatGptBrowserWorker.forProvider(provider); + trackChatGptWebCodexRuntime(worker, connectionRuntimePaths(connectionId).brokerSocketPath); + const maps = toolMaps(parsed); + const events = new AsyncEventQueue(); + const incoming = { + headers: headersFromRecord(input.clientHeaders), + abortSignal: input.signal ?? undefined, + }; + const run = async () => { + try { + await adapter.runTurn(parsed, incoming, (event) => events.push(event)); + } catch (error) { + events.push({ + type: "error", + message: sanitizeErrorMessage(error instanceof Error ? error.message : error), + status: 502, + errorType: "provider_error", + code: "chatgpt_web_codex_turn_failed", + }); + } finally { + try { + const storageState = readConnectionStorageState(storageStatePath); + await input.onCredentialsRefreshed?.({ + apiKey: encodeChatGptWebCodexSecrets({ + storageState, + runtimeKey: secrets.runtimeKey, + }), + }); + } catch (refreshError) { + input.log?.warn?.( + "CHATGPT_WEB_CODEX", + sanitizeErrorMessage( + refreshError instanceof Error ? refreshError.message : refreshError + ) + ); + } + events.close(); + } + }; + + if (!input.stream) { + const running = run(); + const collected = await events.collect(); + await running; + const response = buildResponseJSON(collected, input.model, { + hideThinkingSummary: parsed.options.hideThinkingSummary, + toolNsMap: maps.namespace, + freeformToolNames: maps.freeform, + toolSearchToolNames: maps.toolSearch, + compaction: parsed._compactionRequest, + }); + rememberResponseState(expandedBody, response, { force: true, namespace }); + return wrapped( + new Response(JSON.stringify(response), { status: 200, headers: JSON_HEADERS }), + expandedBody + ); + } + + void run(); + const stream = bridgeToResponsesSSE( + events, + input.model, + maps.namespace, + maps.freeform, + maps.toolSearch, + undefined, + 2_000, + { + hideThinkingSummary: parsed.options.hideThinkingSummary, + compaction: parsed._compactionRequest, + onCompletedResponse: (response) => + rememberResponseState(expandedBody, response, { force: true, namespace }), + } + ); + return wrapped(new Response(stream, { status: 200, headers: SSE_HEADERS }), expandedBody); + } catch (error) { + input.log?.warn?.( + "CHATGPT_WEB_CODEX", + sanitizeErrorMessage(error instanceof Error ? error.message : error) + ); + return wrapped( + errorResponse(400, error instanceof Error ? error.message : error), + input.body + ); + } + } +} diff --git a/open-sse/executors/chatgpt-web-codex/credentials.ts b/open-sse/executors/chatgpt-web-codex/credentials.ts new file mode 100644 index 0000000000..2a5e812069 --- /dev/null +++ b/open-sse/executors/chatgpt-web-codex/credentials.ts @@ -0,0 +1,58 @@ +export type ChatGptWebCodexSecrets = { + cookie?: string; + storageState?: Record; + runtimeKey?: string; +}; + +const VERSION = 2; + +function normalizedCookie(value: string): string { + return value.trim().replace(/^cookie\s*:\s*/i, ""); +} + +export function encodeChatGptWebCodexSecrets(secrets: ChatGptWebCodexSecrets): string { + const cookie = secrets.cookie ? normalizedCookie(secrets.cookie) : ""; + const storageState = secrets.storageState; + if (!cookie && (!storageState || typeof storageState !== "object")) { + throw new Error("ChatGPT Cookie or verified browser storage state is required"); + } + return JSON.stringify({ + version: VERSION, + ...(storageState ? { storageState } : { cookie }), + ...(secrets.runtimeKey?.trim() ? { runtimeKey: secrets.runtimeKey.trim() } : {}), + }); +} + +export function decodeChatGptWebCodexSecrets(value: string): ChatGptWebCodexSecrets { + const trimmed = value.trim(); + if (!trimmed) throw new Error("ChatGPT Web (Codex) credentials are missing"); + try { + const parsed = JSON.parse(trimmed) as Record; + if ( + parsed.version === VERSION && + parsed.storageState && + typeof parsed.storageState === "object" + ) { + return { + storageState: parsed.storageState as Record, + ...(typeof parsed.runtimeKey === "string" && parsed.runtimeKey.trim() + ? { runtimeKey: parsed.runtimeKey.trim() } + : {}), + }; + } + if ((parsed.version === VERSION || parsed.version === 1) && typeof parsed.cookie === "string") { + const cookie = normalizedCookie(parsed.cookie); + if (!cookie) throw new Error("ChatGPT Cookie is missing"); + return { + cookie, + ...(typeof parsed.runtimeKey === "string" && parsed.runtimeKey.trim() + ? { runtimeKey: parsed.runtimeKey.trim() } + : {}), + }; + } + } catch (error) { + if (error instanceof SyntaxError) return { cookie: normalizedCookie(trimmed) }; + throw error; + } + return { cookie: normalizedCookie(trimmed) }; +} diff --git a/open-sse/executors/chatgpt-web-codex/doctor.ts b/open-sse/executors/chatgpt-web-codex/doctor.ts new file mode 100644 index 0000000000..b862d72b47 --- /dev/null +++ b/open-sse/executors/chatgpt-web-codex/doctor.ts @@ -0,0 +1,116 @@ +import { existsSync, readFileSync } from "node:fs"; + +import { browserLoginStateExists } from "../../vendor/codex-chatgpt-web/browser-login.ts"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { detectChromeExecutable } from "../chatgpt-web-codex.ts"; +import { decodeChatGptWebCodexSecrets } from "./credentials.ts"; +import { getChatGptWebCodexRuntimeCounts } from "./runtime.ts"; +import { + connectionRuntimePaths, + ensureConnectionStorageStateFromCredential, +} from "./storageState.ts"; +import { + getTunnelRuntimeStatus, + tunnelClientPaths, + tunnelSupervisorLeaseStatus, +} from "./tunnelClient.ts"; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export async function getChatGptWebCodexDoctorStatus(connection: { + id?: unknown; + apiKey?: unknown; + providerSpecificData?: unknown; + lastError?: unknown; +}) { + const connectionId = typeof connection.id === "string" ? connection.id : ""; + const data = record(connection.providerSpecificData); + const paths = connectionRuntimePaths(connectionId); + const tunnelPaths = tunnelClientPaths(); + const cdpConfigured = Boolean(process.env.CHATGPT_WEB_CODEX_CDP_URL?.trim()); + const chrome = detectChromeExecutable( + typeof data.chromeExecutablePath === "string" ? data.chromeExecutablePath : undefined + ); + let storageState = false; + let login = false; + let proAvailable = data.proAvailable === true; + let credential = false; + try { + const secrets = decodeChatGptWebCodexSecrets(String(connection.apiKey || "")); + credential = Boolean(secrets.storageState); + if (credential) ensureConnectionStorageStateFromCredential(connectionId, secrets); + storageState = existsSync(paths.storageStatePath); + login = browserLoginStateExists({ + mode: "browser-only", + appName: "OmniRoute Codex", + storageStatePath: paths.storageStatePath, + ...(chrome ? { chromeExecutablePath: chrome } : {}), + ...(cdpConfigured ? { cdpEndpoint: process.env.CHATGPT_WEB_CODEX_CDP_URL } : {}), + headed: false, + proAvailable, + autoApproveToolCalls: false, + }); + if (login) { + try { + const marker = JSON.parse( + readFileSync(`${paths.storageStatePath}.verified.json`, "utf8") + ) as Record; + if (typeof marker.proAvailable === "boolean") proAvailable = marker.proAvailable; + } catch { + // Marker detail is optional. + } + } + } catch { + credential = false; + } + + let tunnel = { + ok: false, + processRunning: false, + healthy: false, + ready: false, + detail: "not checked", + }; + try { + if (existsSync(tunnelPaths.binary)) tunnel = await getTunnelRuntimeStatus({}); + } catch (error) { + tunnel.detail = sanitizeErrorMessage(error instanceof Error ? error.message : error); + } + + const runtime = getChatGptWebCodexRuntimeCounts(); + const lease = tunnelSupervisorLeaseStatus(); + return { + browser: { + ready: Boolean(chrome || cdpConfigured), + mode: cdpConfigured ? "internal-cdp" : chrome ? "local-chromium" : "unavailable", + }, + storageState: { ready: storageState && credential }, + login: { ready: login }, + temporaryChats: { ready: login }, + tunnelBinary: { ready: existsSync(tunnelPaths.binary) }, + tunnel: { + ready: tunnel.ok, + processRunning: tunnel.processRunning, + healthy: tunnel.healthy, + detail: tunnel.detail, + }, + connector: { + ready: typeof data.connectorName === "string" && data.connectorName.trim().length > 0, + }, + toolRoundtrip: { ready: tunnel.ok && runtime.brokers > 0 }, + runtime, + lease, + proAvailable, + recovery: { + interactiveLoginRequired: storageState && !login, + }, + lastError: + typeof connection.lastError === "string" && connection.lastError.trim() + ? sanitizeErrorMessage(connection.lastError) + : null, + }; +} diff --git a/open-sse/executors/chatgpt-web-codex/models.ts b/open-sse/executors/chatgpt-web-codex/models.ts new file mode 100644 index 0000000000..646254865e --- /dev/null +++ b/open-sse/executors/chatgpt-web-codex/models.ts @@ -0,0 +1,32 @@ +export type ChatGptWebCodexEffort = "low" | "medium" | "high" | "xhigh" | "max"; + +export interface ChatGptWebCodexModelRoute { + id: string; + effort: ChatGptWebCodexEffort; + pro: boolean; +} + +const ROUTES = new Map([ + ["instant", { id: "instant", effort: "low", pro: false }], + ["medium", { id: "medium", effort: "medium", pro: false }], + ["high", { id: "high", effort: "high", pro: false }], + ["extra-high", { id: "extra-high", effort: "xhigh", pro: false }], + ["pro", { id: "pro", effort: "max", pro: true }], +]); + +export function requireChatGptWebCodexRoute(model: string): ChatGptWebCodexModelRoute { + const normalized = model.replace(/^chatgpt-web-codex\//, ""); + const route = ROUTES.get(normalized); + if (!route) throw new Error(`Unsupported ChatGPT Web (Codex) model: ${model}`); + return route; +} + +export function reasoningEffortOf(body: Record): string | undefined { + const reasoning = body.reasoning; + if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) { + const effort = (reasoning as Record).effort; + return typeof effort === "string" ? effort : undefined; + } + const effort = body.reasoning_effort; + return typeof effort === "string" ? effort : undefined; +} diff --git a/open-sse/executors/chatgpt-web-codex/runtime.ts b/open-sse/executors/chatgpt-web-codex/runtime.ts new file mode 100644 index 0000000000..70d2d7b7b9 --- /dev/null +++ b/open-sse/executors/chatgpt-web-codex/runtime.ts @@ -0,0 +1,45 @@ +import { ChatGptBrowserWorker } from "../../vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts"; +import { TurnBroker } from "../../vendor/codex-chatgpt-web/adapters/chatgpt-web/turn-broker.ts"; +import { chatGptTurnSessions } from "../../vendor/codex-chatgpt-web/adapters/chatgpt-web/turn-execution.ts"; +import { connectionRuntimePaths } from "./storageState.ts"; +import { stopChatGptWebCodexTunnelRuntime } from "./tunnelClient.ts"; + +const activeWorkers = new Set(); +const activeBrokers = new Set(); + +export function trackChatGptWebCodexRuntime( + worker: ChatGptBrowserWorker, + brokerSocketPath: string +): void { + activeWorkers.add(worker); + activeBrokers.add(TurnBroker.forSocket(brokerSocketPath)); +} + +export function getChatGptWebCodexRuntimeCounts(): { + activeTurns: number; + waitingTurns: number; + browserWorkers: number; + brokers: number; +} { + return { + activeTurns: chatGptTurnSessions.activeCount(), + waitingTurns: chatGptTurnSessions.waitingCount(), + browserWorkers: activeWorkers.size, + brokers: activeBrokers.size, + }; +} + +export async function stopChatGptWebCodexRuntime(): Promise { + chatGptTurnSessions.clear(); + const workers = [...activeWorkers]; + const brokers = [...activeBrokers]; + activeWorkers.clear(); + activeBrokers.clear(); + await Promise.allSettled(workers.map((worker) => worker.close())); + await Promise.allSettled(brokers.map((broker) => broker.close())); + await stopChatGptWebCodexTunnelRuntime(); +} + +export function brokerSocketPathForConnection(connectionId: string): string { + return connectionRuntimePaths(connectionId).brokerSocketPath; +} diff --git a/open-sse/executors/chatgpt-web-codex/storageState.ts b/open-sse/executors/chatgpt-web-codex/storageState.ts new file mode 100644 index 0000000000..ce335ea6a1 --- /dev/null +++ b/open-sse/executors/chatgpt-web-codex/storageState.ts @@ -0,0 +1,185 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +import { atomicWriteFile, getConfigDir } from "../../vendor/codex-chatgpt-web/config.ts"; +import { loginVerificationMarkerPath } from "../../vendor/codex-chatgpt-web/browser-login.ts"; + +function connectionSegment(connectionId: string): string { + return createHash("sha256").update(connectionId).digest("hex").slice(0, 32); +} + +export function connectionRuntimePaths(connectionId: string) { + const root = join(getConfigDir(), "connections", connectionSegment(connectionId)); + return { + root, + storageStatePath: join(root, "storage-state.json"), + brokerSocketPath: join(getConfigDir(), "runtime", "turn-broker.sock"), + threadEnvironmentStatePath: join(root, "thread-environments.json"), + }; +} + +function cookieHeaderValue(raw: string): string { + return raw.trim().replace(/^cookie\s*:\s*/i, ""); +} + +function parseCookies(raw: string): Array> { + const header = cookieHeaderValue(raw); + const pairs = header + .split(/;\s*/) + .map((part) => { + const separator = part.indexOf("="); + return separator > 0 ? [part.slice(0, separator).trim(), part.slice(separator + 1)] : null; + }) + .filter((pair): pair is [string, string] => Boolean(pair?.[0])); + if (!pairs.some(([name]) => /^__Secure-next-auth\.session-token(?:\.\d+)?$/.test(name))) { + if (header.includes(";") || header.includes("=")) { + throw new Error("ChatGPT Cookie header is missing __Secure-next-auth.session-token"); + } + pairs.push(["__Secure-next-auth.session-token", header]); + } + return pairs.map(([name, value]) => ({ + name, + value, + domain: ".chatgpt.com", + path: "/", + secure: true, + httpOnly: name.startsWith("__Secure-") || name.startsWith("__Host-"), + sameSite: "Lax", + })); +} + +function cookieFingerprint(raw: string): string { + return createHash("sha256").update(cookieHeaderValue(raw)).digest("hex"); +} + +function stateFingerprint(state: Record): string { + return createHash("sha256").update(JSON.stringify(state)).digest("hex"); +} + +function validStorageState(value: unknown): value is Record { + return ( + !!value && + typeof value === "object" && + !Array.isArray(value) && + Array.isArray((value as Record).cookies) && + Array.isArray((value as Record).origins) + ); +} + +export function readConnectionStorageState(path: string): Record { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (!validStorageState(parsed)) throw new Error("ChatGPT browser storage state is invalid"); + return parsed; +} + +export function ensureConnectionStorageState(connectionId: string, rawCookie: string): string { + const paths = connectionRuntimePaths(connectionId); + const markerPath = loginVerificationMarkerPath(paths.storageStatePath); + const fingerprint = cookieFingerprint(rawCookie); + if (existsSync(paths.storageStatePath) && existsSync(markerPath)) { + try { + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as Record; + if ( + marker.version === 1 && + marker.authenticated === true && + marker.cookieFingerprint === fingerprint + ) { + return paths.storageStatePath; + } + } catch { + // Rebuild the state below. + } + } + + atomicWriteFile( + paths.storageStatePath, + `${JSON.stringify({ cookies: parseCookies(rawCookie), origins: [] })}\n` + ); + atomicWriteFile( + markerPath, + `${JSON.stringify({ + version: 1, + authenticated: true, + verifiedAt: new Date().toISOString(), + cookieFingerprint: fingerprint, + pendingBrowserVerification: true, + })}\n` + ); + return paths.storageStatePath; +} + +export function ensureConnectionStorageStateFromCredential( + connectionId: string, + credential: { cookie?: string; storageState?: Record } +): string { + if (credential.storageState) { + if (!validStorageState(credential.storageState)) { + throw new Error("Encrypted ChatGPT browser storage state is invalid"); + } + const paths = connectionRuntimePaths(connectionId); + const markerPath = loginVerificationMarkerPath(paths.storageStatePath); + const fingerprint = stateFingerprint(credential.storageState); + if (existsSync(paths.storageStatePath) && existsSync(markerPath)) { + try { + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as Record; + if ( + marker.version === 1 && + marker.authenticated === true && + marker.pendingBrowserVerification !== true && + marker.storageStateFingerprint === fingerprint + ) { + return paths.storageStatePath; + } + } catch { + // Rebuild the protected local working copy below. + } + } + atomicWriteFile(paths.storageStatePath, `${JSON.stringify(credential.storageState)}\n`); + atomicWriteFile( + markerPath, + `${JSON.stringify({ + version: 1, + authenticated: true, + verifiedAt: new Date().toISOString(), + storageStateFingerprint: fingerprint, + pendingBrowserVerification: false, + })}\n` + ); + return paths.storageStatePath; + } + if (!credential.cookie) throw new Error("ChatGPT browser credentials are missing"); + return ensureConnectionStorageState(connectionId, credential.cookie); +} + +export function finalizeValidatedChatGptWebCodexSecrets( + encodedCredential: string, + validationId: string +): { encodedCredential: string; storageState: Record } { + const parsed = JSON.parse(encodedCredential) as Record; + const rawCookie = typeof parsed.cookie === "string" ? cookieHeaderValue(parsed.cookie) : ""; + if (!rawCookie) throw new Error("A fresh ChatGPT Cookie is required for browser validation"); + if (!/^validation-[a-f0-9]{24}$/.test(validationId)) { + throw new Error("ChatGPT browser validation reference is invalid"); + } + const paths = connectionRuntimePaths(validationId); + const markerPath = loginVerificationMarkerPath(paths.storageStatePath); + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as Record; + if ( + marker.version !== 1 || + marker.authenticated !== true || + marker.pendingBrowserVerification === true || + marker.cookieFingerprint !== cookieFingerprint(rawCookie) + ) { + throw new Error("ChatGPT browser validation does not match the supplied Cookie"); + } + const storageState = readConnectionStorageState(paths.storageStatePath); + const runtimeKey = typeof parsed.runtimeKey === "string" ? parsed.runtimeKey.trim() : ""; + const next = JSON.stringify({ + version: 2, + storageState, + ...(runtimeKey ? { runtimeKey } : {}), + }); + rmSync(paths.root, { recursive: true, force: true }); + return { encodedCredential: next, storageState }; +} diff --git a/open-sse/executors/chatgpt-web-codex/tunnelClient.ts b/open-sse/executors/chatgpt-web-codex/tunnelClient.ts new file mode 100644 index 0000000000..1a711e93d5 --- /dev/null +++ b/open-sse/executors/chatgpt-web-codex/tunnelClient.ts @@ -0,0 +1,463 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmodSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { basename, join } from "node:path"; + +import { unzipSync } from "fflate"; + +import { atomicWriteFile, getConfigDir } from "../../vendor/codex-chatgpt-web/config.ts"; + +export const CHATGPT_WEB_CODEX_TUNNEL_VERSION = "0.0.10"; +const RELEASE_BASE = `https://github.com/openai/tunnel-client/releases/download/v${CHATGPT_WEB_CODEX_TUNNEL_VERSION}`; +const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024; + +type InstallManifest = { + version: 1; + tunnelClientVersion: string; + asset: string; + archiveSha256: string; + binarySha256: string; +}; + +export type TunnelRuntimeConfig = { + tunnelId: string; + runtimeKey: string; + brokerSocketPath: string; + alias?: string; + profile?: string; +}; + +export type TunnelRuntimeStatus = { + ok: boolean; + processRunning: boolean; + healthy: boolean; + ready: boolean; + state?: string; + detail: string; +}; + +type SupervisorLease = { + version: 1; + pid: number; + startedAt: string; +}; + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function tunnelPlatformAsset(platform = process.platform, arch = process.arch): string { + const os = + platform === "darwin" + ? "darwin" + : platform === "linux" + ? "linux" + : platform === "win32" + ? "windows" + : null; + const cpu = arch === "arm64" ? "arm64" : arch === "x64" ? "amd64" : null; + if (!os || !cpu) { + throw new Error(`openai/tunnel-client has no pinned build for ${platform}/${arch}`); + } + return `tunnel-client-v${CHATGPT_WEB_CODEX_TUNNEL_VERSION}-${os}-${cpu}.zip`; +} + +export function parseTunnelChecksum(text: string, asset: string): string { + const entry = text + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.endsWith(asset)); + const checksum = entry?.split(/\s+/)[0]?.toLowerCase(); + if (!checksum || !/^[a-f0-9]{64}$/.test(checksum)) { + throw new Error(`SHA256SUMS.txt has no valid entry for ${asset}`); + } + return checksum; +} + +async function download(url: string): Promise { + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok) throw new Error(`Tunnel download failed (${response.status})`); + const declared = Number(response.headers.get("content-length") || "0"); + if (Number.isFinite(declared) && declared > MAX_DOWNLOAD_BYTES) { + throw new Error("Tunnel download exceeds the size limit"); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { + throw new Error("Tunnel download exceeds the size limit"); + } + return bytes; +} + +export function tunnelClientPaths() { + const root = join(getConfigDir(), "tunnel-client"); + return { + root, + binary: join(root, process.platform === "win32" ? "tunnel-client.exe" : "tunnel-client"), + manifest: join(root, "manifest.json"), + profileDir: join(root, "profiles"), + supervisorLease: join(root, "supervisor-lease.json"), + }; +} + +function safeDetail(value: unknown): string { + const text = typeof value === "string" ? value : JSON.stringify(value); + return String(text || "") + .replace(/tunnel_[a-f0-9]{32}/g, "[tunnel-id]") + .replace(/(?:sk-|rt_|rk_)[A-Za-z0-9_-]{8,}/g, "[redacted-key]") + .replace(/runtime-key-[A-Fa-f0-9]+/g, "runtime-key-[redacted]") + .slice(0, 2_000); +} + +function processIsAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +let ownsSupervisorLease = false; + +export function acquireTunnelSupervisorLease(): void { + if (ownsSupervisorLease) return; + const paths = tunnelClientPaths(); + mkdirSync(paths.root, { recursive: true, mode: 0o700 }); + const path = paths.supervisorLease; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const fd = openSync(path, "wx", 0o600); + try { + const lease: SupervisorLease = { + version: 1, + pid: process.pid, + startedAt: new Date().toISOString(), + }; + writeFileSync(fd, `${JSON.stringify(lease)}\n`); + } finally { + closeSync(fd); + } + ownsSupervisorLease = true; + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + let ownerPid = 0; + try { + const lease = JSON.parse(readFileSync(path, "utf8")) as Partial; + ownerPid = Number(lease.pid) || 0; + } catch { + ownerPid = 0; + } + if (ownerPid === process.pid) { + ownsSupervisorLease = true; + return; + } + if (processIsAlive(ownerPid)) { + throw new Error(`ChatGPT Web (Codex) supervisor is already owned by process ${ownerPid}`); + } + rmSync(path, { force: true }); + } + } + throw new Error("ChatGPT Web (Codex) supervisor lease could not be acquired"); +} + +export function tunnelSupervisorLeaseStatus(): { + ownedByCurrentProcess: boolean; + conflict: boolean; + ownerPid?: number; +} { + const path = tunnelClientPaths().supervisorLease; + if (!existsSync(path)) return { ownedByCurrentProcess: false, conflict: false }; + try { + const lease = JSON.parse(readFileSync(path, "utf8")) as Partial; + const ownerPid = Number(lease.pid) || undefined; + return { + ownedByCurrentProcess: ownerPid === process.pid, + conflict: Boolean(ownerPid && ownerPid !== process.pid && processIsAlive(ownerPid)), + ...(ownerPid ? { ownerPid } : {}), + }; + } catch { + return { ownedByCurrentProcess: false, conflict: false }; + } +} + +export function releaseTunnelSupervisorLease(): void { + if (!ownsSupervisorLease) return; + const status = tunnelSupervisorLeaseStatus(); + if (status.ownedByCurrentProcess) rmSync(tunnelClientPaths().supervisorLease, { force: true }); + ownsSupervisorLease = false; +} + +export async function ensureTunnelClientInstalled(): Promise { + const paths = tunnelClientPaths(); + if (existsSync(paths.binary) && existsSync(paths.manifest)) { + const manifest = JSON.parse(readFileSync(paths.manifest, "utf8")) as Partial; + const actual = sha256(readFileSync(paths.binary)); + if ( + manifest.version === 1 && + manifest.tunnelClientVersion === CHATGPT_WEB_CODEX_TUNNEL_VERSION && + manifest.binarySha256 === actual + ) { + return paths.binary; + } + throw new Error("Existing tunnel-client failed integrity validation"); + } + + const asset = tunnelPlatformAsset(); + const [archive, checksumFile] = await Promise.all([ + download(`${RELEASE_BASE}/${asset}`), + download(`${RELEASE_BASE}/SHA256SUMS.txt`), + ]); + const expected = parseTunnelChecksum(new TextDecoder().decode(checksumFile), asset); + const archiveSha256 = sha256(archive); + if (archiveSha256 !== expected) throw new Error(`Checksum mismatch for ${asset}`); + + const files = unzipSync(archive); + const executableName = process.platform === "win32" ? "tunnel-client.exe" : "tunnel-client"; + const entry = Object.entries(files).find(([name]) => basename(name) === executableName); + if (!entry) throw new Error(`${asset} does not contain ${executableName}`); + atomicWriteFile(paths.binary, entry[1]); + if (process.platform !== "win32") chmodSync(paths.binary, 0o700); + const manifest: InstallManifest = { + version: 1, + tunnelClientVersion: CHATGPT_WEB_CODEX_TUNNEL_VERSION, + asset, + archiveSha256, + binarySha256: sha256(entry[1]), + }; + atomicWriteFile(paths.manifest, `${JSON.stringify(manifest, null, 2)}\n`); + + const version = spawnSync(paths.binary, ["--version"], { encoding: "utf8" }); + if ( + version.status !== 0 || + !`${version.stdout}\n${version.stderr}`.includes(CHATGPT_WEB_CODEX_TUNNEL_VERSION) + ) { + throw new Error("Installed tunnel-client did not report the pinned version"); + } + return paths.binary; +} + +function validateRuntimeConfig(config: TunnelRuntimeConfig) { + if (!/^tunnel_[a-f0-9]{32}$/.test(config.tunnelId)) { + throw new Error("Tunnel ID must be tunnel_ followed by 32 lowercase hexadecimal characters"); + } + if (!config.runtimeKey.trim() || config.runtimeKey.length > 64 * 1024) { + throw new Error("Tunnel Runtime-Key is missing or too large"); + } + for (const value of [ + config.alias ?? "omniroute-chatgpt-web-codex", + config.profile ?? "omniroute", + ]) { + if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error("Tunnel alias/profile is invalid"); + } +} + +export async function startTunnelRuntime(config: TunnelRuntimeConfig): Promise { + validateRuntimeConfig(config); + acquireTunnelSupervisorLease(); + const binary = await ensureTunnelClientInstalled(); + const paths = tunnelClientPaths(); + const runtimeKeyFile = join( + paths.root, + `runtime-key-${createHash("sha256").update(config.tunnelId).digest("hex").slice(0, 16)}` + ); + atomicWriteFile(runtimeKeyFile, config.runtimeKey.trim()); + runtimeKeyFiles.add(runtimeKeyFile); + const alias = config.alias ?? "omniroute-chatgpt-web-codex"; + const profile = config.profile ?? "omniroute"; + const mcpCommand = [ + process.execPath, + join(process.cwd(), "bin", "chatgpt-web-codex-mcp.mjs"), + "--broker-socket", + config.brokerSocketPath, + ] + .map((value) => JSON.stringify(value)) + .join(" "); + return spawn( + binary, + [ + "runtimes", + "connect", + "--alias", + alias, + "--profile", + profile, + "--profile-dir", + paths.profileDir, + "--tunnel-client-bin", + binary, + "--tunnel-id", + config.tunnelId, + "--runtime-api-key", + `file:${runtimeKeyFile}`, + "--mcp-command", + mcpCommand, + "--json", + ], + { stdio: ["ignore", "pipe", "pipe"], env: process.env } + ); +} + +export function parseTunnelRuntimeStatus(output: string, exitStatus = 0): TunnelRuntimeStatus { + if (exitStatus !== 0) { + return { + ok: false, + processRunning: false, + healthy: false, + ready: false, + detail: safeDetail(output), + }; + } + try { + const parsed = JSON.parse(output) as Record; + const processRunning = parsed.process_running === true; + const healthy = parsed.healthy === true; + const ready = parsed.ready === true || parsed.runtime_state === "ready"; + const state = + typeof parsed.runtime_state === "string" + ? parsed.runtime_state + : typeof parsed.status === "string" + ? parsed.status + : undefined; + const ok = processRunning && healthy && ready; + return { + ok, + processRunning, + healthy, + ready, + ...(state ? { state } : {}), + detail: ok + ? "process_running=true healthy=true ready=true" + : safeDetail( + `process_running=${processRunning}; healthy=${healthy}; ready=${ready}` + + (state ? `; state=${state}` : "") + ), + }; + } catch { + return { + ok: false, + processRunning: false, + healthy: false, + ready: false, + detail: `tunnel-client returned non-JSON status: ${safeDetail(output)}`, + }; + } +} + +export async function getTunnelRuntimeStatus( + config: Pick +): Promise { + const binary = await ensureTunnelClientInstalled(); + const paths = tunnelClientPaths(); + const alias = config.alias ?? "omniroute-chatgpt-web-codex"; + const profile = config.profile ?? "omniroute"; + const result = spawnSync( + binary, + [ + "runtimes", + "status", + alias, + "--profile", + profile, + "--profile-dir", + paths.profileDir, + "--json", + ], + { encoding: "utf8", timeout: 5_000 } + ); + return parseTunnelRuntimeStatus(String(result.stdout || result.stderr || ""), result.status ?? 1); +} + +const connectedRuntimes = new Map>(); +const runtimeKeyFiles = new Set(); + +function runtimeIdentity(config: TunnelRuntimeConfig): string { + return createHash("sha256") + .update( + JSON.stringify({ + tunnelId: config.tunnelId, + alias: config.alias ?? "omniroute-chatgpt-web-codex", + profile: config.profile ?? "omniroute", + brokerSocketPath: config.brokerSocketPath, + }) + ) + .digest("hex"); +} + +export function ensureTunnelRuntimeReady( + config: TunnelRuntimeConfig, + timeoutMs = 30_000 +): Promise { + const identity = runtimeIdentity(config); + const existing = connectedRuntimes.get(identity); + if (existing) return existing; + const connecting = (async () => { + const child = await startTunnelRuntime(config); + await new Promise((resolve, reject) => { + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error("Tunnel runtime startup timed out")); + }, timeoutMs); + child.stderr?.on("data", (chunk) => { + stderr = `${stderr}${String(chunk)}`.slice(-4_096); + }); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("exit", (code, signal) => { + clearTimeout(timer); + if (code === 0 && !signal) resolve(); + else + reject( + new Error(`Tunnel runtime startup failed (${code ?? signal}): ${safeDetail(stderr)}`) + ); + }); + }); + const deadline = Date.now() + timeoutMs; + let status = await getTunnelRuntimeStatus(config); + while (!status.ok && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + status = await getTunnelRuntimeStatus(config); + } + if (!status.ok) throw new Error(`Tunnel runtime is not ready: ${status.detail}`); + })(); + connectedRuntimes.set(identity, connecting); + void connecting.catch(() => connectedRuntimes.delete(identity)); + return connecting; +} + +export async function stopChatGptWebCodexTunnelRuntime(): Promise { + const paths = tunnelClientPaths(); + if (ownsSupervisorLease && existsSync(paths.binary)) { + spawnSync( + paths.binary, + [ + "runtimes", + "stop", + "omniroute-chatgpt-web-codex", + "--profile", + "omniroute", + "--profile-dir", + paths.profileDir, + "--json", + ], + { encoding: "utf8", timeout: 10_000 } + ); + } + connectedRuntimes.clear(); + for (const runtimeKeyFile of runtimeKeyFiles) rmSync(runtimeKeyFile, { force: true }); + runtimeKeyFiles.clear(); + releaseTunnelSupervisorLease(); +} diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 15fc3b7355..9930b84ec6 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -2816,8 +2816,10 @@ export class ChatGptWebExecutor extends BaseExecutor { }; } - // Tool-call emulation (#5240): inject a `` contract when `tools` are - // present; parsed back on the response side. Mirrors qwen-web/perplexity-web. + // Tool-call emulation (#5240, #7679): inject a `` contract when tools + // are present; parsed back on the response side. Hardened for thinking models. + const resolvedModel = resolveChatGptModel(model, body, credentials.providerSpecificData); + const modelSlug = resolvedModel.slug; const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( (body || {}) as Record, messages as Array<{ role: string; content: unknown }> @@ -2918,12 +2920,9 @@ export class ChatGptWebExecutor extends BaseExecutor { log ); - // 2a''. Resolve model + effort and apply thinking-effort preference for - // thinking-capable models. Dedicated thinking models mirror the browser's - // user-config PATCH; GPT-5.5 Pro sends the effort with the conversation - // body because the Pro standard/extended budget is part of that turn. - const resolvedModel = resolveChatGptModel(model, body, credentials.providerSpecificData); - const modelSlug = resolvedModel.slug; + // 2a''. Apply thinking-effort preference for thinking models. + // Dedicated thinking models mirror the browser's user-config PATCH; + // GPT-5.5 Pro effort is sent with the conversation body. const requestedEffort = resolvedModel.effort; if (requestedEffort && isThinkingCapableModel(model, modelSlug)) { await setUserThinkingEffort( 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..f7af6f2459 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 @@ -15,12 +92,40 @@ import type { ProviderCredentials } from "./base.ts"; * When the caller explicitly asks for "none"/"off" we drop the field entirely * (the gateway has no "none" value). Forcing reasoning on plain requests trips * CodeBuddy's content filter and returns an error. + * + * Agent system prompt replacement: Tencent's content filter flags CLI agent system + * prompts ("You are Claude Code, Anthropic's official CLI…") as prompt injection / + * sensitive content and rejects the whole request. Detect agent system prompts + * (length catch-all + identity-marker regex) and replace them with a neutral one, + * while leaving legitimate user system prompts untouched. Content may be a string + * or typed blocks ([{type:"text",text}]) depending on the incoming client format, + * so flatten before matching and preserve the original shape on replacement. */ export class CodeBuddyCnExecutor extends DefaultExecutor { constructor() { 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, @@ -36,16 +141,66 @@ export class CodeBuddyCnExecutor extends DefaultExecutor { const eff = out.reasoning_effort; if (eff === "none" || eff === "off") { - // Gateway has no "none" — just omit. Do NOT set reasoning_summary. delete out.reasoning_effort; } else if (eff) { - // Client explicitly asked for reasoning — mirror the CLI's reasoning_summary - // so CodeBuddy surfaces the model's reasoning. out.reasoning_summary = "auto"; } - // No reasoning requested: leave both unset. Forcing reasoning_effort:"medium" - // + reasoning_summary on plain requests makes CodeBuddy trip its content - // filter and return an error. + + // --- Agent system prompt replacement --- + // Tencent's content filter flags CLI agent system prompts as sensitive content. + // Detect and replace them with a neutral prompt. + const NEUTRAL_PROMPT = "You are a helpful AI assistant that helps with software engineering tasks."; + const AGENT_PATTERN = /you are claude code|claude.?code.+official.+cli|anthropic.+official.+cli|anxthxropic.+official.+cli|you are (?:cursor|windsurf|cline|aider|continue|copilot|cody)|you are an? (?:ai )?(?:coding |code )?agent|cc_entrypoint\s*=\s*(?:cli|vscode|jetbrains|gui)|claude.?code.+issues|give feedback.+claude.?code|you are .{0,30}(?:powerful )?ai agent|orchestration capabilities|OhMyOpenCode|||/i; + const flatten = (content: unknown): string => + typeof content === "string" + ? content + : Array.isArray(content) + ? (content as Array>) + .map((b) => (b && typeof b.text === "string" ? b.text : "")) + .join("\n") + : ""; + + // Handle top-level `system` field (Anthropic format after translation) + if (out.system) { + const text = flatten(out.system); + if (text && (text.length > 2000 || AGENT_PATTERN.test(text))) { + out.system = NEUTRAL_PROMPT; + } + } + + // Handle messages array with role: "system" + if (Array.isArray(out.messages)) { + out.messages = (out.messages as Array>).map((message) => { + if (!message || message.role !== "system") return message; + const text = flatten(message.content); + if (!text) return message; + if (text.length > 2000 || AGENT_PATTERN.test(text)) { + return typeof message.content === "string" + ? { ...message, content: NEUTRAL_PROMPT } + : { ...message, content: [{ type: "text", text: NEUTRAL_PROMPT }] }; + } + return message; + }); + } + + // --- Strip oversized tool descriptions (>64KB) --- + // Large tool descriptions can also trigger the content filter. + if (Array.isArray(out.tools) && out.tools.length > 0) { + try { + const s = JSON.stringify(out.tools); + if (new TextEncoder().encode(s).byteLength >= 65536) { + out.tools = (out.tools as Array>).map((tool) => { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return tool; + if (tool.type !== "function" || !tool.function || typeof tool.function !== "object" || Array.isArray(tool.function)) return tool; + if (!Object.prototype.hasOwnProperty.call(tool.function, "description")) return tool; + const cf = { ...(tool.function as Record) }; + delete cf.description; + return { ...tool, function: cf }; + }); + } + } catch {} + } + return out; } } diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index fb9336c784..c2e1f6b199 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"; @@ -54,6 +55,7 @@ import { splitCodexReasoningSuffix, type CodexEffortLevel as EffortLevel, } from "./codex/reasoningSuffix.ts"; +import { repairMissingCodexToolCallOutputs } from "./codex/toolCallRepair.ts"; // Re-exported for external importers (tests + provider services). export { isCodexFreePlan, normalizeCodexTools } from "./codex/tools.ts"; @@ -222,93 +224,10 @@ 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; + const input = body.input; // A previous_response_id delegates history resolution to the upstream // Responses service, so a matching function_call may legitimately live in // that remote response rather than in the local input array. @@ -317,7 +236,7 @@ function stripOrphanedCodexFunctionCallOutputs(body: Record): v const callIds = new Set(); let outputCount = 0; - for (const item of body.input) { + for (const item of input) { if (!item || typeof item !== "object" || Array.isArray(item)) continue; const record = item as Record; @@ -341,9 +260,7 @@ function stripOrphanedCodexFunctionCallOutputs(body: Record): v } if (outputCount === 0) return; - - const before = body.input.length; - body.input = body.input.filter((item) => { + const filteredInput = input.filter((item) => { if (!item || typeof item !== "object" || Array.isArray(item)) return true; const record = item as Record; if (record.type === "function_call_output" && typeof record.call_id === "string") { @@ -352,7 +269,8 @@ function stripOrphanedCodexFunctionCallOutputs(body: Record): v return true; }); - const removedCount = before - body.input.length; + const removedCount = input.length - filteredInput.length; + body.input = filteredInput; if (removedCount > 0) { console.debug( `[Codex] stripOrphanedCodexFunctionCallOutputs: removed ${removedCount} orphaned function_call_output item(s)` @@ -360,46 +278,6 @@ function stripOrphanedCodexFunctionCallOutputs(body: Record): v } } -function repairMissingCodexFunctionCallOutputs(body: Record): void { - if (!Array.isArray(body.input)) return; - - const existingOutputIds = new Set(); - for (const item of body.input) { - if (!item || typeof item !== "object" || Array.isArray(item)) continue; - const record = item as Record; - if (record.type !== "function_call_output") continue; - if (typeof record.call_id === "string" && record.call_id.trim()) { - existingOutputIds.add(record.call_id.trim()); - } - } - - const repaired: unknown[] = []; - let insertedCount = 0; - for (const item of body.input) { - repaired.push(item); - if (!item || typeof item !== "object" || Array.isArray(item)) continue; - const record = item as Record; - if (record.type !== "function_call") continue; - const callId = typeof record.call_id === "string" ? record.call_id.trim() : ""; - if (!callId || existingOutputIds.has(callId)) continue; - - repaired.push({ - type: "function_call_output", - call_id: callId, - output: "", - }); - existingOutputIds.add(callId); - insertedCount++; - } - - if (insertedCount > 0) { - body.input = repaired; - console.debug( - `[Codex] repairMissingCodexFunctionCallOutputs: inserted ${insertedCount} empty function_call_output item(s)` - ); - } -} - function getResponsesSubpath(endpointPath: unknown): string | null { let normalizedEndpoint = String(endpointPath || ""); while (normalizedEndpoint.endsWith("/") && normalizedEndpoint.length > 0) { @@ -1296,7 +1174,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", @@ -1347,7 +1225,7 @@ export class CodexExecutor extends BaseExecutor { }); } stripOrphanedCodexFunctionCallOutputs(body); - repairMissingCodexFunctionCallOutputs(body); + repairMissingCodexToolCallOutputs(body); // ── Cache-aware system prompt handling (both paths) ── // @@ -1417,13 +1295,9 @@ export class CodexExecutor extends BaseExecutor { dropImageGeneration: isCodexFreePlan(credentials?.providerSpecificData) || getCodexModelScope(model) === "spark", preserveCustomTools: nativeCodexPassthrough, + defaultFunctionStrict: nativeCodexPassthrough ? undefined : false, }); - // 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 +1389,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/toolCallRepair.ts b/open-sse/executors/codex/toolCallRepair.ts new file mode 100644 index 0000000000..b2a4a49c9e --- /dev/null +++ b/open-sse/executors/codex/toolCallRepair.ts @@ -0,0 +1,57 @@ +// Repairs Codex Responses-API `input` arrays that are missing an output item for a +// function/custom tool call, which upstream rejects. Extracted from codex.ts to keep +// the executor chokepoint file under the file-size gate (leaf module, no `this` usage). + +type ResponsesInputItem = Record; + +const TOOL_CALL_OUTPUT_TYPES = new Set(["function_call_output", "custom_tool_call_output"]); + +function outputTypeForCall(callType: "function_call" | "custom_tool_call"): string { + return callType === "custom_tool_call" ? "custom_tool_call_output" : "function_call_output"; +} + +/** + * Mutates `body.input` in place, inserting an empty output item immediately after + * any `function_call`/`custom_tool_call` item that has no matching output item. + */ +export function repairMissingCodexToolCallOutputs(body: Record): void { + if (!Array.isArray(body.input)) return; + + const existingOutputKeys = new Set(); + for (const item of body.input) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as ResponsesInputItem; + if (typeof record.type !== "string" || !TOOL_CALL_OUTPUT_TYPES.has(record.type)) continue; + if (typeof record.call_id === "string" && record.call_id.trim()) { + existingOutputKeys.add(`${record.type}:${record.call_id.trim()}`); + } + } + + const repaired: unknown[] = []; + let insertedCount = 0; + for (const item of body.input) { + repaired.push(item); + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as ResponsesInputItem; + if (record.type !== "function_call" && record.type !== "custom_tool_call") continue; + const callId = typeof record.call_id === "string" ? record.call_id.trim() : ""; + const outputType = outputTypeForCall(record.type); + const outputKey = `${outputType}:${callId}`; + if (!callId || existingOutputKeys.has(outputKey)) continue; + + repaired.push({ + type: outputType, + call_id: callId, + output: "", + }); + existingOutputKeys.add(outputKey); + insertedCount++; + } + + if (insertedCount > 0) { + body.input = repaired; + console.debug( + `[Codex] repairMissingCodexToolCallOutputs: inserted ${insertedCount} empty tool output item(s)` + ); + } +} diff --git a/open-sse/executors/codex/tools.ts b/open-sse/executors/codex/tools.ts index 52d01e9d87..12cb840f2c 100644 --- a/open-sse/executors/codex/tools.ts +++ b/open-sse/executors/codex/tools.ts @@ -30,9 +30,121 @@ 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 } + options?: { + dropImageGeneration?: boolean; + preserveCustomTools?: boolean; + defaultFunctionStrict?: boolean; + } ): void { if (!Array.isArray(body.tools)) return; @@ -133,12 +245,16 @@ export function normalizeCodexTools( ? tool.strict : typeof functionObject?.strict === "boolean" ? functionObject.strict - : undefined; + : typeof options?.defaultFunctionStrict === "boolean" + ? options.defaultFunctionStrict + : undefined; // 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 fe056eab8f..6f7fe08940 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -6,7 +6,7 @@ import { BaseExecutor, mergeUpstreamExtraHeaders, type ExecuteInput } from "./ba type JsonRecord = Record; -export const COMMAND_CODE_VERSION = process.env.COMMAND_CODE_VERSION?.trim() || "0.33.2"; +export const COMMAND_CODE_VERSION = process.env.COMMAND_CODE_VERSION?.trim() || "1.15.1"; // Hard server-side ceiling enforced by Command Code's /alpha/generate endpoint: // any request with params.max_tokens > 200_000 is rejected with a 400 // "Too big: expected number to be <=200000 at params.max_tokens". We only use @@ -48,6 +48,56 @@ 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 + * - valid JSON string arguments -> the string as-is + * - missing / empty / invalid JSON string -> "{}" (a valid empty-object string) + */ +function toolCallArgumentsString(value: unknown): string { + if (isRecord(value)) return JSON.stringify(value); + if (typeof value === "string" && value.trim()) { + try { + const parsed: unknown = JSON.parse(value); + if (isRecord(parsed)) return value; + } catch { + return "{}"; + } + return "{}"; + } + return JSON.stringify(recordOrEmpty(value)); +} + +/** + * Tool names that collide with Command Code's server-side built-in tools. + * The /alpha/generate server normalizes tool-call/tool-result parts against + * ITS OWN built-in registry for matching names; for its built-in `tool_search` + * the result normalization requires `arguments` in a shape we do not send, so + * the result is rejected with `input[N] missing required field 'arguments'` + * (verified live 2026-08-10 — renaming the call/result `tool_search` → `grep` + * makes the identical request pass; the server pairs each tool-result with the + * nearest preceding tool-call, so any result following such a call is affected). + * We rename the colliding name consistently on the wire — definitions, calls + * and results — then un-rename on the response path so the client still sees + * its original tool names. + */ +const COMMAND_CODE_RESERVED_TOOL_NAMES = new Set(["tool_search"]); + +function wireToolName(clientName: string, toolNameMap: Map): string { + if (COMMAND_CODE_RESERVED_TOOL_NAMES.has(clientName)) { + const wire = `omniroute_${clientName}`; + toolNameMap.set(wire, clientName); + return wire; + } + return clientName; +} + +function clientToolName(wireName: string, toolNameMap: Map): string { + return toolNameMap.get(wireName) ?? wireName; +} + function normalizeContentText(content: unknown): string { if (typeof content === "string") return content; return asRecordArray(content) @@ -181,27 +231,42 @@ function convertUserContentParts(content: unknown, isVisionModel: boolean): stri return parts; } -function convertTools(tools: unknown): unknown[] { +function convertTools(tools: unknown, toolNameMap: Map): unknown[] { return asRecordArray(tools).map((tool) => { const fn = isRecord(tool.function) ? tool.function : tool; return { type: "function", - name: stringValue(fn.name) || "", + name: wireToolName(stringValue(fn.name) || "", toolNameMap), description: stringValue(fn.description) || "", input_schema: isRecord(fn.parameters) ? fn.parameters : {}, }; }); } -function completeToolCallIds(messages: JsonRecord[]): Set { +function buildToolCallMetadata( + messages: JsonRecord[], + toolNameMap: Map +): { + pairedToolCallIds: Set; + toolCallNames: Map; + toolCallArgs: Map; +} { const callIds = new Set(); const resultIds = new Set(); + const toolCallNames = new Map(); + const toolCallArgs = new Map(); for (const message of messages) { if (message.role === "assistant") { for (const call of asRecordArray(message.tool_calls)) { const id = stringValue(call.id); - if (id) callIds.add(id); + if (id) { + callIds.add(id); + const fn = isRecord(call.function) ? call.function : {}; + const name = stringValue(fn.name) || stringValue(call.name); + if (name) toolCallNames.set(id, wireToolName(name, toolNameMap)); + toolCallArgs.set(id, toolCallArgumentsString(fn.arguments)); + } } } else if (message.role === "tool") { const id = stringValue(message.tool_call_id); @@ -209,15 +274,20 @@ function completeToolCallIds(messages: JsonRecord[]): Set { } } - return new Set([...callIds].filter((id) => resultIds.has(id))); + const pairedToolCallIds = new Set([...callIds].filter((id) => resultIds.has(id))); + return { pairedToolCallIds, toolCallNames, toolCallArgs }; } function convertMessages( messages: unknown, - model?: string | null + model?: string | null, + toolNameMap?: Map ): { system: string; messages: unknown[] } { const source = asRecordArray(messages); - const pairedToolCallIds = completeToolCallIds(source); + const { pairedToolCallIds, toolCallNames, toolCallArgs } = buildToolCallMetadata( + source, + toolNameMap ?? new Map() + ); const out: unknown[] = []; const system: string[] = []; const isVision = isCommandCodeVisionModel(model); @@ -244,11 +314,18 @@ 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), + toolName: wireToolName( + stringValue(fn.name) || stringValue(call.name) || "unknown", + toolNameMap ?? new Map() + ), + 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), }); } @@ -259,13 +336,20 @@ function convertMessages( if (role === "tool") { const toolCallId = stringValue(message.tool_call_id) || ""; if (!toolCallId || !pairedToolCallIds.has(toolCallId)) continue; + const toolName = wireToolName( + stringValue(message.name) || toolCallNames.get(toolCallId) || "unknown", + toolNameMap ?? new Map() + ); out.push({ role: "tool", content: [ { type: "tool-result", toolCallId, - toolName: stringValue(message.name) || "", + toolName, + // /alpha/generate requires `arguments` here too (same rejection as + // tool-call parts); echo the paired call's args, defensively "{}". + arguments: toolCallArgs.get(toolCallId) ?? "{}", output: { type: "text", value: normalizeContentText(message.content) }, }, ], @@ -303,8 +387,13 @@ const COMMAND_CODE_PASSTHROUGH_FIELDS = [ "extra_body", ] as const; -function buildCommandCodeBody(model: string, body: unknown, stream = false): JsonRecord { +function buildCommandCodeBody( + model: string, + body: unknown, + stream = false +): { body: JsonRecord; toolNameMap: Map } { const input = isRecord(body) ? body : {}; + const toolNameMap = new Map(); // Payload rules may rewrite `body.model` (e.g. deepseek-v4-pro-max → // deepseek/deepseek-v4-pro for the command-code provider). Prefer the @@ -312,14 +401,14 @@ function buildCommandCodeBody(model: string, body: unknown, stream = false): Jso const resolvedModel = typeof input.model === "string" && input.model.trim().length > 0 ? input.model : model; - const converted = convertMessages(input.messages, resolvedModel); + const converted = convertMessages(input.messages, resolvedModel, toolNameMap); const explicitSystem = typeof input.system === "string" ? input.system : ""; const system = [converted.system, explicitSystem].filter(Boolean).join("\n\n"); const params: JsonRecord = { model: resolvedModel, messages: converted.messages, - tools: convertTools(input.tools), + tools: convertTools(input.tools, toolNameMap), system, stream: true, }; @@ -343,22 +432,25 @@ function buildCommandCodeBody(model: string, body: unknown, stream = false): Jso } return { - config: { - workingDir: "/workspace", - date: new Date().toISOString().slice(0, 10), - environment: "external", - structure: [], - isGitRepo: false, - currentBranch: "", - mainBranch: "", - gitStatus: "", - recentCommits: [], + body: { + config: { + workingDir: "/workspace", + date: new Date().toISOString().slice(0, 10), + environment: "external", + structure: [], + isGitRepo: false, + currentBranch: "", + mainBranch: "", + gitStatus: "", + recentCommits: [], + }, + memory: "", + taste: "", + skills: "", + permissionMode: "standard", + params, }, - memory: "", - taste: "", - skills: "", - permissionMode: "standard", - params, + toolNameMap, }; } @@ -420,7 +512,65 @@ type AggregateState = { usage: JsonRecord | null; }; -function applyEventToAggregate(event: JsonRecord, state: AggregateState): void { +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, + toolNameMap: Map +): 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) || ""; @@ -434,20 +584,28 @@ function applyEventToAggregate(event: JsonRecord, state: AggregateState): void { id: stringValue(event.toolCallId) || stringValue(event.id) || randomUUID(), type: "function", function: { - name: stringValue(event.toolName) || stringValue(event.name) || "", + name: clientToolName( + stringValue(event.toolName) || stringValue(event.name) || "", + toolNameMap + ), arguments: JSON.stringify(args), }, }); break; } + case "finish-step": + break; case "finish": state.finishReason = mapFinishReason(event.finishReason); - state.usage = isRecord(event.totalUsage) ? event.totalUsage : null; break; } } -function applyEventToAggregateOrThrow(event: JsonRecord, state: AggregateState): void { +function applyEventToAggregateOrThrow( + event: JsonRecord, + state: AggregateState, + toolNameMap: Map +): void { if (event.type === "error") { const error = isRecord(event.error) ? event.error : {}; throw new Error( @@ -455,26 +613,86 @@ function applyEventToAggregateOrThrow(event: JsonRecord, state: AggregateState): ); } - applyEventToAggregate(event, state); + applyEventToAggregate(event, state, toolNameMap); } function usageFromCommandCode(usage: JsonRecord | null) { if (!usage) return undefined; - const details = isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : {}; + 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 prompt = - (numberValue(usage.inputTokens) || 0) + (numberValue(details.cacheReadTokens) || 0); - const completion = numberValue(usage.outputTokens) || 0; - return { + 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 !== 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; } function createStreamResponse( upstream: Response, model: string, - signal?: AbortSignal | null + signal?: AbortSignal | null, + toolNameMap: Map = new Map() ): Response { const id = `chatcmpl-${randomUUID()}`; const reader = upstream.body?.getReader(); @@ -506,6 +724,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" }))); @@ -533,7 +752,10 @@ function createStreamResponse( id: stringValue(event.toolCallId) || stringValue(event.id) || randomUUID(), type: "function", function: { - name: stringValue(event.toolName) || stringValue(event.name) || "", + name: clientToolName( + stringValue(event.toolName) || stringValue(event.name) || "", + toolNameMap + ), arguments: JSON.stringify(args), }, }; @@ -545,10 +767,27 @@ 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 + // recognizes this shape (see stream.ts:1661) and logs the ACTUAL + // token counts (in/out/cache_read/no_cache) instead of estimates. + const usagePayload = usageFromCommandCode(state.usage); + if (usagePayload) { + controller.enqueue( + sse({ + id, + object: "chat.completion.chunk", + model, + usage: usagePayload, + choices: [], + }) + ); + } controller.enqueue(encoder.encode("data: [DONE]\n\n")); closed = true; controller.close(); @@ -615,7 +854,8 @@ function createStreamResponse( async function createJsonResponse( upstream: Response, model: string, - signal?: AbortSignal | null + signal?: AbortSignal | null, + toolNameMap: Map = new Map() ): Promise { const reader = upstream.body?.getReader(); if (!reader) throw new Error("Command Code response missing body"); @@ -641,12 +881,12 @@ async function createJsonResponse( for (const line of lines) { const event = parseStreamLine(line); if (!isRecord(event)) continue; - applyEventToAggregateOrThrow(event, state); + applyEventToAggregateOrThrow(event, state, toolNameMap); } } if (buffer.trim()) { const event = parseStreamLine(buffer); - if (isRecord(event)) applyEventToAggregateOrThrow(event, state); + if (isRecord(event)) applyEventToAggregateOrThrow(event, state, toolNameMap); } } finally { try { @@ -713,7 +953,7 @@ export class CommandCodeExecutor extends BaseExecutor { }; mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); - const transformedBody = buildCommandCodeBody(model, body, stream); + const { body: transformedBody, toolNameMap } = buildCommandCodeBody(model, body, stream); const url = this.buildUrl(); const upstream = await fetch(url, { method: "POST", @@ -740,8 +980,8 @@ export class CommandCodeExecutor extends BaseExecutor { } const response = stream - ? createStreamResponse(upstream, model, signal) - : await createJsonResponse(upstream, model, signal); + ? createStreamResponse(upstream, model, signal, toolNameMap) + : await createJsonResponse(upstream, model, signal, toolNameMap); return { response, url, headers, transformedBody }; } diff --git a/open-sse/executors/conol-web.ts b/open-sse/executors/conol-web.ts new file mode 100644 index 0000000000..7ba409a7da --- /dev/null +++ b/open-sse/executors/conol-web.ts @@ -0,0 +1,893 @@ +/** + * ConolExecutor — conol.ai browser-session chat (Unofficial/Experimental). + * + * Protocol verified against the web client on 2026-07-30: + * - POST /api/assets for raw image uploads + * - POST /api/sessions to create a session + * - POST /api/sessions/{id}/model to pin preset, then model, then effort + * - POST /api/sessions/{id}/messages to submit a turn + * - GET /api/sessions/{id}/messages?logDeltas=1 for cumulative NDJSON updates + * - Cookie authentication via __Secure-better-auth.session_token + * + * Session creation ignores agentModel/agentEffort and answers with + * `modelDowngraded: true` on the account default, so the session is always + * created empty and configured via /model before the first turn is submitted. + */ +import { createHash } from "node:crypto"; + +import { BaseExecutor, mergeAbortSignals, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; +import { CursorImageError, extractImageUrls, resolveCursorImages } from "../utils/cursorImages.ts"; +import { normalizeConolCookie, resolveConolCredentials } from "../services/conolAuth.ts"; +import { resolveConolModelSelection, type ConolEffort } from "../services/conolModels.ts"; +import { + applyConolSessionModel, + buildConolSessionModelPlan, +} from "../services/conolSessionModel.ts"; + +export { normalizeConolCookie, resolveConolCredentials }; + +const CONOL_ORIGIN = "https://conol.ai"; +const CONOL_SESSION_URL = `${CONOL_ORIGIN}/api/sessions`; +const CONOL_REQUEST_TIMEOUT_MS = 300_000; +const CONOL_MAX_STREAM_BYTES = 16 * 1024 * 1024; +const CONOL_SESSION_TTL_MS = 6 * 60 * 60 * 1000; +const CONOL_MAX_SESSION_BINDINGS = 500; +const 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"; + +interface ChatMessage { + role: string; + content: unknown; +} + +interface ConolRequestBody { + messages?: ChatMessage[]; + model?: string; + timezone?: string; + metadata?: unknown; + conversation_id?: unknown; + conversationId?: unknown; + session_id?: unknown; + sessionId?: unknown; + prompt_cache_key?: unknown; + promptCacheKey?: unknown; +} + +interface ConolMessagePart { + type: "text" | "image"; + content: string; + mediaType?: string; +} + +interface ConolUserTurn { + text: string; + imageUrls: string[]; +} + +interface ConolSessionBinding { + upstreamSessionId: string; + lastUsedAt: number; + /** Model preset already primed on this session — sent once, not per turn. */ + presetApplied: boolean; + /** Model/effort currently pinned upstream, so we only re-pin on an actual switch. */ + appliedModel: string; + appliedEffort: ConolEffort | null; + /** Conol wants `hasImageHistory` sticky once the session has seen an image. */ + hasImageHistory: boolean; +} + +export interface ParsedConolStream { + text: string; + usedTokens: number | null; + contextWindow: number | null; + modelId: string; + done: boolean; +} + +const conolSessionBindings = new Map(); +const conolSessionLocks = new Map>(); + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function extractText(value: unknown): string { + if (typeof value === "string") return value; + if (value == null) return ""; + if (Array.isArray(value)) { + return value + .map((item) => extractText(item)) + .filter(Boolean) + .join("\n"); + } + if (typeof value !== "object") return ""; + const record = value as Record; + const type = readString(record.type).toLowerCase(); + if (type === "image_url" || type === "input_image" || type === "image") return ""; + return ( + readString(record.text) || + (typeof record.content === "string" ? record.content : extractText(record.content)) || + extractText(record.output) || + extractText(record.result) + ); +} + +function extractUserText(value: unknown): string { + if (typeof value === "string") return value; + if (value == null) return ""; + if (Array.isArray(value)) { + return value + .map((item) => extractUserText(item)) + .filter(Boolean) + .join("\n"); + } + if (typeof value !== "object") return ""; + + const record = value as Record; + const type = readString(record.type).toLowerCase(); + if (type === "text" || type === "input_text" || type === "output_text") { + return readString(record.text) || readString(record.content); + } + if (type) { + // Conol owns the agent loop. Do not flatten tool calls/results, images, or + // other agentic protocol blocks into the user's text prompt. + return ""; + } + return readString(record.text) || extractUserText(record.content); +} + +function stripGeneratedImageMarkers(value: string): string { + return value + .replace(/^\s*\[Image\s+\d+\]:\s*\(unavailable\)\s*$/gim, "") + .replace(/^\s*\[Image:\s*source:\s*[^\]\r\n]+\]\s*$/gim, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function buildConolUserTurn(messages: ChatMessage[]): ConolUserTurn { + const latestUserMessage = [...messages] + .reverse() + .find((message) => readString(message.role).toLowerCase() === "user"); + if (!latestUserMessage) return { text: "", imageUrls: [] }; + + return { + text: stripGeneratedImageMarkers(extractUserText(latestUserMessage.content)), + imageUrls: extractImageUrls(latestUserMessage.content), + }; +} + +export function buildConolPromptText(messages: ChatMessage[]): string { + return buildConolUserTurn(messages).text; +} + +function readHeader(headers: Record | null | undefined, name: string): string { + if (!headers) return ""; + const direct = readString(headers[name]); + if (direct) return direct; + const normalizedName = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === normalizedName) return readString(value); + } + return ""; +} + +function readMetadataSessionId(metadata: unknown): string { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return ""; + const record = metadata as Record; + const direct = readString(record.session_id) || readString(record.sessionId); + if (direct) return direct; + + const userId = record.user_id; + if (userId && typeof userId === "object" && !Array.isArray(userId)) { + return readString((userId as Record).session_id); + } + if (typeof userId !== "string" || userId.length > 4096) return ""; + try { + const parsed = JSON.parse(userId) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? readString((parsed as Record).session_id) + : ""; + } catch { + return ""; + } +} + +function hashKey(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function resolveConolClientSessionKey( + body: ConolRequestBody, + clientHeaders?: Record | null +): string | null { + const candidates = [ + readHeader(clientHeaders, "x-claude-code-session-id"), + readHeader(clientHeaders, "x-codex-session-id"), + readHeader(clientHeaders, "x-session-id"), + readHeader(clientHeaders, "x_session_id"), + readHeader(clientHeaders, "session-id"), + readHeader(clientHeaders, "session_id"), + readHeader(clientHeaders, "x-omniroute-session-id"), + readHeader(clientHeaders, "x-omniroute-session"), + readMetadataSessionId(body.metadata), + readString(body.conversation_id), + readString(body.conversationId), + readString(body.session_id), + readString(body.sessionId), + readString(body.prompt_cache_key), + readString(body.promptCacheKey), + ]; + const candidate = candidates.find((value) => value.length > 0 && value.length <= 4096); + return candidate ? hashKey(candidate) : null; +} + +function sweepConolSessionBindings(now = Date.now()): void { + for (const [key, binding] of conolSessionBindings) { + if (now - binding.lastUsedAt > CONOL_SESSION_TTL_MS) { + conolSessionBindings.delete(key); + } + } + while (conolSessionBindings.size > CONOL_MAX_SESSION_BINDINGS) { + let oldestKey = ""; + let oldestTime = Number.POSITIVE_INFINITY; + for (const [key, binding] of conolSessionBindings) { + if (binding.lastUsedAt < oldestTime) { + oldestKey = key; + oldestTime = binding.lastUsedAt; + } + } + if (!oldestKey) break; + conolSessionBindings.delete(oldestKey); + } +} + +function getConolSessionBinding(key: string): ConolSessionBinding | null { + sweepConolSessionBindings(); + const binding = conolSessionBindings.get(key); + if (!binding) return null; + binding.lastUsedAt = Date.now(); + return binding; +} + +function setConolSessionBinding( + key: string, + binding: Omit +): void { + conolSessionBindings.set(key, { ...binding, lastUsedAt: Date.now() }); + sweepConolSessionBindings(); +} + +/** + * Model/effort are deliberately excluded: switching models must re-pin the + * existing Conol session (POST /model) rather than stranding it and losing the + * conversation history. + */ +function buildConolSessionBindingKey( + input: ExecuteInput, + cookie: string, + clientSessionKey: string +): string { + const accountKey = input.credentials.connectionId + ? `connection:${hashKey(input.credentials.connectionId)}` + : `cookie:${hashKey(cookie)}`; + return hashKey(`${accountKey}:${clientSessionKey}`); +} + +async function withConolSessionLock( + key: string | null, + operation: () => Promise +): Promise { + if (!key) return operation(); + + const previous = conolSessionLocks.get(key) ?? Promise.resolve(); + let releaseCurrent!: () => void; + const currentGate = new Promise((resolve) => { + releaseCurrent = resolve; + }); + const current = previous.catch(() => undefined).then(() => currentGate); + conolSessionLocks.set(key, current); + await previous.catch(() => undefined); + + try { + return await operation(); + } finally { + releaseCurrent(); + if (conolSessionLocks.get(key) === current) { + conolSessionLocks.delete(key); + } + } +} + +export function clearConolSessionBindingsForTests(): void { + conolSessionBindings.clear(); + conolSessionLocks.clear(); +} + +/** True when this turn continues a session we created on an earlier request. */ +function reusedSessionCandidate( + cachedBinding: ConolSessionBinding | null, + sessionId: string +): boolean { + return !!cachedBinding && cachedBinding.upstreamSessionId === sessionId; +} + +function messageText(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) return ""; + const message = value as Record; + if (readString(message.role).toLowerCase() !== "assistant") return ""; + return extractText(message.content).trim(); +} + +function stageAssistantText(stages: unknown, field: "logs" | "preview"): string { + if (!Array.isArray(stages)) return ""; + let result = ""; + for (const stage of stages) { + if (!stage || typeof stage !== "object" || Array.isArray(stage)) continue; + const entries = (stage as Record)[field]; + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + const text = messageText(entry); + if (text) result = text; + } + } + return result; +} + +function parseEventLine(originalLine: string): unknown | null { + let line = originalLine.trim(); + if (!line || line.startsWith(":") || line.startsWith("event:")) return null; + if (line.startsWith("data:")) line = line.slice(5).trim(); + if (line.startsWith("message\t")) line = line.slice("message\t".length); + if (!line) return null; + if (line === "[DONE]") return { type: "done" }; + try { + return JSON.parse(line); + } catch { + // Ignore non-JSON keepalive and timestamp lines. + return null; + } +} + +function isDoneEvent(value: unknown): boolean { + return ( + !!value && + typeof value === "object" && + !Array.isArray(value) && + readString((value as Record).type) === "done" + ); +} + +function parseEventLines(raw: string): unknown[] { + const events: unknown[] = []; + for (const line of raw.replace(/\r\n/g, "\n").split("\n")) { + const event = parseEventLine(line); + if (event) events.push(event); + } + return events; +} + +/** + * Conol emits a terminal `done` event but keeps the HTTP stream open. Reading + * `response.text()` therefore waits until the request timeout even though the + * assistant answer is already complete. Consume complete lines and cancel the + * reader as soon as `done` arrives. + */ +export async function collectConolMessageStream(response: Response): Promise { + if (!response.body) return response.text(); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const lines: string[] = []; + let pending = ""; + let totalBytes = 0; + let doneEventReceived = false; + + try { + while (!doneEventReceived) { + const chunk = await reader.read(); + if (chunk.done) { + pending += decoder.decode(); + break; + } + + totalBytes += chunk.value.byteLength; + if (totalBytes > CONOL_MAX_STREAM_BYTES) { + throw new Error("Conol message stream exceeded the safety limit"); + } + pending += decoder.decode(chunk.value, { stream: true }); + const completeLines = pending.split(/\r?\n/); + pending = completeLines.pop() ?? ""; + for (const line of completeLines) { + lines.push(line); + if (isDoneEvent(parseEventLine(line))) { + doneEventReceived = true; + break; + } + } + } + + if (!doneEventReceived && pending) lines.push(pending); + } finally { + if (doneEventReceived) { + try { + await reader.cancel(); + } catch { + // The upstream may close at the same instant as its done event. + } + } else { + reader.releaseLock(); + } + } + + return lines.join("\n"); +} + +export function parseConolMessageStream(raw: string): ParsedConolStream { + let finalizedText = ""; + let previewText = ""; + let streamedText = ""; + let usedTokens: number | null = null; + let contextWindow: number | null = null; + let modelId = ""; + let done = false; + + for (const value of parseEventLines(raw)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const event = value as Record; + const type = readString(event.type); + if (type === "done") { + done = true; + continue; + } + + const finalCandidate = stageAssistantText(event.stages, "logs"); + const previewCandidate = stageAssistantText(event.stages, "preview"); + if (finalCandidate) finalizedText = finalCandidate; + if (previewCandidate) previewText = previewCandidate; + + if (type === "assistant") { + const direct = extractText(event.content ?? event.message ?? event.text).trim(); + if (direct) finalizedText = direct; + } else if (type === "stream_event") { + const delta = extractText(event.delta ?? event.content ?? event.text); + if (delta) streamedText += delta; + } + + const context = + event.contextUsage && + typeof event.contextUsage === "object" && + !Array.isArray(event.contextUsage) + ? (event.contextUsage as Record) + : null; + if (context) { + const used = Number(context.usedTokens); + const window = Number(context.contextWindow); + if (Number.isFinite(used)) usedTokens = used; + if (Number.isFinite(window)) contextWindow = window; + modelId = readString(context.modelId) || modelId; + } + } + + return { + text: finalizedText || previewText || streamedText, + usedTokens, + contextWindow, + modelId, + done, + }; +} + +function conolHeaders( + cookie: string, + extra?: Record, + sessionId?: string +): Record { + return { + accept: "application/json", + "accept-language": "en-US,en;q=0.9", + cookie, + origin: CONOL_ORIGIN, + referer: sessionId + ? `${CONOL_ORIGIN}/home?chat_session=${encodeURIComponent(sessionId)}` + : `${CONOL_ORIGIN}/home`, + "user-agent": USER_AGENT, + ...extra, + }; +} + +function safeTimezone(value: unknown): string { + const explicit = readString(value); + if (/^[A-Za-z_+-]+(?:\/[A-Za-z0-9_+-]+)*$/.test(explicit)) return explicit; + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} + +async function uploadConolImages( + cookie: string, + imageUrls: string[], + signal?: AbortSignal | null, + sessionId?: string +): Promise { + const images = await resolveCursorImages(imageUrls); + const parts: ConolMessagePart[] = []; + for (const image of images) { + const response = await fetch(`${CONOL_ORIGIN}/api/assets`, { + method: "POST", + headers: conolHeaders( + cookie, + { + accept: "application/json", + "content-type": image.mimeType, + }, + sessionId + ), + body: image.data, + signal: signal ?? undefined, + }); + if (!response.ok) { + throw new Error(`Conol image upload failed (HTTP ${response.status})`); + } + const payload = (await response.json()) as Record; + const id = readString(payload.id); + if (!/^[A-Za-z0-9_-]+$/.test(id)) { + throw new Error("Conol image upload returned an invalid asset ID"); + } + parts.push({ + type: "image", + content: `/api/assets/${id}`, + mediaType: readString(payload.mediaType) || image.mimeType, + }); + } + return parts; +} + +function estimateTokens(text: string): number { + return Math.max(0, Math.ceil(text.length / 4)); +} + +function completionResponse( + text: string, + model: string, + sessionId: string, + prompt: string +): Response { + const promptTokens = estimateTokens(prompt); + const completionTokens = estimateTokens(text); + return new Response( + JSON.stringify({ + id: `chatcmpl-conol-${sessionId}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: text }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +function streamResponse(text: string, model: string, sessionId: string): Response { + const encoder = new TextEncoder(); + const id = `chatcmpl-conol-${sessionId}`; + const created = Math.floor(Date.now() / 1000); + const readable = new ReadableStream({ + start(controller) { + const chunks = [ + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }], + }, + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + ]; + for (const chunk of chunks) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(readable, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }); +} + +export class ConolWebExecutor extends BaseExecutor { + constructor() { + super("conol-web", { id: "conol-web", baseUrl: CONOL_SESSION_URL }); + } + + async execute(input: ExecuteInput) { + const requestBody = (input.body || {}) as ConolRequestBody; + const messages = Array.isArray(requestBody.messages) ? requestBody.messages : []; + const userTurn = buildConolUserTurn(messages); + const prompt = userTurn.text; + const imageUrls = userTurn.imageUrls; + if (!prompt && imageUrls.length === 0) { + return makeErrorResult( + 400, + "No user message found", + { model: input.model }, + CONOL_SESSION_URL + ); + } + + const { cookie } = resolveConolCredentials(input.credentials); + if (!cookie) { + return makeErrorResult( + 401, + "Missing Conol session cookie — sign in with the browser or paste the Cookie header", + { model: input.model }, + CONOL_SESSION_URL + ); + } + + const { model, effort, effortExplicit } = resolveConolModelSelection( + input.model || requestBody.model + ); + const clientSessionKey = resolveConolClientSessionKey(requestBody, input.clientHeaders); + const sessionBindingKey = clientSessionKey + ? buildConolSessionBindingKey(input, cookie, clientSessionKey) + : null; + const timeoutSignal = AbortSignal.timeout(CONOL_REQUEST_TIMEOUT_MS); + const upstreamSignal = input.signal + ? mergeAbortSignals(input.signal, timeoutSignal) + : timeoutSignal; + try { + return await withConolSessionLock(sessionBindingKey, async () => { + if (upstreamSignal.aborted) { + throw upstreamSignal.reason ?? new DOMException("Aborted", "AbortError"); + } + + const cachedBinding = sessionBindingKey ? getConolSessionBinding(sessionBindingKey) : null; + let sessionId = cachedBinding?.upstreamSessionId || ""; + let reusedSession = false; + let presetApplied = cachedBinding?.presetApplied ?? false; + let appliedModel = cachedBinding?.appliedModel ?? ""; + let appliedEffort: ConolEffort | null = cachedBinding?.appliedEffort ?? null; + const imageParts = await uploadConolImages( + cookie, + imageUrls, + upstreamSignal, + sessionId || undefined + ); + const parts: ConolMessagePart[] = [...imageParts]; + if (prompt) parts.push({ type: "text", content: prompt }); + const timezone = safeTimezone(requestBody.timezone); + // Sticky: once a session has carried an image, Conol keeps treating it as + // multimodal, which drives preset text/multimodal model resolution. + const hasImageHistory = + (cachedBinding?.hasImageHistory ?? false) || imageParts.length > 0; + + // Conol ignores agentModel/agentEffort on session creation, so create the + // session empty and configure it before any turn is submitted. Otherwise the + // very first turn silently runs on the downgraded account default. + if (!sessionId) { + const createResponse = await fetch(CONOL_SESSION_URL, { + method: "POST", + headers: conolHeaders(cookie, { "content-type": "application/json" }), + body: JSON.stringify({ source: { type: "home" }, messages: [], timezone }), + signal: upstreamSignal, + }); + if (createResponse.status === 401 || createResponse.status === 403) { + return makeErrorResult( + createResponse.status, + "Conol session expired or is invalid — sign in again", + { model }, + CONOL_SESSION_URL + ); + } + if (!createResponse.ok) { + return makeErrorResult( + createResponse.status, + `Conol session creation failed (HTTP ${createResponse.status})`, + { model }, + CONOL_SESSION_URL + ); + } + + const created = (await createResponse.json()) as Record; + sessionId = readString(created.sessionId); + if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) { + return makeErrorResult( + 502, + "Conol returned an invalid session identifier", + { model }, + CONOL_SESSION_URL + ); + } + presetApplied = false; + appliedModel = ""; + appliedEffort = null; + } + + const plan = buildConolSessionModelPlan({ model, effort, hasImageHistory }); + const desiredEffort = plan.effort?.agentEffort ?? null; + // Re-pin only on a real change: a new session, a model switch, or an + // effort switch. Steady-state follow-ups cost no extra round trips. + const needsModelUpdate = + !presetApplied || appliedModel !== model || appliedEffort !== desiredEffort; + if (needsModelUpdate) { + const configured = await applyConolSessionModel({ + sessionId, + plan, + skipPreset: presetApplied, + buildHeaders: (id) => conolHeaders(cookie, undefined, id), + signal: upstreamSignal, + onWarning: (message) => input.log?.warn?.("conol-web", message), + }); + presetApplied = presetApplied || configured.presetApplied; + if (configured.modelApplied) { + appliedModel = model; + appliedEffort = configured.effortApplied; + } + } + + if (reusedSessionCandidate(cachedBinding, sessionId)) { + const followUpUrl = `${CONOL_SESSION_URL}/${sessionId}/messages`; + const followUpResponse = await fetch(followUpUrl, { + method: "POST", + headers: conolHeaders(cookie, { "content-type": "application/json" }, sessionId), + body: JSON.stringify({ messages: parts, timezone }), + signal: upstreamSignal, + }); + if (followUpResponse.status === 401 || followUpResponse.status === 403) { + return makeErrorResult( + followUpResponse.status, + "Conol session expired or is invalid — sign in again", + { model }, + followUpUrl + ); + } + if (followUpResponse.status === 404 || followUpResponse.status === 410) { + if (sessionBindingKey) conolSessionBindings.delete(sessionBindingKey); + return makeErrorResult( + followUpResponse.status, + "Conol session no longer exists — retry to start a new session", + { model, sessionId }, + followUpUrl + ); + } + if (!followUpResponse.ok) { + return makeErrorResult( + followUpResponse.status, + `Conol follow-up submission failed (HTTP ${followUpResponse.status})`, + { model, sessionId }, + followUpUrl + ); + } + reusedSession = true; + await followUpResponse.body?.cancel().catch(() => undefined); + } else { + const firstTurnUrl = `${CONOL_SESSION_URL}/${sessionId}/messages`; + const firstTurnResponse = await fetch(firstTurnUrl, { + method: "POST", + headers: conolHeaders(cookie, { "content-type": "application/json" }, sessionId), + body: JSON.stringify({ messages: parts, timezone }), + signal: upstreamSignal, + }); + if (firstTurnResponse.status === 401 || firstTurnResponse.status === 403) { + return makeErrorResult( + firstTurnResponse.status, + "Conol session expired or is invalid — sign in again", + { model }, + firstTurnUrl + ); + } + if (!firstTurnResponse.ok) { + return makeErrorResult( + firstTurnResponse.status, + `Conol message submission failed (HTTP ${firstTurnResponse.status})`, + { model, sessionId }, + firstTurnUrl + ); + } + await firstTurnResponse.body?.cancel().catch(() => undefined); + } + + if (sessionBindingKey) { + setConolSessionBinding(sessionBindingKey, { + upstreamSessionId: sessionId, + presetApplied, + appliedModel, + appliedEffort, + hasImageHistory, + }); + } + + const messagesUrl = `${CONOL_SESSION_URL}/${sessionId}/messages?logDeltas=1`; + const messageResponse = await fetch(messagesUrl, { + method: "GET", + headers: conolHeaders( + cookie, + { accept: "text/event-stream, application/x-ndjson" }, + sessionId + ), + signal: upstreamSignal, + }); + if (!messageResponse.ok) { + if ( + sessionBindingKey && + (messageResponse.status === 404 || messageResponse.status === 410) + ) { + conolSessionBindings.delete(sessionBindingKey); + } + return makeErrorResult( + messageResponse.status, + `Conol message stream failed (HTTP ${messageResponse.status})`, + { model, sessionId }, + messagesUrl + ); + } + + const parsed = parseConolMessageStream(await collectConolMessageStream(messageResponse)); + if (!parsed.text) { + return makeErrorResult( + 502, + "Conol returned no assistant response", + { model, sessionId }, + messagesUrl + ); + } + const response = input.stream + ? streamResponse(parsed.text, model, sessionId) + : completionResponse(parsed.text, model, sessionId, prompt); + + return { + response, + url: messagesUrl, + headers: { cookie: "***" }, + transformedBody: { + model, + ...(appliedEffort ? { effort: appliedEffort } : {}), + effortRequested: effort, + effortExplicit, + sessionId, + reusedSession, + clientSessionBound: sessionBindingKey !== null, + imageCount: imageParts.length, + }, + }; + }); + } catch (error) { + const isTimeout = error instanceof Error && error.name === "TimeoutError"; + const status = error instanceof CursorImageError ? error.status : isTimeout ? 504 : 502; + const message = + error instanceof CursorImageError + ? error.message + : isTimeout + ? "Conol request timed out" + : error instanceof Error && error.name === "AbortError" + ? "Conol request was cancelled" + : "Conol request failed"; + return makeErrorResult(status, message, { model }, CONOL_SESSION_URL); + } + } +} diff --git a/open-sse/executors/copilot-web.ts b/open-sse/executors/copilot-web.ts index dbeeaf6b06..bcc903ec4b 100644 --- a/open-sse/executors/copilot-web.ts +++ b/open-sse/executors/copilot-web.ts @@ -67,6 +67,11 @@ interface CopilotWsEvent { [key: string]: unknown; } +type NodeWebSocketConstructor = new ( + url: string | URL, + options?: { headers?: Record } +) => WebSocket; + // ─── Helpers ──────────────────────────────────────────────────────────────── export function getCopilotMode(model?: string): string { @@ -94,16 +99,47 @@ export function solveHashcash(parameter: string, difficulty: number): number | n } export function extractAccessToken(credential: string): string | null { - if (!credential) return null; - // Direct token - if (credential.startsWith("ey") || credential.length > 100) return credential; - // Try parsing as cookie string — look for _EDGE_S or similar - const match = credential.match(/access_token=([^;]+)/); - if (match) return match[1]; - // Try HAR-extracted bearer - const bearerMatch = credential.match(/[Bb]earer\s+(.+)/); + const trimmed = credential?.trim(); + if (!trimmed) return null; + + // Parse structured input before applying the direct-token heuristic. Real + // DevTools cookie/HAR exports routinely exceed 100 characters. + const accessTokenMatch = trimmed.match( + /(?:^|[\s;,{"'])access_token\s*[=:]\s*["']?([^\s;,}"']+)/i + ); + if (accessTokenMatch) return accessTokenMatch[1]; + + const bearerMatch = trimmed.match(/(?:^|[\s:{"'])bearer\s+([^\s,}"';]+)/i); if (bearerMatch) return bearerMatch[1]; - return credential; + + // A named cookie is not an OAuth access token. Reject it instead of sending + // the full cookie value as `Authorization: Bearer ...`. + if (/^(?:[^=;\s]+=[^;]*)(?:;|$)/.test(trimmed) || /^(?:\{|\[)/.test(trimmed)) { + return null; + } + + return trimmed; +} + +export function buildCopilotWebSocketUrl( + accessToken?: string, + clientSessionId = crypto.randomUUID() +): string { + const url = new URL(COPILOT_WS_URL); + url.searchParams.set("clientSessionId", clientSessionId); + if (accessToken) { + // Copilot's browser client authenticates the WebSocket with this query + // parameter. Node's browser-compatible global WebSocket cannot set custom + // headers, so the previous header-only fallback silently lost auth on Node 22+. + url.searchParams.set("accessToken", accessToken); + } + return url.toString(); +} + +/* @testonly */ export function buildCopilotWebSocketHeaders( + accessToken: string +): Record { + return { Authorization: `Bearer ${accessToken}` }; } /** @@ -244,8 +280,7 @@ export class CopilotWebExecutor extends BaseExecutor { accessToken?: string, signal?: AbortSignal ): Promise> { - // Build WebSocket URL without credentials in query string - const wsUrl = `${COPILOT_WS_URL}&clientSessionId=${crypto.randomUUID()}`; + const wsUrl = buildCopilotWebSocketUrl(accessToken); return new ReadableStream( { @@ -289,22 +324,19 @@ export class CopilotWebExecutor extends BaseExecutor { signal?.addEventListener("abort", () => abort("Request aborted"), { once: true }); try { - // Use Node.js built-in WebSocket if available, else dynamic import. - // Pass the access token via Authorization header (not URL) to avoid - // credential exposure in server logs. - let WS = globalThis.WebSocket; - if (!WS) { + // Authentication is present in wsUrl for both transports. The Node + // fallback also preserves the Authorization header where supported. + const BrowserWebSocket = globalThis.WebSocket; + if (BrowserWebSocket) { + ws = new BrowserWebSocket(wsUrl); + } else { // @ts-ignore — ws module has no type declarations in this project - WS = (await import("ws")).default as unknown as typeof WebSocket; - if (accessToken) { - // @ts-ignore — ws module supports headers option in second arg - ws = new WS(wsUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - }) as WebSocket; - } - } - if (!ws) { - ws = new WS(wsUrl) as WebSocket; + const NodeWebSocket = (await import("ws")) + .default as unknown as NodeWebSocketConstructor; + ws = new NodeWebSocket( + wsUrl, + accessToken ? { headers: buildCopilotWebSocketHeaders(accessToken) } : undefined + ); } const timeout = setTimeout(() => abort("Copilot WebSocket timeout"), FETCH_TIMEOUT_MS); @@ -525,7 +557,9 @@ export class CopilotWebExecutor extends BaseExecutor { ws.onerror = (err: Event) => { clearTimeout(timeout); - const msg = (err as ErrorEvent).message || "Copilot WebSocket error"; + const msg = sanitizeErrorMessage( + (err as ErrorEvent).message || "Copilot WebSocket error" + ); abort(msg); }; @@ -534,7 +568,11 @@ export class CopilotWebExecutor extends BaseExecutor { finish(); }; } catch (err) { - abort(err instanceof Error ? err.message : "Failed to connect to Copilot"); + abort( + sanitizeErrorMessage( + err instanceof Error ? err.message : "Failed to connect to Copilot" + ) + ); } }, }, @@ -608,7 +646,7 @@ export class CopilotWebExecutor extends BaseExecutor { headers: { "Content-Type": "application/json" }, }), url: COPILOT_START_URL, - headers: accessToken ? { Authorization: `Bearer ${accessToken.slice(0, 20)}...` } : {}, + headers: {}, transformedBody: { conversationId: null, mode, prompt: fullPrompt.slice(0, 100) }, }; } diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 61250dfe4c..42c7bf0055 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -75,6 +75,7 @@ import { visibleComposerContentFromThinking, composerReasoningRemainder, } from "./cursor/composer.ts"; +import { getActiveSyncedCatalog } from "../../src/lib/db/models/activeSyncedCatalog.ts"; // Composer helpers re-exported for external importers (tests). export { isComposerModel, @@ -805,6 +806,20 @@ export class CursorExecutor extends BaseExecutor { return resolveCursorImages(imageUrls); } + /** + * Exact ids from the active Cursor synced catalog. Empty/unavailable → + * undefined so resolveRequestedModel keeps #7289 offline splitting. + */ + private async loadLiveCatalogIds(): Promise | undefined> { + try { + const catalog = await getActiveSyncedCatalog("cursor"); + if (!catalog.models.length) return undefined; + return new Set(catalog.models.map((model) => model.id)); + } catch { + return undefined; + } + } + private async buildRequest( model: string, body: { @@ -819,7 +834,10 @@ export class CursorExecutor extends BaseExecutor { } ): Promise<{ body: Uint8Array; blobStore: Map }> { const { userText, tools } = this.assembleTextAndTools(body); - const images = await this.resolveRequestImages(body); + const [images, liveCatalogIds] = await Promise.all([ + this.resolveRequestImages(body), + this.loadLiveCatalogIds(), + ]); const blobStore = new Map(); const requestBody = buildAgentRequestBody({ @@ -829,6 +847,7 @@ export class CursorExecutor extends BaseExecutor { tools, blobStore, images, + liveCatalogIds, }); return { body: requestBody, blobStore }; } 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..52dd139871 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; } } @@ -359,8 +358,6 @@ export class DefaultExecutor extends BaseExecutor { case "glm": case "glmt": case "kimi-coding": - case "minimax": - case "minimax-cn": return `${this.config.baseUrl}?beta=true`; case "agentrouter": return this.usesClaudeCodeProtocol(credentials) 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/devin-agentic/serializer.ts b/open-sse/executors/devin-agentic/serializer.ts index 0f37bd1d1b..8593eea2cd 100644 --- a/open-sse/executors/devin-agentic/serializer.ts +++ b/open-sse/executors/devin-agentic/serializer.ts @@ -119,7 +119,8 @@ function serializeMessage( "unsupported_role" ); } - const label = role === "assistant" ? "Assistant" : role === "system" ? "System" : "User"; + // role was just narrowed to "user" | "assistant" by the guard above ("system" throws). + const label = role === "assistant" ? "Assistant" : "User"; const content = record.content; if (typeof content === "string") return `[${label}]\n${content}`; 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/gemini-web.ts b/open-sse/executors/gemini-web.ts index 975d13093f..8810b43cc3 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -14,9 +14,13 @@ */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; -import { sanitizeErrorMessage } from "../utils/error.ts"; +import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts"; import { prepareToolMessages } from "../translator/webTools.ts"; import { buildToolModeResponse } from "./chatgptWebTools.ts"; +import { + checkGeminiWebUnsupportedControls, + GEMINI_WEB_UNSUPPORTED_CONTROL_CODE, +} from "./gemini-web/capabilities.ts"; // ─── Constants ────────────────────────────────────────────────────────────── @@ -406,6 +410,33 @@ export class GeminiWebExecutor extends BaseExecutor { const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input; const requestBody = body as GeminiRequestBody; + // #9356: fail fast on controls this provider cannot honor (reasoning_effort + // above "minimal", forced tool_choice). Runs before the credential check and + // before Playwright launches — the request is unservable no matter which + // cookie is used, and answering 200 with ordinary prose made agents believe + // their reasoning/tool requirements had been met. See ./gemini-web/capabilities.ts. + const violation = checkGeminiWebUnsupportedControls(body as Record); + if (violation) { + log?.warn?.( + "GEMINI-WEB", + `Rejected request: "${violation.param}" is not supported by this provider` + ); + return { + response: new Response( + JSON.stringify( + buildErrorBody(400, violation.message, null, { + type: "invalid_request_error", + code: GEMINI_WEB_UNSUPPORTED_CONTROL_CODE, + }) + ), + { status: 400, headers: { "Content-Type": "application/json" } } + ), + url: GEMINI_URL, + headers: {}, + transformedBody: body, + }; + } + const cookie = resolveGeminiWebCookie(credentials); if (!cookie) { return { diff --git a/open-sse/executors/gemini-web/capabilities.ts b/open-sse/executors/gemini-web/capabilities.ts new file mode 100644 index 0000000000..6eefe3072f --- /dev/null +++ b/open-sse/executors/gemini-web/capabilities.ts @@ -0,0 +1,121 @@ +/** + * Request-contract guards for the Gemini Web executor (#9356). + * + * gemini-web is not an API client. It launches Playwright, types ONE flat + * prompt string into the gemini.google.com `.ql-editor` contenteditable, + * presses Enter, and captures the first `StreamGenerate` response off the page + * (see ../gemini-web.ts). There is no JSON request body on the wire, which + * makes two OpenAI controls structurally impossible to honor: + * + * • `reasoning_effort` — no field exists to carry a thinking budget. Unlike + * deepseek-web or perplexity-web, which post a real payload and can flip a + * `thinking_enabled` flag or swap the model preference, there is nothing + * here to set. + * • forced `tool_choice` — the tools support gemini-web does have is the + * prompt-emulation shim (`translator/webTools.ts`, #7286): it ASKS the + * model, in prose, to answer with `{...}` and parses whatever + * comes back. That is best-effort by construction. "required" / "any" / + * a named function is a GUARANTEE, and a prompt cannot make one. + * + * Before this module both were accepted and quietly ignored, so an agent got a + * 200 with `finish_reason: "stop"`, no `reasoning_content`, and `tool_calls: []` + * and concluded its requirements had been met (#9356). Failing the request is + * the honest answer: the caller can drop the control, or route to a model that + * actually implements it. + * + * Deliberately NOT rejected — these are already satisfied or already work: + * • `reasoning_effort: "none" | "minimal"` — asking for as little reasoning as + * possible is something a non-thinking provider trivially complies with. + * • `tool_choice: "auto" | "none"` and plain `tools[]` — the #7286 emulation + * path, which several shipped combos depend on (#5240, #8488). Untouched. + * + * Pure and dependency-free so the whole contract is unit-testable without a + * browser. + */ + +/** `error.code` on every compatibility rejection raised here. */ +export const GEMINI_WEB_UNSUPPORTED_CONTROL_CODE = "unsupported_control_for_provider"; + +/** Effort levels a non-thinking provider already complies with. */ +const SATISFIED_EFFORT_LEVELS = new Set(["none", "minimal"]); + +/** `tool_choice` strings that demand a tool call rather than merely offering one. */ +const FORCING_TOOL_CHOICE_STRINGS = new Set(["required", "any"]); + +/** `tool_choice: { type }` values that pin the model to a specific/any tool. */ +const FORCING_TOOL_CHOICE_TYPES = new Set(["function", "tool", "any"]); + +export interface GeminiWebCapabilityViolation { + /** Which request field could not be honored. */ + param: "reasoning_effort" | "tool_choice"; + /** Client-facing explanation — already safe to put in a response body. */ + message: string; +} + +function normalizeString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim().toLowerCase() : null; +} + +/** + * True when `tool_choice` demands a tool call. Covers the OpenAI strings + * ("required"), the Anthropic-flavored ones the translators also emit ("any"), + * and the object forms that name a function or force any tool. "auto" / "none" + * and every unrecognized shape are treated as non-forcing — this guard only + * blocks contracts it is certain gemini-web cannot keep. + */ +export function isForcingToolChoice(toolChoice: unknown): boolean { + const asString = normalizeString(toolChoice); + if (asString) return FORCING_TOOL_CHOICE_STRINGS.has(asString); + + if (toolChoice && typeof toolChoice === "object" && !Array.isArray(toolChoice)) { + const type = normalizeString((toolChoice as Record).type); + return type !== null && FORCING_TOOL_CHOICE_TYPES.has(type); + } + + return false; +} + +/** True when `reasoning_effort` asks for MORE thinking than "none at all". */ +export function requestsThinkingBudget(reasoningEffort: unknown): boolean { + const effort = normalizeString(reasoningEffort); + if (effort === null) return false; + return !SATISFIED_EFFORT_LEVELS.has(effort); +} + +/** + * Inspect an OpenAI-shaped request body for controls gemini-web cannot honor. + * Returns the first violation found, or `null` when the request is servable. + * + * `reasoning_effort` is checked before `tool_choice` only for determinism; a + * request carrying both is rejected either way. + */ +export function checkGeminiWebUnsupportedControls( + body: Record | null | undefined +): GeminiWebCapabilityViolation | null { + if (!body || typeof body !== "object") return null; + + if (requestsThinkingBudget(body.reasoning_effort)) { + return { + param: "reasoning_effort", + message: + 'Model provider "gemini-web" does not support "reasoning_effort". It drives the ' + + "gemini.google.com web UI through a typed prompt and has no thinking-budget control " + + 'to set, so any effort above "minimal" would be silently ignored. Remove ' + + '"reasoning_effort" (or send "none"/"minimal") or route to a reasoning-capable model.', + }; + } + + if (isForcingToolChoice(body.tool_choice)) { + return { + param: "tool_choice", + message: + 'Model provider "gemini-web" cannot guarantee a forced tool call. Its tool support is ' + + "prompt-emulated — the model is asked to emit a tool block and may answer with prose " + + 'instead — so "tool_choice" values that require one ("required", "any", or a named ' + + 'function) cannot be honored. Use "auto" to keep best-effort tool calling, or route to ' + + "a model with native function calling.", + }; + } + + return null; +} diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 52b4cb845b..3e71bf627f 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -1,4 +1,9 @@ -import { BaseExecutor, ExecuteInput, type ProviderCredentials } from "./base.ts"; +import { + BaseExecutor, + ExecuteInput, + type ProviderConfig, + type ProviderCredentials, +} from "./base.ts"; import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; import { @@ -28,9 +33,11 @@ export interface RefreshedCopilotCredentials { providerSpecificData?: Record; } +type GithubExecutorConfig = ProviderConfig & Record; + export class GithubExecutor extends BaseExecutor { - constructor() { - super("github", PROVIDERS.github); + constructor(provider = "github", config?: GithubExecutorConfig) { + super(provider, config ?? PROVIDERS.github); } getCopilotToken(credentials: Record | null | undefined) { @@ -59,8 +66,24 @@ export class GithubExecutor extends BaseExecutor { return !(m.includes("gemini") || m.includes("claude")); } - buildUrl(model: string, _stream: boolean, _urlIndex = 0) { - const targetFormat = getModelTargetFormat("gh", model); + buildUrl( + model: string, + _stream: boolean, + _urlIndex = 0, + credentials?: ProviderCredentials | null + ) { + // #2905/#7364-pattern: a custom Copilot model's per-model targetFormat + // override isn't in the static PROVIDER_MODELS registry, so + // getModelTargetFormat() can't see it. chatCore/executionCredentials.ts + // threads the resolved override onto providerSpecificData.targetFormat + // for exactly this case — prefer it when present. + const overrideTargetFormat = ( + credentials as { providerSpecificData?: { targetFormat?: unknown } } + )?.providerSpecificData?.targetFormat; + const targetFormat = + typeof overrideTargetFormat === "string" + ? overrideTargetFormat + : getModelTargetFormat("gh", model); // Claude models: route to Copilot's Anthropic-native /v1/messages shim — the // only Copilot endpoint that surfaces prompt-cache token counts for Claude and // avoids a lossy round-trip of tool_use/tool_result/thinking content blocks diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index b25f4d9555..fac8c94e34 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -22,9 +22,11 @@ import { GrokWebExecutor } from "./grok-web.ts"; import { GeminiWebExecutor } from "./gemini-web.ts"; import { GeminiBusinessExecutor } from "./gemini-business.ts"; import { ChatGptWebExecutor } from "./chatgpt-web.ts"; +import { ChatGptWebCodexExecutor } from "./chatgpt-web-codex.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"; @@ -68,9 +70,11 @@ import { MimocodeExecutor } from "./mimocode.ts"; import { GrokCliExecutor } from "./grok-cli.ts"; import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; import { ZenmuxFreeExecutor } from "./zenmux-free.ts"; +import { TinyCmsExecutor } from "./tinycms.ts"; import { HyperAgentExecutor } from "./hyperagent.ts"; import { XaiExecutor } from "./xai.ts"; import { PromptQlExecutor } from "./promptql.ts"; +import { ConolWebExecutor } from "./conol-web.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -82,6 +86,8 @@ const executors = { "amazon-q": new KiroExecutor("amazon-q"), bedrock: new BedrockExecutor(), codex: new CodexExecutor(), + "chatgpt-web-codex": new ChatGptWebCodexExecutor(), + "cgpt-codex": new ChatGptWebCodexExecutor(), cursor: new CursorExecutor(), trae: new TraeExecutor(), glm: new GlmExecutor("glm"), @@ -89,6 +95,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(), @@ -197,6 +204,8 @@ const executors = { "codebuddy-cn": new CodeBuddyCnExecutor(), cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn "zenmux-free": new ZenmuxFreeExecutor(), + "tinycms-web": new TinyCmsExecutor(), + tcw: new TinyCmsExecutor(), // Alias hyperagent: new HyperAgentExecutor(), ha: new HyperAgentExecutor(), // Alias zmf: new ZenmuxFreeExecutor(), // Alias for zenmux-free @@ -204,6 +213,9 @@ const executors = { xai: new XaiExecutor(), "xai-oauth": new XaiExecutor("xai-oauth"), xao: new XaiExecutor("xai-oauth"), + qw: new QwenWebExecutor(), // Alias + "conol-web": new ConolWebExecutor(), + cnl: new ConolWebExecutor(), // Alias }; const defaultCache = new Map(); @@ -263,6 +275,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"; @@ -294,8 +307,10 @@ export { MimocodeExecutor } from "./mimocode.ts"; export { GrokCliExecutor } from "./grok-cli.ts"; export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; export { ZenmuxFreeExecutor } from "./zenmux-free.ts"; +export { TinyCmsExecutor } from "./tinycms.ts"; export { HyperAgentExecutor } from "./hyperagent.ts"; export { XaiExecutor } from "./xai.ts"; export { MoonshotExecutor } from "./moonshot.ts"; export { CheaperInferenceExecutor } from "./cheaperinference.ts"; export { PromptQlExecutor } from "./promptql.ts"; +export { ConolWebExecutor } from "./conol-web.ts"; diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 65c4e6186a..815d3a6348 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -61,7 +61,7 @@ type KiroStreamState = { contextUsagePercentage?: number; hasContextUsage?: boolean; hasMeteringEvent?: boolean; - usage?: UsageSummary; + usage?: Partial; hasReasoningContent?: boolean; reasoningChunkCount?: number; // Inline-thinking splitter state (populated only when thinkingExpected=true). @@ -185,8 +185,7 @@ function resolveKiroMaxInputTokens(model: string): number { * inflate `total_tokens`. */ function ensureKiroUsage(state: KiroStreamState, model: string) { - if (state.usage) return; - + if (state.usage?.total_tokens !== undefined) return; const estimatedOutputTokens = state.totalContentLength && state.totalContentLength > 0 ? Math.max(1, Math.floor(state.totalContentLength / 4)) @@ -198,11 +197,11 @@ function ensureKiroUsage(state: KiroStreamState, model: string) { : 0; if (estimatedTotalTokens <= 0 && estimatedOutputTokens <= 0) return; - // Without a percentage there is no total to split, so the output estimate is // all that is known and stands on its own. if (estimatedTotalTokens <= 0) { state.usage = { + ...state.usage, prompt_tokens: 0, completion_tokens: estimatedOutputTokens, total_tokens: estimatedOutputTokens, @@ -213,6 +212,7 @@ function ensureKiroUsage(state: KiroStreamState, model: string) { const promptTokens = Math.max(0, estimatedTotalTokens - estimatedOutputTokens); state.usage = { + ...state.usage, prompt_tokens: promptTokens, completion_tokens: estimatedOutputTokens, total_tokens: promptTokens + estimatedOutputTokens, diff --git a/open-sse/executors/lmarena/response.ts b/open-sse/executors/lmarena/response.ts index 64aef907c9..acc86f915a 100644 --- a/open-sse/executors/lmarena/response.ts +++ b/open-sse/executors/lmarena/response.ts @@ -7,6 +7,8 @@ import { isCloudflareChallenge } from "../../services/lmarenaTlsClient.ts"; import { markLMArenaCatalogModelDead } from "./models.ts"; import { parseArenaSSE } from "./stream.ts"; +const encoder = new TextEncoder(); + export function errorResponse( status: number, message: string, @@ -165,7 +167,7 @@ function baseChunk(model: string) { } function enqueueSse(controller: ReadableStreamDefaultController, chunk: Record) { - controller.enqueue(`data: ${JSON.stringify(chunk)}\n\n`); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); } function emitStopAndDone(controller: ReadableStreamDefaultController, model: string) { @@ -173,7 +175,8 @@ function emitStopAndDone(controller: ReadableStreamDefaultController, model: str ...baseChunk(model), choices: [{ index: 0, delta: {}, finish_reason: "stop" }], }); - controller.enqueue("data: [DONE]\n\n"); + + controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.close(); } @@ -213,7 +216,7 @@ export function createOpenAIArenaStream(opts: { model: string; signal?: AbortSignal; log?: { error?: (scope: string, msg: string) => void }; -}): ReadableStream { +}): ReadableStream { const { reader, model, signal, log } = opts; const decoder = new TextDecoder(); let buffer = ""; diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 8b66d6e421..75f7e372f5 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -40,6 +40,31 @@ const OPENCODE_COOLDOWN_MAX_MS = 60_000; const EFFORT_LEVELS = ["low", "medium", "high", "max"] as const; +/** + * Models that work WITHOUT any API key on the free/noauth opencode tier. + * + * The upstream free tier rotates frequently — when a `-free` suffix model is + * delisted upstream, the upstream returns "Model X is not supported" (a separate + * issue from this gate). The set is defined by two data sources: + * + * 1. **Known free models** — models explicitly listed in the noauth + * `opencode` provider registry (`open-sse/config/providers/registry/opencode/index.ts`). + * These are the canonical free models. `deepseek-v4-flash-free` appears in both + * the noauth AND the zen registry (it is free on both tiers). + * 2. **`-free` suffix** — any model whose id ends in `-free`. This automatically + * covers upstream free-tier additions without a code deploy. + * + * For `opencode-go`, there is no free tier — ALL models require an API key. + */ +const OPENCODE_FREE_MODELS = new Set([ + "big-pickle", + "deepseek-v4-flash-free", + "mimo-v2.5-free", + "hy3-free", + "nemotron-3-ultra-free", + "north-mini-code-free", +]); + /** * Models on opencode-go that support effort-tier aliases. Each entry maps the * canonical base id to the set of effort suffixes the upstream supports. @@ -86,7 +111,31 @@ export function parseEffortLevel(model: string): { baseModel: string; effort: st return null; } +/** + * Determine whether a model requires an API key on the given opencode provider. + * + * - `opencode-go`: ALL models require a key (no free tier). + * - `opencode` / `opencode-zen`: premium = any model NOT in the free set (known + * free models OR ending in `-free`). + * - Unknown models are assumed premium (fail-safe). + */ +export function isPremiumOpencodeModel(model: string, provider: string): boolean { + // opencode-go has no free tier — every model requires a key. + if (provider === "opencode-go") return true; + + // Models ending in `-free` are always free on the noauth/zen tier. + if (model.endsWith("-free")) return false; + + // Check the known free model catalog. + return !OPENCODE_FREE_MODELS.has(model); +} + export class OpencodeExecutor extends BaseExecutor { + /** Delegates to `isPremiumOpencodeModel`. Exported for testability. */ + static isPremiumModel(model: string, provider: string): boolean { + return isPremiumOpencodeModel(model, provider); + } + _requestFormat: string | null = null; /** @@ -181,6 +230,33 @@ export class OpencodeExecutor extends BaseExecutor { async execute(input: ExecuteInput) { this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai"; + + // #8681: Gate premium opencode models behind a usable API key. + // When the connection is keyless (no apiKey, no accessToken) and the model + // is a premium model (not on the free tier), return a clear 402 error + // instead of proxying the raw upstream 401 "Missing API key" response. + const creds = input.credentials; + const isKeyless = + !creds?.apiKey && !creds?.accessToken && !creds?.providerSpecificData?.extraApiKeys; + if (isKeyless && isPremiumOpencodeModel(input.model, this.provider)) { + const bodyJson = JSON.stringify({ + error: { + message: "This model requires an opencode API key — add one in Settings → Providers.", + type: "invalid_request_error", + code: "premium_model_requires_key", + }, + }); + return { + response: new Response(bodyJson, { + status: 402, + headers: { "Content-Type": "application/json" }, + }), + url: "", + headers: {} as Record, + transformedBody: null, + }; + } + try { this.syncAccountsFromCredentials(input.credentials); @@ -322,6 +398,77 @@ export class OpencodeExecutor extends BaseExecutor { return headers; } + /** + * OpenCode's free DeepSeek V4 Flash endpoint accepts json_object but + * rejects json_schema response_format with HTTP 400. Preserve the schema + * as an instruction and downgrade only this proven-incompatible route to + * json_object so callers still receive structured JSON. + */ + private applyDeepSeekJsonSchemaFallback(model: string, body: T): T { + if ( + model !== "deepseek-v4-flash-free" || + (this.provider !== "opencode" && this.provider !== "opencode-zen") + ) { + return body; + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + return body; + } + + const record = body as Record; + const responseFormat = record.response_format as + | { + type?: string; + json_schema?: { + schema?: unknown; + }; + } + | undefined; + + if (responseFormat?.type !== "json_schema" || !responseFormat.json_schema?.schema) { + return body; + } + + const schemaJson = JSON.stringify(responseFormat.json_schema.schema, null, 2); + + const prompt = + "You must respond with valid JSON that strictly follows " + + "this JSON schema:\\n```json\\n" + + schemaJson + + "\\n```\\nRespond ONLY with the JSON object, no other text."; + + const messages: Array> = Array.isArray(record.messages) + ? (record.messages as Array>).map((message) => ({ ...message })) + : []; + + const systemMessage = messages.find((message) => message.role === "system"); + + if (systemMessage) { + if (typeof systemMessage.content === "string") { + systemMessage.content = `${systemMessage.content}\\n\\n${prompt}`; + } else if (Array.isArray(systemMessage.content)) { + systemMessage.content.push({ + type: "text", + text: `\\n\\n${prompt}`, + }); + } + } else { + messages.unshift({ + role: "system", + content: prompt, + }); + } + + return { + ...record, + messages, + response_format: { + type: "json_object", + }, + } as T; + } + transformRequest( model: string, body: any, @@ -329,6 +476,7 @@ export class OpencodeExecutor extends BaseExecutor { credentials: ProviderCredentials ): any { let modifiedBody = super.transformRequest(model, body, stream, credentials); + modifiedBody = this.applyDeepSeekJsonSchemaFallback(model, modifiedBody); // 9router#1442: OpenCode upstreams (e.g. kimi-k2.6 via opencode-go) return // 400 "Extra inputs are not permitted, field: 'client_metadata'" — an // OpenAI-Codex/Claude-CLI passthrough field with no equivalent here. The diff --git a/open-sse/executors/qoder.ts b/open-sse/executors/qoder.ts index 7dfa139462..1b19289a1a 100644 --- a/open-sse/executors/qoder.ts +++ b/open-sse/executors/qoder.ts @@ -372,8 +372,16 @@ export class QoderExecutor extends BaseExecutor { const { text, isError, errorMessage } = parseQoderCliResult(run.stdout); if (isError) { + // When qodercli exits 0 but returns is_error=true with an empty result, + // the real upstream error is almost always on stderr. Surface it instead + // of the generic "qodercli returned an error" fallback (#9319). + let effectiveError = errorMessage; + if (errorMessage === "qodercli returned an error" && run.stderr.trim()) { + const stderrTrimmed = run.stderr.trim().slice(0, 300); + effectiveError = `qodercli returned an error: ${stderrTrimmed}`; + } return { - response: createQoderErrorResponse(parseQoderCliFailure(errorMessage)), + response: createQoderErrorResponse(parseQoderCliFailure(effectiveError)), url, headers: {}, transformedBody: body, diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts index 6c0a710862..57036a3cbb 100644 --- a/open-sse/executors/qwen-web.ts +++ b/open-sse/executors/qwen-web.ts @@ -47,8 +47,8 @@ const BX_UMIDTOKEN_FALLBACK = "T2gA0000000000000000000000000000000000000000"; // header the upstream returns HTTP 200 with `{"success":false,"data":{"code":"Bad_Request"}}` // for every completion request, even with a valid session. The version string is // the SPA build identifier shipped in the React client's `version` request header. -// Pinned from a live capture (2026-07); bump if Qwen ships a breaking change. -const QWEN_SPA_VERSION = "0.2.66"; +// Pinned from a live capture (2026-08); bump if Qwen ships a breaking change. +const QWEN_SPA_VERSION = "0.2.81"; const MODEL_ALIASES: Record = { // Legacy OmniRoute ids → current upstream catalog (GET /api/models). diff --git a/open-sse/executors/raycast.ts b/open-sse/executors/raycast.ts index 4f788c115c..bfa8d28028 100644 --- a/open-sse/executors/raycast.ts +++ b/open-sse/executors/raycast.ts @@ -28,7 +28,14 @@ export class RaycastExecutor extends BaseExecutor { return RAYCAST_CHAT_URL; } - buildHeaders(credentials: ProviderCredentials, payload?: string): Record { + // Not a BaseExecutor.buildHeaders override: Raycast signs headers over the exact + // request payload (2nd param is the body string, not the base's `stream` boolean), + // and execute() below is fully custom — keep it as a distinct helper so a + // polymorphic buildHeaders(credentials, true) call can never land here. + private buildRaycastRequestHeaders( + credentials: ProviderCredentials, + payload?: string + ): Record { const body = payload || "{}"; return buildRaycastHeaders(body, credentials as JsonRecord); } @@ -44,7 +51,11 @@ export class RaycastExecutor extends BaseExecutor { return { response: new Response( JSON.stringify({ - error: { message: sanitizeErrorMessage(message), type: "invalid_request_error", code: "" }, + error: { + message: sanitizeErrorMessage(message), + type: "invalid_request_error", + code: "", + }, }), { status: 400, headers: { "Content-Type": "application/json" } } ), @@ -54,7 +65,7 @@ export class RaycastExecutor extends BaseExecutor { }; } - const headers = this.buildHeaders(credentials as ProviderCredentials, payload); + const headers = this.buildRaycastRequestHeaders(credentials as ProviderCredentials, payload); mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders as Record | null); let raycastResponse: Response; 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/executors/tinycms.ts b/open-sse/executors/tinycms.ts new file mode 100644 index 0000000000..4cbb0bf8f8 --- /dev/null +++ b/open-sse/executors/tinycms.ts @@ -0,0 +1,132 @@ +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; +import { initTinyCmsWasm, generateSecurePayload } from "./tinycmsSigner.ts"; + +const CHAT_URL = "https://gov.freegpt.win/api/openai/oneapi/v1/chat/completions"; +const CHALLENGE_URL = "https://gov.freegpt.win/api/challenge"; + +let publicIp: string | null = null; +let lastIpFetch = 0; + +async function getPublicIp(): Promise { + const now = Date.now(); + if (publicIp && now - lastIpFetch < 300000) { + return publicIp; + } + try { + const res = await fetch("https://api64.ipify.org?format=json"); + const json = (await res.json()) as { ip: string }; + publicIp = json.ip; + lastIpFetch = now; + return publicIp; + } catch { + return publicIp || "127.0.0.1"; + } +} + +async function fetchChallenge(uuid: string): Promise { + const res = await fetch(CHALLENGE_URL, { + method: "GET", + headers: { + "uuid": uuid, + "x-origin": "https://gov.freegpt.win", + "Accept": "application/json", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + }, + }); + if (!res.ok) { + throw new Error(`Failed to fetch challenge: ${res.status}`); + } + return await res.json(); +} + +export class TinyCmsExecutor extends BaseExecutor { + constructor() { + super("tinycms-web", { id: "tinycms-web", baseUrl: CHAT_URL }); + } + + async execute(input: ExecuteInput) { + const { body, credentials, signal } = input; + const bodyObj = (body || {}) as Record; + + // TinyCMS uses 'uuid' header for identification + const uuid = String(credentials?.apiKey ?? "").trim(); + if (!uuid || !uuid.startsWith("R")) { + return makeErrorResult( + 401, + "TinyCMS: Invalid or missing device UUID (must start with 'R')", + body, + CHAT_URL + ); + } + + try { + await initTinyCmsWasm(); + + const ip = await getPublicIp(); + const challengeObj = await fetchChallenge(uuid); + + const timestamp = Date.now().toString(); + const nonceJs = + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + + const securePayload = generateSecurePayload( + uuid, + timestamp, + nonceJs, + challengeObj.challenge, + ip, + challengeObj.difficulty + ); + + const signedHeaders: Record = { + uuid: uuid, + "x-origin": "https://gov.freegpt.win", + referer: "https://gov.freegpt.win/", + "x-secure-challenge-id": challengeObj.challengeId, + "x-secure-challenge-expires-at": String(challengeObj.expiresAt), + "x-secure-challenge-version": challengeObj.version, + "x-secure-signature": securePayload.signature, + "x-secure-fingerprint": securePayload.fingerprint, + "x-secure-client-ip": securePayload.client_ip, + "x-secure-pow-seed-nonce": String(securePayload.pow.seed_nonce), + "x-secure-pow-nonce": String(securePayload.pow.nonce), + "x-secure-pow-hash": securePayload.pow.hash, + "x-secure-pow-difficulty": String(securePayload.pow.difficulty), + "x-secure-timestamp": timestamp, + "x-secure-nonce": nonceJs, + "x-secure-version": securePayload.v, + "x-session-id": nonceJs, + // Use configurable userid from providerSpecificData if present, otherwise generate one + // from the UUID (the server uses it for request attribution, not auth). + userid: String(credentials?.providerSpecificData?.userid ?? "") || uuid.slice(0, 20), + Accept: bodyObj.stream ? "text/event-stream" : "application/json", + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + }; + + const fetchOptions: RequestInit = { + method: "POST", + headers: signedHeaders, + body: JSON.stringify(bodyObj), + signal, + }; + + const response = await fetch(CHAT_URL, fetchOptions); + return { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + body: response.body, + }; + } catch (err: any) { + return makeErrorResult( + 500, + `TinyCMS Error: ${err.message}`, + body, + CHAT_URL + ); + } + } +} diff --git a/open-sse/executors/tinycmsSigner.ts b/open-sse/executors/tinycmsSigner.ts new file mode 100644 index 0000000000..61c0a22469 --- /dev/null +++ b/open-sse/executors/tinycmsSigner.ts @@ -0,0 +1,509 @@ +// Runtime DOM shims for the wasm-bindgen glue code (compiled from Rust wasm-pack, +// targeting the browser). These are NOT test mocks — the WASM module calls into +// gl.bindTexImage2D-style canvas APIs via the wasm-bindgen generated JS, which +// expects window, document, HTMLCanvasElement, and CanvasRenderingContext2D at +// module load time. When running in Node.js (the OmniRoute server), these globals +// don't exist, so we provide minimal stubs that satisfy the wasm-bindgen +// constructor shape checks. The stubs are never called for actual rendering -- +// the WASM signer only uses the canvas to compute a hashed fingerprint value. +// +// Deliberately NOT a module-load side effect: installing these globals just by +// importing this file would leak `global.window`/`global.document` stubs into +// every other test file that transitively imports it (e.g. through the provider +// registry), even when that test never touches TinyCMS. `initTinyCmsWasm()` +// below calls `setupDomMocks()` once, right before instantiating the WASM +// module, on the production path. Tests call it explicitly in a before/ +// beforeEach hook and restore the previous globals via the returned callback in +// after/afterEach. +export type DomMockRestore = () => void; + +export function setupDomMocks(): DomMockRestore { + if (typeof global === 'undefined') return () => {}; + // Single typed handle to `global` so the rest of this function reads/writes + // window/document/HTMLCanvasElement/CanvasRenderingContext2D — none of which + // exist on Node's `global` type — through one cast instead of one per site. + const g = global as Record; + const hadWindow = 'window' in g; + const hadWindowCtor = 'Window' in g; + const hadCanvasElement = 'HTMLCanvasElement' in g; + const hadCanvasContext = 'CanvasRenderingContext2D' in g; + const hadDocument = 'document' in g; + + if (!g.window) g.window = g; + if (!g.Window) g.Window = function () {}; + if (!g.HTMLCanvasElement) g.HTMLCanvasElement = function () {}; + if (!g.CanvasRenderingContext2D) g.CanvasRenderingContext2D = function () {}; + if (!g.document) { + g.document = { + createElement(tag: string) { + if (tag === 'canvas') { + const canvas = { + width: 100, + height: 100, + getContext(type: string) { + if (type === '2d') { + const ctx = { + fillStyle: '', + font: '', + fillRect() {}, + fillText() {}, + toDataURL() { return 'data:image/png;base64,MOCK_DATA'; } + }; + Object.setPrototypeOf(ctx, g.CanvasRenderingContext2D.prototype); + return ctx; + } + return null; + }, + toDataURL() { return 'data:image/png;base64,MOCK_DATA'; } + }; + Object.setPrototypeOf(canvas, g.HTMLCanvasElement.prototype); + return canvas; + } + return null; + } + }; + } + Object.setPrototypeOf(g.window, g.Window.prototype); + + return () => { + if (!hadWindow) delete g.window; + if (!hadWindowCtor) delete g.Window; + if (!hadCanvasElement) delete g.HTMLCanvasElement; + if (!hadCanvasContext) delete g.CanvasRenderingContext2D; + if (!hadDocument) delete g.document; + }; +} + +// WASM binary compiled from the TinyCMS signer wasm-bindgen source +// (wasm_signer_bg.wasm). Extracted from the upstream client's +// wasm_signer_bg.js wasm-bindgen glue at build time — the WebAssembly module +// implements the cryptographic signature + Proof-of-Work routines the +// TinyCMS server requires for anti-abuse challenge verification. +// The source .wasm is compiled from Rust via wasm-pack (wasm-bindgen), +// targeting the browser environment. +const WASM_BASE64 = "AGFzbQEAAAABkQIoYAJ/fwF/YAJ/fwBgA39/fwF/YAF/AGADf39/AGAFf39/f38AYAR/f39/AGAAAX9gAW8Bf2AEf39/fwF/YAZ/f39/f38AYAAAYAF/AX9gAn9vAGACb38AYANvf38AYAJ/fwFvYAV/f35/fwBgBX9/fX9/AGAFf398f38AYAV/f39/fwF/YAADf39/YANvb28AYANvf38Bb2ADb39/AX9gBW98fHx8AGAFb39/fHwAYAABb2AAAXxgAXwBb2ABfgFvYAZ/f39+f38AYAZ/f399f38AYAZ/f398f38AYAt/f39/f39/f39/fwN/f39gBn9/f39/fwF/YAR/fX9/AGAEf3x/fwBgBH9+f38AYAF8AX8C9QwcEy4vd2FzbV9zaWduZXJfYmcuanMaX193Ymdfc2V0XzZiZTQyNzY4YzY5MGUzODAAFhMuL3dhc21fc2lnbmVyX2JnLmpzHV9fd2JnX1N0cmluZ184NTY0ZTU1OTc5OWVjY2RhAA0TLi93YXNtX3NpZ25lcl9iZy5qcyhfX3diZ19pbnN0YW5jZW9mX1dpbmRvd18yM2U2NzdkMmM2ODQzOTIyAAgTLi93YXNtX3NpZ25lcl9iZy5qcx9fX3diZ19kb2N1bWVudF9jMDMyMGNkNDE4M2M2ZDliAAgTLi93YXNtX3NpZ25lcl9iZy5qcyRfX3diZ19jcmVhdGVFbGVtZW50XzliMGFhYjI2NWM1NDlkZWQAFxMuL3dhc21fc2lnbmVyX2JnLmpzIV9fd2JnX3NldF9oZWlnaHRfYjY1NDhhMDFiZGNiNjg5YQAOEy4vd2FzbV9zaWduZXJfYmcuanMhX193YmdfZ2V0Q29udGV4dF9mMDRiZjhmMjJkY2IyZDUzABgTLi93YXNtX3NpZ25lcl9iZy5qcyBfX3diZ190b0RhdGFVUkxfYmY5OWQ4NWIzOWNlNTdjYwANEy4vd2FzbV9zaWduZXJfYmcuanMgX193Ymdfc2V0X3dpZHRoX2MwZmNhYTJkYTUzY2Q1NDAADhMuL3dhc21fc2lnbmVyX2JnLmpzM19fd2JnX2luc3RhbmNlb2ZfSHRtbENhbnZhc0VsZW1lbnRfMjYxMjUzMzlmOTM2YmU1MAAIEy4vd2FzbV9zaWduZXJfYmcuanM6X193YmdfaW5zdGFuY2VvZl9DYW52YXNSZW5kZXJpbmdDb250ZXh0MmRfMDhiOWQxOTNjMjJmYTg4NgAIEy4vd2FzbV9zaWduZXJfYmcuanMkX193Ymdfc2V0X2ZpbGxTdHlsZV81ODQxN2I2YjU0OGFlNDc1AA8TLi93YXNtX3NpZ25lcl9iZy5qcx9fX3diZ19zZXRfZm9udF9iMDM4Nzk3YjM1NzNhZTVlAA8TLi93YXNtX3NpZ25lcl9iZy5qcx9fX3diZ19maWxsUmVjdF80ZTU1OTZjYTk1NDIyNmU3ABkTLi93YXNtX3NpZ25lcl9iZy5qcx9fX3diZ19maWxsVGV4dF9iMTcyMmI2MTc5NjkyYjg1ABoTLi93YXNtX3NpZ25lcl9iZy5qcxpfX3diZ19uZXdfYWI3OWRmNWJkN2MyNjA2NwAbEy4vd2FzbV9zaWduZXJfYmcuanMyX193Ymdfc3RhdGljX2FjY2Vzc29yX0dMT0JBTF9USElTX2FkMzU2ZTBkYjkxYzc5MTMABxMuL3dhc21fc2lnbmVyX2JnLmpzK19fd2JnX3N0YXRpY19hY2Nlc3Nvcl9TRUxGX2YyMDdjODU3NTY2ZGIyNDgABxMuL3dhc21fc2lnbmVyX2JnLmpzLV9fd2JnX3N0YXRpY19hY2Nlc3Nvcl9HTE9CQUxfOGFkYjk1NWJkMzNmYWMyZgAHEy4vd2FzbV9zaWduZXJfYmcuanMtX193Ymdfc3RhdGljX2FjY2Vzc29yX1dJTkRPV19iYjlmMWJhNjlkNjFiMzg2AAcTLi93YXNtX3NpZ25lcl9iZy5qcx1fX3diZ19yYW5kb21fNWJiODZjYWU2NWE0NWJmNgAcEy4vd2FzbV9zaWduZXJfYmcuanMnX193YmdfX193YmluZGdlbl90aHJvd182ZGRkNjA5YjYyOTQwZDU1AAETLi93YXNtX3NpZ25lcl9iZy5qcxxfX3diZ19FcnJvcl84Mzc0MmI0NmYwMWNlMjJkABATLi93YXNtX3NpZ25lcl9iZy5qcy5fX3diZ19fX3diaW5kZ2VuX2lzX3VuZGVmaW5lZF81MjcwOWU3MmZiOWYxNzljAAgTLi93YXNtX3NpZ25lcl9iZy5qcx9fX3diaW5kZ2VuX2luaXRfZXh0ZXJucmVmX3RhYmxlAAsTLi93YXNtX3NpZ25lcl9iZy5qcyBfX3diaW5kZ2VuX2Nhc3RfMDAwMDAwMDAwMDAwMDAwMQAdEy4vd2FzbV9zaWduZXJfYmcuanMgX193YmluZGdlbl9jYXN0XzAwMDAwMDAwMDAwMDAwMDIAEBMuL3dhc21fc2lnbmVyX2JnLmpzIF9fd2JpbmRnZW5fY2FzdF8wMDAwMDAwMDAwMDAwMDAzAB4DamkEDAIDAgACAQEBAAEHAQAAAAABBAEHBQEKCgUEBAQABQMKAQUMBQYKHyAhBQYCCwIAAQEiCQABCSMUEgUTEQMGCQIDAQEBAAMDAwMABAECJwAAAAkBAwABAAwBBAEACwABAAABAgAAAQMECQJwAUFBbwCACAUDAQARBgkBfwFBgIDAAAsHxQEJBm1lbW9yeQIAF2dlbmVyYXRlX3NlY3VyZV9wYXlsb2FkAE8RX193YmluZGdlbl9tYWxsb2MAURJfX3diaW5kZ2VuX3JlYWxsb2MAUxRfX3diaW5kZ2VuX2V4bl9zdG9yZQBxF19fZXh0ZXJucmVmX3RhYmxlX2FsbG9jACgVX193YmluZGdlbl9leHRlcm5yZWZzAQEZX19leHRlcm5yZWZfdGFibGVfZGVhbGxvYwA8EF9fd2JpbmRnZW5fc3RhcnQAGAlIAQBBAQtAbW4rTCpBVTY1Q0RZQ0dXRlhDW1dFV1ZIP1U9VEJdXGNkZWYxOxkaGz5eSSx7ck1zfFo6LjODAWBffl5LLX2BAXRsDAEHCqC/Amn+PgEhfyAAKAIcISEgACgCGCEfIAAoAhQhHiAAKAIQIRwgACgCDCEiIAAoAgghICAAKAIEIR0gACgCACEDIAIEQCABIAJBBnRqISMDQCADIAEoAAAiAkEYdCACQYD+A3FBCHRyIAJBCHZBgP4DcSACQRh2cnIiESAhIBxBGncgHEEVd3MgHEEHd3NqIB4gH3MgHHEgH3NqakGY36iUBGoiBCAdICBzIANxIB0gIHFzIANBHncgA0ETd3MgA0EKd3NqaiICQR53IAJBE3dzIAJBCndzIAIgAyAdc3EgAyAdcXNqIB8gAUEEaigAACIFQRh0IAVBgP4DcUEIdHIgBUEIdkGA/gNxIAVBGHZyciISaiAEICJqIgkgHCAec3EgHnNqIAlBGncgCUEVd3MgCUEHd3NqQZGJ3YkHaiIGaiIFQR53IAVBE3dzIAVBCndzIAUgAiADc3EgAiADcXNqIB4gAUEIaigAACIEQRh0IARBgP4DcUEIdHIgBEEIdkGA/gNxIARBGHZyciITaiAGICBqIgogCSAcc3EgHHNqIApBGncgCkEVd3MgCkEHd3NqQbGI/NEEayIHaiIEQR53IARBE3dzIARBCndzIAQgAiAFc3EgAiAFcXNqIBwgAUEMaigAACIGQRh0IAZBgP4DcUEIdHIgBkEIdkGA/gNxIAZBGHZyciIUaiAHIB1qIgcgCSAKc3EgCXNqIAdBGncgB0EVd3MgB0EHd3NqQdvIqLIBayIOaiIGQR53IAZBE3dzIAZBCndzIAYgBCAFc3EgBCAFcXNqIAkgAUEQaigAACIIQRh0IAhBgP4DcUEIdHIgCEEIdkGA/gNxIAhBGHZyciIVaiADIA5qIgkgByAKc3EgCnNqIAlBGncgCUEVd3MgCUEHd3NqQduE28oDaiIIaiIDQR53IANBE3dzIANBCndzIAMgBCAGc3EgBCAGcXNqIAogAUEUaigAACIKQRh0IApBgP4DcUEIdHIgCkEIdkGA/gNxIApBGHZyciIWaiACIAhqIgogByAJc3EgB3NqIApBGncgCkEVd3MgCkEHd3NqQfGjxM8FaiIIaiICQR53IAJBE3dzIAJBCndzIAIgAyAGc3EgAyAGcXNqIAcgAUEYaigAACIHQRh0IAdBgP4DcUEIdHIgB0EIdkGA/gNxIAdBGHZyciIXaiAFIAhqIgcgCSAKc3EgCXNqIAdBGncgB0EVd3MgB0EHd3NqQdz6ge4GayIIaiIFQR53IAVBE3dzIAVBCndzIAUgAiADc3EgAiADcXNqIAkgAUEcaigAACIJQRh0IAlBgP4DcUEIdHIgCUEIdkGA/gNxIAlBGHZyciIZaiAEIAhqIgkgByAKc3EgCnNqIAlBGncgCUEVd3MgCUEHd3NqQavCjqcFayIIaiIEQR53IARBE3dzIARBCndzIAQgAiAFc3EgAiAFcXNqIAogAUEgaigAACIKQRh0IApBgP4DcUEIdHIgCkEIdkGA/gNxIApBGHZyciIaaiAGIAhqIgogByAJc3EgB3NqIApBGncgCkEVd3MgCkEHd3NqQeiq4b8CayIIaiIGQR53IAZBE3dzIAZBCndzIAYgBCAFc3EgBCAFcXNqIAcgAUEkaigAACIHQRh0IAdBgP4DcUEIdHIgB0EIdkGA/gNxIAdBGHZyciIYaiADIAhqIgcgCSAKc3EgCXNqIAdBGncgB0EVd3MgB0EHd3NqQYG2jZQBaiIIaiIDQR53IANBE3dzIANBCndzIAMgBCAGc3EgBCAGcXNqIAkgAUEoaigAACIJQRh0IAlBgP4DcUEIdHIgCUEIdkGA/gNxIAlBGHZyciILaiACIAhqIgkgByAKc3EgCnNqIAlBGncgCUEVd3MgCUEHd3NqQb6LxqECaiIIaiICQR53IAJBE3dzIAJBCndzIAIgAyAGc3EgAyAGcXNqIAogAUEsaigAACIKQRh0IApBgP4DcUEIdHIgCkEIdkGA/gNxIApBGHZyciIMaiAFIAhqIgogByAJc3EgB3NqIApBGncgCkEVd3MgCkEHd3NqQcP7sagFaiIIaiIFQR53IAVBE3dzIAVBCndzIAUgAiADc3EgAiADcXNqIAcgAUEwaigAACIHQRh0IAdBgP4DcUEIdHIgB0EIdkGA/gNxIAdBGHZyciINaiAEIAhqIgcgCSAKc3EgCXNqIAdBGncgB0EVd3MgB0EHd3NqQfS6+ZUHaiIIaiIEQR53IARBE3dzIARBCndzIAQgAiAFc3EgAiAFcXNqIAkgAUE0aigAACIJQRh0IAlBgP4DcUEIdHIgCUEIdkGA/gNxIAlBGHZyciIPaiAGIAhqIgggByAKc3EgCnNqIAhBGncgCEEVd3MgCEEHd3NqQYKchfkHayIOaiIGQR53IAZBE3dzIAZBCndzIAYgBCAFc3EgBCAFcXNqIAFBOGooAAAiCUEYdCAJQYD+A3FBCHRyIAlBCHZBgP4DcSAJQRh2cnIiCSAKaiADIA5qIg4gByAIc3EgB3NqIA5BGncgDkEVd3MgDkEHd3NqQdnyj6EGayIQaiIDQR53IANBE3dzIANBCndzIAMgBCAGc3EgBCAGcXNqIAFBPGooAAAiCkEYdCAKQYD+A3FBCHRyIApBCHZBgP4DcSAKQRh2cnIiCiAHaiACIBBqIhAgCCAOc3EgCHNqIBBBGncgEEEVd3MgEEEHd3NqQYydkPMDayIbaiICQR53IAJBE3dzIAJBCndzIAIgAyAGc3EgAyAGcXNqIBJBGXcgEkEOd3MgEkEDdnMgEWogGGogCUEPdyAJQQ13cyAJQQp2c2oiByAIaiAFIBtqIhEgDiAQc3EgDnNqIBFBGncgEUEVd3MgEUEHd3NqQb+sktsBayIbaiIFQR53IAVBE3dzIAVBCndzIAUgAiADc3EgAiADcXNqIBNBGXcgE0EOd3MgE0EDdnMgEmogC2ogCkEPdyAKQQ13cyAKQQp2c2oiCCAOaiAEIBtqIhIgECARc3EgEHNqIBJBGncgEkEVd3MgEkEHd3NqQfrwhoIBayIbaiIEQR53IARBE3dzIARBCndzIAQgAiAFc3EgAiAFcXNqIBRBGXcgFEEOd3MgFEEDdnMgE2ogDGogB0EPdyAHQQ13cyAHQQp2c2oiDiAQaiAGIBtqIhMgESASc3EgEXNqIBNBGncgE0EVd3MgE0EHd3NqQca7hv4AaiIbaiIGQR53IAZBE3dzIAZBCndzIAYgBCAFc3EgBCAFcXNqIBVBGXcgFUEOd3MgFUEDdnMgFGogDWogCEEPdyAIQQ13cyAIQQp2c2oiECARaiADIBtqIhQgEiATc3EgEnNqIBRBGncgFEEVd3MgFEEHd3NqQczDsqACaiIbaiIDQR53IANBE3dzIANBCndzIAMgBCAGc3EgBCAGcXNqIBZBGXcgFkEOd3MgFkEDdnMgFWogD2ogDkEPdyAOQQ13cyAOQQp2c2oiESASaiACIBtqIhUgEyAUc3EgE3NqIBVBGncgFUEVd3MgFUEHd3NqQe/YpO8CaiIbaiICQR53IAJBE3dzIAJBCndzIAIgAyAGc3EgAyAGcXNqIBdBGXcgF0EOd3MgF0EDdnMgFmogCWogEEEPdyAQQQ13cyAQQQp2c2oiEiATaiAFIBtqIhYgFCAVc3EgFHNqIBZBGncgFkEVd3MgFkEHd3NqQaqJ0tMEaiIbaiIFQR53IAVBE3dzIAVBCndzIAUgAiADc3EgAiADcXNqIBlBGXcgGUEOd3MgGUEDdnMgF2ogCmogEUEPdyARQQ13cyARQQp2c2oiEyAUaiAEIBtqIhcgFSAWc3EgFXNqIBdBGncgF0EVd3MgF0EHd3NqQdzTwuUFaiIbaiIEQR53IARBE3dzIARBCndzIAQgAiAFc3EgAiAFcXNqIBpBGXcgGkEOd3MgGkEDdnMgGWogB2ogEkEPdyASQQ13cyASQQp2c2oiFCAVaiAGIBtqIhkgFiAXc3EgFnNqIBlBGncgGUEVd3MgGUEHd3NqQdqR5rcHaiIbaiIGQR53IAZBE3dzIAZBCndzIAYgBCAFc3EgBCAFcXNqIBhBGXcgGEEOd3MgGEEDdnMgGmogCGogE0EPdyATQQ13cyATQQp2c2oiFSAWaiADIBtqIhogFyAZc3EgF3NqIBpBGncgGkEVd3MgGkEHd3NqQa7dhr4GayIbaiIDQR53IANBE3dzIANBCndzIAMgBCAGc3EgBCAGcXNqIAtBGXcgC0EOd3MgC0EDdnMgGGogDmogFEEPdyAUQQ13cyAUQQp2c2oiFiAXaiACIBtqIhggGSAac3EgGXNqIBhBGncgGEEVd3MgGEEHd3NqQZPzuL4FayIbaiICQR53IAJBE3dzIAJBCndzIAIgAyAGc3EgAyAGcXNqIAxBGXcgDEEOd3MgDEEDdnMgC2ogEGogFUEPdyAVQQ13cyAVQQp2c2oiFyAZaiAFIBtqIgsgGCAac3EgGnNqIAtBGncgC0EVd3MgC0EHd3NqQbiw8/8EayIbaiIFQR53IAVBE3dzIAVBCndzIAUgAiADc3EgAiADcXNqIA1BGXcgDUEOd3MgDUEDdnMgDGogEWogFkEPdyAWQQ13cyAWQQp2c2oiGSAaaiAEIBtqIgwgCyAYc3EgGHNqIAxBGncgDEEVd3MgDEEHd3NqQbmAmoUEayIbaiIEQR53IARBE3dzIARBCndzIAQgAiAFc3EgAiAFcXNqIA9BGXcgD0EOd3MgD0EDdnMgDWogEmogF0EPdyAXQQ13cyAXQQp2c2oiGiAYaiAGIBtqIg0gCyAMc3EgC3NqIA1BGncgDUEVd3MgDUEHd3NqQY3o/8gDayIbaiIGQR53IAZBE3dzIAZBCndzIAYgBCAFc3EgBCAFcXNqIAlBGXcgCUEOd3MgCUEDdnMgD2ogE2ogGUEPdyAZQQ13cyAZQQp2c2oiGCALaiADIBtqIgsgDCANc3EgDHNqIAtBGncgC0EVd3MgC0EHd3NqQbnd4dICayIPaiIDQR53IANBE3dzIANBCndzIAMgBCAGc3EgBCAGcXNqIApBGXcgCkEOd3MgCkEDdnMgCWogFGogGkEPdyAaQQ13cyAaQQp2c2oiCSAMaiACIA9qIgwgCyANc3EgDXNqIAxBGncgDEEVd3MgDEEHd3NqQdHGqTZqIg9qIgJBHncgAkETd3MgAkEKd3MgAiADIAZzcSADIAZxc2ogB0EZdyAHQQ53cyAHQQN2cyAKaiAVaiAYQQ93IBhBDXdzIBhBCnZzaiIKIA1qIAUgD2oiDSALIAxzcSALc2ogDUEadyANQRV3cyANQQd3c2pB59KkoQFqIg9qIgVBHncgBUETd3MgBUEKd3MgBSACIANzcSACIANxc2ogCEEZdyAIQQ53cyAIQQN2cyAHaiAWaiAJQQ93IAlBDXdzIAlBCnZzaiIHIAtqIAQgD2oiCyAMIA1zcSAMc2ogC0EadyALQRV3cyALQQd3c2pBhZXcvQJqIg9qIgRBHncgBEETd3MgBEEKd3MgBCACIAVzcSACIAVxc2ogDkEZdyAOQQ53cyAOQQN2cyAIaiAXaiAKQQ93IApBDXdzIApBCnZzaiIIIAxqIAYgD2oiDCALIA1zcSANc2ogDEEadyAMQRV3cyAMQQd3c2pBuMLs8AJqIg9qIgZBHncgBkETd3MgBkEKd3MgBiAEIAVzcSAEIAVxc2ogEEEZdyAQQQ53cyAQQQN2cyAOaiAZaiAHQQ93IAdBDXdzIAdBCnZzaiIOIA1qIAMgD2oiDSALIAxzcSALc2ogDUEadyANQRV3cyANQQd3c2pB/Nux6QRqIg9qIgNBHncgA0ETd3MgA0EKd3MgAyAEIAZzcSAEIAZxc2ogEUEZdyARQQ53cyARQQN2cyAQaiAaaiAIQQ93IAhBDXdzIAhBCnZzaiIQIAtqIAIgD2oiCyAMIA1zcSAMc2ogC0EadyALQRV3cyALQQd3c2pBk5rgmQVqIg9qIgJBHncgAkETd3MgAkEKd3MgAiADIAZzcSADIAZxc2ogEkEZdyASQQ53cyASQQN2cyARaiAYaiAOQQ93IA5BDXdzIA5BCnZzaiIRIAxqIAUgD2oiDCALIA1zcSANc2ogDEEadyAMQRV3cyAMQQd3c2pB1OapqAZqIg9qIgVBHncgBUETd3MgBUEKd3MgBSACIANzcSACIANxc2ogE0EZdyATQQ53cyATQQN2cyASaiAJaiAQQQ93IBBBDXdzIBBBCnZzaiISIA1qIAQgD2oiDSALIAxzcSALc2ogDUEadyANQRV3cyANQQd3c2pBu5WoswdqIg9qIgRBHncgBEETd3MgBEEKd3MgBCACIAVzcSACIAVxc2ogFEEZdyAUQQ53cyAUQQN2cyATaiAKaiARQQ93IBFBDXdzIBFBCnZzaiITIAtqIAYgD2oiCyAMIA1zcSAMc2ogC0EadyALQRV3cyALQQd3c2pB0u308QdrIg9qIgZBHncgBkETd3MgBkEKd3MgBiAEIAVzcSAEIAVxc2ogFUEZdyAVQQ53cyAVQQN2cyAUaiAHaiASQQ93IBJBDXdzIBJBCnZzaiIUIAxqIAMgD2oiDCALIA1zcSANc2ogDEEadyAMQRV3cyAMQQd3c2pB+6a37AZrIg9qIgNBHncgA0ETd3MgA0EKd3MgAyAEIAZzcSAEIAZxc2ogFkEZdyAWQQ53cyAWQQN2cyAVaiAIaiATQQ93IBNBDXdzIBNBCnZzaiIVIA1qIAIgD2oiDSALIAxzcSALc2ogDUEadyANQRV3cyANQQd3c2pB366A6gVrIg9qIgJBHncgAkETd3MgAkEKd3MgAiADIAZzcSADIAZxc2ogF0EZdyAXQQ53cyAXQQN2cyAWaiAOaiAUQQ93IBRBDXdzIBRBCnZzaiIWIAtqIAUgD2oiCyAMIA1zcSAMc2ogC0EadyALQRV3cyALQQd3c2pBtbOWvwVrIg9qIgVBHncgBUETd3MgBUEKd3MgBSACIANzcSACIANxc2ogGUEZdyAZQQ53cyAZQQN2cyAXaiAQaiAVQQ93IBVBDXdzIBVBCnZzaiIXIAxqIAQgD2oiDCALIA1zcSANc2ogDEEadyAMQRV3cyAMQQd3c2pBkOnR7QNrIg9qIgRBHncgBEETd3MgBEEKd3MgBCACIAVzcSACIAVxc2ogGkEZdyAaQQ53cyAaQQN2cyAZaiARaiAWQQ93IBZBDXdzIBZBCnZzaiIZIA1qIAYgD2oiDSALIAxzcSALc2ogDUEadyANQRV3cyANQQd3c2pB3dzOxANrIg9qIgZBHncgBkETd3MgBkEKd3MgBiAEIAVzcSAEIAVxc2ogGEEZdyAYQQ53cyAYQQN2cyAaaiASaiAXQQ93IBdBDXdzIBdBCnZzaiIaIAtqIAMgD2oiCyAMIA1zcSAMc2ogC0EadyALQRV3cyALQQd3c2pB56+08wJrIg9qIgNBHncgA0ETd3MgA0EKd3MgAyAEIAZzcSAEIAZxc2ogCUEZdyAJQQ53cyAJQQN2cyAYaiATaiAZQQ93IBlBDXdzIBlBCnZzaiIYIAxqIAIgD2oiDCALIA1zcSANc2ogDEEadyAMQRV3cyAMQQd3c2pB3PObywJrIg9qIgJBHncgAkETd3MgAkEKd3MgAiADIAZzcSADIAZxc2ogCkEZdyAKQQ53cyAKQQN2cyAJaiAUaiAaQQ93IBpBDXdzIBpBCnZzaiIJIA1qIAUgD2oiDSALIAxzcSALc2ogDUEadyANQRV3cyANQQd3c2pB+5TH3wBrIg9qIgVBHncgBUETd3MgBUEKd3MgBSACIANzcSACIANxc2ogB0EZdyAHQQ53cyAHQQN2cyAKaiAVaiAYQQ93IBhBDXdzIBhBCnZzaiIKIAtqIAQgD2oiCyAMIA1zcSAMc2ogC0EadyALQRV3cyALQQd3c2pB8MCqgwFqIg9qIgRBHncgBEETd3MgBEEKd3MgBCACIAVzcSACIAVxc2ogDCAIQRl3IAhBDndzIAhBA3ZzIAdqIBZqIAlBD3cgCUENd3MgCUEKdnNqIgxqIAYgD2oiByALIA1zcSANc2ogB0EadyAHQRV3cyAHQQd3c2pBloKTzQFqIg9qIgZBHncgBkETd3MgBkEKd3MgBiAEIAVzcSAEIAVxc2ogDSAOQRl3IA5BDndzIA5BA3ZzIAhqIBdqIApBD3cgCkENd3MgCkEKdnNqIg1qIAMgD2oiCCAHIAtzcSALc2ogCEEadyAIQRV3cyAIQQd3c2pBiNjd8QFqIg9qIgNBHncgA0ETd3MgA0EKd3MgAyAEIAZzcSAEIAZxc2ogCyAQQRl3IBBBDndzIBBBA3ZzIA5qIBlqIAxBD3cgDEENd3MgDEEKdnNqIgtqIAIgD2oiDiAHIAhzcSAHc2ogDkEadyAOQRV3cyAOQQd3c2pBzO6hugJqIhtqIgJBHncgAkETd3MgAkEKd3MgAiADIAZzcSADIAZxc2ogEUEZdyARQQ53cyARQQN2cyAQaiAaaiANQQ93IA1BDXdzIA1BCnZzaiIPIAdqIAUgG2oiByAIIA5zcSAIc2ogB0EadyAHQRV3cyAHQQd3c2pBtfnCpQNqIhBqIgVBHncgBUETd3MgBUEKd3MgBSACIANzcSACIANxc2ogEkEZdyASQQ53cyASQQN2cyARaiAYaiALQQ93IAtBDXdzIAtBCnZzaiIRIAhqIAQgEGoiCCAHIA5zcSAOc2ogCEEadyAIQRV3cyAIQQd3c2pBs5nwyANqIhBqIgRBHncgBEETd3MgBEEKd3MgBCACIAVzcSACIAVxc2ogE0EZdyATQQ53cyATQQN2cyASaiAJaiAPQQ93IA9BDXdzIA9BCnZzaiISIA5qIAYgEGoiDiAHIAhzcSAHc2ogDkEadyAOQRV3cyAOQQd3c2pBytTi9gRqIhBqIgZBHncgBkETd3MgBkEKd3MgBiAEIAVzcSAEIAVxc2ogFEEZdyAUQQ53cyAUQQN2cyATaiAKaiARQQ93IBFBDXdzIBFBCnZzaiITIAdqIAMgEGoiByAIIA5zcSAIc2ogB0EadyAHQRV3cyAHQQd3c2pBz5Tz3AVqIhBqIgNBHncgA0ETd3MgA0EKd3MgAyAEIAZzcSAEIAZxc2ogFUEZdyAVQQ53cyAVQQN2cyAUaiAMaiASQQ93IBJBDXdzIBJBCnZzaiIUIAhqIAIgEGoiCCAHIA5zcSAOc2ogCEEadyAIQRV3cyAIQQd3c2pB89+5wQZqIhBqIgJBHncgAkETd3MgAkEKd3MgAiADIAZzcSADIAZxc2ogFkEZdyAWQQ53cyAWQQN2cyAVaiANaiATQQ93IBNBDXdzIBNBCnZzaiIVIA5qIAUgEGoiDiAHIAhzcSAHc2ogDkEadyAOQRV3cyAOQQd3c2pB7oW+pAdqIhBqIgVBHncgBUETd3MgBUEKd3MgBSACIANzcSACIANxc2ogByAXQRl3IBdBDndzIBdBA3ZzIBZqIAtqIBRBD3cgFEENd3MgFEEKdnNqIgdqIAQgEGoiECAIIA5zcSAIc2ogEEEadyAQQRV3cyAQQQd3c2pB78aVxQdqIgtqIgRBHncgBEETd3MgBEEKd3MgBCACIAVzcSACIAVxc2ogGUEZdyAZQQ53cyAZQQN2cyAXaiAPaiAVQQ93IBVBDXdzIBVBCnZzaiIWIAhqIAYgC2oiCCAOIBBzcSAOc2ogCEEadyAIQRV3cyAIQQd3c2pB7I/e2QdrIhdqIgZBHncgBkETd3MgBkEKd3MgBiAEIAVzcSAEIAVxc2ogGkEZdyAaQQ53cyAaQQN2cyAZaiARaiAHQQ93IAdBDXdzIAdBCnZzaiIRIA5qIAMgF2oiAyAIIBBzcSAQc2ogA0EadyADQRV3cyADQQd3c2pB+PvjmQdrIg5qIgdBHncgB0ETd3MgB0EKd3MgByAEIAZzcSAEIAZxc2ogECAYQRl3IBhBDndzIBhBA3ZzIBpqIBJqIBZBD3cgFkENd3MgFkEKdnNqIhBqIAIgDmoiDiADIAhzcSAIc2ogDkEadyAOQRV3cyAOQQd3c2pBhoCE+gZrIhJqIgJBHncgAkETd3MgAkEKd3MgAiAGIAdzcSAGIAdxc2ogCUEZdyAJQQ53cyAJQQN2cyAYaiATaiARQQ93IBFBDXdzIBFBCnZzaiIRIAhqIAUgEmoiBSADIA5zcSADc2ogBUEadyAFQRV3cyAFQQd3c2pBlaa+3QVrIhJqIghBHncgCEETd3MgCEEKd3MgCCACIAdzcSACIAdxc2ogCSAKQRl3IApBDndzIApBA3ZzaiAUaiAQQQ93IBBBDXdzIBBBCnZzaiADaiAEIBJqIgQgBSAOc3EgDnNqIARBGncgBEEVd3MgBEEHd3NqQYm4mYgEayIDaiIJIAIgCHNxIAIgCHFzaiAJQR53IAlBE3dzIAlBCndzaiAKIAxBGXcgDEEOd3MgDEEDdnNqIBVqIBFBD3cgEUENd3MgEUEKdnNqIA5qIAMgBmoiBiAEIAVzcSAFc2ogBkEadyAGQRV3cyAGQQd3c2pBjo66zANrIgpqIQMgCSAdaiEdIAcgHGogCmohHCAIICBqISAgBiAeaiEeIAIgImohIiAEIB9qIR8gBSAhaiEhIAFBQGsiASAjRw0ACwsgACAhNgIcIAAgHzYCGCAAIB42AhQgACAcNgIQIAAgIjYCDCAAICA2AgggACAdNgIEIAAgAzYCAAvJJQIJfwF+IwBBEGsiCCQAAkACQAJAAkACQCAAQfUBTwRAIABBzP97SwRAQQAhAAwGCyAAQQtqIgJBeHEhBUHUmMAAKAIAIglFDQRBHyEGQQAgBWshAyAAQfT//wdNBEAgBUEmIAJBCHZnIgBrdkEBcSAAQQF0a0E+aiEGCyAGQQJ0QbiVwABqKAIAIgJFBEBBACEADAILIAVBGSAGQQF2a0EAIAZBH0cbdCEEQQAhAANAAkAgAigCBEF4cSIHIAVJDQAgByAFayIHIANPDQAgAiEBIAciAw0AQQAhAyABIQAMBAsgAigCFCIHIAAgByACIARBHXZBBHFqKAIQIgJHGyAAIAcbIQAgBEEBdCEEIAINAAsMAQsCQAJAAkACQAJAQdCYwAAoAgAiBEEQIABBC2pB+ANxIABBC0kbIgVBA3YiAHYiAUEDcQRAIAFBf3NBAXEgAGoiB0EDdCIBQciWwABqIgAgAUHQlsAAaigCACICKAIIIgNGDQEgAyAANgIMIAAgAzYCCAwCCyAFQdiYwAAoAgBNDQggAQ0CQdSYwAAoAgAiAEUNCCAAaEECdEG4lcAAaigCACICKAIEQXhxIAVrIQMgAiEBA0ACQCABKAIQIgANACABKAIUIgANACACKAIYIQYCQAJAIAIgAigCDCIARgRAIAJBFEEQIAIoAhQiABtqKAIAIgENAUEAIQAMAgsgAigCCCIBIAA2AgwgACABNgIIDAELIAJBFGogAkEQaiAAGyEEA0AgBCEHIAEiAEEUaiAAQRBqIAAoAhQiARshBCAAQRRBECABG2ooAgAiAQ0ACyAHQQA2AgALIAZFDQYCQCACKAIcQQJ0QbiVwABqIgEoAgAgAkcEQCACIAYoAhBHBEAgBiAANgIUIAANAgwJCyAGIAA2AhAgAA0BDAgLIAEgADYCACAARQ0GCyAAIAY2AhggAigCECIBBEAgACABNgIQIAEgADYCGAsgAigCFCIBRQ0GIAAgATYCFCABIAA2AhgMBgsgACgCBEF4cSAFayIBIAMgASADSSIBGyEDIAAgAiABGyECIAAhAQwACwALQdCYwAAgBEF+IAd3cTYCAAsgAkEIaiEAIAIgAUEDcjYCBCABIAJqIgEgASgCBEEBcjYCBAwHCwJAQQIgAHQiAkEAIAJrciABIAB0cWgiB0EDdCIBQciWwABqIgIgAUHQlsAAaigCACIAKAIIIgNHBEAgAyACNgIMIAIgAzYCCAwBC0HQmMAAIARBfiAHd3E2AgALIAAgBUEDcjYCBCAAIAVqIgYgASAFayIHQQFyNgIEIAAgAWogBzYCAEHYmMAAKAIAIgIEQEHgmMAAKAIAIQECQEHQmMAAKAIAIgRBASACQQN2dCIDcUUEQEHQmMAAIAMgBHI2AgAgAkF4cUHIlsAAaiIDIQQMAQsgAkF4cSICQciWwABqIQQgAkHQlsAAaigCACEDCyAEIAE2AgggAyABNgIMIAEgBDYCDCABIAM2AggLIABBCGohAEHgmMAAIAY2AgBB2JjAACAHNgIADAYLQdSYwABB1JjAACgCAEF+IAIoAhx3cTYCAAsCQAJAIANBEE8EQCACIAVBA3I2AgQgAiAFaiIHIANBAXI2AgQgAyAHaiADNgIAQdiYwAAoAgAiAUUNAUHgmMAAKAIAIQACQEHQmMAAKAIAIgRBASABQQN2dCIGcUUEQEHQmMAAIAQgBnI2AgAgAUF4cUHIlsAAaiIEIQEMAQsgAUF4cSIEQciWwABqIQEgBEHQlsAAaigCACEECyABIAA2AgggBCAANgIMIAAgATYCDCAAIAQ2AggMAQsgAiADIAVqIgBBA3I2AgQgACACaiIAIAAoAgRBAXI2AgQMAQtB4JjAACAHNgIAQdiYwAAgAzYCAAsgAkEIaiIARQ0DDAQLIAAgAXJFBEBBACEBQQIgBnQiAEEAIABrciAJcSIARQ0DIABoQQJ0QbiVwABqKAIAIQALIABFDQELA0AgAyAAKAIEQXhxIgIgBWsiBCADIAMgBEsiBBsgAiAFSSICGyEDIAEgACABIAQbIAIbIQEgACgCECICBH8gAgUgACgCFAsiAA0ACwsgAUUNACAFQdiYwAAoAgAiAE0gAyAAIAVrT3ENACABKAIYIQYCQAJAIAEgASgCDCIARgRAIAFBFEEQIAEoAhQiABtqKAIAIgINAUEAIQAMAgsgASgCCCICIAA2AgwgACACNgIIDAELIAFBFGogAUEQaiAAGyEEA0AgBCEHIAIiAEEUaiAAQRBqIAAoAhQiAhshBCAAQRRBECACG2ooAgAiAg0ACyAHQQA2AgALAkAgBkUNAAJAAkAgASgCHEECdEG4lcAAaiICKAIAIAFHBEAgASAGKAIQRwRAIAYgADYCFCAADQIMBAsgBiAANgIQIAANAQwDCyACIAA2AgAgAEUNAQsgACAGNgIYIAEoAhAiAgRAIAAgAjYCECACIAA2AhgLIAEoAhQiAkUNASAAIAI2AhQgAiAANgIYDAELQdSYwABB1JjAACgCAEF+IAEoAhx3cTYCAAsCQCADQRBPBEAgASAFQQNyNgIEIAEgBWoiACADQQFyNgIEIAAgA2ogAzYCACADQYACTwRAIAAgAxApDAILAkBB0JjAACgCACICQQEgA0EDdnQiBHFFBEBB0JjAACACIARyNgIAIANB+AFxQciWwABqIgMhAgwBCyADQfgBcSIEQciWwABqIQIgBEHQlsAAaigCACEDCyACIAA2AgggAyAANgIMIAAgAjYCDCAAIAM2AggMAQsgASADIAVqIgBBA3I2AgQgACABaiIAIAAoAgRBAXI2AgQLIAFBCGoiAA0BCwJAAkACQAJAAkAgBUHYmMAAKAIAIgFLBEAgBUHcmMAAKAIAIgBPBEAgCEEEaiEAAn8gBUGvgARqQYCAfHEiAUEQdiABQf//A3FBAEdqIgFAACIEQX9GBEBBACEBQQAMAQsgAUEQdCICQRBrIAIgBEEQdCIBQQAgAmtGGwshAiAAQQA2AgggACACNgIEIAAgATYCACAIKAIEIgFFBEBBACEADAgLIAgoAgwhB0HomMAAIAgoAggiBEHomMAAKAIAaiIANgIAQeyYwAAgAEHsmMAAKAIAIgIgACACSxs2AgACQAJAQeSYwAAoAgAiAgRAQbiWwAAhAANAIAEgACgCACIDIAAoAgQiBmpGDQIgACgCCCIADQALDAILQfSYwAAoAgAiAEEAIAAgAU0bRQRAQfSYwAAgATYCAAtB+JjAAEH/HzYCAEHElsAAIAc2AgBBvJbAACAENgIAQbiWwAAgATYCAEHUlsAAQciWwAA2AgBB3JbAAEHQlsAANgIAQdCWwABByJbAADYCAEHklsAAQdiWwAA2AgBB2JbAAEHQlsAANgIAQeyWwABB4JbAADYCAEHglsAAQdiWwAA2AgBB9JbAAEHolsAANgIAQeiWwABB4JbAADYCAEH8lsAAQfCWwAA2AgBB8JbAAEHolsAANgIAQYSXwABB+JbAADYCAEH4lsAAQfCWwAA2AgBBjJfAAEGAl8AANgIAQYCXwABB+JbAADYCAEGUl8AAQYiXwAA2AgBBiJfAAEGAl8AANgIAQZCXwABBiJfAADYCAEGcl8AAQZCXwAA2AgBBmJfAAEGQl8AANgIAQaSXwABBmJfAADYCAEGgl8AAQZiXwAA2AgBBrJfAAEGgl8AANgIAQaiXwABBoJfAADYCAEG0l8AAQaiXwAA2AgBBsJfAAEGol8AANgIAQbyXwABBsJfAADYCAEG4l8AAQbCXwAA2AgBBxJfAAEG4l8AANgIAQcCXwABBuJfAADYCAEHMl8AAQcCXwAA2AgBByJfAAEHAl8AANgIAQdSXwABByJfAADYCAEHcl8AAQdCXwAA2AgBB0JfAAEHIl8AANgIAQeSXwABB2JfAADYCAEHYl8AAQdCXwAA2AgBB7JfAAEHgl8AANgIAQeCXwABB2JfAADYCAEH0l8AAQeiXwAA2AgBB6JfAAEHgl8AANgIAQfyXwABB8JfAADYCAEHwl8AAQeiXwAA2AgBBhJjAAEH4l8AANgIAQfiXwABB8JfAADYCAEGMmMAAQYCYwAA2AgBBgJjAAEH4l8AANgIAQZSYwABBiJjAADYCAEGImMAAQYCYwAA2AgBBnJjAAEGQmMAANgIAQZCYwABBiJjAADYCAEGkmMAAQZiYwAA2AgBBmJjAAEGQmMAANgIAQayYwABBoJjAADYCAEGgmMAAQZiYwAA2AgBBtJjAAEGomMAANgIAQaiYwABBoJjAADYCAEG8mMAAQbCYwAA2AgBBsJjAAEGomMAANgIAQcSYwABBuJjAADYCAEG4mMAAQbCYwAA2AgBBzJjAAEHAmMAANgIAQcCYwABBuJjAADYCAEHkmMAAIAFBD2pBeHEiAEEIayICNgIAQciYwABBwJjAADYCAEHcmMAAIARBKGsiBCABIABrakEIaiIANgIAIAIgAEEBcjYCBCABIARqQSg2AgRB8JjAAEGAgIABNgIADAgLIAIgA0kgASACTXINACAAKAIMIgNBAXENACADQQF2IAdGDQMLQfSYwABB9JjAACgCACIAIAEgACABSRs2AgAgASAEaiEDQbiWwAAhAAJAAkADQCADIAAoAgAiBkcEQCAAKAIIIgANAQwCCwsgACgCDCIDQQFxDQAgA0EBdiAHRg0BC0G4lsAAIQADQAJAIAIgACgCACIDTwRAIAIgAyAAKAIEaiIGSQ0BCyAAKAIIIQAMAQsLQeSYwAAgAUEPakF4cSIAQQhrIgM2AgBB3JjAACAEQShrIgkgASAAa2pBCGoiADYCACADIABBAXI2AgQgASAJakEoNgIEQfCYwABBgICAATYCACACIAZBIGtBeHFBCGsiACAAIAJBEGpJGyIDQRs2AgRBuJbAACkCACEKIANBEGpBwJbAACkCADcCACADQQhqIgAgCjcCAEHElsAAIAc2AgBBvJbAACAENgIAQbiWwAAgATYCAEHAlsAAIAA2AgAgA0EcaiEAA0AgAEEHNgIAIABBBGoiACAGSQ0ACyACIANGDQcgAyADKAIEQX5xNgIEIAIgAyACayIAQQFyNgIEIAMgADYCACAAQYACTwRAIAIgABApDAgLAkBB0JjAACgCACIBQQEgAEEDdnQiBHFFBEBB0JjAACABIARyNgIAIABB+AFxQciWwABqIgAhAQwBCyAAQfgBcSIAQciWwABqIQEgAEHQlsAAaigCACEACyABIAI2AgggACACNgIMIAIgATYCDCACIAA2AggMBwsgACABNgIAIAAgACgCBCAEajYCBCABQQ9qQXhxQQhrIgQgBUEDcjYCBCAGQQ9qQXhxQQhrIgMgBCAFaiIAayEFIANB5JjAACgCAEYNAyADQeCYwAAoAgBGDQQgAygCBCICQQNxQQFGBEAgAyACQXhxIgEQJyABIAVqIQUgASADaiIDKAIEIQILIAMgAkF+cTYCBCAAIAVBAXI2AgQgACAFaiAFNgIAIAVBgAJPBEAgACAFECkMBgsCQEHQmMAAKAIAIgFBASAFQQN2dCICcUUEQEHQmMAAIAEgAnI2AgAgBUH4AXFByJbAAGoiBSEDDAELIAVB+AFxIgFByJbAAGohAyABQdCWwABqKAIAIQULIAMgADYCCCAFIAA2AgwgACADNgIMIAAgBTYCCAwFC0HcmMAAIAAgBWsiATYCAEHkmMAAQeSYwAAoAgAiACAFaiICNgIAIAIgAUEBcjYCBCAAIAVBA3I2AgQgAEEIaiEADAYLQeCYwAAoAgAhAAJAIAEgBWsiAkEPTQRAQeCYwABBADYCAEHYmMAAQQA2AgAgACABQQNyNgIEIAAgAWoiASABKAIEQQFyNgIEDAELQdiYwAAgAjYCAEHgmMAAIAAgBWoiBDYCACAEIAJBAXI2AgQgACABaiACNgIAIAAgBUEDcjYCBAsgAEEIaiEADAULIAAgBCAGajYCBEHkmMAAQeSYwAAoAgAiAEEPakF4cSIBQQhrIgI2AgBB3JjAAEHcmMAAKAIAIARqIgQgACABa2pBCGoiATYCACACIAFBAXI2AgQgACAEakEoNgIEQfCYwABBgICAATYCAAwDC0HkmMAAIAA2AgBB3JjAAEHcmMAAKAIAIAVqIgE2AgAgACABQQFyNgIEDAELQeCYwAAgADYCAEHYmMAAQdiYwAAoAgAgBWoiATYCACAAIAFBAXI2AgQgACABaiABNgIACyAEQQhqIQAMAQtBACEAQdyYwAAoAgAiASAFTQ0AQdyYwAAgASAFayIBNgIAQeSYwABB5JjAACgCACIAIAVqIgI2AgAgAiABQQFyNgIEIAAgBUEDcjYCBCAAQQhqIQALIAhBEGokACAAC/EDAgh/AX5BASEJQStBgIDEACAAKAIIIgRBgICAAXEiAxshCiADQRV2IAJqIQMCQCAEQYCAgARxRQRAQQAhCQwBCwsCQCAALwEMIgcgA0sEQAJAAkAgBEGAgIAIcUUEQCAHIANrIQdBACEDAkACQAJAIARBHXZBA3FBAWsOAwABAAILIAchAwwBCyAHQf7/A3FBAXYhAwsgBEH///8AcSEIIAAoAgQhBiAAKAIAIQADQCAFQf//A3EgA0H//wNxTw0CQQEhBCAFQQFqIQUgACAIIAYoAhARAABFDQALDAQLIAAgACkCCCILp0GAgID/eXFBsICAgAJyNgIIQQEhBCAAKAIAIgYgACgCBCIIIAogCRBQDQMgByADa0H//wNxIQMDQCAFQf//A3EgA08NAiAFQQFqIQUgBkEwIAgoAhARAABFDQALDAMLQQEhBCAAIAYgCiAJEFANAiAAIAEgAiAGKAIMEQIADQJBACEFIAcgA2tB//8DcSEBA0AgBUH//wNxIgIgAUkhBCABIAJNDQMgBUEBaiEFIAAgCCAGKAIQEQAARQ0ACwwCCyAGIAEgAiAIKAIMEQIADQEgACALNwIIQQAPC0EBIQQgACgCACIDIAAoAgQiACAKIAkQUA0AIAMgASACIAAoAgwRAgAhBAsgBAuUBgEFfyAAQQhrIgEgAEEEaygCACIDQXhxIgBqIQICQAJAIANBAXENACADQQJxRQ0BIAEoAgAiAyAAaiEAIAEgA2siAUHgmMAAKAIARgRAIAIoAgRBA3FBA0cNAUHYmMAAIAA2AgAgAiACKAIEQX5xNgIEIAEgAEEBcjYCBCACIAA2AgAPCyABIAMQJwsCQAJAAkACQAJAIAIoAgQiA0ECcUUEQCACQeSYwAAoAgBGDQIgAkHgmMAAKAIARg0DIAIgA0F4cSICECcgASAAIAJqIgBBAXI2AgQgACABaiAANgIAIAFB4JjAACgCAEcNAUHYmMAAIAA2AgAPCyACIANBfnE2AgQgASAAQQFyNgIEIAAgAWogADYCAAsgAEGAAkkNAiABIAAQKUEAIQFB+JjAAEH4mMAAKAIAQQFrIgA2AgAgAA0EQcCWwAAoAgAiAARAA0AgAUEBaiEBIAAoAggiAA0ACwtB+JjAAEH/HyABIAFB/x9NGzYCAA8LQeSYwAAgATYCAEHcmMAAQdyYwAAoAgAgAGoiADYCACABIABBAXI2AgRB4JjAACgCACABRgRAQdiYwABBADYCAEHgmMAAQQA2AgALIABB8JjAACgCACIDTQ0DQeSYwAAoAgAiAkUNA0EAIQBB3JjAACgCACIEQSlJDQJBuJbAACEBA0AgAiABKAIAIgVPBEAgAiAFIAEoAgRqSQ0ECyABKAIIIQEMAAsAC0HgmMAAIAE2AgBB2JjAAEHYmMAAKAIAIABqIgA2AgAgASAAQQFyNgIEIAAgAWogADYCAA8LAkBB0JjAACgCACICQQEgAEEDdnQiA3FFBEBB0JjAACACIANyNgIAIABB+AFxQciWwABqIgAhAgwBCyAAQfgBcSIAQciWwABqIQIgAEHQlsAAaigCACEACyACIAE2AgggACABNgIMIAEgAjYCDCABIAA2AggPC0HAlsAAKAIAIgEEQANAIABBAWohACABKAIIIgENAAsLQfiYwABB/x8gACAAQf8fTRs2AgAgAyAETw0AQfCYwABBfzYCAAsLiAsBC38CQAJAIAAoAggiDUGAgIDAAXFFDQACQAJAAkACQCANQYCAgIABcQRAIAAvAQ4iBA0BQQAhAgwCCyACQRBPBEACfwJAAkAgAiABQQNqQXxxIgUgAWsiA0kNACACIANrIgtBBEkNACABIAVHBEAgASAFayIFQXxNBEADQCAEIAEgCWoiBiwAAEG/f0pqIAZBAWosAABBv39KaiAGQQJqLAAAQb9/SmogBkEDaiwAAEG/f0pqIQQgCUEEaiIJDQALCyABIAlqIQgDQCAEIAgsAABBv39KaiEEIAhBAWohCCAFQQFqIgUNAAsLIAEgA2ohBQJAIAtBA3EiBkUNACAFIAtBfHFqIgMsAABBv39KIQogBkEBRg0AIAogAywAAUG/f0pqIQogBkECRg0AIAogAywAAkG/f0pqIQoLIAtBAnYhDCAEIApqIQkDQCAFIQMgDEUNAkHAASAMIAxBwAFPGyIHQQNxIQoCQCAHQQJ0IgtB8AdxIgVFBEBBACEIDAELQQAhCCADIQQDQCAIIAQoAgAiBkF/c0EHdiAGQQZ2ckGBgoQIcWogBEEEaigCACIGQX9zQQd2IAZBBnZyQYGChAhxaiAEQQhqKAIAIgZBf3NBB3YgBkEGdnJBgYKECHFqIARBDGooAgAiBkF/c0EHdiAGQQZ2ckGBgoQIcWohCCAEQRBqIQQgBUEQayIFDQALCyAMIAdrIQwgAyALaiEFIAhBCHZB/4H8B3EgCEH/gfwHcWpBgYAEbEEQdiAJaiEJIApFDQALAn8gAyAHQfwBcUECdGoiBCgCACIDQX9zQQd2IANBBnZyQYGChAhxIgUgCkEBRg0AGiAFIAQoAgQiA0F/c0EHdiADQQZ2ckGBgoQIcWoiAyAKQQJGDQAaIAMgBCgCCCIDQX9zQQd2IANBBnZyQYGChAhxagsiA0EIdkH/gRxxIANB/4H8B3FqQYGABGxBEHYgCWohCQwBC0EAIAJFDQEaIAJBA3EhBSACQQRPBEAgAkF8cSEDA0AgCSABIAhqIgQsAABBv39KaiAEQQFqLAAAQb9/SmogBEECaiwAAEG/f0pqIARBA2osAABBv39KaiEJIAMgCEEEaiIIRw0ACwsgBUUNACABIAhqIQQDQCAJIAQsAABBv39KaiEJIARBAWohBCAFQQFrIgUNAAsLIAkLIQcMBAsgAkUEQEEAIQIMBAsgAkEDcSEGIAJBBE8EQCACQQxxIQMDQCAHIAEgBWoiBCwAAEG/f0pqIARBAWosAABBv39KaiAEQQJqLAAAQb9/SmogBEEDaiwAAEG/f0pqIQcgAyAFQQRqIgVHDQALCyAGRQ0DIAEgBWohAwNAIAcgAywAAEG/f0pqIQcgA0EBaiEDIAZBAWsiBg0ACwwDCyABIAJqIQtBACECIAEhAyAEIQUDQCADIgYgC0YNAiACAn8gA0EBaiADLAAAIgJBAE4NABogA0ECaiACQWBJDQAaIANBA2ogAkFwSQ0AGiADQQRqCyIDIAZraiECIAVBAWsiBQ0ACwtBACEFCyAEIAVrIQcLIAcgAC8BDCIDTw0AIAMgB2shBEEAIQdBACEFAkACQAJAIA1BHXZBA3FBAWsOAgABAgsgBCEFDAELIARB/v8DcUEBdiEFCyANQf///wBxIQYgACgCBCEKIAAoAgAhCwNAIAdB//8DcSAFQf//A3FJBEBBASEDIAdBAWohByALIAYgCigCEBEAAEUNAQwDCwtBASEDIAsgASACIAooAgwRAgANAUEAIQcgBCAFa0H//wNxIQEDQCAHQf//A3EiACABSSEDIAAgAU8NAiAHQQFqIQcgCyAGIAooAhARAABFDQALDAELIAAoAgAgASACIAAoAgQoAgwRAgAhAwsgAwuTFAIVfwN+IwBBEGsiFCQAQaSVwAAtAABBAUcEQAJAIwBBIGsiAyQAAkACQAJAQaSVwAAtAABBAWsOAgACAQtBpJXAAEECOgAAQZiVwAAoAgAiCkUNAEGglcAAKAIAIgUEQEGUlcAAKAIAIghBCGohBiAIKQMAQn+FQoCBgoSIkKDAgH+DIRcDQCAXUARAA0AgCEHgAGshCCAGKQMAIAZBCGohBkKAgYKEiJCgwIB/gyIXQoCBgoSIkKDAgH9RDQALIBdCgIGChIiQoMCAf4UhFwsgCCAXeqdBA3ZBdGxqQQRrKAIAIgRBhAhPBEAgBBA8CyAXQgF9IBeDIRcgBUEBayIFDQALCyAKIApBDGxBE2pBeHEiBWpBCWoiBEUNAEGUlcAAKAIAIAVrIAQQdgtBpJXAAEEBOgAAQZSVwABBiInAACkCADcCAEGclcAAQZCJwAApAgA3AgBBkJXAAEEANgIAIANBIGokAAwBCyADQQA2AhggA0EBNgIMIANB2InAADYCCCADQgQ3AhAgA0EIakHgicAAEFIACwtBkJXAACgCAEUEQEGQlcAAQX82AgBBmJXAACgCACIFIABxIQMgAEEZdiIVrUKBgoSIkKDAgAF+IRhBlJXAACgCACEEAkADQCADIARqKQAAIhkgGIUiF0J/hSAXQoGChIiQoMCAAX2DQoCBgoSIkKDAgH+DIhdQRQRAA0AgACAEIBd6p0EDdiADaiAFcUF0bGoiCEEMaygCAEYEQCAIQQhrKAIAIAFGDQQLIBdCAX0gF4MiF1BFDQALCyAZIBlCAYaDQoCBgoSIkKDAgH+DUARAIAMgAkEIaiICaiAFcSEDDAELC0GclcAAKAIARQRAIBRBCGohFiMAQSBrIg4kAAJAQaCVwAAoAgAiCkEBaiIEIApPBEBBmJXAACgCACILIAtBAWoiDEEDdiICQQdsIAtBCEkbIhFBAXYgBEkEQAJAAkACQAJAAkACfyARQQFqIgIgBCACIARLGyICQQ9PBEAgAkH/////AUsNAkF/IAJBA3RBB25BAWtndkEBagwBC0EEIAJBCHFBCGogAkEESRsLIgStQgx+IhdCIIinDQIgF6ciAkF4Sw0CIAJBB2pBeHEiAyAEQQhqIgVqIgYgA0kgBkH4////B0tyDQIgBkEIEHkiAg0BQQggBhB/AAsQSiAOKAIcIQQgDigCGCEDDAYLIAIgA2ohDSAFBEAgDUH/ASAF/AsACyAEQQFrIgkgBEEDdkEHbCAJQQhJGyEQIAoNAUGUlcAAKAIAIQIMAgsQSiAOKAIMIQQgDigCCCEDDAQLIA1BDGshESANQQhqIRJBlJXAACgCACICQQxrIQwgAikDAEJ/hUKAgYKEiJCgwIB/gyEYQQAhBCAKIQUgAiEDA0AgGFAEQANAIARBCGohBCADQQhqIgMpAwBCgIGChIiQoMCAf4MiF0KAgYKEiJCgwIB/UQ0ACyAXQoCBgoSIkKDAgH+FIRgLIA0gDCAYeqdBA3YgBGoiE0F0bGoiCCgCACIGIAgoAgQgBhsiCCAJcSIHaikAAEKAgYKEiJCgwIB/gyIXUARAQQghDwNAIAcgD2ohBiAPQQhqIQ8gDSAGIAlxIgdqKQAAQoCBgoSIkKDAgH+DIhdQDQALCyAYQgF9IBiDIRggDSAXeqdBA3YgB2ogCXEiB2osAABBAE4EQCANKQMAQoCBgoSIkKDAgH+DeqdBA3YhBwsgByANaiAIQRl2IgY6AAAgEiAHQQhrIAlxaiAGOgAAIBEgB0F0bGoiCEEIaiAMIBNBdGxqIgZBCGooAAA2AAAgCCAGKQAANwAAIAVBAWsiBQ0ACwtBmJXAACAJNgIAQZSVwAAgDTYCAEGclcAAIBAgCms2AgBBgYCAgHghAyALRQ0CIAsgC0EMbEETakF4cSIEakEJaiIFRQ0CIAIgBGsgBRB2DAILIAwEQEGUlcAAKAIAIQdBACEEIAIgDEEHcUEAR2oiAkEBcSACQQFHBEAgAkH+////A3EhAgNAIAQgB2oiBSAFKQMAIhdCf4VCB4hCgYKEiJCgwIABgyAXQv/+/fv379+//wCEfDcDACAFQQhqIgUgBSkDACIXQn+FQgeIQoGChIiQoMCAAYMgF0L//v379+/fv/8AhHw3AwAgBEEQaiEEIAJBAmsiAg0ACwsEQCAEIAdqIgIgAikDACIXQn+FQgeIQoGChIiQoMCAAYMgF0L//v379+/fv/8AhHw3AwALIAdBCGohEAJAIAxBCE8EQCAHIAxqIAcpAAA3AAAMAQsgDEUNACAQIAcgDPwKAAALIAdBDGshEkEBIQJBACEEA0AgBCEFIAIhBAJAIAUgB2oiEy0AAEGAAUcNACASIAVBdGxqIQkCQANAIAkoAgAiAiAJKAIEIAIbIgggC3EiAyECIAMgB2opAABCgIGChIiQoMCAf4MiGFAEQEEIIQ8DQCACIA9qIQIgD0EIaiEPIAcgAiALcSICaikAAEKAgYKEiJCgwIB/gyIYUA0ACwsgByAYeqdBA3YgAmogC3EiAmosAABBAE4EQCAHKQMAQoCBgoSIkKDAgH+DeqdBA3YhAgsgAiADayAFIANrcyALcUEITwRAIAIgB2oiAy0AACADIAhBGXYiAzoAACAQIAJBCGsgC3FqIAM6AAAgEiACQXRsaiEDQf8BRg0CIAkoAAAhAiAJIAMoAAA2AAAgAyACNgAAIAMoAAQhAiADIAkoAAQ2AAQgCSACNgAEIAkoAAghAiAJIAMoAAg2AAggAyACNgAIDAELCyATIAhBGXYiAjoAACAQIAVBCGsgC3FqIAI6AAAMAQsgE0H/AToAACAQIAVBCGsgC3FqQf8BOgAAIANBCGogCUEIaigAADYAACADIAkpAAA3AAALIAQgBCAMSSIFaiECIAUNAAsLQZyVwAAgESAKazYCAEGBgICAeCEDDAELEEogDigCBCEEIA4oAgAhAwsgFiAENgIEIBYgAzYCACAOQSBqJAALIAAgARBnIQRBlJXAACgCACIKQZiVwAAoAgAiBSAAcSIDaikAAEKAgYKEiJCgwIB/gyIXUARAQQghBgNAIAMgBmohAiAGQQhqIQYgCiACIAVxIgNqKQAAQoCBgoSIkKDAgH+DIhdQDQALCyAKIBd6p0EDdiADaiAFcSIDaiwAACIGQQBOBEAgCiAKKQMAQoCBgoSIkKDAgH+DeqdBA3YiA2otAAAhBgsgAyAKaiAVOgAAIAogA0EIayAFcWpBCGogFToAAEGclcAAQZyVwAAoAgAgBkEBcWs2AgBBoJXAAEGglcAAKAIAQQFqNgIAIAogA0F0bGoiCEEEayAENgIAIAhBCGsgATYCACAIQQxrIAA2AgALIAhBBGsoAgAQdUGQlcAAQZCVwAAoAgBBAWo2AgAgFEEQaiQADwtB8IjAABCEAQALuAQBCH8jAEEQayIDJAAgAyABNgIEIAMgADYCACADQqCAgIAONwIIAn8CQAJAAkAgAigCECIJBEAgAigCFCIADQEMAgsgAigCDCIARQ0BIAIoAggiASAAQQN0IgBqIQQgAEEIa0EDdkEBaiEGIAIoAgAhAANAAkAgAEEEaigCACIFRQ0AIAMoAgAgACgCACAFIAMoAgQoAgwRAgBFDQBBAQwFC0EBIAEoAgAgAyABQQRqKAIAEQAADQQaIABBCGohACAEIAFBCGoiAUcNAAsMAgsgAEEYbCEKIABBAWtB/////wFxQQFqIQYgAigCCCEEIAIoAgAhAANAAkAgAEEEaigCACIBRQ0AIAMoAgAgACgCACABIAMoAgQoAgwRAgBFDQBBAQwEC0EAIQdBACEIAkACQAJAIAUgCWoiAUEIai8BAEEBaw4CAQIACyABQQpqLwEAIQgMAQsgBCABQQxqKAIAQQN0ai8BBCEICwJAAkACQCABLwEAQQFrDgIBAgALIAFBAmovAQAhBwwBCyAEIAFBBGooAgBBA3RqLwEEIQcLIAMgBzsBDiADIAg7AQwgAyABQRRqKAIANgIIQQEgBCABQRBqKAIAQQN0aiIBKAIAIAMgASgCBBEAAA0DGiAAQQhqIQAgBUEYaiIFIApHDQALDAELCwJAIAYgAigCBE8NACADKAIAIAIoAgAgBkEDdGoiACgCACAAKAIEIAMoAgQoAgwRAgBFDQBBAQwBC0EACyADQRBqJAALjwQBAn8gACABaiECAkACQCAAKAIEIgNBAXENACADQQJxRQ0BIAAoAgAiAyABaiEBIAAgA2siAEHgmMAAKAIARgRAIAIoAgRBA3FBA0cNAUHYmMAAIAE2AgAgAiACKAIEQX5xNgIEIAAgAUEBcjYCBCACIAE2AgAMAgsgACADECcLAkACQAJAIAIoAgQiA0ECcUUEQCACQeSYwAAoAgBGDQIgAkHgmMAAKAIARg0DIAIgA0F4cSICECcgACABIAJqIgFBAXI2AgQgACABaiABNgIAIABB4JjAACgCAEcNAUHYmMAAIAE2AgAPCyACIANBfnE2AgQgACABQQFyNgIEIAAgAWogATYCAAsgAUGAAk8EQCAAIAEQKQ8LAkBB0JjAACgCACICQQEgAUEDdnQiA3FFBEBB0JjAACACIANyNgIAIAFB+AFxQciWwABqIgEhAgwBCyABQfgBcSIBQciWwABqIQIgAUHQlsAAaigCACEBCyACIAA2AgggASAANgIMIAAgAjYCDCAAIAE2AggPC0HkmMAAIAA2AgBB3JjAAEHcmMAAKAIAIAFqIgE2AgAgACABQQFyNgIEIABB4JjAACgCAEcNAUHYmMAAQQA2AgBB4JjAAEEANgIADwtB4JjAACAANgIAQdiYwABB2JjAACgCACABaiIBNgIAIAAgAUEBcjYCBCAAIAFqIAE2AgALC5kEAQd/IwBBMGsiBCQAAkACQAJAAkAgASgCBCICBEAgASgCACEGIAJBA3EhBQJAIAJBBEkEQEEAIQIMAQsgBkEcaiEDIAJBfHEhCEEAIQIDQCADKAIAIANBCGsoAgAgA0EQaygCACADQRhrKAIAIAJqampqIQIgA0EgaiEDIAggB0EEaiIHRw0ACwsgBQRAIAdBA3QgBmpBBGohAwNAIAMoAgAgAmohAiADQQhqIQMgBUEBayIFDQALCyABKAIMRQ0CIAJBD0sNASAGKAIEDQEMAwtBACECIAEoAgxFDQILIAJBACACQQBKG0EBdCECC0EAIQMgAkEATgRAIAJFDQFBASEDIAJBARB5IgUNAgsgAyACEGEAC0EBIQVBACECCyAEQQA2AgwgBCAFNgIIIAQgAjYCBCAEQSBqIAFBEGopAgA3AwAgBEEYaiABQQhqKQIANwMAIAQgASkCADcDECAEQQRqQbCQwAAgBEEQahAiRQRAIAAgBCkCBDcCACAAQQhqIARBDGooAgA2AgAgBEEwaiQADwsjAEFAaiIAJAAgAEHWADYCDCAAQZyPwAA2AgggAEGMj8AANgIUIAAgBEEvajYCECAAQQI2AhwgAEGclMAANgIYIABCAjcCJCAAIABBEGqtQoCAgICACIQ3AzggACAAQQhqrUKAgICA8AeENwMwIAAgAEEwajYCICAAQRhqQfSPwAAQUgALwQMBB38jAEEgayICJAAgAkEANgIMIAJCgICAgBA3AgQgASgCDCEEIAEoAggiAyABKAIEIgdrQQF0IAEoAgAiAUGAgMQAR3IiBQRAIAJBBGpBACAFEDgLIAIgBDYCHCACIAM2AhggAiAHNgIUIAIgATYCECACQRBqEEAiAUGAgMQARwRAIAIoAgwhBANAIAQhAwJ/QQEgAUGAAUkiBQ0AGkECIAFBgBBJDQAaQQNBBCABQYCABEkbCyIHIAIoAgQgBGtLBH8gAkEEaiAEIAcQOCACKAIMBSADCyACKAIIaiEDAkAgBUUEQCABQT9xQYB/ciEFIAFBBnYhBiABQYAQSQRAIAMgBToAASADIAZBwAFyOgAADAILIAFBDHYhCCAGQT9xQYB/ciEGIAFB//8DTQRAIAMgBToAAiADIAY6AAEgAyAIQeABcjoAAAwCCyADIAU6AAMgAyAGOgACIAMgCEE/cUGAf3I6AAEgAyABQRJ2QXByOgAADAELIAMgAToAAAsgAiAEIAdqIgQ2AgwgAkEQahBAIgFBgIDEAEcNAAsLIAAgAikCBDcCACAAQQhqIAJBDGooAgA2AgAgAkEgaiQAC+cCAQV/AkAgAUHN/3tBECAAIABBEE0bIgBrTw0AIABBECABQQtqQXhxIAFBC0kbIgRqQQxqEB0iAkUNACACQQhrIQECQCAAQQFrIgMgAnFFBEAgASEADAELIAJBBGsiBSgCACIGQXhxIAIgA2pBACAAa3FBCGsiAiAAQQAgAiABa0EQTRtqIgAgAWsiAmshAyAGQQNxBEAgACADIAAoAgRBAXFyQQJyNgIEIAAgA2oiAyADKAIEQQFyNgIEIAUgAiAFKAIAQQFxckECcjYCACABIAJqIgMgAygCBEEBcjYCBCABIAIQIwwBCyABKAIAIQEgACADNgIEIAAgASACajYCAAsCQCAAKAIEIgFBA3FFDQAgAUF4cSICIARBEGpNDQAgACAEIAFBAXFyQQJyNgIEIAAgBGoiASACIARrIgRBA3I2AgQgACACaiICIAIoAgRBAXI2AgQgASAEECMLIABBCGohAwsgAwuCAwEEfyAAKAIMIQICQAJAAkAgAUGAAk8EQCAAKAIYIQMCQAJAIAAgAkYEQCAAQRRBECAAKAIUIgIbaigCACIBDQFBACECDAILIAAoAggiASACNgIMIAIgATYCCAwBCyAAQRRqIABBEGogAhshBANAIAQhBSABIgJBFGogAkEQaiACKAIUIgEbIQQgAkEUQRAgARtqKAIAIgENAAsgBUEANgIACyADRQ0CAkAgACgCHEECdEG4lcAAaiIBKAIAIABHBEAgAygCECAARg0BIAMgAjYCFCACDQMMBAsgASACNgIAIAJFDQQMAgsgAyACNgIQIAINAQwCCyAAKAIIIgAgAkcEQCAAIAI2AgwgAiAANgIIDwtB0JjAAEHQmMAAKAIAQX4gAUEDdndxNgIADwsgAiADNgIYIAAoAhAiAQRAIAIgATYCECABIAI2AhgLIAAoAhQiAEUNACACIAA2AhQgACACNgIYDwsPC0HUmMAAQdSYwAAoAgBBfiAAKAIcd3E2AgAL8wIBBX8jAEEQayIDJAACQEH4lMAAKAIARQRAQfiUwABBfzYCAEGIlcAAKAIAIgBBhJXAACgCACIBRgRAAn8gACAAQfyUwAAoAgAiAkcNABrQb0GAASAAIABBgAFNGyIE/A8BIgJBf0YNAwJAQYyVwAAoAgAiAUUEQEGMlcAAIAI2AgAMAQsgACABaiACRw0EC0H8lMAAKAIAIgEgAGsgBE8EQCABIQIgAAwBCyADQQRqIAFBgJXAACgCACAAIARqIgJBBEEEEDQgAygCBEEBRg0DQYCVwAAgAygCCDYCAEH8lMAAIAI2AgBBhJXAACgCAAsiASACTw0CQYCVwAAoAgAgAUECdGogAEEBajYCAEGElcAAIAFBAWoiATYCAAsgACABTw0BQYiVwABBgJXAACgCACAAQQJ0aigCADYCAEH4lMAAQfiUwAAoAgBBAWo2AgBBjJXAACgCACEBIANBEGokACAAIAFqDwtB3IvAABCEAQsAC8QCAQR/IABCADcCECAAAn9BACABQYACSQ0AGkEfIAFB////B0sNABogAUEmIAFBCHZnIgNrdkEBcSADQQF0a0E+agsiAjYCHCACQQJ0QbiVwABqIQRBASACdCIDQdSYwAAoAgBxRQRAIAQgADYCACAAIAQ2AhggACAANgIMIAAgADYCCEHUmMAAQdSYwAAoAgAgA3I2AgAPCwJAAkAgASAEKAIAIgMoAgRBeHFGBEAgAyECDAELIAFBGSACQQF2a0EAIAJBH0cbdCEFA0AgAyAFQR12QQRxaiIEKAIQIgJFDQIgBUEBdCEFIAIhAyACKAIEQXhxIAFHDQALCyACKAIIIgEgADYCDCACIAA2AgggAEEANgIYIAAgAjYCDCAAIAE2AggPCyAEQRBqIAA2AgAgACADNgIYIAAgADYCDCAAIAA2AggLlgICBH8DfiMAQSBrIgMkAEEUIQIgACkDACIIIQYgCELoB1oEQCAIIQcDQCADQQxqIAJqIgBBBGsgByAHQpDOAIAiBkKQzgB+faciBEH//wNxQeQAbiIFQQF0LwDQkEA7AAAgAEECayAEIAVB5ABsa0H//wNxQQF0LwDQkEA7AAAgAkEEayECIAdC/6ziBFYgBiEHDQALCyAGQglWBEAgAkECayICIANBDGpqIAanIgAgAEH//wNxQeQAbiIAQeQAbGtB//8DcUEBdC8A0JBAOwAAIACtIQYLIAhQRSAGUHFFBEAgAkEBayICIANBDGpqIAanQQF0LQDRkEA6AAALIAEgA0EMaiACakEUIAJrEB4gA0EgaiQAC5ICAQd/IwBBEGsiBCQAQQohAiAAKAIAIgUhAyAFQegHTwRAIAUhAANAIARBBmogAmoiBkEEayAAIABBkM4AbiIDQZDOAGxrIgdB//8DcUHkAG4iCEEBdC8A0JBAOwAAIAZBAmsgByAIQeQAbGtB//8DcUEBdC8A0JBAOwAAIAJBBGshAiAAQf+s4gRLIAMhAA0ACwsCQCADQQlNBEAgAyEADAELIAJBAmsiAiAEQQZqaiADIANB//8DcUHkAG4iAEHkAGxrQf//A3FBAXQvANCQQDsAAAtBACAFIAAbRQRAIAJBAWsiAiAEQQZqaiAAQQF0LQDRkEA6AAALIAEgBEEGaiACakEKIAJrEB4gBEEQaiQAC4gCAQZ/IAAoAggiBCECAn9BASABQYABSQ0AGkECIAFBgBBJDQAaQQNBBCABQYCABEkbCyIGIAAoAgAgBGtLBH8gACAEIAYQNyAAKAIIBSACCyAAKAIEaiECAkAgAUGAAU8EQCABQT9xQYB/ciEFIAFBBnYhAyABQYAQSQRAIAIgBToAASACIANBwAFyOgAADAILIAFBDHYhByADQT9xQYB/ciEDIAFB//8DTQRAIAIgBToAAiACIAM6AAEgAiAHQeABcjoAAAwCCyACIAU6AAMgAiADOgACIAIgB0E/cUGAf3I6AAEgAiABQRJ2QXByOgAADAELIAIgAToAAAsgACAEIAZqNgIIQQALiAIBBn8gACgCCCIEIQICf0EBIAFBgAFJDQAaQQIgAUGAEEkNABpBA0EEIAFBgIAESRsLIgYgACgCACAEa0sEfyAAIAQgBhA5IAAoAggFIAILIAAoAgRqIQICQCABQYABTwRAIAFBP3FBgH9yIQUgAUEGdiEDIAFBgBBJBEAgAiAFOgABIAIgA0HAAXI6AAAMAgsgAUEMdiEHIANBP3FBgH9yIQMgAUH//wNNBEAgAiAFOgACIAIgAzoAASACIAdB4AFyOgAADAILIAIgBToAAyACIAM6AAIgAiAHQT9xQYB/cjoAASACIAFBEnZBcHI6AAAMAQsgAiABOgAACyAAIAQgBmo2AghBAAufAgIDfwF+IwBBQGoiAiQAIAEoAgBBgICAgHhGBEAgASgCDCEDIAJBJGoiBEEANgIAIAJCgICAgBA3AhwgAkEwaiADKAIAIgNBCGopAgA3AwAgAkE4aiADQRBqKQIANwMAIAIgAykCADcDKCACQRxqQeCMwAAgAkEoahAiGiACQRhqIAQoAgAiAzYCACACIAIpAhwiBTcDECABQQhqIAM2AgAgASAFNwIACyABKQIAIQUgAUKAgICAEDcCACACQQhqIgMgAUEIaiIBKAIANgIAIAFBADYCACACIAU3AwBBDEEEEHkiAUUEQEEEQQwQfwALIAEgAikDADcCACABQQhqIAMoAgA2AgAgAEHAjsAANgIEIAAgATYCACACQUBrJAAL+QEBB39BCiEDIAEiBUHoB08EQCACQQRrIQcgBSEEA0AgAyAHaiIGIAQgBEGQzgBuIgVBkM4AbGsiCEH//wNxQeQAbiIJQQF0LwDQkEA7AAAgBkECaiAIIAlB5ABsa0H//wNxQQF0LwDQkEA7AAAgA0EEayEDIARB/6ziBEsgBSEEDQALCwJAIAVBCU0EQCAFIQQMAQsgAiADQQJrIgNqIAUgBUH//wNxQeQAbiIEQeQAbGtB//8DcUEBdC8A0JBAOwAAC0EAIAEgBBtFBEAgAiADQQFrIgNqIARBAXQtANGQQDoAAAsgAEEKIANrNgIEIAAgAiADajYCAAuiAgEEfyMAQSBrIgIkAAJAAkACQCABKAIAIgEoAgAiBEECRw0AIAEoAgghAyABQQA2AgggA0UNASACIAMRAwAgAigCBCEFIAIoAgAhAyABKAIAIgRBAkYEQCABIAM2AgAgAUEEaiAFNgIAIAMhBAwBCyADQQJHDQILQQEhAwJAIARBAXFFBEBBACEDDAELIAFBBGooAgAQdSEBCyAAIAE2AgQgACADNgIAIAJBIGokAA8LIAJBADYCGCACQQE2AgwgAkHoisAANgIIIAJCBDcCECACQQhqQfCKwAAQUgALIANFIANBAkZyIAVBhAhJckUEQCAFEDwLIAJBADYCGCACQQE2AgwgAkGQi8AANgIIIAJCBDcCECACQQhqQZiLwAAQUgALxQEBA38jAEEwayIAJAAgAEEgakGwisAAEDACQCAAAn8gACgCIEEBcQRAIAAoAiQMAQsgAEEYakG4isAAEDAgACgCGEEBcQRAIAAoAhwMAQsgAEEQakGsisAAEDAgACgCEEEBcQRAIAAoAhQMAQsgAEEIakG0isAAEDBBgAghASAAKAIIQQFxRQ0BIAAoAgwLIgI2AiwgAEEsaigCACUBEBdFBEAgAiEBDAELQYAIIQEgAkGECEkNACACEDwLIABBMGokACABC5QCAQJ/IwBBIGsiBSQAQYiZwABBiJnAACgCACIGQQFqNgIAAkACf0EAIAZBAEgNABpBAUGEmcAALQAADQAaQYSZwABBAToAAEGAmcAAQYCZwAAoAgBBAWo2AgBBAgtB/wFxIgZBAkcEQCAGQQFxRQ0BIAVBCGogACABKAIYEQEADAELQYyZwAAoAgAiBkEASA0AQYyZwAAgBkEBajYCAEGQmcAAKAIABEAgBSAAIAEoAhQRAQAgBSAEOgAdIAUgAzoAHCAFIAI2AhggBSAFKQMANwIQQZCZwAAoAgAgBUEQakGUmcAAKAIAKAIUEQEAC0GMmcAAQYyZwAAoAgBBAWs2AgBBhJnAAEEAOgAAIANFDQAACwALwQECA38BfiMAQTBrIgIkACABKAIAQYCAgIB4RgRAIAEoAgwhAyACQRRqIgRBADYCACACQoCAgIAQNwIMIAJBIGogAygCACIDQQhqKQIANwMAIAJBKGogA0EQaikCADcDACACIAMpAgA3AxggAkEMakHgjMAAIAJBGGoQIhogAkEIaiAEKAIAIgM2AgAgAiACKQIMIgU3AwAgAUEIaiADNgIAIAEgBTcCAAsgAEHAjsAANgIEIAAgATYCACACQTBqJAALqAECAn8BfkEBIQdBBCEGAkAgBCAFakEBa0EAIARrca0gA61+IghCIIhQRQRAQQAhAwwBCyAIpyIDQYCAgIB4IARrSwRAQQAhAwwBCwJAAkACfyABBEAgAiABIAVsIAQgAxBvDAELIANFBEAgBCEGDAILIAMgBBB5CyIGDQAgACAENgIEDAELIAAgBjYCBEEAIQcLQQghBgsgACAGaiADNgIAIAAgBzYCAAucAQEBfyMAQRBrIgYkAAJAIAEEQCAGQQRqIAEgAyAEIAUgAigCEBEFAAJAIAYoAgQiAiAGKAIMIgFNBEAgBigCCCEFDAELIAJBAnQhAiAGKAIIIQMgAUUEQEEEIQUgAyACEHYMAQsgAyACQQQgAUECdCICEG8iBUUNAgsgACABNgIEIAAgBTYCACAGQRBqJAAPCxB6AAtBBCACEGEAC5oBAQF/IwBBEGsiBSQAAkAgAQRAIAVBBGogASADIAQgAigCEBEGAAJAIAUoAgQiAiAFKAIMIgFNBEAgBSgCCCEEDAELIAJBAnQhAiAFKAIIIQMgAUUEQEEEIQQgAyACEHYMAQsgAyACQQQgAUECdCICEG8iBEUNAgsgACABNgIEIAAgBDYCACAFQRBqJAAPCxB6AAtBBCACEGEAC4cBAQF/IwBBEGsiAyQAIAIgASACaiIBSwRAQQBBABBhAAsgA0EEaiAAKAIAIgIgACgCBEEIIAEgAkEBdCICIAEgAksbIgEgAUEITRsiAUEBQQEQNCADKAIEQQFGBEAgAygCCCADKAIMEGEACyADKAIIIQIgACABNgIAIAAgAjYCBCADQRBqJAAL7AEBBH8jAEEQayIDJAAgAiABIAJqIgRLBEBBAEEAEGEACyADQQRqIQEgACgCACICIQUgACgCBCEGAkBBCCAEIAJBAXQiAiACIARJGyICIAJBCE0bIgJBAEgEQCABQQA2AgQgAUEBNgIADAELAn8gBQRAIAYgBUEBIAIQbwwBCyACQQEQeQsiBEUEQCABIAI2AgggAUEBNgIEIAFBATYCAAwBCyABIAI2AgggASAENgIEIAFBADYCAAsgAygCBEEBRgRAIAMoAgggAygCDBBhAAsgAygCCCEBIAAgAjYCACAAIAE2AgQgA0EQaiQAC/EBAQR/IwBBEGsiAyQAIAIgASACaiIBSwRAQQBBABBhAAsgA0EEaiEEIAAoAgQhBgJ/QQggASAAKAIAIgJBAXQiBSABIAVLGyIBIAFBCE0bIgUiAUEASARAQQEhAkEAIQFBBAwBCwJ/AkACfyACBEAgBiACQQEgARBvDAELIAFFBEBBASECDAILIAFBARB5CyICDQAgBEEBNgIEQQEMAQsgBCACNgIEQQALIQJBCAsgBGogATYCACAEIAI2AgAgAygCBEEBRgRAIAMoAgggAygCDBBhAAsgAygCCCEBIAAgBTYCACAAIAE2AgQgA0EQaiQAC3kBAX8jAEEgayICJAACfyAAKAIAQYCAgIB4RwRAIAEgACgCBCAAKAIIEGoMAQsgAkEQaiAAKAIMKAIAIgBBCGopAgA3AwAgAkEYaiAAQRBqKQIANwMAIAIgACkCADcDCCABKAIAIAEoAgQgAkEIahAiCyACQSBqJAALZwEBfyMAQRBrIgUkACABRQRAEHoACyAFQQhqIAEgAyAEIAIoAhARBgAgACAFKAIIIgJBAkYiATYCCCAAIAUoAgwiA0EAIAEbNgIEIABBACADQYAIIAJBAXEbIAEbNgIAIAVBEGokAAuOAQEBfwJAAkAgAEGECE8EQCAA0G8mAUH4lMAAKAIADQFB+JTAAEF/NgIAIABBjJXAACgCACIBSQ0CIAAgAWsiAEGElcAAKAIATw0CQYCVwAAoAgAgAEECdGpBiJXAACgCADYCAEGIlcAAIAA2AgBB+JTAAEH4lMAAKAIAQQFqNgIACw8LQeyLwAAQhAELAAtiAQF/IwBBEGsiBiQAIAFFBEAQegALIAZBCGogASADIAQgBSACKAIQEQUAIAYoAgwhASAAIAYoAggiAjYCCCAAIAFBACACQQFxIgIbNgIEIABBACABIAIbNgIAIAZBEGokAAsSACMAQTBrIgAkACAAQTBqJAALYAEBfyMAQRBrIgUkACABRQRAEHoACyAFQQhqIAEgAyAEIAIoAhARBgAgBSgCDCEBIAAgBSgCCCICNgIIIAAgAUEAIAJBAXEiAhs2AgQgAEEAIAEgAhs2AgAgBUEQaiQAC2sBAn8gACgCACEBIABBgIDEADYCAAJAIAFBgIDEAEcNAEGAgMQAIQEgACgCBCICIAAoAghGDQAgACACQQFqNgIEIAAgACgCDCIAIAItAAAiAUEPcWotAAA2AgAgACABQQR2ai0AACEBCyABC1oBAX8jAEEQayIFJAAgAUUEQBB6AAsgBUEIaiABIAMgBCACKAIQEQYAIAAgBS0ACCIBNgIIIAAgBSgCDEEAIAEbNgIEIABBACAFLQAJIAEbNgIAIAVBEGokAAtYAQF/IwBBEGsiBCQAIAFFBEAQegALIARBCGogASADIAIoAhARBAAgACAELQAIIgE2AgggACAEKAIMQQAgARs2AgQgAEEAIAQtAAkgARs2AgAgBEEQaiQAC1QBAX8jAEEQayIGJAAgAUUEQBB6AAsgBkEIaiABIAMgBCAFIAIoAhARBQAgBigCDCEBIAAgBigCCCICNgIEIAAgAUEAIAJBAXEbNgIAIAZBEGokAAtUAQF/IwBBEGsiBiQAIAFFBEAQegALIAZBCGogASADIAQgBSACKAIQEREAIAYoAgwhASAAIAYoAggiAjYCBCAAIAFBACACQQFxGzYCACAGQRBqJAALVAEBfyMAQRBrIgYkACABRQRAEHoACyAGQQhqIAEgAyAEIAUgAigCEBESACAGKAIMIQEgACAGKAIIIgI2AgQgACABQQAgAkEBcRs2AgAgBkEQaiQAC1QBAX8jAEEQayIGJAAgAUUEQBB6AAsgBkEIaiABIAMgBCAFIAIoAhAREwAgBigCDCEBIAAgBigCCCICNgIEIAAgAUEAIAJBAXEbNgIAIAZBEGokAAtSAQF/IwBBEGsiBSQAIAFFBEAQegALIAVBCGogASADIAQgAigCEBEGACAFKAIMIQEgACAFKAIIIgI2AgQgACABQQAgAkEBcRs2AgAgBUEQaiQAC1ABAX8jAEEQayIEJAAgAUUEQBB6AAsgBEEIaiABIAMgAigCEBEEACAEKAIMIQEgACAEKAIIIgI2AgQgACABQQAgAkEBcRs2AgAgBEEQaiQAC0cBAX8gACgCACAAKAIIIgNrIAJJBEAgACADIAIQNyAAKAIIIQMLIAIEQCAAKAIEIANqIAEgAvwKAAALIAAgAiADajYCCEEACzkBAX8jAEEgayIAJAAgAEEANgIYIABBATYCDCAAQeyOwAA2AgggAEIENwIQIABBCGpB9I7AABBSAAtHAQF/IAAoAgAgACgCCCIDayACSQRAIAAgAyACEDkgACgCCCEDCyACBEAgACgCBCADaiABIAL8CgAACyAAIAIgA2o2AghBAAtAAQJ/IwBBEGsiAiQAIAJBCGogACgCACUBEAEgAigCCCIDIAIoAgwiACABEIABIAAEQCADIAAQdgsgAkEQaiQAC0QBAn8gASgCBCECIAEoAgAhA0EIQQQQeSIBRQRAQQRBCBB/AAsgASACNgIEIAEgAzYCACAAQbCNwAA2AgQgACABNgIAC0EBAX8jAEEgayICJAAgAkEANgIQIAJBATYCBCACQgQ3AgggAkEuNgIcIAIgADYCGCACIAJBGGo2AgAgAiABEFIAC8JXAyh/BX4BbyMAQRBrIhskACMAQRBrIhwkACAcQQhqIR4gCiEOIwBBwAZrIgskACALIAE2AmQgCyAANgJgIAsgAzYCbCALIAI2AmggCyAFNgJ0IAsgBDYCcCALIAc2AnwgCyAGNgJ4IAsgCTYChAEgCyAINgKAAQJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQEEaQQEQeSIQBEAgEEHt6gE7ABggEELhgMHzl83bvuUANwAQIBBC7sKVi5avmbvhADcACCAQQvj4zfuH7pm29wA3AAAgC0HoAmohFSAQQQNqQXxxIBBrIQ0CQANAAkACQAJAAkAgECASai0AACIRwCIMQQBOBEAgDSASa0EDcQ0BIBJBE08NAgNAIBAgEmoiCkEEaigCACAKKAIAckGAgYKEeHENAyASQQhqIhJBE0kNAAsMAgtCgICAgIAgITNCgICAgBAhNAJAAkACfgJAAkACQAJAAkACQAJAAkACQCARLQCakkBBAmsOAwABAgoLIBJBAWoiDEEaSQ0CQgAhM0IAITQMCQtCACEzIBJBAWoiCkEaSQ0CQgAhNAwIC0IAITMgEkEBaiIKQRpJDQJCACE0DAcLIAwgEGosAABBv39KDQYMBwsgCiAQaiwAACEPAkACQCARQeABayIKBEAgCkENRgRADAIFDAMLAAsgD0FgcUGgf0YNBAwDCyAPQZ9/Sg0CDAMLIAxBH2pB/wFxQQxPBEAgDEF+cUFuRw0CIA9BQEgNAwwCCyAPQUBIDQIMAQsgCiAQaiwAACEKAkACQAJAAkAgEUHwAWsOBQEAAAACAAsgDEEPakH/AXFBAksgCkFATnINAwwCCyAKQfAAakH/AXFBME8NAgwBCyAKQY9/Sg0BCyASQQJqIgpBGk8EQEIAITQMBQsgCiAQaiwAAEG/f0oNAkIAITQgEkEDaiIMQRpPDQQgDCAQaiwAAEFASA0FQoCAgICA4AAMAwtCgICAgIAgDAILQgAhNCASQQJqIgxBGk8NAiAMIBBqLAAAQb9/TA0DC0KAgICAgMAACyEzQoCAgIAQITQLIBUgMyASrYQgNIQ3AgQgFUEBNgIADAYLIAxBAWohEgwCCyASQQFqIRIMAQsgEkEaTw0AA0AgECASaiwAAEEASA0BIBJBAWoiEkEaRw0ACwwBCyASQRpJDQELCyAVQRo2AgggFSAQNgIEIBVBADYCAAsCQCALKALoAkEBRgRAQQhBARB5IgpFDQMgCkLmwrHjpqzYsesANwAAIAtBCDYCkAEgCyAKNgKMASALQQg2AogBIBBBGhB2DAELIAtBGjYCkAEgCyAQNgKMASALQRo2AogBCyALIAtBiAFqrUKAgICAEIQ3A8gBIAsgC0H4AGqtQoCAgIAghCI2NwPAASALQgI3AvQCIAtBAjYC7AIgC0GEgcAANgLoAiALIAtBwAFqNgLwAiALQZQBaiALQegCahAkAn8jAEEgayINJAACQAJAAkBBqJXAAC0AAARAQayVwAAoAgAhDAwBC0H0lMAAKAIAIQpB9JTAAEEANgIAIApFDQEgChEHACEMQaiVwAAtAAANAkGslcAAIAw2AgBBqJXAAEEBOgAACyAMEHUgDUEgaiQADAILIA1BADYCGCANQQE2AgwgDUHoisAANgIIIA1CBDcCECANQQhqQfCKwAAQUgALIAxBgwhLBEAgDBA8CyANQQA2AhggDUEBNgIMIA1BkIvAADYCCCANQgQ3AhAgDUEIakGYi8AAEFIACyIMJQEQAiINIAxBhAhJckUEQCAMEDwLIAtB2ABqIgogDDYCBCAKIA1BAEc2AgAgCygCWEEBcUUEQEGYgsAAQQ8QZyEMIAtBgICAgHg2AuAEIAsgDDYC5AQMCgsgCyALKAJcIhU2ArABIAtB0ABqIg0gC0GwAWooAgAlARADIgo2AgQgDSAKQQBHNgIAIAsoAlBBAXFFBEBBp4LAAEEREGchCiALQYCAgIB4NgLgBCALIAo2AuQEDAcLIAsgCygCVCISNgLQBCALQdAEaigCACUBQbiCwABBBhAEITgQKCIRIDgmAUG0lcAAKAIAIQxBsJXAACgCACEKQbCVwABCADcCACALQcgAaiINIAwgESAKQQFGIgobNgIEIA0gCjYCACALKAJMIRggCygCSEEBcQ0CIAsgGDYC6AIgC0HoAmoiESgCACUBEAlFDQIgCyAYNgLQBSALQdAFaiIKKAIAJQFByAEQCCAKKAIAJQFBMhAFIAooAgAlAUG+gsAAQQIQBiEMQbSVwAAoAgAhDUGwlcAAKAIAIQpBsJXAAEIANwIAIBEgDSAMIApBAUYiChs2AgQgEUECIAxBAEcgChs2AgAgCygC7AIhFyALKALoAiIKQQJGBEAgC0GAgICAeDYC4AQgCyAXNgLkBAwFCyAKQQFxRQRAQcCCwABBDRBnIQogC0GAgICAeDYC4AQgCyAKNgLkBAwFCyALIBc2AugCIAtB6AJqKAIAJQEQCkUEQCALQYCAgIB4NgLgBCALIBc2AuQEDAULIAsgFzYC3AUgC0HcBWoiCkHNgsAAEHggCigCACUBRAAAAAAAAAAARAAAAAAAAAAARAAAAAAAwGJARAAAAAAAAD5AEA0gCigCACUBQdGCwABBFxAMIApB6ILAABB4IAooAgAlAUHsgsAAQQ9EAAAAAAAAJEBEAAAAAAAANEAQDkGwlcAAKAIAIQxBtJXAACgCACENQbCVwABCADcCACALQUBrIgogDTYCBCAKIAxBAUY2AgAgCygCQEEBcQRAIAsoAkQhEQwECyALQegCaiETIwBBEGsiCiQAIApBCGogC0HQBWooAgAlARAHAkBBsJXAACgCAEEBRgRAQbSVwAAoAgAhDEGAgICAeCENDAELIAooAgghDCATIAooAgwiDTYCCAsgEyAMNgIEQbCVwABCADcCACATIA02AgAgCkEQaiQAIAsoAuwCIREgCygC6AIiD0GAgICAeEYNAyALKALwAiEMIAtB6AFqIhRBAEHBAPwLACALQdgBakHwgMAAKQMANwMAIAtB0AFqQeiAwAApAwA3AwAgC0HIAWpB4IDAACkDADcDACALQgA3A+ABIAtB2IDAACkDADcDwAEgESEKIAtBwAFqIRYCQAJAQcAAIBQtAEAiEGsiDSAMTQRAIBBFDQEgDQRAIBAgFGogCiAN/AoAAAsgFiAWKQMgQgF8NwMgIBYgFEEBEBwgCiANaiEKIAwgDWshDAwBCyAMBEAgECAUaiAKIAz8CgAACyAMIBBqIRAMAQsgDEE/cSEQIAxBwABPBEAgFiAWKQMgIAxBBnYiDa18NwMgIBYgCiANEBwLIBBFDQAgFCAKIAxBQHFqIBD8CgAACyAUIBA6AEAgEyAWQfAA/AoAACALQeAEaiEUIwBBQGoiFiQAIAtBkANqIhAtAEAiDCAQaiINQYABOgAAIAytIjRCO4YgEykDICI1QgmGIjMgNEIDhoQiNEKA/gODQiiGhCA0QoCA/AeDQhiGIDRCgICA+A+DQgiGhIQgNUIBhkKAgID4D4MgNUIPiEKAgPwHg4QgNUIfiEKA/gODIDNCOIiEhIQhMwJAAkAgDEE/RwRAIAxBP3MiCgRAIA1BAWpBACAK/AsACyAMQThzQQdLDQELIBMgEEEBEBwgFkEwakIANwMAIBZBKGpCADcDACAWQSBqQgA3AwAgFkEYakIANwMAIBZBEGpCADcDACAWQQhqQgA3AwAgFkIANwMAIBYgMzcDOCATIBZBARAcDAELIBAgMzcAOCATIBBBARAcCyAQQQA6AEAgFCATKAIcIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgAcIBQgEygCGCIKQRh0IApBgP4DcUEIdHIgCkEIdkGA/gNxIApBGHZycjYAGCAUIBMoAhQiCkEYdCAKQYD+A3FBCHRyIApBCHZBgP4DcSAKQRh2cnI2ABQgFCATKAIQIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgAQIBQgEygCDCIKQRh0IApBgP4DcUEIdHIgCkEIdkGA/gNxIApBGHZycjYADCAUIBMoAggiCkEYdCAKQYD+A3FBCHRyIApBCHZBgP4DcSAKQRh2cnI2AAggFCATKAIEIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgAEIBQgEygCACIKQRh0IApBgP4DcUEIdHIgCkEIdkGA/gNxIApBGHZycjYAACAWQUBrJAAgC0GYBmogC0H4BGopAAA3AwAgC0GQBmogC0HwBGopAAA3AwAgC0GIBmogC0HoBGopAAA3AwAgCyALKQDgBDcDgAYgC0H7gsAANgL0AiALIAtBoAZqNgLwAiALQYCAxAA2AugCIAsgC0GABmo2AuwCIBQgExAlIA8EQCARIA8QdgsgF0GECE8EQCAXEDwLIBhBhAhPBEAgGBA8CyASQYQITwRAIBIQPAsgFUGDCE0NCAwHC0EBQRoQYQALQQFBCBBhAAsgC0GAgICAeDYC4AQgCyAYNgLkBAwCCyALQYCAgIB4NgLgBCALIBE2AuQEIBdBhAhJDQAgFxA8CyAYQYQISQ0AIBgQPAsgEkGECEkNACASEDwLIBVBhAhJDQELIBUQPAsgCygC4ARBgICAgHhHDQEgCygC5AQhDAtBCEEBEHkiCkUNAiAKQubg/aqmzty38gA3AAAgC0EINgKoASALIAo2AqQBIAtBCDYCoAEgDEGECEkNASAMEDwMAQsgC0GoAWogC0HoBGooAgA2AgAgCyALKQLgBDcDoAELIAsQFEQAAOD////vQaKc/AM2AqwBIAsgC0GsAWqtQoCAgIAwhDcDgAMgCyALQegAaq1CgICAgCCEIjc3A/gCIAsgC0HgAGqtQoCAgIAghCI1NwPwAiALIDY3A+gCIAtCBDcCzAEgC0EENgLEASALQZiBwAA2AsABIAsgC0HoAmo2AsgBIAtBsAFqIAtBwAFqECQgCygCtAEhDCALKAK4ASENIAtB0ARqIQ9BACEQIwBBEGsiFSQAAkAgDkEDIA4bIh8iFEUEQCAPQQA2AgggD0KAgICAEDcCAAwBCwJAIBStIjNCIIhQBEACQCAzpyIOQQBIDQACQCAORQRAQQEhEQwBC0EBIRAgDkEBEHkiEUUNAQtBACEQIBVBADYCDCAVIBE2AgggFSAONgIEIA5FBEAgFUEEakEAQQEQOCAVKAIMIRAgFSgCCCERCyAQIBFqQdWAwAAtAAA6AAAgEEEBaiEQIBRBAUcEQANAIBAEQCAQIBFqIBEgEPwKAAALIBBBAXQhECAUQQRJIBRBAXYhFEUNAAsLIBUgEDYCDCAOIBBGDQIgDiAQayIKBEAgECARaiARIAr8CgAACyAVIA42AgwMAgsgECAOEGEACyMAQTBrIgAkACAAQRE2AgwgAEGLg8AANgIIIABBATYCFCAAQciQwAA2AhAgAEIBNwIcIAAgAEEIaq1CgICAgPAHhDcDKCAAIABBKGo2AhggAEEQakG8iMAAEFIACyAPIBUpAgQ3AgAgD0EIaiAVQQxqKAIANgIACyAVQRBqJAAgC0GIBWoiGkEAQcEA/AsAIAtB+ARqQfCAwAApAwA3AwAgC0HwBGpB6IDAACkDADcDACALQegEakHggMAAKQMANwMAIAtCADcDgAUgC0HYgMAAKQMANwPgBAJAIA1BwABPBEAgCyANQQZ2IgqtNwOABSALQeAEaiAMIAoQHCANQT9xIgpFBEAgCiENDAILIBogDCANQUBxaiAK/AoAACAKIQ0MAQsgDUUNACAaIAwgDfwKAAALIAsgDToAyAUgC0HgAWoiICALQYAFaiIhKQMANwMAIAtB2AFqIiIgC0H4BGoiIykDADcDACALQdABaiIkIAtB8ARqIiUpAwA3AwAgC0HIAWoiJiALQegEaiInKQMANwMAIAtB8AFqIBpBCGoiKCkDADcDACALQfgBaiAaQRBqIikpAwA3AwAgC0GAAmogGkEYaiIqKQMANwMAIAtBiAJqIBpBIGoiKykDADcDACALQZACaiAaQShqIiwpAwA3AwAgC0GYAmogGkEwaiItKQMANwMAIAtBoAJqIBpBOGoiLikDADcDACALIAspA+AENwPAASALIBopAwA3A+gBIAsgDToAqAIgC0E4akEAIAtB6AJqEC8gCygCPCIOQQBIBEBBAEEAEGEACyALQfwFaiEvIAtBkANqIR0gC0HoAWohGSALKAI4IRIgC0GwBmohMCALQagGaiExIAtBoAZqITIgC0GYBmohEyALQZAGaiEXIAtBiAZqIRhBACEQAkACQANAQQEhCgJAIA5FDQBBASEUIA5BARB5IgoNACAOIQoMBgsgDkUiDUUEQCAKIBIgDvwKAAALAkACQEHAACALLQCoAiIMayIRIA5NBEAgDEUEQCAOIRIgCiEMDAILIBEEQCAMIBlqIAogEfwKAAALIAsgCykD4AFCAXw3A+ABIAtBwAFqIBlBARAcIAogEWohDCAOIBFrIRIMAQsgDUUEQCAMIBlqIAogDvwKAAALIAwgDmohFAwBCyASQT9xIRQgEkHAAE8EQCALIAspA+ABIBJBBnYiDa18NwPgASALQcABaiAMIA0QHAsgFEUNACAZIAwgEkFAcWogFPwKAAALIAsgFDoAqAIgDgRAIAogDhB2CyALQegCaiALQcABakHwAPwKAAAgHSALLQDQAyIMaiINQYABOgAAIAytIjRCO4YgCykDiAMiNkIJhiIzIDRCA4aEIjRCgP4Dg0IohoQgNEKAgPwHg0IYhiA0QoCAgPgPg0IIhoSEIDZCAYZCgICA+A+DIDZCD4hCgID8B4OEIDZCH4hCgP4DgyAzQjiIhISEITMCQAJAIAxBP0cEQCAMQT9zIg4EQCANQQFqQQAgDvwLAAsgDEE4c0EHSw0BCyALQegCaiIOIB1BARAcIDBCADcDACAxQgA3AwAgMkIANwMAIBNCADcDACAXQgA3AwAgGEIANwMAIAtCADcDgAYgCyAzNwO4BiAOIAtBgAZqQQEQHAwBCyALIDM3A8gDIAtB6AJqIB1BARAcCyALIAsoAoQDIg5BGHQgDkGA/gNxQQh0ciAOQQh2QYD+A3EgDkEYdnJyNgL4BSALIAsoAoADIg5BGHQgDkGA/gNxQQh0ciAOQQh2QYD+A3EgDkEYdnJyNgL0BSALIAsoAvwCIg5BGHQgDkGA/gNxQQh0ciAOQQh2QYD+A3EgDkEYdnJyNgLwBSALIAsoAvgCIg5BGHQgDkGA/gNxQQh0ciAOQQh2QYD+A3EgDkEYdnJyNgLsBSALIAsoAvQCIg5BGHQgDkGA/gNxQQh0ciAOQQh2QYD+A3EgDkEYdnJyNgLoBSALIAsoAvACIg5BGHQgDkGA/gNxQQh0ciAOQQh2QYD+A3EgDkEYdnJyNgLkBSALIAsoAuwCIg5BGHQgDkGA/gNxQQh0ciAOQQh2QYD+A3EgDkEYdnJyNgLgBSALIAsoAugCIg5BGHQgDkGA/gNxQQh0ciAOQQh2QYD+A3EgDkEYdnJyNgLcBSALQfuCwAA2AvQCIAsgLzYC8AIgC0GAgMQANgLoAiALIAtB3AVqNgLsAiALQdAFaiALQegCahAlIAsoAtQEIQ8gCygC1AUhDiALKALYBSIMIAsoAtgEIg1PBEAgDyEVIA4hEUEAIRICQCANRQ0AA0AgFS0AACIWIBEtAAAiFEYEQCAVQQFqIRUgEUEBaiERIA1BAWsiDQ0BDAILCyAWIBRrIRILIBJFDQILIBBBwJaxAkcEQCALKALQBSINBEAgDiANEHYLIAstAMgFIQ4gICAhKQMANwMAICIgIykDADcDACAkICUpAwA3AwAgJiAnKQMANwMAIBkgGikDADcDACAZQQhqICgpAwA3AwAgGUEQaiApKQMANwMAIBlBGGogKikDADcDACAZQSBqICspAwA3AwAgGUEoaiAsKQMANwMAIBlBMGogLSkDADcDACAZQThqIC4pAwA3AwAgCyALKQPgBDcDwAEgCyAOOgCoAiALQTBqIBBBAWoiECALQegCahAvQQAhFCALKAIwIRIgCygCNCIOQQBODQEMBgsLQQshDUELQQEQeSIKRQ0BIApBB2pB/4DAACgAADYAACAKQfiAwAApAAA3AABBwZaxAiEQIAsoAtAFIgwEQCAOIAwQdgsgCiEOQQshDAwDCyALKALQBSENDAILQQFBCxBhAAtBAUEIEGEACyALKALQBCIKBEAgDyAKEHYLIAsgEDYCvAEgCyAMNgLYBSALIA42AtQFIAsgDTYC0AUgCyALQYABaq1CgICAgCCENwOYAyALIAtBvAFqrUKAgICAMIQ3A5ADIAsgC0HQBWqtQoCAgIAQhDcDiAMgCyALQaABaq1CgICAgBCENwOAAyALIAtB8ABqrUKAgICAIIQ3A/gCIAsgNzcD8AIgCyA1NwPoAiALQgc3AswBIAtBBzYCxAEgC0G4gcAANgLAASALIAtB6AJqIhE2AsgBIAtB3AVqIAtBwAFqECQgCygCmAEhDiALKAKcASENIwBB4AJrIg8kACAPQThqQgA3AwAgD0EwakIANwMAIA9BKGpCADcDACAPQSBqQgA3AwAgD0EYakIANwMAIA9BEGpCADcDACAPQQhqQgA3AwAgD0IANwMAAkAgDUHBAE8EQCAPQaABakIANwMAIA9BmAFqQgA3AwAgD0GQAWpCADcDACAPQYgBakIANwMAIA9BgAFqQgA3AwAgD0H4AGpCADcDACAPQfAAakIANwMAIA9BADoAqAEgD0HIAGpB2IjAACkDADcDACAPQdAAakHgiMAAKQMANwMAIA9B2ABqQeiIwAApAwA3AwAgD0IANwNoIA9B0IjAACkDADcDQCAPIA1BBnYiCq03A2AgD0FAayAOIAoQHCANQT9xIgoEQCAPQegAaiAOIA1BQHFqIAr8CgAACyAPIAo6AKgBIA9BsAFqIA9BQGtB8AD8CgAAIA9B2AFqIg0gDy0AmAIiDGoiDkGAAToAACAMrSI0QjuGIA8pA9ABIjVCCYYiMyA0QgOGhCI0QoD+A4NCKIaEIDRCgID8B4NCGIYgNEKAgID4D4NCCIaEhCA1QgGGQoCAgPgPgyA1Qg+IQoCA/AeDhCA1Qh+IQoD+A4MgM0I4iISEhCEzAkACQCAMQT9HBEAgDEE/cyIKBEAgDkEBakEAIAr8CwALIAxBOHNBB0sNAQsgD0GwAWoiCiANQQEQHCAPQdACakIANwMAIA9ByAJqQgA3AwAgD0HAAmpCADcDACAPQbgCakIANwMAIA9BsAJqQgA3AwAgD0GoAmpCADcDACAPQgA3A6ACIA8gMzcD2AIgCiAPQaACakEBEBwMAQsgDyAzNwOQAiAPQbABaiANQQEQHAsgDyAPKALMASIKQRh0IApBgP4DcUEIdHIgCkEIdkGA/gNxIApBGHZycjYCHCAPIA8oAsgBIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgIYIA8gDygCxAEiCkEYdCAKQYD+A3FBCHRyIApBCHZBgP4DcSAKQRh2cnI2AhQgDyAPKALAASIKQRh0IApBgP4DcUEIdHIgCkEIdkGA/gNxIApBGHZycjYCECAPIA8oArwBIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgIMIA8gDygCuAEiCkEYdCAKQYD+A3FBCHRyIApBCHZBgP4DcSAKQRh2cnI2AgggDyAPKAK0ASIKQRh0IApBgP4DcUEIdHIgCkEIdkGA/gNxIApBGHZycjYCBCAPIA8oArABIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgIADAELIA1FDQAgDyAOIA38CgAACyARIA8pAwA3AAAgEUE4aiAPQThqKQMANwAAIBFBMGogD0EwaikDADcAACARQShqIA9BKGopAwA3AAAgEUEgaiAPQSBqKQMANwAAIBFBGGogD0EYaikDADcAACARQRBqIA9BEGopAwA3AAAgEUEIaiAPQQhqKQMANwAAIA9B4AJqJABBACENA0AgC0HoAmoiDiANaiIMIAwtAABBNnM6AAAgDEEBaiIKIAotAABBNnM6AAAgDEECaiIKIAotAABBNnM6AAAgDEEDaiIKIAotAABBNnM6AAAgDUEEaiINQcAARw0AC0EAIQ0gC0H4BGpB8IDAACkDADcDACALQfAEakHogMAAKQMANwMAIAtB6ARqQeCAwAApAwA3AwAgC0IBNwOABSALQdiAwAApAwA3A+AEIAtB4ARqIA5BARAcA0AgC0HoAmoiDyANaiIOIA4tAABB6gBzOgAAIA5BAWoiCiAKLQAAQeoAczoAACAOQQJqIgogCi0AAEHqAHM6AAAgDkEDaiIKIAotAABB6gBzOgAAIA1BBGoiDUHAAEcNAAsgC0HYAWoiEUHwgMAAKQMANwMAIAtB0AFqIgxB6IDAACkDADcDACALQcgBaiINQeCAwAApAwA3AwAgC0HgAWoiDkIBNwMAIAtB2IDAACkDADcDwAEgC0HAAWoiCiAPQQEQHCALQcgEaiAOKQMANwMAIAtBwARqIBEpAwA3AwAgC0G4BGogDCkDADcDACALQbAEaiANKQMANwMAIAtBiARqIAtB6ARqKQMANwMAIAtBkARqIAtB8ARqKQMANwMAIAtBmARqIAtB+ARqKQMANwMAIAtBoARqIAtBgAVqKQMANwMAIAsgCykDwAE3A6gEIAsgCykD4AQ3A4AEIAtBuANqQQBBwQD8CwAgDyALQYAEakHQAPwKAAAgCiAPQZgB/AoAACALQZACaiERIAsoAuAFIQ4CQAJAIAsoAuQFIgxBwAAgCy0A0AIiDWsiCk8EQCANRQ0BIAoEQCANIBFqIA4gCvwKAAALIAsgCykD4AFCAXw3A+ABIAtBwAFqIBFBARAcIAogDmohDiAMIAprIQwMAQsgDARAIA0gEWogDiAM/AoAAAsgDCANaiENDAELIAxBP3EhDSAMQcAATwRAIAsgCykD4AEgDEEGdiIKrXw3A+ABIAtBwAFqIA4gChAcCyANRQ0AIBEgDiAMQUBxaiAN/AoAAAsgCyANOgDQAiALQegCaiALQcABakGYAfwKAAAgC0G4A2oiDSALLQD4AyIMaiIOQYABOgAAIAytIjRCO4YgCykDiAMiNUIJhiIzIDRCA4aEIjRCgP4Dg0IohoQgNEKAgPwHg0IYhiA0QoCAgPgPg0IIhoSEIDVCAYZCgICA+A+DIDVCD4hCgID8B4OEIDVCH4hCgP4DgyAzQjiIhISEITMCQAJAIAxBP0cEQCAMQT9zIgoEQCAOQQFqQQAgCvwLAAsgDEE4c0EHSw0BCyALQegCaiIKIA1BARAcIAtBkAVqQgA3AwAgC0GIBWpCADcDACALQYAFakIANwMAIAtB+ARqQgA3AwAgC0HwBGpCADcDACALQegEakIANwMAIAtCADcD4AQgCyAzNwOYBSAKIAtB4ARqQQEQHAwBCyALIDM3A/ADIAtB6AJqIA1BARAcCyALIAsoAoQDIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgLUAyALIAsoAoADIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgLQAyALIAsoAvwCIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgLMAyALIAsoAvgCIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgLIAyALIAsoAvQCIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgLEAyALIAsoAvACIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgLAAyALIAsoAuwCIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgK8AyALIAsoAugCIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgK4AyALQSA6APgDIAspA7ADITUgC0HhA2pCADcAACALQYABOgDYAyALQegDakIANwAAIAtCADcA2QMgCyA1QgmGIjNCgAKEIjRCgP4Dg0IohiA0QoCA/AeDQhiGIDRCgICA+A+DQgiGhIQgNUIBhkKAgID4D4MgNUIPiEKAgPwHg4QgNUIfiEKA/gODIDNCOIiEhIQ3A/ADQQEhDiALQZADaiANQQEQHCALIAsoAqwDIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgL8BCALIAsoAqgDIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgL4BCALIAsoAqQDIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgL0BCALIAsoAqADIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgLwBCALIAsoApwDIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgLsBCALIAsoApgDIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgLoBCALIAsoApQDIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgLkBCALIAsoApADIgpBGHQgCkGA/gNxQQh0ciAKQQh2QYD+A3EgCkEYdnJyNgLgBCALQfuCwAA2AvQCIAsgC0GABWo2AvACIAtBgIDEADYC6AIgCyALQeAEajYC7AIgC0HcAmogC0HoAmoQJSALQYgGaiALQagBaigCADYCACALIAspA6ABNwOABkEAIQ0CfwJAAkACQCALKAKEASIXQQBIDQAgCygCgAEhCiAXBEBBASENIBdBARB5Ig5FDQELIBcEQCAOIAogF/wKAAALIAtB6ARqIgwgC0HYBWooAgA2AgAgCyALKQLQBTcD4AQgCygCrAEhDSALKAK8ASEKQQNBARB5IhhFDQEgGEECakHygcAALQAAOgAAIBhB8IHAAC8AADsAACALQfACaiALQeQCaigCADYCACALQfwCaiALQYgGaigCADYCACALQZQDaiAMKAIANgIAIAsgCykC3AI3A+gCIAsgCykDgAY3AvQCIAsgFzYCiAMgCyAONgKEAyALIBc2AoADIAsgCykD4AQ3AowDIAtBAzYCrAMgCyAYNgKoAyALQQM2AqQDIAsgHzYCoAMgCyAKNgKcAyALIA02ApgDIAtBADYC0AQgC0HgBGogC0HQBGoQcCALKALkBCEKAkAgCygC4AQiDUUEQCAKIRAMAQsgCyAKNgKEBiALIA02AoAGIAtBKGogCygC7AIgCygC8AIQaCALKAIsIRACQCALKAIoQQFxDQAgC0GEBmoiFkG0gMAAQQkQISAQEHcgC0EgaiALKAL4AiALKAL8AhBoIAsoAiQhECALKAIgQQFxDQAgFkG9gMAAQQsQISAQEHcgC0EYaiAOIBcQaCALKAIcIRAgCygCGEEBcQ0AIBZByIDAAEEJECEgEBB3IAtBEGohEiALQYwDaiERIwBBMGsiEyQAIBNBKGogDRBwIBMoAiwhDQJAIBMoAigiEEUEQEEBIRUMAQsgEyANNgIsIBMgEDYCKCATQSBqIBEoAgwQaUEBIRUgEygCJCEMAkAgEygCIEEBcQ0AIBNBLGoiFEGKgMAAQQoQISAMEHcgE0EYaiARKAIQEGkgEygCHCEMIBMoAhhBAXENACAUQZSAwABBBRAhIAwQdyATQRBqIBEoAgQgESgCCBBoIBMoAhQhDCATKAIQQQFxDQAgFEGZgMAAQQQQISAMEHcgETUCFCEzIwBBMGsiDyQAIA8gMzcDCCATQQhqIhECfyAQLQACRQRAIDO6EGsMAQsgMxAbITgQKCIMIDgmASAMCzYCBCARQQA2AgAgD0EwaiQAIBMoAgwhDCATKAIIQQFxDQAgFEGdgMAAQQoQISAMEHdBACEVDAELIA1BhAhJBEAgDCENDAELIA0QPCAMIQ0LIBIgDTYCBCASIBU2AgAgE0EwaiQAIAsoAhQhECALKAIQQQFxDQAgFkHRgMAAQQMQISAQEHcgC0EIaiAYQQMQaCALKAIMIRAgCygCCEEBcUUNBAsgCkGECEkNACAKEDwLIAsgEDYC/AUgCyALQfwFaq1CgICAgMAAhDcD0AQgC0IBNwLsBCALQQE2AuQEIAtBkILAADYC4AQgCyALQdAEajYC6AQgC0GABmogC0HgBGoQJCALKAKEBiIMIAsoAogGEIIBIQogCygCgAYiDQRAIAwgDRB2CyALKAL8BSINQYQITwRAIA0QPAtBAQwDCyANIBcQYQALQQFBAxBhAAsgFkHUgMAAQQEQISAQEHdBAAshDCALKALoAiINBEAgCygC7AIgDRB2CyALKAL0AiINBEAgCygC+AIgDRB2CyAXBEAgDiAXEHYLIAsoAowDIg4EQCALKAKQAyAOEHYLIBhBAxB2IAsoAtwFIg4EQCALKALgBSAOEHYLIAsoArABIg4EQCALKAK0ASAOEHYLIAsoApQBIg4EQCALKAKYASAOEHYLIAsoAogBIg4EQCALKAKMASAOEHYLIB4gCjYCBCAeIAw2AgAgC0HABmokAAwBCyAUIAoQYQALIBwoAgwhDiAcKAIIIQogCQRAIAggCRB2CyAHBEAgBiAHEHYLIAUEQCAEIAUQdgsgAwRAIAIgAxB2CyABBEAgACABEHYLIBsgCjYCCCAbIA5BACAKQQFxIgAbNgIEIBtBACAOIAAbNgIAIBxBEGokACAbKAIAIBsoAgQgGygCCCAbQRBqJAALOAACQCACQYCAxABGDQAgACACIAEoAhARAABFDQBBAQ8LIANFBEBBAA8LIAAgA0EAIAEoAgwRAgALIgACQCAAIAEQYkUNACAABEAgACABEHkiAUUNAQsgAQ8LAAv6AQICfwF+IwBBEGsiAiQAIAJBATsBDCACIAE2AgggAiAANgIEIwBBEGsiASQAIAJBBGoiACkCACEEIAEgADYCDCABIAQ3AgQjAEEQayIAJAAgAUEEaiIBKAIAIgIoAgwhAwJAAkACQAJAIAIoAgQOAgABAgsgAw0BQQEhAkEAIQMMAgsgAw0AIAIoAgAiAigCBCEDIAIoAgAhAgwBCyAAQYCAgIB4NgIAIAAgATYCDCAAQZSNwAAgASgCBCABKAIIIgAtAAggAC0ACRAyAAsgACADNgIEIAAgAjYCACAAQfiMwAAgASgCBCABKAIIIgAtAAggAC0ACRAyAAsfAAJAIAEgAxBiBEAgACABIAMgAhBvIgANAQsACyAACx0AIABFBEAQegALIAAgAiADIAQgBSABKAIQERQACxsAIABFBEAQegALIAAgAiADIAQgASgCEBEJAAsbACAARQRAEHoACyAAIAIgAyAEIAEoAhARJAALGwAgAEUEQBB6AAsgACACIAMgBCABKAIQEQYACxsAIABFBEAQegALIAAgAiADIAQgASgCEBElAAsbACAARQRAEHoACyAAIAIgAyAEIAEoAhARJgALJQEBfyAAKAIAIgFBgICAgHhyQYCAgIB4RwRAIAAoAgQgARB2CwsZACAARQRAEHoACyAAIAIgAyABKAIQEQQACxkAIABFBEAQegALIAAgAiADIAEoAhARAgALFwAgAEUEQBB6AAsgACACIAEoAhARAAALFwEBfyAAKAIAIgEEQCAAKAIEIAEQdgsLHwAgAEEIakGEjMAAKQIANwIAIABB/IvAACkCADcCAAsfACAAQQhqQZSMwAApAgA3AgAgAEGMjMAAKQIANwIAC0MAIAAEQCAAIAEQfwALIwBBIGsiACQAIABBADYCGCAAQQE2AgwgAEGYkMAANgIIIABCBDcCECAAQQhqQaCQwAAQUgALFQAgAWlBAUYgAEGAgICAeCABa01xCxcBAX8gABAQIgE2AgQgACABQQBHNgIACxcBAX8gABARIgE2AgQgACABQQBHNgIACxcBAX8gABASIgE2AgQgACABQQBHNgIACxcBAX8gABATIgE2AgQgACABQQBHNgIACxYBAW8gACABEBohAhAoIgAgAiYBIAALFAAgACABIAIQZzYCBCAAQQA2AgALEwAgACABuBBrNgIEIABBADYCAAsWACAAKAIAIAEgAiAAKAIEKAIMEQIACxYCAW8BfyAAEBkhARAoIgIgASYBIAILFAAgACgCACABIAAoAgQoAgwRAAALEQAgACgCBCAAKAIIIAEQgAELEQAgACgCACAAKAIEIAEQgAEL3wYBBX8CfwJAAkACQAJAAkACQAJAIABBBGsiBygCACIIQXhxIgRBBEEIIAhBA3EiBRsgAWpPBEAgBUEAIAFBJ2oiBiAESRsNAQJAIAJBCU8EQCACIAMQJiICDQFBAAwKC0EAIQIgA0HM/3tLDQhBECADQQtqQXhxIANBC0kbIQEgAEEIayEGIAVFBEAgBkUgAUGAAklyIAQgAWtBgIAISyABIARPcnINByAADAoLIAQgBmohBQJAIAEgBEsEQCAFQeSYwAAoAgBGDQFB4JjAACgCACAFRwRAIAUoAgQiCEECcQ0JIAhBeHEiCCAEaiIEIAFJDQkgBSAIECcgBCABayIFQRBPBEAgByABIAcoAgBBAXFyQQJyNgIAIAEgBmoiASAFQQNyNgIEIAQgBmoiBCAEKAIEQQFyNgIEIAEgBRAjDAkLIAcgBCAHKAIAQQFxckECcjYCACAEIAZqIgEgASgCBEEBcjYCBAwIC0HYmMAAKAIAIARqIgQgAUkNCAJAIAQgAWsiBUEPTQRAIAcgCEEBcSAEckECcjYCACAEIAZqIgEgASgCBEEBcjYCBEEAIQVBACEBDAELIAcgASAIQQFxckECcjYCACABIAZqIgEgBUEBcjYCBCAEIAZqIgQgBTYCACAEIAQoAgRBfnE2AgQLQeCYwAAgATYCAEHYmMAAIAU2AgAMBwsgBCABayIEQQ9NDQYgByABIAhBAXFyQQJyNgIAIAEgBmoiASAEQQNyNgIEIAUgBSgCBEEBcjYCBCABIAQQIwwGC0HcmMAAKAIAIARqIgQgAUsNBAwGCyADIAEgASADSxsiAwRAIAIgACAD/AoAAAsgBygCACIDQXhxIgcgAUEEQQggA0EDcSIDG2pJDQIgA0UgBiAHT3INBkGAjsAAQbCOwAAQTgALQcCNwABB8I3AABBOAAtBgI7AAEGwjsAAEE4AC0HAjcAAQfCNwAAQTgALIAcgASAIQQFxckECcjYCACABIAZqIgUgBCABayIBQQFyNgIEQdyYwAAgATYCAEHkmMAAIAU2AgALIAZFDQAgAAwDCyADEB0iAUUNASADQXxBeCAHKAIAIgJBA3EbIAJBeHFqIgIgAiADSxsiAgRAIAEgACAC/AoAAAsgASECCyAAEB8LIAILCyACAW8BfxAPIQIQKCIDIAImASAAIAM2AgQgACABNgIACxYAQbSVwAAgADYCAEGwlcAAQQE2AgALEAAgASAAKAIAIAAoAgQQagsTACAAQbCNwAA2AgQgACABNgIACxAAIAEgACgCACAAKAIEECALEAEBfxAoIgEgACUBJgEgAQtbAQJ/AkACQCAAQQRrKAIAIgJBeHEiA0EEQQggAkEDcSICGyABak8EQCACQQAgAyABQSdqSxsNASAAEB8MAgtBwI3AAEHwjcAAEE4AC0GAjsAAQbCOwAAQTgALCx0BAW8gACgCACUBIAElASABEDwgAiUBIAIQPBAACw8AIAAoAgAlASABQQQQCwsZAAJ/IAFBCU8EQCABIAAQJgwBCyAAEB0LCwwAQaiLwABBMhAVAAsNACAAQeCMwAAgARAiCwwAIAAgASkCADcDAAsNACAAQbCQwAAgARAiCw0AIAFBhI/AAEEFEGoLGQAgACABQfyYwAAoAgAiAEEpIAAbEQEAAAsKACACIAAgARAgCw0AIAFBrJTAAEEYECALFgEBbyAAIAEQFiECECgiACACJgEgAAsJACAAQQA2AgALTAEBfyMAQTBrIgEkACABQQE2AgwgAUHIkMAANgIIIAFCATcCFCABIAFBL2qtQoCAgIDgB4Q3AyAgASABQSBqNgIQIAFBCGogABBSAAsLyxQHAEGAgMAAC4sJUG93UGF5bG9hZHNlZWRfbm9uY2Vub25jZWhhc2hkaWZmaWN1bHR5U2VjdXJlUGF5bG9hZHNpZ25hdHVyZWZpbmdlcnByaW50Y2xpZW50X2lwcG93djAAAGfmCWqFrme7cvNuPDr1T6V/Ug5RjGgFm6vZgx8ZzeBbcG93X3RpbWVvdXRfAQAAAAAAAACDABAAAQAAADoAAAABAAAAAAAAAJQAEAABAAAAlAAQAAEAAACUABAAAQAAAAEAAAAAAAAAlAAQAAEAAACUABAAAQAAAJQAEAABAAAAlAAQAAEAAACUABAAAQAAAJQAEAABAAAAMy4wc2VyaWFsaXplIHBheWxvYWQgZmFpbGVkOiAAAADzABAAGgAAAE5vIHdpbmRvdyBmb3VuZE5vIGRvY3VtZW50IGZvdW5kY2FudmFzMmRObyAyRCBjb250ZXh0I2Y2MGJvbGQgMTJweCAnQ291cmllciBOZXcnIzA2OUNoYXROZXh0X1NlY3VyZTAxMjM0NTY3ODlhYmNkZWZjYXBhY2l0eSBvdmVyZmxvd2xpYnJhcnkvYWxsb2Mvc3JjL2ZtdC5ycwAvcnVzdGMvZGVkNWMwNmNmMjFkMmI5M2JmZmQ1ZDg4NGFhNmU5NjkzNGVlNDIzNC9saWJyYXJ5L3N0ZC9zcmMvc3lzL3RocmVhZF9sb2NhbC9ub190aHJlYWRzLnJzAEM6XFVzZXJzXEFkbWluaXN0cmF0b3JcLmNhcmdvXHJlZ2lzdHJ5XHNyY1xpbmRleC5jcmF0ZXMuaW8tMTk0OWNmOGM2YjViNTU3Zlx3YXNtLWJpbmRnZW4tMC4yLjExNFxzcmNcZXh0ZXJucmVmLnJzAC9ydXN0Yy9kZWQ1YzA2Y2YyMWQyYjkzYmZmZDVkODg0YWE2ZTk2OTM0ZWU0MjM0L2xpYnJhcnkvYWxsb2Mvc3JjL3NsaWNlLnJzAC9ydXN0L2RlcHMvaGFzaGJyb3duLTAuMTUuNS9zcmMvcmF3L21vZC5ycwBsaWJyYXJ5L2FsbG9jL3NyYy9yYXdfdmVjL21vZC5ycwAvcnVzdC9kZXBzL2RsbWFsbG9jLTAuMi4xMC9zcmMvZGxtYWxsb2MucnMAbGlicmFyeS9zdGQvc3JjL2FsbG9jLnJzAEM6XFVzZXJzXEFkbWluaXN0cmF0b3JcLmNhcmdvXHJlZ2lzdHJ5XHNyY1xpbmRleC5jcmF0ZXMuaW8tMTk0OWNmOGM2YjViNTU3ZlxzZXJkZS13YXNtLWJpbmRnZW4tMC42LjVcc3JjXGxpYi5ycwBDOlxVc2Vyc1xBZG1pbmlzdHJhdG9yXC5jYXJnb1xyZWdpc3RyeVxzcmNcaW5kZXguY3JhdGVzLmlvLTE5NDljZjhjNmI1YjU1N2Zcb25jZV9jZWxsLTEuMjEuNFxzcmNcbGliLnJzAAAAAIYCEABKAAAABwIAADIAAAAAAAAAZ+YJaoWuZ7ty8248OvVPpX9SDlGMaAWbq9mDHxnN4FthAxAAbwAAADUAAAAOAAAA//////////+ABBAAQZiJwAAL8QVBdHRlbXB0ZWQgdG8gaW5pdGlhbGl6ZSB0aHJlYWQtbG9jYWwgd2hpbGUgaXQgaXMgYmVpbmcgZHJvcHBlZAAAmAQQAD4AAAC1ARAAXgAAAGsAAAANAAAAIGNhbid0IGJlIHJlcHJlc2VudGVkIGFzIGEgSmF2YVNjcmlwdCBudW1iZXIBAAAAAAAAAPAEEAAsAAAARAoQAFAKEABcChAAaAoQAExhenkgaW5zdGFuY2UgaGFzIHByZXZpb3VzbHkgYmVlbiBwb2lzb25lZAAAPAUQACoAAADRAxAAZwAAABIDAAAZAAAAcmVlbnRyYW50IGluaXQAAIAFEAAOAAAA0QMQAGcAAACEAgAADQAAAGNsb3N1cmUgaW52b2tlZCByZWN1cnNpdmVseSBvciBhZnRlciBiZWluZyBkcm9wcGVkAAAUAhAAcQAAAH8AAAARAAAAFAIQAHEAAACMAAAAEQAAAHz9izJX5lf5At9Ev+NI569tXcvWLFDrY3hBpldxG4u5bWVtb3J5IGFsbG9jYXRpb24gb2YgIGJ5dGVzIGZhaWxlZAAAHAYQABUAAAAxBhAADQAAAEgDEAAYAAAAZAEAAAkAAAAqAAAADAAAAAQAAAArAAAALAAAAC0AAAAAAAAACAAAAAQAAAAuAAAALwAAADAAAAAxAAAAMgAAABAAAAAEAAAAMwAAADQAAAA1AAAANgAAAAAAAAAIAAAABAAAADcAAABhc3NlcnRpb24gZmFpbGVkOiBwc2l6ZSA+PSBzaXplICsgbWluX292ZXJoZWFkAAAdAxAAKgAAALEEAAAJAAAAYXNzZXJ0aW9uIGZhaWxlZDogcHNpemUgPD0gc2l6ZSArIG1heF9vdmVyaGVhZAAAHQMQACoAAAC3BAAADQAAACoAAAAMAAAABAAAADgAAABIYXNoIHRhYmxlIGNhcGFjaXR5IG92ZXJmbG93UAcQABwAAADRAhAAKgAAACUAAAAoAAAARXJyb3IAQZSPwAALhgQBAAAAOQAAAGEgZm9ybWF0dGluZyB0cmFpdCBpbXBsZW1lbnRhdGlvbiByZXR1cm5lZCBhbiBlcnJvciB3aGVuIHRoZSB1bmRlcmx5aW5nIHN0cmVhbSBkaWQgbm90AACcARAAGAAAAIoCAAAOAAAAY2FwYWNpdHkgb3ZlcmZsb3cAAAAECBAAEQAAAPwCEAAgAAAAHAAAAAUAAAA6AAAADAAAAAQAAAA7AAAAPAAAAD0AAAABAAAAAAAAADAwMDEwMjAzMDQwNTA2MDcwODA5MTAxMTEyMTMxNDE1MTYxNzE4MTkyMDIxMjIyMzI0MjUyNjI3MjgyOTMwMzEzMjMzMzQzNTM2MzczODM5NDA0MTQyNDM0NDQ1NDY0NzQ4NDk1MDUxNTI1MzU0NTU1NjU3NTg1OTYwNjE2MjYzNjQ2NTY2Njc2ODY5NzA3MTcyNzM3NDc1NzY3Nzc4Nzk4MDgxODI4Mzg0ODU4Njg3ODg4OTkwOTE5MjkzOTQ5NTk2OTc5ODk5OiABAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQBB3JPAAAszAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAwMDAwMDAwMDAwMDAwMDAwQEBAQEAEGclMAACygBAAAAAAAAABgJEAACAAAAUmVmQ2VsbCBhbHJlYWR5IGJvcnJvd2VkAEHElMAACzECAAAAAAAAACAAAAACAAAAAAAAACEAAAACAAAAAAAAACIAAAACAAAAAAAAACMAAAAkAEGAlcAACwEEAHwJcHJvZHVjZXJzAghsYW5ndWFnZQEEUnVzdAAMcHJvY2Vzc2VkLWJ5AwVydXN0Yx0xLjkyLjAgKGRlZDVjMDZjZiAyMDI1LTEyLTA4KQZ3YWxydXMGMC4yNS4yDHdhc20tYmluZGdlbhMwLjIuMTE0ICgyMmNmZDU1NjgpAGsPdGFyZ2V0X2ZlYXR1cmVzBisPbXV0YWJsZS1nbG9iYWxzKxNub250cmFwcGluZy1mcHRvaW50KwtidWxrLW1lbW9yeSsIc2lnbi1leHQrD3JlZmVyZW5jZS10eXBlcysKbXVsdGl2YWx1ZQ=="; + +// --- wasm-bindgen wrapper starts here --- +/* @ts-self-types="./wasm_signer.d.ts" */ + +/** + * @param {string} username + * @param {string} timestamp + * @param {string} nonce_js + * @param {string} challenge + * @param {string} client_ip + * @param {number} difficulty + * @returns {any} + */ +function generate_secure_payload(username, timestamp, nonce_js, challenge, client_ip, difficulty) { + const ptr0 = passStringToWasm0(username, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(timestamp, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ptr2 = passStringToWasm0(nonce_js, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len2 = WASM_VECTOR_LEN; + const ptr3 = passStringToWasm0(challenge, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len3 = WASM_VECTOR_LEN; + const ptr4 = passStringToWasm0(client_ip, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len4 = WASM_VECTOR_LEN; + const ret = wasm.generate_secure_payload(ptr0, len0, ptr1, len1, ptr2, len2, ptr3, len3, ptr4, len4, difficulty); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg_Error_83742b46f01ce22d: function(arg0, arg1) { + const ret = Error(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg_String_8564e559799eccda: function(arg0, arg1) { + const ret = String(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_is_undefined_52709e72fb9f179c: function(arg0) { + const ret = arg0 === undefined; + return ret; + }, + __wbg___wbindgen_throw_6ddd609b62940d55: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg_createElement_9b0aab265c549ded: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.createElement(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments); }, + __wbg_document_c0320cd4183c6d9b: function(arg0) { + const ret = arg0.document; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_fillRect_4e5596ca954226e7: function(arg0, arg1, arg2, arg3, arg4) { + arg0.fillRect(arg1, arg2, arg3, arg4); + }, + __wbg_fillText_b1722b6179692b85: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.fillText(getStringFromWasm0(arg1, arg2), arg3, arg4); + }, arguments); }, + __wbg_getContext_f04bf8f22dcb2d53: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.getContext(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_instanceof_CanvasRenderingContext2d_08b9d193c22fa886: function(arg0) { + let result; + try { + result = arg0 instanceof CanvasRenderingContext2D; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_HtmlCanvasElement_26125339f936be50: function(arg0) { + let result; + try { + result = arg0 instanceof HTMLCanvasElement; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Window_23e677d2c6843922: function(arg0) { + let result; + try { + result = arg0 instanceof Window; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_new_ab79df5bd7c26067: function() { + const ret = new Object(); + return ret; + }, + __wbg_random_5bb86cae65a45bf6: function() { + const ret = Math.random(); + return ret; + }, + __wbg_set_6be42768c690e380: function(arg0, arg1, arg2) { + arg0[arg1] = arg2; + }, + __wbg_set_fillStyle_58417b6b548ae475: function(arg0, arg1, arg2) { + arg0.fillStyle = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_font_b038797b3573ae5e: function(arg0, arg1, arg2) { + arg0.font = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_height_b6548a01bdcb689a: function(arg0, arg1) { + arg0.height = arg1 >>> 0; + }, + __wbg_set_width_c0fcaa2da53cd540: function(arg0, arg1) { + arg0.width = arg1 >>> 0; + }, + __wbg_static_accessor_GLOBAL_8adb955bd33fac2f: function() { + const ret = typeof global === 'undefined' ? null : global; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_GLOBAL_THIS_ad356e0db91c7913: function() { + const ret = typeof globalThis === 'undefined' ? null : globalThis; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_SELF_f207c857566db248: function() { + const ret = typeof self === 'undefined' ? null : self; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_WINDOW_bb9f1ba69d61b386: function() { + const ret = typeof window === 'undefined' ? null : window; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_toDataURL_bf99d85b39ce57cc: function() { return handleError(function (arg0, arg1) { + const ret = arg1.toDataURL(); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments); }, + __wbindgen_cast_0000000000000001: function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_cast_0000000000000003: function(arg0) { + // Cast intrinsic for `U64 -> Externref`. + const ret = BigInt.asUintN(64, arg0); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./wasm_signer_bg.js": import0, + }; +} + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + ptr = ptr >>> 0; + return decodeText(ptr, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasm; +function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('wasm_signer_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + + + +// --- wasm-bindgen wrapper ends here --- + +// Exported Initialization Helper +let wasmInitialized = false; +export async function initTinyCmsWasm() { + if (wasmInitialized) return; + // Install the DOM shims the wasm-bindgen glue expects before instantiating + // the module (see setupDomMocks() above). Left installed for the process + // lifetime — generateSecurePayload() keeps calling into the same canvas + // shims on every invocation, not just at init. + setupDomMocks(); + const wasmBuffer = Buffer.from(WASM_BASE64, 'base64'); + await __wbg_init(wasmBuffer); + wasmInitialized = true; +} + +// Add type bindings +export interface PowPayload { + seed_nonce: number; + nonce: number; + hash: string; + difficulty: number; +} + +export interface SecurePayload { + signature: string; + fingerprint: string; + client_ip: string; + pow: PowPayload; + v: string; +} + +// Export wrapper function typed +export function generateSecurePayload( + username: string, + timestamp: string, + nonce_js: string, + challenge: string, + client_ip: string, + difficulty: number +): SecurePayload { + return generate_secure_payload(username, timestamp, nonce_js, challenge, client_ip, difficulty) as SecurePayload; +} diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index b931f6d543..912c81e27f 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -228,6 +228,35 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) { return audioStreamResponse(res); } +/** + * Handle Soniox TTS (OpenAI speech shape → Soniox /tts, returns raw audio bytes) + */ +async function handleSonioxSpeech(providerConfig, body, modelId, token) { + const fmt = typeof body.response_format === "string" ? body.response_format : "mp3"; + const audioFormat = fmt === "pcm" ? "pcm_s16le" : fmt; + + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...buildAuthHeaders(providerConfig, token), + }, + body: JSON.stringify({ + text: body.input, + model: modelId, + ...(body.voice ? { voice: body.voice } : {}), + audio_format: audioFormat, + }), + }); + + if (!res.ok) { + return upstreamErrorResponse(res, await res.text()); + } + + const contentType = fmt === "wav" ? "audio/wav" : fmt === "opus" ? "audio/opus" : "audio/mpeg"; + return audioStreamResponse(res, contentType); +} + /** * Handle ElevenLabs TTS * POST {baseUrl}/{voice_id} with { text, model_id } @@ -846,6 +875,10 @@ export async function handleAudioSpeech({ return handleDeepgramSpeech(providerConfig, body, modelId, token); } + if (providerConfig.format === "soniox-tts") { + return handleSonioxSpeech(providerConfig, body, modelId, token); + } + if (providerConfig.format === "elevenlabs") { return handleElevenLabsSpeech(providerConfig, body, modelId, token); } diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index aea16a0377..9fe9d2277e 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -336,6 +336,78 @@ async function handleGladiaTranscription(providerConfig, file, modelId, token) { return errorResponse(504, "Gladia transcription timed out after 120s"); } +/** + * Handle Soniox transcription (async: upload file → create job → poll → get transcript) + */ +async function handleSonioxTranscription(providerConfig, file, modelId, token) { + const authHeaders = buildAuthHeaders(providerConfig, token); + + const { body: uploadBody, contentType: uploadContentType } = await buildMultipartBody(file, {}); + const uploadRes = await fetch("https://api.soniox.com/v1/files", { + method: "POST", + headers: { ...authHeaders, "Content-Type": uploadContentType }, + body: uploadBody, + }); + if (!uploadRes.ok) { + return upstreamErrorResponse(uploadRes, await uploadRes.text()); + } + const fileId = (await uploadRes.json()).id; + + const createRes = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ + model: modelId, + file_id: fileId, + enable_language_identification: true, + }), + }); + if (!createRes.ok) { + return upstreamErrorResponse(createRes, await createRes.text()); + } + const { id: transcriptionId } = await createRes.json(); + + const statusUrl = `${providerConfig.baseUrl}/${transcriptionId}`; + const maxWait = 120_000; + const start = Date.now(); + let completed = false; + while (Date.now() - start < maxWait) { + await new Promise((r) => setTimeout(r, 2000)); + const pollRes = await fetch(statusUrl, { headers: authHeaders }); + if (!pollRes.ok) { + continue; + } + const result = await pollRes.json(); + if (result.status === "completed") { + completed = true; + break; + } + if (result.status === "error") { + return errorResponse( + 500, + result.error_message || result.error || "Soniox transcription failed" + ); + } + } + if (!completed) { + return errorResponse(504, "Soniox transcription timed out after 120s"); + } + + const transcriptRes = await fetch(`${statusUrl}/transcript`, { headers: authHeaders }); + if (!transcriptRes.ok) { + return upstreamErrorResponse(transcriptRes, await transcriptRes.text()); + } + const transcript = await transcriptRes.json(); + const text = + typeof transcript.text === "string" && transcript.text.length > 0 + ? transcript.text + : Array.isArray(transcript.tokens) + ? transcript.tokens.map((t: { text?: string }) => t.text ?? "").join("") + : ""; + + return Response.json({ text }, { headers: { ...CORS_HEADERS } }); +} + /** * Handle Nvidia NIM transcription * Multipart POST, transform response to { text } @@ -735,6 +807,10 @@ export async function handleAudioTranscription({ return handleGladiaTranscription(providerConfig, file, modelId, token); } + if (providerConfig.format === "soniox") { + return handleSonioxTranscription(providerConfig, file, modelId, token); + } + if (providerConfig.format === "nvidia-asr") { return handleNvidiaTranscription(providerConfig, file, modelId, token); } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 9b1c84c4e5..4130bed738 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1,3 +1,4 @@ +import { extractRequestToolIdentityMap } from "./chatCore/requestToolIdentity.ts"; import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts"; import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts"; import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts"; @@ -21,6 +22,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, @@ -97,7 +99,7 @@ import { resolveAgentGoalPolicy } from "../utils/agentGoalPolicy.ts"; import { createStreamController } from "../utils/streamHandler.ts"; import * as streamFailure from "../utils/streamFailureFinalization.ts"; import { createSseHeartbeatTransform, shapeForClientFormat } from "../utils/sseHeartbeat.ts"; -import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/usageTracking.ts"; +import { addBufferToUsage, filterUsageForFormat, estimateUsage, sanitizeUsagePayloadForRequest } from "../utils/usageTracking.ts"; import { refreshWithRetry, isUnrecoverableRefreshError, @@ -141,6 +143,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 +174,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 { @@ -195,6 +200,7 @@ import { import { wrapReadableStreamWithFinalize } from "./chatCore/streamFinalize.ts"; import { buildCacheUsageLogMeta } from "./chatCore/cacheUsageMeta.ts"; import { buildExecutorClientHeaders } from "./chatCore/executorClientHeaders.ts"; +import { getExecutionConnectionId } from "./chatCore/executionCredentials.ts"; import { resolveExecutionCredentials as resolveExecutionCredentialsFor } from "./chatCore/executionCredentials.ts"; import { resolveExecutorWithProxy as resolveExecutorWithProxyFor } from "./chatCore/executorProxy.ts"; import type { ClaudeMessage } from "./chatCore/claudeMessageTypes.ts"; @@ -207,7 +213,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, @@ -227,7 +232,8 @@ import { normalizeOpenAIToolFinishReasons, restoreNonStreamingToolNames, } from "./chatCore/passthroughToolNames.ts"; -import { resolveCompressionSettings } from "./chatCore/compressionSettings.ts"; +import { createDisabledCompressionConfig, resolveCompressionSettings } from "./chatCore/compressionSettings.ts"; +import type { EnforceDecision } from "@/lib/quota/types"; import { isCompressionExcluded } from "../services/compression/exclusions.ts"; import { isBuiltinStackedPipeline, @@ -266,7 +272,7 @@ import { normalizeExecutorResult, executeWithUpstreamStartTimeout, } from "./chatCore/upstreamTimeouts.ts"; -import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb"; +import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/db/models"; import { getProviderCredentials, extractSessionAffinityKey } from "@/sse/services/auth"; import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; import { getCacheControlSettings } from "@/lib/cacheControlSettings"; @@ -302,6 +308,7 @@ import { updateFromResponseBody, initializeRateLimits, } from "../services/rateLimitManager.ts"; +import * as localLimiterErrors from "../services/rateLimitManager/errors.ts"; import { acquire as acquireAccountSemaphore, markBlocked as markAccountSemaphoreBlocked, @@ -370,9 +377,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 @@ -392,10 +397,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, @@ -431,7 +434,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, @@ -445,7 +447,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", { @@ -493,9 +494,10 @@ export async function handleChatCore({ model, provider, apiKeyInfo, + headers: clientRawRequest?.headers, log, }); - if (pluginGate.blocked) { + if (pluginGate.blocked === true) { return { success: false, status: 403, @@ -528,7 +530,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. @@ -557,7 +558,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 = ( @@ -565,11 +565,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" @@ -584,28 +582,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) @@ -624,13 +617,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 { @@ -736,7 +727,10 @@ export async function handleChatCore({ // wins; native Claude passthrough is left untouched (it carries its own `thinking`), // and non-thinking base models are cleaned up later by normalizeThinkingForModel(). // Extracted to chatCore/claudeEffortVariant.ts (#3501); mutates body in place and returns the - // stripped model + an optional log line, keeping behaviour byte-identical. + // stripped model + an optional log line. The strip is unconditional (byte-identical to the + // original behavior) for the claude/Claude-Code-compatible lane; for any other provider it + // additionally requires isKnownClaudeEffortBaseModel(baseModel) to verify the base id is a + // real, effort-capable Claude model before stripping (vertex-claude-catalog-dispatch fix). { const effortVariant = applyClaudeEffortVariant({ provider, @@ -1053,6 +1047,13 @@ export async function handleChatCore({ log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`); + // Preserve original body for cache signature — the body variable is mutated + // multiple times below (sanitization, memory/skills injection) before the + // cache store path runs at Phase 9.1 (non-streaming) / Phase 9.2 (streaming). + // Without this snapshot, the write-time signature differs from the read-time + // one, producing 0% hit rate. (#cache-signature-asymmetry) + const bodyForCacheWrite = body; + // ── Phase 9.1: Semantic cache check (temp=0, any streaming mode) ── const cacheHit = await checkSemanticCache({ semanticCacheEnabled, @@ -1068,11 +1069,20 @@ export async function handleChatCore({ log, persistAttemptLogs, apiKeyId: apiKeyInfo?.id ?? undefined, + cacheDefaultMode: (apiKeyInfo as { cacheDefaultMode?: "legacy" | "bypass" } | null) + ?.cacheDefaultMode, }); if (cacheHit) { 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 @@ -1124,13 +1134,20 @@ export async function handleChatCore({ const compressionSettings: CompressionConfig | null = compressionSettingsResult.settings; // #8034 — operator-named model/endpoint exclusions bypass the whole pipeline, exactly // like compression being globally disabled, so the body is provably byte-identical. - const compressionExcluded = isCompressionExcluded( - { provider, model: effectiveModel }, - compressionSettings?.exclusions - ); - let promptCompressionEnabled = compressionSettingsResult.enabled && !compressionExcluded; + const compressionExcluded = + nativeCodexPassthrough || + isCompressionExcluded({ provider, model: effectiveModel }, compressionSettings?.exclusions); + // A per-key opt-out is a request-scoped hard kill for prompt compression. It + // deliberately does not disable the independent reactive context-fit safety + // passes, matching the existing x-omniroute-compression: off contract. + const apiKeyCompressionEnabled = apiKeyInfo?.compressionEnabled !== false; + let promptCompressionEnabled = + compressionSettingsResult.enabled && !compressionExcluded && apiKeyCompressionEnabled; reactiveContextCompactionEnabled = compressionSettingsResult.enabled && !compressionExcluded; contextEditingEnabled = compressionSettingsResult.contextEditingEnabled; + if (!apiKeyCompressionEnabled) { + log?.debug?.("COMPRESSION", "Prompt compression disabled for this API key"); + } if (compressionExcluded) { void writeCompressionSkip( { @@ -1172,15 +1189,10 @@ export async function handleChatCore({ formatCompressionAnnotation, } = await import("../services/compression/strategySelector.ts"); const { trackCompressionStats } = await import("../services/compression/stats.ts"); - let config: CompressionConfig = compressionSettings ?? { - enabled: false, - defaultMode: "off", - autoTriggerTokens: 0, - cacheMinutes: 5, - preserveSystemPrompt: true, - comboOverrides: {}, - }; - if (compressionExcluded) config = { ...config, enabled: false }; + let config: CompressionConfig = compressionSettings ?? createDisabledCompressionConfig(); + if (compressionExcluded || !apiKeyCompressionEnabled) { + config = { ...config, enabled: false }; + } if (!promptCompressionEnabled || !compressionSettings) { log?.debug?.("COMPRESSION", "Prompt compression disabled or unavailable"); } @@ -1781,7 +1793,7 @@ export async function handleChatCore({ // engines (Caveman/RTK). Codex Desktop / Responses clients need this path even // when those engines are off, otherwise multi-turn image sessions hard-reject // at the budget check below (#8560). - if (reactiveContextCompactionEnabled && estimatedTokens > threshold) { + if (reactiveContextCompactionEnabled && !nativeCodexPassthrough && estimatedTokens > threshold) { log?.info?.( "CONTEXT", `Proactive compression triggered: ${estimatedTokens} tokens > ${threshold} threshold (${contextLimit} limit)` @@ -1851,7 +1863,7 @@ export async function handleChatCore({ // Last-resort compaction against the concrete input budget (not the 70% threshold). // Covers cases where the proactive pass was skipped or still left the request oversized (#8560). - if (reactiveContextCompactionEnabled && finalEstimatedInputTokens >= finalContextLimit && body) { + if (reactiveContextCompactionEnabled && !nativeCodexPassthrough && finalEstimatedInputTokens >= finalContextLimit && body) { const lastResortTarget = Math.max(1, finalContextLimit - toolsReserve - 1); const lastResortAdapter = adaptBodyForCompression(body as Record); const lastResortResult = compressContext(lastResortAdapter.body, { @@ -1886,7 +1898,7 @@ export async function handleChatCore({ modelOutputCap, toPositiveInteger(resolveInputTokenCapForGate({ provider, model: effectiveModel }, { isCombo })) ); - if (!outputBudget.ok) { + if (outputBudget.ok === false) { const exceededInputCap = outputBudget.maxInputTokens !== undefined; const message = `Input exceeds ${exceededInputCap ? "maximum input tokens" : "context window"} for ${provider}/${effectiveModel}: ` + @@ -2266,9 +2278,7 @@ 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. - const requestToolIdentityMap = - translatedBody._toolNameMap instanceof Map ? translatedBody._toolNameMap : null; - delete translatedBody._toolNameMap; + const requestToolIdentityMap = extractRequestToolIdentityMap(translatedBody); // Kiro: sanitize tool schemas before dispatch. Kiro returns 400 "Improperly // formed request" for unsupported JSON-Schema keywords (anyOf/$ref/if-then, @@ -2576,7 +2586,7 @@ export async function handleChatCore({ // router/log use. Operators configure per-(key,model) caps against THIS id. model: model || undefined, estimatedCost: {}, - }).catch((err: unknown) => { + }).catch((err: unknown): EnforceDecision => { log?.warn?.( "QUOTA_SHARE", `enforceQuotaShare failed; fail-open: ${err instanceof Error ? err.message : String(err)}` @@ -2638,7 +2648,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 = () => @@ -2732,7 +2751,8 @@ export async function handleChatCore({ stage: "sending_to_provider", }); const execCreds = getExecutionCredentials(); - const attemptConnectionId = execCreds?.connectionId || connectionId; + const executionConnectionId = getExecutionConnectionId(execCreds); + const attemptConnectionId = executionConnectionId || connectionId; const accountSemaphoreMaxConcurrency = resolveAccountSemaphoreMaxConcurrency(execCreds); const accountSemaphoreKey = resolveAccountSemaphoreKey({ provider, @@ -2816,7 +2836,7 @@ export async function handleChatCore({ stage: "provider_response_started", }); - if (res.response.status === 401 && execCreds?.connectionId) { + if (res.response.status === 401 && executionConnectionId) { recordKeyHealthStatus(401, execCreds); } @@ -2848,7 +2868,7 @@ export async function handleChatCore({ attempts < maxAttempts - 1 ) { const failedConnectionId = - execCreds?.connectionId || credentials?.connectionId || connectionId; + executionConnectionId || credentials?.connectionId || connectionId; const normalizedHeaders = normalizeHeaders(res.response.headers); const retryAfterHeader = normalizedHeaders["retry-after"] ?? null; const retryAfterMs = retryAfterHeader @@ -2965,6 +2985,8 @@ export async function handleChatCore({ const okStatus = res.response.status >= 200 && res.response.status < 300; let streamRecoveryEnabled = false; let continueMidStreamEnabled = false; + let throughputWatchdog = + resolveResilienceSettings(null).streamRecovery.throughputWatchdog; if (okStatus) { try { // Reuse the request-consolidated settings read (see line ~2076) — no @@ -2979,6 +3001,7 @@ export async function handleChatCore({ const goalOverride = !operatorExplicit && agentGoalPolicy.streamRecoveryEnabled; streamRecoveryEnabled = sr.enabled || goalOverride; continueMidStreamEnabled = sr.continueMidStream === true; + throughputWatchdog = sr.throughputWatchdog; if (goalOverride && !sr.enabled) { log?.info?.( "AGENT_GOAL", @@ -2988,11 +3011,13 @@ export async function handleChatCore({ } catch { streamRecoveryEnabled = false; continueMidStreamEnabled = false; + throughputWatchdog = + resolveResilienceSettings(null).streamRecovery.throughputWatchdog; } } let clientBody: ReadableStream; - if (streamRecoveryEnabled) { + if (streamRecoveryEnabled || throughputWatchdog.enabled) { // Run the SAME upstream (same account/creds) with a given body and return // its 2xx stream, or null. Used both by the early-retry re-open (same body) // and the mid-stream continuation (assistant-prefilled body). @@ -3074,6 +3099,12 @@ export async function handleChatCore({ "STREAM_RECOVERY", `mid-stream continuation attempt ${attempt}/${STREAM_RECOVERY.EARLY_RETRY_MAX}` ), + throughputWatchdog, + onWatchdogAbort: () => + log?.warn?.( + "STREAM_WATCHDOG", + "active upstream stream stayed below the configured useful-output rate" + ), } ); } else { @@ -3334,27 +3365,22 @@ export async function handleChatCore({ errorCode: error.code, }; } - // abort(reason) can reject the upstream fetch with a raw string reason - // (e.g. "request_signal_aborted") that has no `name`/`status`; classify - // via isLocalStreamLifecycleError so those map to 499 instead of falling - // through to the 502 provider-failure default. + // abort(reason) can reject with a raw string lacking `name`/`status`; classify + // it through isLocalStreamLifecycleError so it maps to 499 rather than the + // 502 provider-failure default. const isRequestAborted = isLocalStreamLifecycleError(error); - // #8376: an unreachable upstream proxy (ECONNREFUSED/ECONNRESET/...) is tagged by - // proxyFetch.ts (tagProxyUnreachable) with `.errorCode = "proxy_unreachable"` before - // it reaches this catch. Classify it explicitly to 502 instead of falling through - // the generic `error.status` branch (a raw connect-refused error has no `.status` at - // all, so it used to collapse into an ordinary 502/504 the provider-breaker predicate - // can't tell apart from a per-model 5xx). + // #8376: proxyFetch tags unreachable transport failures so they remain + // distinguishable from ordinary provider 5xx responses. const isProxyUnreachableFailure = !isRequestAborted && (error as { errorCode?: unknown })?.errorCode === "proxy_unreachable"; const errorCode = getUpstreamErrorIdentifier(error); - const isLocalQueueTimeout = errorCode === "RATE_LIMIT_QUEUE_TIMEOUT"; + const localRateLimitFailure = localLimiterErrors.getClientSafeLocalRateLimitError(error); const failureStatus = isRequestAborted ? 499 : isProxyUnreachableFailure ? HTTP_STATUS.BAD_GATEWAY - : isLocalQueueTimeout - ? HTTP_STATUS.SERVICE_UNAVAILABLE + : localRateLimitFailure + ? localRateLimitFailure.status : error.name === "TimeoutError" || error.name === "BodyTimeoutError" ? HTTP_STATUS.GATEWAY_TIMEOUT : error.status && typeof error.status === "number" @@ -3362,8 +3388,9 @@ export async function handleChatCore({ : HTTP_STATUS.BAD_GATEWAY; const failureMessage = isRequestAborted ? "Request aborted" - : formatProviderError(error, provider, model, failureStatus); - const upstreamErrorCode = isProxyUnreachableFailure ? "proxy_unreachable" : errorCode; + : formatProviderError(localRateLimitFailure ?? error, provider, model, failureStatus); + const upstreamErrorCode = + localRateLimitFailure?.code ?? (isProxyUnreachableFailure ? "proxy_unreachable" : errorCode); // Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError, // both surfaced as a 504) as "upstream_timeout" so the cooldown layer can tell a // slow-but-not-failed request apart from a real provider 5xx. (Antigravity already @@ -3413,19 +3440,22 @@ export async function handleChatCore({ upstreamErrorCode, upstreamErrorType ); + localLimiterErrors.markTrustedLocalRateLimitResponse(result.response, error); return { ...result, errorType: upstreamErrorType, errorCode: upstreamErrorCode, }; } - return createErrorResult( + const result = createErrorResult( failureStatus, failureMessage, null, upstreamErrorCode, upstreamErrorType ); + localLimiterErrors.markTrustedLocalRateLimitResponse(result.response, error); + return result; } let upstreamErrorParsed = false; let parsedStatusCode = providerResponse.status; @@ -4245,8 +4275,8 @@ export async function handleChatCore({ } : responseBody ); + sanitizeUsagePayloadForRequest(responseBody, finalBody || translatedBody || body, responsePayloadFormat); effectiveServiceTier = resolveReportedServiceTier(responseBody) ?? effectiveServiceTier; - // Notify success - caller can clear error status if needed if (onRequestSuccess) { await onRequestSuccess(); @@ -4335,9 +4365,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 @@ -4378,7 +4413,7 @@ export async function handleChatCore({ // #8331: keep the client-visible metering fields real everywhere except Claude-Code-compatible // providers, where Claude Code's own context accounting relies on the buffered number — see // clientUsageBuffer.ts module docstring. - applyClientUsageBuffer(translatedResponse, body, clientResponseFormat, { + applyClientUsageBuffer(translatedResponse, finalBody || translatedBody || body, clientResponseFormat, { preserveContextBudgetInVisibleUsage: isClaudeCodeCompatible, }); @@ -4541,7 +4576,7 @@ export async function handleChatCore({ // ── Phase 9.1: Cache store (non-streaming, temp=0) ── storeSemanticCacheResponse({ enabled: semanticCacheEnabled, - body, + body: bodyForCacheWrite, headers: clientRawRequest?.headers, translatedResponse, model, @@ -4625,6 +4660,7 @@ export async function handleChatCore({ model, provider, apiKeyInfo, + headers: clientRawRequest?.headers, response: { status: 200, data: translatedResponse }, }); @@ -4761,12 +4797,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 @@ -4891,7 +4930,7 @@ export async function handleChatCore({ enabled: semanticCacheEnabled, streamStatus, streamResponseBody, - body, + body: bodyForCacheWrite, headers: clientRawRequest?.headers, model, apiKeyId: apiKeyInfo?.id ?? undefined, @@ -4920,13 +4959,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 @@ -5026,6 +5072,7 @@ export async function handleChatCore({ model, provider, apiKeyInfo, + headers: clientRawRequest?.headers, response: { status: 200, streamed: true }, }); @@ -5036,7 +5083,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/claudeEffortVariant.ts b/open-sse/handlers/chatCore/claudeEffortVariant.ts index e2a2a8e188..dac50233ad 100644 --- a/open-sse/handlers/chatCore/claudeEffortVariant.ts +++ b/open-sse/handlers/chatCore/claudeEffortVariant.ts @@ -15,6 +15,7 @@ import { splitClaudeEffortSuffix } from "../../config/providerModels.ts"; import { isClaudeCodeCompatibleProvider } from "../../services/claudeCodeCompatible.ts"; import { FORMATS } from "../../translator/formats.ts"; +import { isKnownClaudeEffortBaseModel } from "../../utils/claudeEffortVariants.ts"; /** * True when the client already supplied an explicit reasoning effort (top-level reasoning_effort, @@ -40,12 +41,10 @@ export function applyClaudeEffortVariant(opts: { let effectiveModel = opts.effectiveModel; let log: string | null = null; - if ( - (provider === "claude" || isClaudeCodeCompatibleProvider(provider)) && - typeof effectiveModel === "string" - ) { + if (typeof effectiveModel === "string") { const { baseModel, effort } = splitClaudeEffortSuffix(effectiveModel); - if (effort) { + const isDirectClaudeLane = provider === "claude" || isClaudeCodeCompatibleProvider(provider); + if (effort && (isDirectClaudeLane || isKnownClaudeEffortBaseModel(baseModel))) { effectiveModel = baseModel; if (body && typeof body === "object" && !Array.isArray(body)) { const claudeBody = body as Record; diff --git a/open-sse/handlers/chatCore/claudeMessageTypes.ts b/open-sse/handlers/chatCore/claudeMessageTypes.ts index 24492a3957..ad959db8f5 100644 --- a/open-sse/handlers/chatCore/claudeMessageTypes.ts +++ b/open-sse/handlers/chatCore/claudeMessageTypes.ts @@ -7,9 +7,19 @@ * shapes the handler already used inline; behaviour is unchanged. */ -export type ClaudeContentBlock = Record; +export type ClaudeContentBlock = { + type?: string; + text?: string; + name?: string; + tool_use_id?: string; + cache_control?: unknown; + signature?: string; + thinking?: string; + [key: string]: unknown; +}; export type ClaudeMessage = { - role?: unknown; - content?: unknown; + role?: string; + content?: string | ClaudeContentBlock[]; + [key: string]: unknown; }; diff --git a/open-sse/handlers/chatCore/claudeSystemRole.ts b/open-sse/handlers/chatCore/claudeSystemRole.ts index 0c22180166..a661847f29 100644 --- a/open-sse/handlers/chatCore/claudeSystemRole.ts +++ b/open-sse/handlers/chatCore/claudeSystemRole.ts @@ -7,8 +7,96 @@ * chat role, so they must be hoisted. `developer` is OpenAI's Responses-API rename of `system` and * is treated identically. Mutates the payload in place; behaviour is byte-identical to the previous * top-level definition (still re-exported from chatCore.ts for existing importers/tests). + * + * `relocateHoistedCacheBoundary` keeps that hoist from destroying the client's prompt-cache + * layout (#9436); both hoisting implementations share it. */ +export type HoistedCacheBoundary = "moved" | "kept" | "dropped"; + +/** Effective cache TTL of a `cache_control` value; Anthropic defaults to 5m when `ttl` is absent. */ +function effectiveTtl(marker: unknown): string { + const ttl = (marker as Record | null | undefined)?.ttl; + return typeof ttl === "string" ? ttl : "5m"; +} + +/** + * Whether a content block can carry a cache breakpoint. Excludes blocks Anthropic does not accept + * as one (thinking) and blocks the upstream normalisation discards or empties out anyway. + */ +function isCacheBreakpointTarget(block: unknown): block is Record { + if (block === null || typeof block !== "object") return false; + const candidate = block as Record; + switch (candidate.type) { + case "text": + // Empty text blocks are stripped before the payload goes upstream. + return typeof candidate.text === "string" && candidate.text.length > 0; + case "tool_use": + case "image": + case "image_url": + case "file": + case "file_url": + case "document": + return true; + case "tool_result": { + // A tool_result that yields no text collapses to nothing during normalisation. + const payload = candidate.content ?? candidate.text ?? candidate.output; + if (typeof payload === "string") return payload.length > 0; + if (Array.isArray(payload)) { + // Only the non-empty text parts of the array survive; images and unknown parts do not. + return payload.some((part) => { + const text = (part as Record | null)?.text; + return ( + (part as Record | null)?.type === "text" && + typeof text === "string" && + text.length > 0 + ); + }); + } + return payload != null; + } + default: + // thinking, redacted_thinking, and anything unrecognised. + return false; + } +} + +/** + * Preserves a message-level cache boundary when a marked system/developer block is hoisted into + * top-level `system[]`. + * + * The marker is moved to the nearest preceding block that can carry a breakpoint. If that block is + * already marked, both are kept — except where the hoisted marker, which ends up ahead of the + * target in `system[]`, would put a 5m breakpoint before a 1h one; Anthropic requires the longer + * TTL first, so the hoisted marker is dropped instead. + * + * @returns `"moved"` or `"dropped"` — the caller must remove the marker from the hoisted block; + * `"kept"` — the marker stays on it + */ +export function relocateHoistedCacheBoundary( + marker: unknown, + preceding: ReadonlyArray<{ content?: unknown }> +): HoistedCacheBoundary { + for (let i = preceding.length - 1; i >= 0; i--) { + const content = preceding[i]?.content; + if (!Array.isArray(content)) continue; + for (let j = content.length - 1; j >= 0; j--) { + const block = content[j]; + if (!isCacheBreakpointTarget(block)) continue; + if (block.cache_control == null) { + block.cache_control = marker; + return "moved"; + } + // Occupied: overwriting would discard the client's own marker, and stepping further back + // would only shorten the prefix — so both stay, unless the TTL order forbids it. + return effectiveTtl(marker) === "5m" && effectiveTtl(block.cache_control) === "1h" + ? "dropped" + : "kept"; + } + } + return "kept"; +} + export function extractSystemRoleMessages(payload: Record): void { if (!Array.isArray(payload.messages)) return; const messages = payload.messages as Array<{ role?: unknown; content?: unknown }>; @@ -23,13 +111,27 @@ export function extractSystemRoleMessages(payload: Record): voi if (systemMessages.length === 0) return; const extraBlocks: Array> = []; - for (const sm of systemMessages) { + // Walk in order rather than over the filtered list: re-anchoring a hoisted `cache_control` + // needs the messages that precede it and stay behind (#9436). + const preceding: Array<{ content?: unknown }> = []; + for (const sm of messages) { + if (!isSystemRole(sm.role)) { + preceding.push(sm); + continue; + } if (typeof sm.content === "string" && sm.content.length > 0) { extraBlocks.push({ type: "text", text: sm.content }); } else if (Array.isArray(sm.content)) { for (const block of sm.content as Array>) { if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) { - extraBlocks.push({ ...block }); + const hoisted = { ...block }; + if ( + hoisted.cache_control != null && + relocateHoistedCacheBoundary(hoisted.cache_control, preceding) !== "kept" + ) { + delete hoisted.cache_control; + } + extraBlocks.push(hoisted); } } } diff --git a/open-sse/handlers/chatCore/claudeUpstreamMessages.ts b/open-sse/handlers/chatCore/claudeUpstreamMessages.ts index b34ff7b36d..3202ed6502 100644 --- a/open-sse/handlers/chatCore/claudeUpstreamMessages.ts +++ b/open-sse/handlers/chatCore/claudeUpstreamMessages.ts @@ -12,27 +12,58 @@ */ import type { ClaudeContentBlock, ClaudeMessage } from "./claudeMessageTypes.ts"; -import { extractSystemRoleMessages } from "./claudeSystemRole.ts"; +import { extractSystemRoleMessages, relocateHoistedCacheBoundary } from "./claudeSystemRole.ts"; import { splitMisplacedToolResults } from "../../translator/helpers/claudeHelper.ts"; type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; +/** + * Carries a replaced block's `cache_control` onto its substitute. Rewriting a marked block into a + * plain text block would otherwise drop the breakpoint the client (or the #9436 hoist) put there. + */ +function withCacheControl( + replacement: ClaudeContentBlock, + original: ClaudeContentBlock +): ClaudeContentBlock { + if (original.cache_control != null) replacement.cache_control = original.cache_control; + return replacement; +} + export function extractSystemMessagesToBody(payload: Record) { if (!Array.isArray(payload.messages)) return; const messages = payload.messages as ClaudeMessage[]; - const systemMessages = messages.filter((m) => { - const role = String(m.role || "").toLowerCase(); - return role === "system" || role === "developer"; - }); + const isSystemRole = (role: unknown): boolean => { + const normalized = String(role || "").toLowerCase(); + return normalized === "system" || normalized === "developer"; + }; + const systemMessages = messages.filter((m) => isSystemRole(m.role)); if (systemMessages.length === 0) return; const extraBlocks: ClaudeContentBlock[] = []; - for (const sm of systemMessages) { + // Same in-order walk as extractSystemRoleMessages: re-anchoring a hoisted `cache_control` + // needs the messages that precede it and stay behind (#9436). + const preceding: ClaudeMessage[] = []; + for (const sm of messages) { + if (!isSystemRole(sm.role)) { + preceding.push(sm); + continue; + } if (typeof sm.content === "string" && sm.content.length > 0) { extraBlocks.push({ type: "text", text: sm.content }); } else if (Array.isArray(sm.content)) { for (const block of sm.content as ClaudeContentBlock[]) { if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) { - extraBlocks.push(block); + // Blocks are pushed by reference here (the sibling implementation spreads them), so + // only a block whose marker actually moves is copied. + if ( + block.cache_control != null && + relocateHoistedCacheBoundary(block.cache_control, preceding) !== "kept" + ) { + const withoutMarker: ClaudeContentBlock = { ...block }; + delete withoutMarker.cache_control; + extraBlocks.push(withoutMarker); + } else { + extraBlocks.push(block); + } } } } @@ -47,10 +78,7 @@ export function extractSystemMessagesToBody(payload: Record) { payload.system = extraBlocks; } } - payload.messages = messages.filter((m) => { - const role = String(m.role || "").toLowerCase(); - return role !== "system" && role !== "developer"; - }); + payload.messages = messages.filter((m) => !isSystemRole(m.role)); } export function normalizeClaudeUpstreamMessages( @@ -104,7 +132,7 @@ export function normalizeClaudeUpstreamMessages( const fileName = (block.file as Record)?.name ?? block.name ?? "attachment"; if (typeof fileContent === "string" && fileContent.length > 0) { - return [{ type: "text", text: `[${fileName}]\n${fileContent}` }]; + return [withCacheControl({ type: "text", text: `[${fileName}]\n${fileContent}` }, block)]; } } return [block]; @@ -126,7 +154,9 @@ export function normalizeClaudeUpstreamMessages( .join("\n") : JSON.stringify(resultContent); if (resultText.length > 0) { - return [{ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }]; + return [ + withCacheControl({ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }, block), + ]; } return []; } diff --git a/open-sse/handlers/chatCore/clientUsageBuffer.ts b/open-sse/handlers/chatCore/clientUsageBuffer.ts index a916edc1de..4c7b1e48dc 100644 --- a/open-sse/handlers/chatCore/clientUsageBuffer.ts +++ b/open-sse/handlers/chatCore/clientUsageBuffer.ts @@ -22,6 +22,7 @@ import { addBufferToUsage as defaultAddBuffer, filterUsageForFormat as defaultFilterUsage, estimateUsage as defaultEstimateUsage, + sanitizeProviderUsageForRequest, } from "../../utils/usageTracking.ts"; type ResponseLike = @@ -103,6 +104,14 @@ export function applyClientUsageBuffer( deps: ClientUsageBufferDeps = DEFAULT_DEPS ): void { const { preserveContextBudgetInVisibleUsage = false } = options; + if (translatedResponse?.usage) { + translatedResponse.usage = sanitizeProviderUsageForRequest( + translatedResponse.usage, + body, + clientResponseFormat + ); + } + // Add buffer and filter usage for client (to prevent CLI context errors) if (translatedResponse?.usage && !isEmptyUsage(translatedResponse.usage)) { const buffered = deps.addBufferToUsage(translatedResponse.usage) as Record; diff --git a/open-sse/handlers/chatCore/compressionSettings.ts b/open-sse/handlers/chatCore/compressionSettings.ts index 5642fb5028..467a851f56 100644 --- a/open-sse/handlers/chatCore/compressionSettings.ts +++ b/open-sse/handlers/chatCore/compressionSettings.ts @@ -12,6 +12,19 @@ import type { CompressionConfig } from "../../services/compression/types.ts"; type LoggerLike = { warn?: (...args: unknown[]) => void } | null | undefined; +export function createDisabledCompressionConfig(): CompressionConfig { + return { + enabled: false, + defaultMode: "off", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, + engines: {}, + activeComboId: null, + }; +} + export async function resolveCompressionSettings(log?: LoggerLike): Promise<{ settings: CompressionConfig | null; enabled: boolean; diff --git a/open-sse/handlers/chatCore/executionCredentials.ts b/open-sse/handlers/chatCore/executionCredentials.ts index c8e4223774..1569b61178 100644 --- a/open-sse/handlers/chatCore/executionCredentials.ts +++ b/open-sse/handlers/chatCore/executionCredentials.ts @@ -150,8 +150,18 @@ export function resolveExecutionCredentials(opts: { providerSpecificData.targetFormat = targetFormat; } - applyKimiExecutionMetadata(providerSpecificData, provider, targetFormat, modelInfo); + // GitHub Copilot custom models (custom-model dropdown, #2905) can carry a + // per-model targetFormat override resolving to "openai-responses" so a + // Codex-family custom model routes through Copilot's native /responses + // endpoint. GithubExecutor.buildUrl() only consults the static + // PROVIDER_MODELS registry via getModelTargetFormat() and has no other way + // to see a custom model's override — mirrors the zai/glm-coding-apikey fix + // (#7364) for the same class of bug. + if (targetFormat === FORMATS.OPENAI_RESPONSES && provider === "github") { + providerSpecificData.targetFormat = targetFormat; + } + applyKimiExecutionMetadata(providerSpecificData, provider, targetFormat, modelInfo); const withApiType = { ...nextCredentials, providerSpecificData, @@ -167,3 +177,9 @@ export function resolveExecutionCredentials(opts: { }, }; } + +export function getExecutionConnectionId(credentials: unknown): string | null { + if (!credentials || typeof credentials !== "object") return null; + const connectionId = (credentials as Record).connectionId; + return typeof connectionId === "string" && connectionId.trim() ? connectionId.trim() : null; +} 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/chatCore/memorySkillsInjection.ts b/open-sse/handlers/chatCore/memorySkillsInjection.ts index 8a18c945a8..2a43c17cbf 100644 --- a/open-sse/handlers/chatCore/memorySkillsInjection.ts +++ b/open-sse/handlers/chatCore/memorySkillsInjection.ts @@ -2,6 +2,7 @@ import { retrieveMemories } from "@/lib/memory/retrieval"; import { getMemorySettings, DEFAULT_MEMORY_SETTINGS, toMemoryRetrievalConfig } from "@/lib/memory/settings"; import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection"; import { injectSkills } from "@/lib/skills/injection"; +import { skillRegistry } from "@/lib/skills/registry"; import { FORMATS } from "../../translator/formats.ts"; import { detectCachingContext } from "../../services/compression/cachingAware.ts"; @@ -138,6 +139,12 @@ export async function injectMemoryAndSkills({ } if (memoryOwnerId && memorySettings?.skillsEnabled) { + // Ensure the registry cache is warm before listing: on a cold/fresh + // process skills that exist only in the DB would be missed (false + // negative -> silent skip). loadFromDatabase() is a no-op when the cache + // is already warm (TTL = 60 s), so repeated calls are cheap. Mirrors the + // pattern in src/lib/skills/interception.ts (#2815). + await skillRegistry.loadFromDatabase(memoryOwnerId); const existingTools = Array.isArray(body.tools) ? body.tools : []; const mergedTools = injectSkills({ provider: getSkillsProviderForFormat(sourceFormat), diff --git a/open-sse/handlers/chatCore/passthroughHelpers.ts b/open-sse/handlers/chatCore/passthroughHelpers.ts index e644878329..943dd3f5ae 100644 --- a/open-sse/handlers/chatCore/passthroughHelpers.ts +++ b/open-sse/handlers/chatCore/passthroughHelpers.ts @@ -1,4 +1,5 @@ import { FORMATS } from "../../translator/formats.ts"; +import { isVerifiedNativeCodexRequest } from "../../config/codexIdentity.ts"; import { isClaudeCodeCompatibleProvider } from "../../services/claudeCodeCompatible.ts"; import { isResponsesEndpointPath } from "../../utils/responsesEndpoint.ts"; import { getHeaderValueCaseInsensitive } from "./headers.ts"; @@ -11,14 +12,22 @@ export function shouldUseNativeCodexPassthrough({ provider, sourceFormat, endpointPath, + body, + headers, }: { provider?: string | null; sourceFormat?: string | null; endpointPath?: string | null; + body?: unknown; + headers?: Headers | Record | null; }): boolean { - if (provider !== "codex") return false; + if (provider !== "codex" && provider !== "chatgpt-web-codex") return false; if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false; - return isResponsesEndpointPath(endpointPath); + let normalizedEndpoint = String(endpointPath || ""); + while (normalizedEndpoint.endsWith("/")) normalizedEndpoint = normalizedEndpoint.slice(0, -1); + const segments = normalizedEndpoint.split("/"); + if (!segments.includes("responses")) return false; + return provider === "codex" || isVerifiedNativeCodexRequest(body, headers); } export function shouldUseNativeXaiResponsesPassthrough({ diff --git a/open-sse/handlers/chatCore/pluginOnRequest.ts b/open-sse/handlers/chatCore/pluginOnRequest.ts index 170c276c5e..a4737d53af 100644 --- a/open-sse/handlers/chatCore/pluginOnRequest.ts +++ b/open-sse/handlers/chatCore/pluginOnRequest.ts @@ -10,13 +10,10 @@ */ type LoggerLike = - | { info?: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void } - | null - | undefined; + { info?: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void } | null | undefined; export type PluginOnRequestGate = - | { blocked: true; response: Response } - | { blocked: false; body?: unknown }; + { blocked: true; response: Response } | { blocked: false; body?: unknown }; const JSON_HEADERS = { status: 403, headers: { "Content-Type": "application/json" } } as const; @@ -26,6 +23,7 @@ export async function runPluginOnRequestHook(args: { model: string | null | undefined; provider: string | null | undefined; apiKeyInfo: unknown; + headers?: Record; log?: LoggerLike; }): Promise { try { @@ -36,6 +34,7 @@ export async function runPluginOnRequestHook(args: { model: args.model, provider: args.provider, apiKeyInfo: args.apiKeyInfo, + headers: args.headers, metadata: {}, }; const pluginResult = await runOnRequest(pluginCtx); diff --git a/open-sse/handlers/chatCore/pluginOnResponse.ts b/open-sse/handlers/chatCore/pluginOnResponse.ts index 92e9c266a1..eb100c5b5e 100644 --- a/open-sse/handlers/chatCore/pluginOnResponse.ts +++ b/open-sse/handlers/chatCore/pluginOnResponse.ts @@ -24,6 +24,7 @@ export async function runPluginOnResponseHook(args: { model: string | null | undefined; provider: string | null | undefined; apiKeyInfo: unknown; + headers?: Record; response: PluginOnResponsePayload; }): Promise { try { @@ -35,6 +36,7 @@ export async function runPluginOnResponseHook(args: { model: args.model, provider: args.provider, apiKeyInfo: args.apiKeyInfo, + headers: args.headers, metadata: {}, }, args.response diff --git a/open-sse/handlers/chatCore/requestFormat.ts b/open-sse/handlers/chatCore/requestFormat.ts index d5ce5ad23b..d986be2051 100644 --- a/open-sse/handlers/chatCore/requestFormat.ts +++ b/open-sse/handlers/chatCore/requestFormat.ts @@ -94,6 +94,8 @@ export function resolveChatCoreRequestFormat(opts: { provider, sourceFormat, endpointPath, + body, + headers: clientRawRequest?.headers, }); const nativeXaiResponsesPassthrough = shouldUseNativeXaiResponsesPassthrough({ provider, diff --git a/open-sse/handlers/chatCore/requestToolIdentity.ts b/open-sse/handlers/chatCore/requestToolIdentity.ts new file mode 100644 index 0000000000..5b77866925 --- /dev/null +++ b/open-sse/handlers/chatCore/requestToolIdentity.ts @@ -0,0 +1,26 @@ +type NamespaceIdentity = { namespace: string; name: string }; + +/** + * Extract the #7936 request-tool identity map from the translated body and + * strip both side channels before dispatch. + * + * #9780 — prefer the dedicated `_namespaceToolIdentityMap`: on a pivot the + * openai->claude/gemini step publishes its own alias `Map` on + * `_toolNameMap`, so that property alone can yield aliases instead of + * identities. The `_toolNameMap` read stays as the fallback for the non-pivot + * producers (executors/base.ts, cliproxyapi.ts, antigravity). + */ +export function extractRequestToolIdentityMap( + translatedBody: Record +): Map | null { + const namespaceIdentityMap = translatedBody._namespaceToolIdentityMap; + const requestToolIdentityMap = + namespaceIdentityMap instanceof Map + ? namespaceIdentityMap + : translatedBody._toolNameMap instanceof Map + ? translatedBody._toolNameMap + : null; + delete translatedBody._namespaceToolIdentityMap; + delete translatedBody._toolNameMap; + return requestToolIdentityMap as Map | null; +} diff --git a/open-sse/handlers/chatCore/sanitization.ts b/open-sse/handlers/chatCore/sanitization.ts index 62b43615ed..f36fcc02be 100644 --- a/open-sse/handlers/chatCore/sanitization.ts +++ b/open-sse/handlers/chatCore/sanitization.ts @@ -6,8 +6,8 @@ export function sanitizeChatRequestBody( sourceFormat: string, targetFormat: string ): Record { - const prefersResponsesTokenField = - sourceFormat === FORMATS.OPENAI_RESPONSES || targetFormat === FORMATS.OPENAI_RESPONSES; + void sourceFormat; + const prefersResponsesTokenField = targetFormat === FORMATS.OPENAI_RESPONSES; if (prefersResponsesTokenField) { if (body.max_output_tokens === undefined) { @@ -46,7 +46,7 @@ export function sanitizeChatRequestBody( } if (Array.isArray(body.tools)) { - body.tools = body.tools.filter((tool: Record) => { + const tools = body.tools.filter((tool: Record) => { const toolType = typeof tool.type === "string" ? tool.type : ""; if (toolType && toolType !== "function" && !tool.function && tool.name === undefined) { return true; @@ -56,7 +56,7 @@ export function sanitizeChatRequestBody( return name && String(name).trim().length > 0; }); - body.tools = body.tools.map((tool) => sanitizeOpenAITool(tool) as (typeof body.tools)[number]); + body.tools = tools.map((tool) => sanitizeOpenAITool(tool)); } return body; diff --git a/open-sse/handlers/chatCore/semanticCache.ts b/open-sse/handlers/chatCore/semanticCache.ts index 5a3de78e25..fbcf53fedb 100644 --- a/open-sse/handlers/chatCore/semanticCache.ts +++ b/open-sse/handlers/chatCore/semanticCache.ts @@ -24,6 +24,7 @@ export async function checkSemanticCache({ log, persistAttemptLogs, apiKeyId, + cacheDefaultMode, }: { semanticCacheEnabled: boolean; // Only the fields this read path actually touches are named; everything else @@ -40,7 +41,10 @@ export async function checkSemanticCache({ log: { debug?: (...args: unknown[]) => void } | null; persistAttemptLogs: (args: unknown) => void; apiKeyId?: string | null; + cacheDefaultMode?: "legacy" | "bypass" | null; }) { + // Per-key bypass: skip cache lookup entirely when the API key opts out. + if (cacheDefaultMode === "bypass") return null; if (semanticCacheEnabled && isCacheableForRead(body, clientRawRequest?.headers)) { const signature = generateSignature( model, @@ -75,6 +79,9 @@ export async function checkSemanticCache({ const headers: Record = { "Content-Type": cachedSse ? "text/event-stream" : "application/json", [OMNIROUTE_RESPONSE_HEADERS.cache]: "HIT", + // Marker for latency measurement tools: this response served from cache + // has synthetic (near-zero) latency, not real upstream latency. + [OMNIROUTE_RESPONSE_HEADERS.cacheLatency]: "synthetic", }; // A cache HIT serves WITHOUT an upstream call, so the incremental cost billed to // the client is 0 (consumers that sum X-OmniRoute-Response-Cost must not charge for diff --git a/open-sse/handlers/chatCore/targetFormat.ts b/open-sse/handlers/chatCore/targetFormat.ts index cde4be6162..991a5c3eb2 100644 --- a/open-sse/handlers/chatCore/targetFormat.ts +++ b/open-sse/handlers/chatCore/targetFormat.ts @@ -2,13 +2,12 @@ * chatCore wire target-format resolver (Quality Gate v2 / Fase 9 — chatCore god-file * decomposition, #3501). * - * Pure resolution of the provider alias + the upstream target format used to translate the request: - * apiFormat==="responses" forces OpenAI Responses; otherwise the model's registry target format, then - * the per-model custom override (#2905), then AgentRouter's matching inbound protocol when the - * connection has no explicit override, then the provider default. Returns both `alias` (reused by - * the handler when stripping the `alias/` prefix off the upstream model id) and `targetFormat`. - * Side-effect-free; sits alongside the other request-setup resolvers - * (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat). + * Pure resolution of the provider alias + the upstream target format used to translate the request. + * Model/custom overrides win first. A Responses-shaped inbound request normally keeps the Responses + * wire format, except for custom OpenAI-compatible connections explicitly configured for Chat. + * AgentRouter may inherit the inbound protocol when no explicit connection override exists. + * Returns both `alias` (reused by the handler when stripping the `alias/` prefix off the upstream + * model id) and `targetFormat`. */ import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts"; @@ -46,15 +45,19 @@ export function resolveChatCoreTargetFormat(opts: { sourceFormat === FORMATS.CLAUDE) ? sourceFormat : undefined; + const providerTargetFormat = getTargetFormat(provider, providerSpecificData); + const customOpenAICompatible = provider.startsWith("openai-compatible-"); // #8994: model-level targetFormat overrides (from registry or custom-model DB override) // take precedence over apiFormat="responses" — otherwise Vertex Claude models with // targetFormat="claude" get wrongly routed to OpenAI Responses format. + // #9161: a custom OpenAI-compatible Chat connection must likewise keep its configured + // outbound protocol when a Responses-shaped client (for example Codex) calls /responses. let targetFormat = modelTargetFormat || customModelTargetFormat || - (apiFormat === "responses" + (apiFormat === "responses" && !customOpenAICompatible ? FORMATS.OPENAI_RESPONSES - : inferredAgentRouterTargetFormat || getTargetFormat(provider, providerSpecificData)); + : inferredAgentRouterTargetFormat || providerTargetFormat); if (nativeXaiResponsesPassthrough) targetFormat = FORMATS.OPENAI_RESPONSES; return { alias, targetFormat }; } diff --git a/open-sse/handlers/chatCore/upstreamBody.ts b/open-sse/handlers/chatCore/upstreamBody.ts index 57e3a358b4..f2d8368c8c 100644 --- a/open-sse/handlers/chatCore/upstreamBody.ts +++ b/open-sse/handlers/chatCore/upstreamBody.ts @@ -99,7 +99,7 @@ async function injectPromptCacheKey( providerSupportsCaching(provider, undefined, connectionCacheOverride) && !bodyToSend.prompt_cache_key && Array.isArray(bodyToSend.messages) && - !["nvidia", "codex", "xai"].includes(provider) + !["nvidia", "xai"].includes(provider) ) { const { generatePromptCacheKey } = await import("@/lib/promptCache"); const cacheKey = generatePromptCacheKey(bodyToSend.messages); diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 97941262ed..f8f8b3d001 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -627,6 +627,17 @@ export async function handleImageGeneration({ }); } + if ( + providerConfig.format === "agnes-image" && + (typeof body.size !== "string" || body.size.trim().length === 0) + ) { + return { + success: false, + status: 400, + error: "Size is required for Agnes Image 2.1 Flash", + }; + } + if ( providerConfig.format === "alibaba-image" || providerConfig.format === "qwen-cloud-image" || @@ -1017,6 +1028,33 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden /** * Handle OpenAI-compatible image generation (standard providers + Nebius fallback) */ +function buildAgnesImageRequestBody(model, body) { + const upstreamBody: Record = { + model, + prompt: body.prompt, + }; + + if (body.size !== undefined) upstreamBody.size = body.size; + if (body.ratio !== undefined) { + upstreamBody.ratio = body.ratio; + } else if (body.aspect_ratio !== undefined) { + upstreamBody.ratio = body.aspect_ratio; + } + if (body.return_base64 !== undefined) upstreamBody.return_base64 = body.return_base64; + + const explicitExtraBody = + body.extra_body && typeof body.extra_body === "object" && !Array.isArray(body.extra_body) + ? body.extra_body + : {}; + const extraBody: Record = { ...explicitExtraBody }; + const { imageUrls } = extractImageInputs(body); + if (imageUrls.length > 0) extraBody.image = imageUrls; + if (body.response_format !== undefined) extraBody.response_format = body.response_format; + if (Object.keys(extraBody).length > 0) upstreamBody.extra_body = extraBody; + + return upstreamBody; +} + async function handleOpenAIImageGeneration({ model, provider, @@ -1040,21 +1078,26 @@ async function handleOpenAIImageGeneration({ }; // Build upstream request (OpenAI-compatible format) - const upstreamBody: Record = { - model: model, - prompt: body.prompt, - }; + const upstreamBody: Record = + providerConfig.format === "agnes-image" + ? buildAgnesImageRequestBody(model, body) + : { + model, + prompt: body.prompt, + }; - // Pass optional parameters - if (body.n !== undefined) upstreamBody.n = body.n; - if (body.size !== undefined) upstreamBody.size = body.size; - if (body.quality !== undefined) upstreamBody.quality = body.quality; - if (body.response_format !== undefined) upstreamBody.response_format = body.response_format; - if (body.style !== undefined) upstreamBody.style = body.style; + if (providerConfig.format !== "agnes-image") { + // Pass optional parameters for ordinary OpenAI-compatible providers. + if (body.n !== undefined) upstreamBody.n = body.n; + if (body.size !== undefined) upstreamBody.size = body.size; + if (body.quality !== undefined) upstreamBody.quality = body.quality; + if (body.response_format !== undefined) upstreamBody.response_format = body.response_format; + if (body.style !== undefined) upstreamBody.style = body.style; - const { imageUrl } = extractImageInputs(body); - if (imageUrl && OPENAI_IMAGE_TO_IMAGE_MODELS.has(model)) { - upstreamBody.image_url = imageUrl; + const { imageUrl } = extractImageInputs(body); + if (imageUrl && OPENAI_IMAGE_TO_IMAGE_MODELS.has(model)) { + upstreamBody.image_url = imageUrl; + } } // Build headers @@ -1455,6 +1498,7 @@ async function handleFalAIImageGeneration({ }) { const startTime = Date.now(); const token = credentials.apiKey || credentials.accessToken; + const falModel = model.startsWith("fal-ai/") ? model : `fal-ai/${model}`; const { imageUrl, imageUrls } = extractImageInputs(body); const upstreamBody: Record = { prompt: body.prompt, @@ -1500,7 +1544,7 @@ async function handleFalAIImageGeneration({ } try { - const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/${model}`, { + const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/${falModel}`, { method: "POST", headers: { "Content-Type": "application/json", @@ -1524,7 +1568,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 +1758,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 +1877,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 +1952,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, @@ -2149,7 +2193,7 @@ function parseSizeToDimensions(size, fallback = 1024) { }; } -function normalizeRequestedImageFormat( +export function normalizeRequestedImageFormat( body, fallback = "png", allowedFormats = ["jpeg", "png", "webp"] @@ -2169,7 +2213,7 @@ function normalizeRequestedImageFormat( return fallback; } -function mapFalImageSize(size, fallback = "square_hd") { +export function mapFalImageSize(size, fallback = "square_hd") { if (typeof size !== "string") return fallback; if (FAL_PRESET_SIZES[size]) return FAL_PRESET_SIZES[size]; if (size.includes("x")) { @@ -2200,7 +2244,7 @@ function shouldIncludeStabilityMask(model) { ]).has(model); } -async function normalizeProviderImagePayload(payload, body, log) { +export async function normalizeProviderImagePayload(payload, body, log, defaultFormat) { const candidates = []; const pushCandidate = (value) => { @@ -2226,7 +2270,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 +2284,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 24f2ba361d..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, - adobeFireflyMaxImageRefs, - resolveAdobeAccessToken, resolveAdobeSourceImageIds, resolveAdobeImageModel, } from "../../../services/adobeFireflyClient.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,47 +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. gpt-image: 2 subject refs max (3–4+ stalls colligo → 504). - // nano: 4 general refs for multi-panel composition. + // Cap uploads by model family (matches MediaViewModel GetSourceImageLimit). const { id: resolvedId } = resolveAdobeImageModel(model); - const maxRefs = adobeFireflyMaxImageRefs(resolvedId); + const maxRefs = resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") ? 4 : 2; const sourceImageIds = await resolveAdobeSourceImageIds({ accessToken, body, 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: sourceImageIds.length, - }); - log?.info?.( "IMAGE", `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + - (sourceImageIds.length ? ` | refs: ${sourceImageIds.length}/${maxRefs}` : "") + - ` | pollTimeoutMs=${timeoutMs}` + (sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") + + ` | session=${session.source}` ); const result = await adobeFireflyGenerateImage({ @@ -139,10 +126,12 @@ export async function handleAdobeFireflyImageGeneration({ aspectRatio: body.aspect_ratio ?? body.aspectRatio ?? body.size, quality: body.quality, seed: Number.isFinite(seed as number) ? (seed as number) : undefined, - negativePrompt: - typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, + negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, sessionCookie, + arpSessionId, + sessionFingerprint: session.fingerprint, + sessionBrowserKey: session.browserSessionKey, timeoutMs, fetchImpl, log, diff --git a/open-sse/handlers/imageGeneration/providers/fal.ts b/open-sse/handlers/imageGeneration/providers/fal.ts new file mode 100644 index 0000000000..5d4617623e --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/fal.ts @@ -0,0 +1,115 @@ +import type { ExecutorLog, ProviderCredentials } from "../../../executors/base.ts"; +import { + mapFalImageSize, + normalizeProviderImagePayload, + normalizeRequestedImageFormat, + saveImageErrorResult, + saveImageSuccessResult, +} from "../../imageGeneration.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export const FAL_IMAGE_EDIT_MODELS = new Set([ + "fal-ai/flux-2-flex", + "fal-ai/flux-2-pro", + "fal-ai/flux-2-max", +]); + +export const FAL_IMAGE_EDIT_MAX_REFERENCES = 10; + +export function isFalImageEditModel(model: string | null): boolean { + return typeof model === "string" && FAL_IMAGE_EDIT_MODELS.has(model); +} + +type FalAIImageEditOptions = { + model: string; + provider: string; + providerConfig: { baseUrl: string }; + body: Record; + images: Array<{ bytes: Buffer; mime: string }>; + credentials: ProviderCredentials; + log: ExecutorLog | null | undefined; +}; + +export async function handleFalAIImageEdit({ + model, + provider, + providerConfig, + body, + images, + credentials, + log, +}: FalAIImageEditOptions) { + const startTime = Date.now(); + const editModel = `${model}/edit`; + const outputFormat = normalizeRequestedImageFormat(body, "png", ["jpeg", "png"]); + const upstreamBody: Record = { + prompt: body.prompt, + image_urls: images.map( + ({ bytes, mime }) => `data:${mime || "image/png"};base64,${bytes.toString("base64")}` + ), + image_size: mapFalImageSize(body.size, "auto"), + output_format: outputFormat, + sync_mode: body.sync_mode ?? true, + }; + + if (body.n !== undefined) upstreamBody.num_images = Number(body.n) || 1; + if (body.seed !== undefined) upstreamBody.seed = body.seed; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${editModel} (fal-ai edit) | prompt: "${promptPreview}..."`); + } + + try { + const token = credentials.apiKey || credentials.accessToken; + const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/${editModel}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Key ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + return saveImageErrorResult({ + provider, + model: editModel, + status: response.status, + startTime, + error: errorText, + requestBody: upstreamBody, + path: "/v1/images/edits", + }); + } + + const payload = await response.json(); + const normalizedBody = + body.response_format === undefined ? { ...body, response_format: "b64_json" } : body; + const imagesOut = await normalizeProviderImagePayload(payload, normalizedBody, log, "b64_json"); + return saveImageSuccessResult({ + provider, + model: editModel, + startTime, + requestBody: upstreamBody, + responseBody: { images_count: imagesOut.length }, + created: payload.created, + images: imagesOut, + path: "/v1/images/edits", + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (log) log.error("IMAGE", `${provider} fetch error: ${message}`); + return saveImageErrorResult({ + provider, + model: editModel, + status: 502, + startTime, + error: `Image provider error: ${sanitizeErrorMessage(message || err)}`, + path: "/v1/images/edits", + }); + } +} diff --git a/open-sse/handlers/mediaGeneration/fal.test.ts b/open-sse/handlers/mediaGeneration/fal.test.ts new file mode 100644 index 0000000000..ec9e05fcf6 --- /dev/null +++ b/open-sse/handlers/mediaGeneration/fal.test.ts @@ -0,0 +1,359 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + buildFalMusicRequestBody, + buildFalVideoRequestBody, + handleFalMusicGeneration, + handleFalVideoGeneration, + normalizeFalMediaResult, +} from "./fal.ts"; +import { parseImageModel } from "../../config/imageRegistry.ts"; +import { parseMusicModel } from "../../config/musicRegistry.ts"; +import { parseVideoModel } from "../../config/videoRegistry.ts"; + +test("buildFalVideoRequestBody maps the OpenAI-compatible request", () => { + assert.deepEqual( + buildFalVideoRequestBody({ + prompt: "A quiet train crossing a snowy bridge", + aspect_ratio: "9:16", + duration: 6, + resolution: "1080p", + generate_audio: false, + negative_prompt: "text overlays", + seed: 42, + }), + { + prompt: "A quiet train crossing a snowy bridge", + aspect_ratio: "9:16", + duration: "6s", + resolution: "1080p", + generate_audio: false, + negative_prompt: "text overlays", + seed: 42, + } + ); +}); + +test("buildFalVideoRequestBody maps the Fal-hosted Grok endpoint schema", () => { + assert.deepEqual( + buildFalVideoRequestBody( + { + prompt: "A realistic dog walking through a park", + aspect_ratio: "16:9", + duration: "8s", + resolution: "720p", + generate_audio: true, + }, + "xai/grok-imagine-video/text-to-video" + ), + { + prompt: "A realistic dog walking through a park", + aspect_ratio: "16:9", + duration: 8, + resolution: "720p", + } + ); +}); + +test("buildFalVideoRequestBody maps one provider-neutral image reference", () => { + assert.deepEqual( + buildFalVideoRequestBody( + { + prompt: "Animate this dog", + image_urls: ["data:image/png;base64,ZmFrZQ=="], + }, + "xai/grok-imagine-video/text-to-video" + ), + { + prompt: "Animate this dog", + aspect_ratio: "16:9", + duration: 6, + resolution: "720p", + image_url: "data:image/png;base64,ZmFrZQ==", + } + ); +}); + +test("buildFalVideoRequestBody maps multiple provider-neutral image references", () => { + assert.deepEqual( + buildFalVideoRequestBody( + { + prompt: "Combine these references", + image_urls: ["data:image/png;base64,YQ==", "data:image/png;base64,Yg=="], + }, + "xai/grok-imagine-video/text-to-video" + ), + { + prompt: "Combine these references", + aspect_ratio: "16:9", + duration: 6, + resolution: "720p", + reference_image_urls: ["data:image/png;base64,YQ==", "data:image/png;base64,Yg=="], + } + ); +}); + +test("buildFalVideoRequestBody maps the Gemini Omni Flash video schema", () => { + assert.deepEqual( + buildFalVideoRequestBody( + { + prompt: "A realistic dog walking through a park", + aspect_ratio: "9:16", + duration: 10, + resolution: "1080p", + generate_audio: false, + }, + "google/gemini-omni-flash" + ), + { + prompt: "A realistic dog walking through a park", + aspect_ratio: "9:16", + duration: 10, + } + ); +}); + +test("handleFalVideoGeneration selects Gemini Omni Flash image-to-video", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; body: string }> = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), body: String(init?.body) }); + return new Response(JSON.stringify({ video: { url: "https://cdn.example/gemini.mp4" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleFalVideoGeneration({ + model: "google/gemini-omni-flash", + provider: "fal-ai", + providerConfig: { baseUrl: "https://queue.fal.run" }, + body: { + prompt: "Animate this dog", + image_urls: ["data:image/png;base64,ZmFrZQ=="], + duration: 8, + }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true); + assert.equal(requests[0]?.url, "https://queue.fal.run/google/gemini-omni-flash/image-to-video"); + assert.deepEqual(JSON.parse(requests[0]?.body || "{}"), { + prompt: "Animate this dog", + aspect_ratio: "16:9", + duration: 8, + image_url: "data:image/png;base64,ZmFrZQ==", + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("buildFalMusicRequestBody uses prompt as tags and supports lyrics", () => { + assert.deepEqual( + buildFalMusicRequestBody({ + prompt: "warm analog synthwave", + lyrics: "[verse] Drive through the night", + duration: 30, + seed: 7, + }), + { + tags: "warm analog synthwave", + lyrics: "[verse] Drive through the night", + duration: 30, + seed: 7, + } + ); +}); + +test("normalizeFalMediaResult returns typed media URLs", () => { + assert.deepEqual( + normalizeFalMediaResult( + { + video: { + url: "https://cdn.example/video.mp4", + content_type: "video/mp4", + }, + }, + "video" + ), + { + success: true, + data: { + created: 0, + data: [{ url: "https://cdn.example/video.mp4", format: "mp4" }], + }, + } + ); + + assert.deepEqual( + normalizeFalMediaResult({ audio: { url: "https://cdn.example/song.wav" } }, "music"), + { + success: true, + data: { + created: 0, + data: [{ url: "https://cdn.example/song.wav", format: "wav" }], + }, + } + ); +}); + +test("normalizeFalMediaResult rejects a missing artifact", () => { + assert.deepEqual(normalizeFalMediaResult({}, "video"), { + success: false, + status: 502, + error: "Fal video generation returned no media URL", + }); +}); + +test("media registries expose provider-neutral model IDs", () => { + assert.deepEqual(parseImageModel("fal-ai/flux-2-pro"), { + provider: "fal-ai", + model: "flux-2-pro", + }); + assert.deepEqual(parseVideoModel("fal-ai/veo3.1/lite"), { + provider: "fal-ai", + model: "veo3.1/lite", + }); + assert.deepEqual(parseMusicModel("fal-ai/ace-step"), { + provider: "fal-ai", + model: "ace-step", + }); +}); + +test("handleFalVideoGeneration uses the provider-neutral queue contract", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; body: string }> = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), body: String(init?.body) }); + return new Response(JSON.stringify({ video: { url: "https://cdn.example/video.mp4" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleFalVideoGeneration({ + model: "veo3.1/lite", + provider: "fal-ai", + providerConfig: { baseUrl: "https://queue.fal.run" }, + body: { prompt: "a slow pan across a forest", duration: 4 }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true); + assert.equal(requests[0]?.url, "https://queue.fal.run/fal-ai/veo3.1/lite"); + assert.deepEqual(JSON.parse(requests[0]?.body || "{}"), { + prompt: "a slow pan across a forest", + aspect_ratio: "16:9", + duration: "4s", + resolution: "720p", + generate_audio: true, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleFalVideoGeneration preserves Fal model paths outside the fal-ai namespace", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; body: string }> = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), body: String(init?.body) }); + return new Response(JSON.stringify({ video: { url: "https://cdn.example/grok.mp4" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleFalVideoGeneration({ + model: "xai/grok-imagine-video/text-to-video", + provider: "fal-ai", + providerConfig: { baseUrl: "https://queue.fal.run" }, + body: { prompt: "a dog walking through a park", duration: 8 }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true); + assert.equal(requests[0]?.url, "https://queue.fal.run/xai/grok-imagine-video/text-to-video"); + assert.deepEqual(JSON.parse(requests[0]?.body || "{}"), { + prompt: "a dog walking through a park", + aspect_ratio: "16:9", + duration: 8, + resolution: "720p", + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleFalVideoGeneration selects Grok image-to-video for one reference image", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; body: string }> = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), body: String(init?.body) }); + return new Response(JSON.stringify({ video: { url: "https://cdn.example/grok-i2v.mp4" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleFalVideoGeneration({ + model: "xai/grok-imagine-video/text-to-video", + provider: "fal-ai", + providerConfig: { baseUrl: "https://queue.fal.run" }, + body: { + prompt: "Animate this dog", + image_urls: ["data:image/png;base64,ZmFrZQ=="], + }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true); + assert.equal(requests[0]?.url, "https://queue.fal.run/xai/grok-imagine-video/image-to-video"); + assert.deepEqual(JSON.parse(requests[0]?.body || "{}"), { + prompt: "Animate this dog", + aspect_ratio: "16:9", + duration: 6, + resolution: "720p", + image_url: "data:image/png;base64,ZmFrZQ==", + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleFalMusicGeneration uses the provider-neutral queue contract", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; body: string }> = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), body: String(init?.body) }); + return new Response(JSON.stringify({ audio: { url: "https://cdn.example/music.wav" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleFalMusicGeneration({ + model: "ace-step", + provider: "fal-ai", + providerConfig: { baseUrl: "https://queue.fal.run" }, + body: { prompt: "ambient synthwave", lyrics: "stay awake", duration: 30 }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true); + assert.equal(requests[0]?.url, "https://queue.fal.run/fal-ai/ace-step"); + assert.deepEqual(JSON.parse(requests[0]?.body || "{}"), { + tags: "ambient synthwave", + lyrics: "stay awake", + duration: 30, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/open-sse/handlers/mediaGeneration/fal.ts b/open-sse/handlers/mediaGeneration/fal.ts new file mode 100644 index 0000000000..85053ef17d --- /dev/null +++ b/open-sse/handlers/mediaGeneration/fal.ts @@ -0,0 +1,396 @@ +import { saveCallLog } from "@/lib/usageDb"; +import { + FetchTimeoutError, + fetchWithTimeout, + getConfiguredTimeout, +} from "../../../src/shared/utils/fetchTimeout.ts"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +type MediaKind = "video" | "music"; + +type FalBody = Record; + +type FalCredentials = { + apiKey?: unknown; + accessToken?: unknown; +}; + +type FalProviderConfig = { + baseUrl: string; +}; + +type FalLog = { + info?: (scope: string, message: string, meta?: unknown) => void; + error?: (scope: string, message: string) => void; +}; + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function stringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map(stringValue).filter((value): value is string => Boolean(value)); +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function falDuration(value: unknown, fallback: string): string { + if (typeof value === "string" && /^(4|6|8)s$/.test(value)) return value; + const numeric = numberValue(value); + return numeric && [4, 6, 8].includes(numeric) ? `${numeric}s` : fallback; +} + +function grokDuration(value: unknown, fallback = 6): number { + const numeric = numberValue(value); + if (numeric !== undefined) return Math.round(numeric); + if (typeof value === "string") { + const match = value.trim().match(/^(\d+)s$/); + if (match) return Number(match[1]); + } + return fallback; +} + +function geminiDuration(value: unknown, fallback = 8): number { + const numeric = numberValue(value); + const parsed = + numeric ?? + (typeof value === "string" && /^\d+(?:\.\d+)?s$/.test(value.trim()) + ? Number(value.trim().slice(0, -1)) + : undefined); + return parsed === undefined ? fallback : Math.min(10, Math.max(3, Math.round(parsed))); +} + +export function buildFalVideoRequestBody(body: FalBody, model = ""): FalBody { + if (model.startsWith("google/gemini-omni-flash")) { + const request: FalBody = { + prompt: stringValue(body.prompt) || "", + aspect_ratio: stringValue(body.aspect_ratio) || "16:9", + duration: geminiDuration(body.duration), + }; + + const imageUrl = stringValue(body.image_url) || stringArray(body.image_urls)[0]; + if (imageUrl) request.image_url = imageUrl; + + return request; + } + + if (model.startsWith("xai/grok-imagine-video/")) { + const request: FalBody = { + prompt: stringValue(body.prompt) || "", + aspect_ratio: stringValue(body.aspect_ratio) || "16:9", + duration: grokDuration(body.duration), + resolution: stringValue(body.resolution) || "720p", + }; + + const imageUrls = stringArray(body.image_urls); + if (imageUrls.length === 1) { + request.image_url = imageUrls[0]; + } else if (imageUrls.length > 1) { + request.reference_image_urls = imageUrls; + } + + return request; + } + + const request: FalBody = { + prompt: stringValue(body.prompt) || "", + aspect_ratio: stringValue(body.aspect_ratio) || "16:9", + duration: falDuration(body.duration, "8s"), + resolution: stringValue(body.resolution) || (body.quality === "hd" ? "1080p" : "720p"), + generate_audio: typeof body.generate_audio === "boolean" ? body.generate_audio : true, + }; + + const optionalStringFields = ["negative_prompt", "safety_tolerance"]; + for (const field of optionalStringFields) { + const value = stringValue(body[field]); + if (value) request[field] = value; + } + + const seed = numberValue(body.seed); + if (seed !== undefined) request.seed = seed; + if (typeof body.auto_fix === "boolean") request.auto_fix = body.auto_fix; + + return request; +} + +function resolveFalModel(model: string, body: FalBody, kind: MediaKind): string { + if (kind !== "video") return model; + + if (model.startsWith("google/gemini-omni-flash") && !model.endsWith("/image-to-video")) { + const hasImage = typeof body.image_url === "string" || stringArray(body.image_urls).length > 0; + return hasImage ? "google/gemini-omni-flash/image-to-video" : model; + } + + if (!model.startsWith("xai/grok-imagine-video/")) return model; + + const suffix = Array.isArray(body.reference_image_urls) + ? "reference-to-video" + : typeof body.image_url === "string" + ? "image-to-video" + : "text-to-video"; + return `xai/grok-imagine-video/${suffix}`; +} + +export function buildFalMusicRequestBody(body: FalBody): FalBody { + const request: FalBody = { + tags: stringValue(body.tags) || stringValue(body.prompt) || "", + }; + + const lyrics = stringValue(body.lyrics); + if (lyrics) request.lyrics = lyrics; + + const duration = numberValue(body.duration); + if (duration !== undefined) request.duration = Math.min(240, Math.max(5, duration)); + + const seed = numberValue(body.seed); + if (seed !== undefined) request.seed = seed; + + const optionalNumberFields = [ + "number_of_steps", + "granularity_scale", + "guidance_interval", + "guidance_interval_decay", + "tag_guidance_scale", + "lyric_guidance_scale", + "minimum_guidance_scale", + "guidance_scale", + ]; + for (const field of optionalNumberFields) { + const value = numberValue(body[field]); + if (value !== undefined) request[field] = value; + } + + const scheduler = stringValue(body.scheduler); + if (scheduler === "euler" || scheduler === "heun") request.scheduler = scheduler; + + const guidanceType = stringValue(body.guidance_type); + if (guidanceType === "cfg" || guidanceType === "apg" || guidanceType === "cfg_star") { + request.guidance_type = guidanceType; + } + + return request; +} + +function extensionFromMedia(item: Record, kind: MediaKind): string { + const contentType = stringValue(item.content_type); + if (contentType?.includes("/")) return contentType.split("/", 2)[1]; + + const fileName = stringValue(item.file_name); + const url = stringValue(item.url); + const candidate = fileName || url || ""; + const extension = candidate.match(/\.([a-z0-9]+)(?:\?|$)/i)?.[1]?.toLowerCase(); + return extension || (kind === "video" ? "mp4" : "wav"); +} + +export function normalizeFalMediaResult(payload: unknown, kind: MediaKind) { + const record = payload && typeof payload === "object" ? (payload as FalBody) : {}; + const media = record[kind === "video" ? "video" : "audio"]; + const item = media && typeof media === "object" ? (media as Record) : null; + const url = stringValue(item?.url); + + if (!url) { + return { + success: false as const, + status: 502, + error: `Fal ${kind} generation returned no media URL`, + }; + } + + return { + success: true as const, + data: { + created: numberValue(record.created) || 0, + data: [{ url, format: extensionFromMedia(item, kind) }], + }, + }; +} + +function absoluteFalUrl(value: unknown, baseUrl: string): string | undefined { + const url = stringValue(value); + if (!url) return undefined; + return url.startsWith("http://") || url.startsWith("https://") + ? url + : `${baseUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`; +} + +function getToken(credentials: FalCredentials | null | undefined): string { + return String(credentials?.apiKey || credentials?.accessToken || ""); +} + +async function wait(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runFalQueue({ + model, + body, + kind, + provider, + providerConfig, + credentials, + log, +}: { + model: string; + body: FalBody; + kind: MediaKind; + provider: string; + providerConfig: FalProviderConfig; + credentials: FalCredentials | null | undefined; + log?: FalLog | null; +}) { + const startTime = Date.now(); + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const token = getToken(credentials); + const headers = { + Authorization: `Key ${token}`, + "Content-Type": "application/json", + }; + const timeoutMs = getConfiguredTimeout(); + const deadline = startTime + timeoutMs; + const resolvedModel = resolveFalModel(model, body, kind); + const falModel = + resolvedModel.startsWith("fal-ai/") || + resolvedModel.startsWith("xai/") || + resolvedModel.startsWith("google/") + ? resolvedModel + : `fal-ai/${resolvedModel}`; + const queueUrl = `${baseUrl}/${falModel}`; + + try { + const createResponse = await fetchWithTimeout(queueUrl, { + method: "POST", + headers, + body: JSON.stringify(body), + timeoutMs, + }); + const createPayload = await createResponse.json().catch(() => ({})); + + if (!createResponse.ok) { + const error = JSON.stringify(createPayload).slice(0, 500); + log?.error?.( + "MEDIA", + `${provider} ${kind} create failed (${createResponse.status}): ${error}` + ); + saveCallLog({ + method: "POST", + path: `/v1/${kind === "video" ? "videos" : "music"}/generations`, + status: createResponse.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error, + }).catch(() => {}); + return { success: false, status: createResponse.status, error }; + } + + const requestId = stringValue(createPayload?.request_id); + if (!requestId) { + const normalized = normalizeFalMediaResult(createPayload, kind); + if (!normalized.success) return normalized; + return normalized; + } + + const statusUrl = + absoluteFalUrl(createPayload.status_url, baseUrl) || + `${queueUrl}/requests/${requestId}/status`; + const responseUrl = + absoluteFalUrl(createPayload.response_url, baseUrl) || `${queueUrl}/requests/${requestId}`; + + while (Date.now() < deadline) { + const statusResponse = await fetchWithTimeout(statusUrl, { + headers: { Authorization: `Key ${token}` }, + timeoutMs: Math.min(getConfiguredTimeout(), Math.max(1000, deadline - Date.now())), + }); + const statusPayload = await statusResponse.json().catch(() => ({})); + + if (!statusResponse.ok) { + const error = JSON.stringify(statusPayload).slice(0, 500); + return { success: false, status: statusResponse.status, error }; + } + + const status = stringValue(statusPayload?.status); + if (status === "COMPLETED") { + const resultResponse = await fetchWithTimeout(responseUrl, { + headers: { Authorization: `Key ${token}` }, + timeoutMs: Math.min(getConfiguredTimeout(), Math.max(1000, deadline - Date.now())), + }); + const resultPayload = await resultResponse.json().catch(() => ({})); + if (!resultResponse.ok) { + return { + success: false, + status: resultResponse.status, + error: JSON.stringify(resultPayload).slice(0, 500), + }; + } + + const normalized = normalizeFalMediaResult(resultPayload, kind); + saveCallLog({ + method: "POST", + path: `/v1/${kind === "video" ? "videos" : "music"}/generations`, + status: normalized.success ? 200 : normalized.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + ...(normalized.success ? {} : { error: normalized.error }), + }).catch(() => {}); + return normalized; + } + + if (status && !["IN_QUEUE", "IN_PROGRESS"].includes(status)) { + return { + success: false, + status: 502, + error: `Fal ${kind} generation ended with status ${status}`, + }; + } + + await wait(Math.min(1000, Math.max(100, deadline - Date.now()))); + } + + return { + success: false, + status: 504, + error: `Fal ${kind} generation timed out after ${timeoutMs}ms`, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const isTimeout = + error instanceof FetchTimeoutError || (error as { name?: string })?.name === "AbortError"; + const status = isTimeout ? 504 : 502; + log?.error?.("MEDIA", `${provider} ${kind} request failed: ${sanitizeErrorMessage(message)}`); + return { + success: false, + status, + error: `Fal ${kind} provider error: ${sanitizeErrorMessage(message)}`, + }; + } +} + +export function handleFalVideoGeneration(args: { + model: string; + provider: string; + providerConfig: FalProviderConfig; + body: FalBody; + credentials: FalCredentials | null | undefined; + log?: FalLog | null; +}) { + return runFalQueue({ + ...args, + body: buildFalVideoRequestBody(args.body, args.model), + kind: "video", + }); +} + +export function handleFalMusicGeneration(args: { + model: string; + provider: string; + providerConfig: FalProviderConfig; + body: FalBody; + credentials: FalCredentials | null | undefined; + log?: FalLog | null; +}) { + return runFalQueue({ ...args, body: buildFalMusicRequestBody(args.body), kind: "music" }); +} diff --git a/open-sse/handlers/musicGeneration.ts b/open-sse/handlers/musicGeneration.ts index 766abdd542..92ee333c2e 100644 --- a/open-sse/handlers/musicGeneration.ts +++ b/open-sse/handlers/musicGeneration.ts @@ -32,6 +32,7 @@ import { parseKieResultJson, } from "../utils/kieTask.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { handleFalMusicGeneration } from "./mediaGeneration/fal.ts"; function normalizeKieSunoModel(model: string): string { const map: Record = { @@ -124,6 +125,10 @@ export async function handleMusicGeneration({ body, credentials, log }) { } } + if (providerConfig.format === "fal-ai-music") { + return handleFalMusicGeneration({ model, provider, providerConfig, body, credentials, log }); + } + if (providerConfig.format === "comfyui") { return handleComfyUIMusicGeneration({ model, diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 77a126ed34..4ef3e20973 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -8,6 +8,7 @@ import { collapseExcessiveNewlines, extractThinkingFromContent, } from "./responseSanitizer/reasoning.ts"; +import { applyCacheHitTokensToUsage, applyCacheHitTokensToResponsesUsage } from "./responseSanitizer/cacheHitTokens.ts"; export { extractThinkingFromContent, shouldParseTextualReasoningTags, @@ -30,7 +31,7 @@ const ALLOWED_USAGE_FIELDS = new Set([ "total_tokens", "cached_tokens", "prompt_tokens_details", - "completion_tokens_details", + "completion_tokens_details", "cache_read_input_tokens", "cache_creation_input_tokens", // Keep through sanitize → applyClientUsageBuffer so heuristic web usage is // not inflated by the default USAGE_TOKEN_BUFFER (2000). "estimated", @@ -495,7 +496,7 @@ function sanitizeUsage(usage: unknown): unknown { sanitized[key] = usageRecord[key]; } } - + applyCacheHitTokensToUsage(usageRecord, sanitized); // DeepSeek/MiniMax/Bedrock cache-hit passthrough (#8171) // Ensure required fields const promptTokens = toNumber(sanitized.prompt_tokens) ?? 0; const completionTokens = toNumber(sanitized.completion_tokens) ?? 0; @@ -533,6 +534,29 @@ function sanitizeResponsesUsage(usage: unknown): unknown { normalized.output_tokens_details = normalized.completion_tokens_details; } + // DeepSeek native API: map flat prompt_cache_hit_tokens into input_tokens_details + if ( + normalized.prompt_cache_hit_tokens !== undefined && + !normalized.input_tokens_details?.cached_tokens + ) { + normalized.input_tokens_details = { + ...(normalized.input_tokens_details as Record || {}), + cached_tokens: normalized.prompt_cache_hit_tokens, + }; + } + + // MiniMax / Bedrock: flat cache_read_input_tokens → input_tokens_details.cached_tokens + if ( + normalized.cache_read_input_tokens !== undefined && + normalized.cache_read_input_tokens !== 0 && + !normalized.input_tokens_details?.cached_tokens + ) { + normalized.input_tokens_details = { + ...(normalized.input_tokens_details as Record || {}), + cached_tokens: normalized.cache_read_input_tokens, + }; + } + const inputDetails = toRecord(normalized.input_tokens_details) || {}; const cachedTokens = normalized.cached_tokens ?? normalized.cache_read_input_tokens; if (cachedTokens !== undefined && inputDetails.cached_tokens === undefined) { @@ -576,15 +600,21 @@ function sanitizeResponsesUsage(usage: unknown): unknown { /** * Normalize response ID to use chatcmpl- prefix. + * Preserves numeric/short custom ids as their string form rather than + * regenerating them — a passthrough numeric id (e.g. `123`) must stay `"123"` + * so streaming clients can correlate chunks (#3427/#5776). Only a genuinely + * missing/empty id gets a fresh `chatcmpl-` token. */ function normalizeResponseId(id: unknown): string { - if (!id || typeof id !== "string") { + if (!id || (typeof id !== "string" && typeof id !== "number")) { return `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 29)}`; } - // Already correct format - if (id.startsWith("chatcmpl-")) return id; - // Keep custom IDs but don't break them - return id; + const str = String(id); + if (str === "") { + return `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 29)}`; + } + // Already correct format, or a custom/numeric id — keep it. + return str; } function normalizeResponsesId(id: unknown): string { diff --git a/open-sse/handlers/responseSanitizer/cacheHitTokens.ts b/open-sse/handlers/responseSanitizer/cacheHitTokens.ts new file mode 100644 index 0000000000..4542c6ddac --- /dev/null +++ b/open-sse/handlers/responseSanitizer/cacheHitTokens.ts @@ -0,0 +1,72 @@ +/** + * Cache-hit token normalization — shared by chat-completions and Responses API + * usage sanitizers. + * + * Several providers report a prompt-cache-hit count using a flat, non-standard + * field instead of the OpenAI-style nested `*_tokens_details.cached_tokens` + * shape. Without this mapping, clients (Cline / Cursor / Claude Code / any + * OpenAI-SDK consumer) never see the real cache-hit count (#8171). + * + * - DeepSeek native API: flat `prompt_cache_hit_tokens`. + * - MiniMax / Bedrock etc.: flat `cache_read_input_tokens`. + */ + +type JsonRecord = Record; + +/** + * Chat Completions shape: writes into `sanitized.prompt_tokens_details.cached_tokens`. + * `usageRecord` is the raw (pre-whitelist) usage object; `sanitized` is the + * whitelisted usage object being built. + */ +export function applyCacheHitTokensToUsage(usageRecord: JsonRecord, sanitized: JsonRecord): void { + if ( + usageRecord.prompt_cache_hit_tokens !== undefined && + (!sanitized.prompt_tokens_details || + !(sanitized.prompt_tokens_details as JsonRecord).cached_tokens) + ) { + const details = (sanitized.prompt_tokens_details as JsonRecord) ?? {}; + details.cached_tokens = usageRecord.prompt_cache_hit_tokens; + sanitized.prompt_tokens_details = details; + } + + if ( + sanitized.cache_read_input_tokens !== undefined && + sanitized.cache_read_input_tokens !== 0 && + (!sanitized.prompt_tokens_details || + !(sanitized.prompt_tokens_details as JsonRecord).cached_tokens) + ) { + const details = (sanitized.prompt_tokens_details as JsonRecord) ?? {}; + details.cached_tokens = sanitized.cache_read_input_tokens; + sanitized.prompt_tokens_details = details; + } +} + +/** + * Responses API shape: writes into `normalized.input_tokens_details.cached_tokens`. + * `toRecordFn` is injected to reuse the caller's `toRecord()` helper. + */ +export function applyCacheHitTokensToResponsesUsage( + normalized: JsonRecord, + toRecordFn: (value: unknown) => JsonRecord | null +): void { + if ( + normalized.prompt_cache_hit_tokens !== undefined && + !toRecordFn(normalized.input_tokens_details)?.cached_tokens + ) { + normalized.input_tokens_details = { + ...(toRecordFn(normalized.input_tokens_details) || {}), + cached_tokens: normalized.prompt_cache_hit_tokens, + }; + } + + if ( + normalized.cache_read_input_tokens !== undefined && + normalized.cache_read_input_tokens !== 0 && + !toRecordFn(normalized.input_tokens_details)?.cached_tokens + ) { + normalized.input_tokens_details = { + ...(toRecordFn(normalized.input_tokens_details) || {}), + cached_tokens: normalized.cache_read_input_tokens, + }; + } +} diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 165e292dc0..3527cdfec5 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -523,6 +523,19 @@ export function translateNonStreamingResponse( } } + // #9971: a content-less-but-valid Claude body (thinking / redacted_thinking + // / tool_use-only, or a truncated extended-thinking-only stream) has blocks + // but no final text. Surfacing it here helps correlate a live VPS capture + // with detectMalformedNonStream's clause; the content itself is valid output + // (see detectMalformedNonStream), so this is observation, not a decision. + if (textContent.length === 0 && process.env.DEBUG_CLAUDE_NONSTREAM === "true") { + console.log( + `[ClaudeNonStream] ${contentBlocks.length} content block(s), empty textContent ` + + `(thinking=${thinkingContent.length}, toolCalls=${toolCalls.length}); ` + + `content-less-but-valid body preserved (not empty_choices)` + ); + } + const message: JsonRecord = { role: "assistant" }; if (textContent) { message.content = textContent; diff --git a/open-sse/handlers/responsesHandler.ts b/open-sse/handlers/responsesHandler.ts index 37c4169d64..e05b264ac3 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; @@ -58,6 +63,7 @@ export async function handleResponsesCore({ connectionId, userAgent: null, comboName: null, + onStreamFailure: null, }); // handleChatCore's union includes a bare Response (early returns that never diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index b5e909abe9..bad08b9998 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -27,6 +27,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { z } from "zod"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { resolveSearchProxy, executeProviderFetch } from "./search/searchProxy.ts"; export interface SearchResult { title: string; @@ -96,6 +97,9 @@ interface SearchHandlerOptions { alternateProvider?: string; alternateCredentials?: Record | null; log?: any; + /** Connection ID (proxy resolution + call-log attribution) and API key ID (per-key proxy). */ + connectionId?: string; + apiKeyId?: string; } // ── Constants ──────────────────────────────────────────────────────────── @@ -332,8 +336,10 @@ function buildExaRequest( query: params.query, numResults: params.maxResults, type: "auto", - text: true, - highlights: true, + contents: { + text: true, + highlights: true, + }, }; if (includes.length) body.includeDomains = includes; if (excludes.length) body.excludeDomains = excludes; @@ -1195,6 +1201,8 @@ export async function handleSearch(options: SearchHandlerOptions): Promise, credentials: Record, globalStartTime: number, - log?: any + log?: any, + connectionId?: string, + apiKeyId?: string ): Promise { const startTime = Date.now(); const providerSpecificData = @@ -1421,6 +1442,10 @@ async function tryProvider( }; } + // Resolve proxy for the selected connection (see search/searchProxy.ts for the + // resolveProxyForConnection precedence chain: per-key, account, provider, combo, global). + const { proxy, proxyLevel } = await resolveSearchProxy(connectionId, apiKeyId, config.id); + // Timeout: min of provider timeout and remaining global timeout const remainingGlobal = GLOBAL_TIMEOUT_MS - (Date.now() - globalStartTime); const timeout = Math.min(config.timeoutMs, Math.max(remainingGlobal, 1000)); @@ -1431,105 +1456,22 @@ async function tryProvider( log.info("SEARCH", `${config.id} | query: "${query.slice(0, 80)}" | type: ${searchType}`); } - try { - const response = await fetch(url, { ...init, signal: controller.signal }); - clearTimeout(timer); - - if (!response.ok) { - const errorText = await response.text(); - if (log) { - log.error("SEARCH", `${config.id} error ${response.status}: ${errorText.slice(0, 200)}`); - } - - saveCallLog({ - method: config.method, - path: "/v1/search", - status: response.status, - model: config.id, - provider: config.id, - duration: Date.now() - startTime, - requestType: "search", - error: errorText.slice(0, 500), - requestBody: { - query: query.slice(0, 200), - search_type: searchType, - max_results: maxResults, - }, - }).catch(() => { - /* non-critical — logging must not block search response */ - }); - - return { - success: false, - status: response.status, - error: `Search provider ${config.id} returned ${response.status}`, - }; - } - - const data = await response.json(); - const normalized = normalizeResponse(config.id, data, query, searchType); - // Enforce max_results — some providers return more than requested - const results = normalized.results.slice(0, maxResults); - const totalResults = normalized.totalResults; - const duration = Date.now() - startTime; - - saveCallLog({ - method: config.method, - path: "/v1/search", - status: 200, - model: config.id, - provider: config.id, - duration, - requestType: "search", - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults }, - responseBody: { results_count: results.length, cached: false }, - }).catch(() => { - /* non-critical — logging must not block search response */ - }); - - return { - success: true, - data: { - provider: config.id, - query, - results, - answer: null, - usage: { queries_used: 1, search_cost_usd: config.costPerQuery }, - metrics: { - response_time_ms: duration, - upstream_latency_ms: duration, - total_results_available: totalResults, - }, - errors: [], - }, - }; - } catch (err: any) { - clearTimeout(timer); - - const isTimeout = err.name === "AbortError"; - if (log) { - log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${err.message}`); - } - - saveCallLog({ - method: config.method, - path: "/v1/search", - status: isTimeout ? 504 : 502, - model: config.id, - provider: config.id, - duration: Date.now() - startTime, - requestType: "search", - error: err.message, - requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults }, - }).catch(() => { - /* non-critical — logging must not block search response */ - }); - - return { - success: false, - status: isTimeout ? 504 : 502, - error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`, - }; - } + // Delegate the fetch + response handling (proxy fetch, call-log, sanitized + // proxy event, result shaping) to the shared chokepoint in searchProxy.ts. + return executeProviderFetch({ + config, + url, + init, + controller, + timer, + query, + searchType, + maxResults, + startTime, + connectionId, + proxy, + proxyLevel, + log, + normalize: normalizeResponse, + }); } diff --git a/open-sse/handlers/search/searchProxy.ts b/open-sse/handlers/search/searchProxy.ts new file mode 100644 index 0000000000..f134b4a3bd --- /dev/null +++ b/open-sse/handlers/search/searchProxy.ts @@ -0,0 +1,245 @@ +/** + * Per-attempt proxy binding for web search provider calls. + * + * Extracted from ../search.ts (tryProvider) to keep the provider-dispatch + * chokepoint under the frozen file-size cap. Resolves the proxy for a given + * connection/apiKey/provider triple, wraps a fetch in that proxy context, + * and emits a sanitized proxy event for observability (never includes + * query, API key, or proxy credentials). + */ + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import type { SearchProviderConfig } from "../../config/searchRegistry.ts"; +import type { SearchResult } from "../search.ts"; + +/** Resolved proxy binding for a single provider attempt. */ +export interface ResolvedSearchProxy { + proxy: unknown; + proxyLevel: string; +} + +/** + * Resolve the proxy for the selected connection. Uses the existing + * resolveProxyForConnection(connectionId, apiKeyId, providerId) precedence + * chain so per-key, account, provider, combo, and global proxy rules apply + * consistently with other data-plane routes. + * + * Never throws — proxy resolution failure must not block the search. + */ +export async function resolveSearchProxy( + connectionId: string | undefined, + apiKeyId: string | undefined, + providerId: string +): Promise { + if (!connectionId) { + return { proxy: null, proxyLevel: "direct" }; + } + try { + const { resolveProxyForConnection } = await import("@/lib/db/settings"); + const proxyInfo = await resolveProxyForConnection(connectionId, apiKeyId, providerId); + return { proxy: proxyInfo.proxy, proxyLevel: proxyInfo.level || "direct" }; + } catch { + return { proxy: null, proxyLevel: "direct" }; + } +} + +/** + * Run a fetch, routed through the resolved proxy context when one is set. + * Wraps the patched globalThis.fetch so the upstream call egresses via the + * configured proxy instead of directly. + */ +export async function fetchWithSearchProxy( + proxy: unknown, + doFetch: () => Promise +): Promise { + if (!proxy) return doFetch(); + const { runWithProxyContext } = await import("../../utils/proxyFetch.ts"); + return runWithProxyContext(proxy, doFetch); +} + +/** + * Emit a sanitized proxy event for a search provider attempt. + * Never includes query, API key, proxy username, or proxy password. + */ +export async function emitSearchProxyEvent( + provider: string, + connectionId: string | undefined, + proxy: unknown, + proxyLevel: string, + targetUrl: string, + startTime: number, + status: string +): Promise { + try { + const { logProxyEvent } = await import("@/lib/proxyLogger"); + let targetOrigin = ""; + let targetPath = ""; + try { + const u = new URL(targetUrl); + targetOrigin = u.origin; + targetPath = u.pathname; + } catch { + targetOrigin = targetUrl.slice(0, 80); + } + const proxyRecord = + proxy && typeof proxy === "object" ? (proxy as Record) : null; + const proxyInfo = proxyRecord + ? { + type: String(proxyRecord.type || "http"), + host: String(proxyRecord.host || ""), + port: Number(proxyRecord.port || 0), + } + : null; + logProxyEvent({ + status, + proxy: proxyInfo, + level: proxyLevel, + levelId: connectionId || null, + provider: provider || null, + targetUrl: `${targetOrigin}${targetPath}`, + latencyMs: Date.now() - startTime, + connectionId: connectionId || null, + account: connectionId ? connectionId.slice(0, 8) : null, + }); + } catch { + // Non-critical — proxy logging must not block search response + } +} + +/** Loose result shape mirroring SearchHandlerResult in ../search.ts. */ +export interface ProviderFetchResult { + success: boolean; + status?: number; + error?: string; + data?: { + provider: string; + query: string; + results: SearchResult[]; + answer: null; + usage: { queries_used: number; search_cost_usd: number }; + metrics: { response_time_ms: number; upstream_latency_ms: number; total_results_available: number | null }; + errors: []; + }; +} + +/** Minimal logger shape used by the search handlers (pino-compatible). */ +export interface SearchLog { + info: (tag: string, message: string) => void; + error: (tag: string, message: string) => void; + warn?: (tag: string, message: string) => void; +} + +export interface ExecuteProviderFetchParams { + config: SearchProviderConfig; + url: string; + init: RequestInit; + controller: AbortController; + timer: ReturnType; + query: string; + searchType: string; + maxResults: number; + startTime: number; + connectionId?: string; + proxy: unknown; + proxyLevel: string; + log?: SearchLog; + normalize: ( + providerId: string, + data: unknown, + query: string, + searchType: string + ) => { results: SearchResult[]; totalResults: number | null }; +} + +/** + * Perform the upstream search HTTP call (through the resolved proxy, if any), + * then handle the success/error/exception branches: call-log persistence, + * sanitized proxy-event emission, and SearchHandlerResult construction. + * This is the single chokepoint tryProvider() delegates to after building + * the request and resolving the proxy — keeps search.ts to wiring only. + */ +export async function executeProviderFetch(p: ExecuteProviderFetchParams): Promise { + const { config, url, init, controller, timer, query, searchType, maxResults, startTime } = p; + const { connectionId, proxy, proxyLevel, log, normalize } = p; + const emitEvent = (status: string) => + emitSearchProxyEvent(config.id, connectionId, proxy, proxyLevel, url, startTime, status); + const logCall = (fields: Record) => + saveCallLog({ + method: config.method, + path: "/v1/search", + model: config.id, + provider: config.id, + connectionId: connectionId || null, + requestType: "search", + requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults }, + ...fields, + }).catch(() => { + /* non-critical — logging must not block search response */ + }); + + try { + const response = await fetchWithSearchProxy(proxy, () => + fetch(url, { ...init, signal: controller.signal }) + ); + clearTimeout(timer); + + if (!response.ok) { + const errorText = await response.text(); + if (log) { + log.error("SEARCH", `${config.id} error ${response.status}: ${errorText.slice(0, 200)}`); + } + logCall({ status: response.status, duration: Date.now() - startTime, error: errorText.slice(0, 500) }); + await emitEvent("error"); + return { + success: false, + status: response.status, + error: `Search provider ${config.id} returned ${response.status}`, + }; + } + + const data = await response.json(); + const normalized = normalize(config.id, data, query, searchType); + const results = normalized.results.slice(0, maxResults); + const duration = Date.now() - startTime; + + logCall({ + status: 200, + duration, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + responseBody: { results_count: results.length, cached: false }, + }); + await emitEvent("success"); + + return { + success: true, + data: { + provider: config.id, + query, + results, + answer: null, + usage: { queries_used: 1, search_cost_usd: config.costPerQuery }, + metrics: { + response_time_ms: duration, + upstream_latency_ms: duration, + total_results_available: normalized.totalResults, + }, + errors: [], + }, + }; + } catch (err: unknown) { + clearTimeout(timer); + const error = err instanceof Error ? err : new Error(String(err)); + const isTimeout = error.name === "AbortError"; + if (log) { + log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${error.message}`); + } + logCall({ status: isTimeout ? 504 : 502, duration: Date.now() - startTime, error: error.message }); + await emitEvent(isTimeout ? "timeout" : "error"); + return { + success: false, + status: isTimeout ? 504 : 502, + error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(error.message)}`, + }; + } +} diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index 3871534646..8239c3feb9 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -26,7 +26,8 @@ export function extractUsageFromResponse(responseBody, provider) { responseBody.usage.prompt_tokens_details?.cached_tokens ?? responseBody.usage.input_tokens_details?.cached_tokens ?? responseBody.usage.prompt_cache_hit_tokens ?? - responseBody.usage.cached_tokens, + responseBody.usage.cached_tokens ?? + responseBody.usage.cache_read_input_tokens, reasoning_tokens: responseBody.usage.completion_tokens_details?.reasoning_tokens ?? responseBody.usage.output_tokens_details?.reasoning_tokens ?? diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 0b26f7d673..2972962713 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,17 @@ 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 { handleFalVideoGeneration } from "./videoGeneration/falHandler.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 +44,95 @@ 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"; +import { handleFalVideoGeneration } from "./mediaGeneration/fal.ts"; + +/** + * 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,17 +144,78 @@ 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 (getVideoJobPreset(providerConfig.format)) { + return handleVideoJobGeneration({ + model, + presetName: providerConfig.format, + body, + credentials, + log, + }); + } + if (providerConfig.format === "openai-video") { + return handleOpenAIVideoGeneration({ model, provider, providerConfig, body, credentials, log }); } if (providerConfig.format === "vertex-veo") { return handleVertexVeoGeneration({ model, body, credentials, log }); } + if (providerConfig.format === "fal-ai-video") { + return handleFalVideoGeneration({ model, provider, providerConfig, body, credentials, log }); + } + if (providerConfig.format === "google-flow") { return handleGoogleFlowVideoGeneration({ model, providerConfig, body, credentials, log }); } @@ -158,7 +312,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 +989,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 62250f3f27..b812bb7b74 100644 --- a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts +++ b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts @@ -9,10 +9,10 @@ import { sanitizeErrorMessage } from "../../utils/error.ts"; import { AdobeFireflyError, adobeFireflyGenerateVideo, - resolveAdobeAccessToken, resolveAdobeSourceImageIds, resolveAdobeVideoModel, } from "../../services/adobeFireflyClient.ts"; +import { ensureAdobeFireflySession } from "../../services/adobeFireflySession.ts"; function normalizePositiveNumber(value: unknown, fallback: number): number { const n = Number(value); @@ -31,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; }) { @@ -46,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" @@ -54,14 +71,6 @@ 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); // Kling i2v / Veo ref / Sora frame: upload reference images first. const { id: videoModelId } = resolveAdobeVideoModel(String(model)); @@ -71,6 +80,7 @@ export async function handleAdobeFireflyVideoGeneration({ body, 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 ? "..." : ""}"` + - (sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") + (sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") + + ` | session=${session.source}` ); const result = await adobeFireflyGenerateVideo({ @@ -101,6 +112,9 @@ export async function handleAdobeFireflyVideoGeneration({ generateAudio: body.generate_audio !== false && body.generateAudio !== false, sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, sessionCookie, + arpSessionId, + sessionFingerprint: session.fingerprint, + sessionBrowserKey: session.browserSessionKey, timeoutMs, fetchImpl, log, diff --git a/open-sse/handlers/videoGeneration/falHandler.ts b/open-sse/handlers/videoGeneration/falHandler.ts new file mode 100644 index 0000000000..bd98f5068a --- /dev/null +++ b/open-sse/handlers/videoGeneration/falHandler.ts @@ -0,0 +1,254 @@ +import { saveCallLog } from "@/lib/usageDb"; +import { + FetchTimeoutError, + fetchWithTimeout, + getConfiguredTimeout, +} from "@/shared/utils/fetchTimeout"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +interface FalVideoBody { + prompt?: unknown; + aspect_ratio?: unknown; + duration?: unknown; + resolution?: unknown; + quality?: unknown; + generate_audio?: unknown; + poll_interval_ms?: unknown; + [key: string]: unknown; +} + +interface FalCredentials { + apiKey?: unknown; + accessToken?: unknown; +} + +interface FalProviderConfig { + baseUrl: string; +} + +interface FalLog { + info?: (scope: string, message: string, meta?: unknown) => void; + error?: (scope: string, message: string) => void; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function grokDuration(value: unknown, fallback = 6): number { + const numeric = numberValue(value); + if (numeric !== undefined) return Math.round(numeric); + + if (typeof value === "string") { + const match = value.trim().match(/^(\d+)s$/); + if (match) return Number(match[1]); + } + + return fallback; +} + +function falDuration(value: unknown, fallback = "8s"): string { + if (typeof value === "string" && /^(4|6|8)s$/.test(value)) return value; + + const numeric = numberValue(value); + return numeric && [4, 6, 8].includes(numeric) ? `${numeric}s` : fallback; +} + +export function buildFalVideoPayload(model: string, body: FalVideoBody): Record { + const prompt = stringValue(body.prompt) || ""; + const aspectRatio = stringValue(body.aspect_ratio) || "16:9"; + const resolution = stringValue(body.resolution) || (body.quality === "hd" ? "1080p" : "720p"); + + if (model.startsWith("xai/grok-imagine-video/")) { + return { + prompt, + aspect_ratio: aspectRatio, + duration: grokDuration(body.duration), + resolution, + }; + } + + return { + prompt, + aspect_ratio: aspectRatio, + duration: falDuration(body.duration), + resolution, + generate_audio: typeof body.generate_audio === "boolean" ? body.generate_audio : true, + }; +} + +function absoluteFalUrl(value: unknown, baseUrl: string): string | undefined { + const url = stringValue(value); + if (!url) return undefined; + if (url.startsWith("http://") || url.startsWith("https://")) return url; + return `${baseUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`; +} + +function normalizeFalVideoResponse(payload: unknown) { + const record = payload && typeof payload === "object" ? (payload as Record) : {}; + const video = + record.video && typeof record.video === "object" + ? (record.video as Record) + : null; + const url = video && typeof video.url === "string" ? video.url.trim() : ""; + + if (!url) { + return { + success: false as const, + status: 502, + error: "Fal video generation returned no video URL", + }; + } + + return { + success: true as const, + data: { + created: typeof record.created === "number" ? record.created : Math.floor(Date.now() / 1000), + data: [{ url, format: "mp4" }], + }, + }; +} + +function falModelPath(model: string): string { + return model.startsWith("xai/") ? model : `fal-ai/${model}`; +} + +function getToken(credentials: FalCredentials | null | undefined): string { + return String(credentials?.apiKey || credentials?.accessToken || ""); +} + +function responseError(payload: unknown): string { + return sanitizeErrorMessage(JSON.stringify(payload).slice(0, 500)); +} + +export async function handleFalVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: FalProviderConfig; + body: FalVideoBody; + credentials: FalCredentials | null | undefined; + log?: FalLog | null; +}) { + const token = getToken(credentials); + if (!token) return { success: false as const, status: 401, error: "Fal API key is required" }; + + const startTime = Date.now(); + const timeoutMs = getConfiguredTimeout(); + const pollIntervalMs = Math.max(100, numberValue(body.poll_interval_ms) || 1000); + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const queueUrl = `${baseUrl}/${falModelPath(model)}`; + const headers = { + Authorization: `Key ${token}`, + "Content-Type": "application/json", + }; + + log?.info?.("VIDEO", `${provider}/${model} (fal-ai-video)`, { + prompt: stringValue(body.prompt)?.slice(0, 200) || "", + }); + + try { + const createResponse = await fetchWithTimeout(queueUrl, { + method: "POST", + headers, + body: JSON.stringify(buildFalVideoPayload(model, body)), + timeoutMs, + }); + const createPayload = await createResponse.json().catch(() => ({})); + + if (!createResponse.ok) { + const error = responseError(createPayload); + log?.error?.("VIDEO", `Fal create failed (${createResponse.status}): ${error}`); + return { success: false as const, status: createResponse.status, error }; + } + + const requestId = stringValue(createPayload.request_id); + if (!requestId) return normalizeFalVideoResponse(createPayload); + + const statusUrl = + absoluteFalUrl(createPayload.status_url, baseUrl) || + `${queueUrl}/requests/${requestId}/status`; + const responseUrl = + absoluteFalUrl(createPayload.response_url, baseUrl) || `${queueUrl}/requests/${requestId}`; + const deadline = startTime + timeoutMs; + + while (Date.now() < deadline) { + const remainingMs = Math.max(1000, deadline - Date.now()); + const statusResponse = await fetchWithTimeout(statusUrl, { + headers: { Authorization: `Key ${token}` }, + timeoutMs: Math.min(timeoutMs, remainingMs), + }); + const statusPayload = await statusResponse.json().catch(() => ({})); + + if (!statusResponse.ok) { + return { + success: false as const, + status: statusResponse.status, + error: responseError(statusPayload), + }; + } + + const status = stringValue(statusPayload.status); + if (status === "COMPLETED") { + const resultResponse = await fetchWithTimeout(responseUrl, { + headers: { Authorization: `Key ${token}` }, + timeoutMs: Math.min(timeoutMs, Math.max(1000, deadline - Date.now())), + }); + const resultPayload = await resultResponse.json().catch(() => ({})); + if (!resultResponse.ok) { + return { + success: false as const, + status: resultResponse.status, + error: responseError(resultPayload), + }; + } + + const result = normalizeFalVideoResponse(resultPayload); + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: result.success ? 200 : result.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + ...(result.success ? {} : { error: result.error }), + }).catch(() => {}); + return result; + } + + if (status && !["IN_QUEUE", "IN_PROGRESS"].includes(status)) { + return { + success: false as const, + status: 502, + error: `Fal video generation ended with status ${status}`, + }; + } + + await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remainingMs))); + } + + return { + success: false as const, + status: 504, + error: `Fal video generation timed out after ${timeoutMs}ms`, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const isTimeout = + error instanceof FetchTimeoutError || (error as { name?: string }).name === "AbortError"; + const status = isTimeout ? 504 : 502; + const safeMessage = sanitizeErrorMessage(message); + log?.error?.("VIDEO", `Fal request failed: ${safeMessage}`); + return { success: false as const, status, error: `Fal video provider error: ${safeMessage}` }; + } +} diff --git a/open-sse/handlers/videoGeneration/job.ts b/open-sse/handlers/videoGeneration/job.ts new file mode 100644 index 0000000000..030fe97a48 --- /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: "Authorization", + authScheme: "bearer", + // Official Agnes flow: POST /v1/videos returns video_id, then the recommended + // status endpoint GET /agnesapi?video_id=… exposes status and metadata.url. + 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: "video_id", + poll: { pathTemplate: "/agnesapi?video_id={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/mcp-server/__tests__/essentialTools.test.ts b/open-sse/mcp-server/__tests__/essentialTools.test.ts index 88353eed92..1e6fd76c93 100644 --- a/open-sse/mcp-server/__tests__/essentialTools.test.ts +++ b/open-sse/mcp-server/__tests__/essentialTools.test.ts @@ -296,3 +296,108 @@ describe("omniroute_web_search handler (via MCP dispatch)", () => { expect(result.isError).toBe(true); }); }); + +// ── omniroute_get_health: handler dispatch tests ────────────────────────────── +// These tests use InMemoryTransport + Client to exercise the actual registered +// handler (not mockFetch directly), so they catch the real bug the original +// mock-only tests above (lines 39-56) could never catch: process.uptime() +// returns a *number*, and a naive toString() guard silently discards it. + +describe("omniroute_get_health handler (via MCP dispatch)", () => { + let client: Client; + + beforeEach(async () => { + mockFetch.mockReset(); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + }); + + function mockHealthSources(opts: { + health?: unknown; + healthError?: Error; + resilience?: unknown; + resilienceError?: Error; + rateLimits?: unknown; + rateLimitsError?: Error; + }) { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("/api/monitoring/health")) { + if (opts.healthError) throw opts.healthError; + return { ok: true, json: async () => opts.health ?? {} }; + } + if (url.includes("/api/resilience")) { + if (opts.resilienceError) throw opts.resilienceError; + return { ok: true, json: async () => opts.resilience ?? {} }; + } + if (url.includes("/api/rate-limits")) { + if (opts.rateLimitsError) throw opts.rateLimitsError; + return { ok: true, json: async () => opts.rateLimits ?? {} }; + } + throw new Error(`unexpected fetch: ${url}`); + }); + } + + it("should render a real numeric uptime as a string, not fall back to unknown", async () => { + mockHealthSources({ + health: { + uptime: 4731.9817064, + version: "3.8.50", + memoryUsage: { heapUsed: 746337096, heapTotal: 765358080 }, + }, + resilience: { circuitBreakers: [] }, + rateLimits: { limits: [] }, + }); + + const result = await client.callTool({ name: "omniroute_get_health", arguments: {} }); + + expect(result.isError).toBeFalsy(); + const content = result.content as Array<{ type: string; text: string }>; + const data = JSON.parse(content[0].text); + expect(data.uptime).toBe("4731.9817064"); + expect(data.version).toBe("3.8.50"); + }); + + it("should surface a degraded entry when one source fetch fails, instead of silently faking success", async () => { + mockHealthSources({ + health: { uptime: 100, version: "3.8.50" }, + resilience: { circuitBreakers: [] }, + rateLimitsError: new Error("connect ECONNREFUSED 127.0.0.1:20128"), + }); + + const result = await client.callTool({ name: "omniroute_get_health", arguments: {} }); + + expect(result.isError).toBeFalsy(); + const content = result.content as Array<{ type: string; text: string }>; + const data = JSON.parse(content[0].text); + // The two healthy sources still come through untouched. + expect(data.uptime).toBe("100"); + expect(data.rateLimits).toEqual([]); + // But the failure is visible instead of being indistinguishable from "no rate limits". + expect(Array.isArray(data.degraded)).toBe(true); + expect(data.degraded).toHaveLength(1); + expect(data.degraded[0].source).toBe("rateLimits"); + expect(data.degraded[0].error).toContain("ECONNREFUSED"); + }); + + it("should omit degraded entirely when every source succeeds", async () => { + mockHealthSources({ + health: { uptime: 1, version: "3.8.50" }, + resilience: { circuitBreakers: [] }, + rateLimits: { limits: [] }, + }); + + const result = await client.callTool({ name: "omniroute_get_health", arguments: {} }); + + const content = result.content as Array<{ type: string; text: string }>; + const data = JSON.parse(content[0].text); + expect(data.degraded).toBeUndefined(); + }); +}); diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 4db6a850dc..07b0b8db77 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -68,12 +68,20 @@ export const getHealthOutput = z.object({ provider: z.string(), }) .optional(), + degraded: z + .array( + z.object({ + source: z.enum(["health", "resilience", "rateLimits"]), + error: z.string(), + }) + ) + .optional(), }); export const getHealthTool: McpToolDefinition = { name: "omniroute_get_health", description: - "Returns the current health status of OmniRoute including uptime, memory usage, circuit breaker states for all providers, rate limit status, and cache statistics.", + "Returns the current health status of OmniRoute including uptime, memory usage, circuit breaker states for all providers, rate limit status, and cache statistics. If an underlying source (health/resilience/rate-limits) could not be reached, it is listed in `degraded` instead of being silently reported as empty/zero.", inputSchema: getHealthInput, outputSchema: getHealthOutput, scopes: ["read:health"], diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index d37a157f71..48366ce405 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -270,6 +270,15 @@ function withScopeEnforcement( }; } +// process.uptime() (the source of health.uptime) returns a number, not a string; +// the shared toString() helper only passes through actual strings, so a naive +// toString(health.uptime, "unknown") silently discarded every real uptime value. +function toUptimeString(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return "unknown"; +} + async function handleGetHealth() { const start = Date.now(); try { @@ -287,8 +296,25 @@ async function handleGetHealth() { const resilienceCircuitBreakers = toArray(resilience.circuitBreakers); const rateLimitEntries = toArray(rateLimits.limits); + // Surface fetch failures instead of letting Promise.allSettled's {} fallback + // masquerade as genuine zero/empty data (indistinguishable "no data" vs. + // "couldn't reach the source" was the actual root confusion this fixes). + const degradedSources: Array<{ source: string; settled: PromiseSettledResult }> = [ + { source: "health", settled: healthRaw }, + { source: "resilience", settled: resilienceRaw }, + { source: "rateLimits", settled: rateLimitsRaw }, + ]; + const degraded = degradedSources + .filter(({ settled }) => settled.status === "rejected") + .map(({ source, settled }) => ({ + source, + error: sanitizeErrorMessage( + settled.status === "rejected" ? (settled as PromiseRejectedResult).reason : undefined + ), + })); + const result = { - uptime: toString(health.uptime, "unknown"), + uptime: toUptimeString(health.uptime), version: toString(health.version, "unknown"), memoryUsage: { heapUsed: toNumber(memoryUsageRaw.heapUsed, 0), @@ -310,6 +336,7 @@ async function handleGetHealth() { provider: toString(toRecord(health.cryptography).provider, "unknown"), } : undefined, + degraded: degraded.length > 0 ? degraded : undefined, }; await logToolCall("omniroute_get_health", {}, result, Date.now() - start, true); @@ -1403,6 +1430,10 @@ export function createMcpServer(): McpServer { * Called when `omniroute --mcp` is used. */ export async function startMcpStdio(): Promise { + // Stdout is reserved for JSON-RPC — bin/mcpStdioConsoleGuard.mjs is preloaded via + // `node --import` (see bin/mcp-server.mjs) so console.log/warn already redirect to + // stderr before this module's own imports evaluate (DB init happens as a side effect of + // createMcpServer()'s tool registration, earlier than any code placed here could catch). const server = createMcpServer(); const transport = new StdioServerTransport(); const version = process.env.npm_package_version || "1.8.1"; diff --git a/open-sse/package.json b/open-sse/package.json index b2f90507e1..858e80d0c8 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,18 +1,7 @@ { "name": "@omniroute/open-sse", "version": "3.8.50", - "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", + "description": "OmniRoute streaming engine — handles provider dispatch, protocol translation, and SSE streaming", "type": "module", - "main": "index.js", - "types": "types.d.ts", - "private": true, - "exports": { - ".": "./index.js", - "./*": "./*" - }, - "dependencies": { - "@toon-format/toon": "^4.1.0", - "safe-regex": "^2.1.1", - "smol-toml": "1.7.1" - } + "private": true } diff --git a/open-sse/services/__tests__/tierResolver.test.ts b/open-sse/services/__tests__/tierResolver.test.ts index 06836772c3..62ad4f1867 100644 --- a/open-sse/services/__tests__/tierResolver.test.ts +++ b/open-sse/services/__tests__/tierResolver.test.ts @@ -197,12 +197,12 @@ describe("TierResolver", () => { { provider: "openai", model: "gpt-4o" }, { provider: "openai", model: "gpt-4o" }, ]); - // Observable effect of the cache: the duplicate resolves to the same tier and only +// Observable effect of the cache: the duplicate resolves to the same tier and only // ONE entry is memoized (getTierStats counts cache entries, not classify calls). - assert.equal(results.length, 2); - assert.equal(results[0].tier, results[1].tier); + expect(results).toHaveLength(2); + expect(results[0].tier).toBe(results[1].tier); const stats = getTierStats(); - assert.equal(stats.free + stats.cheap + stats.premium, 1); + expect(stats.free + stats.cheap + stats.premium).toBe(1); }); }); diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index b51cace449..d8175272c9 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -31,6 +31,7 @@ import { looksLikeQuotaExhausted, type FailureKind, } from "../../src/shared/utils/classify429"; +import { recordProviderSuccess as resetCooldownFailureCount } from "./providerCooldownTracker.ts"; import { resolveProviderId } from "../../src/shared/constants/providers"; import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints"; import { getCodexModelScope } from "../config/codexQuotaScopes.ts"; @@ -125,6 +126,15 @@ const CONNECTION_FAILURE_DEDUP_MS = 5000; const MAX_CONNECTION_FAILURE_DEDUP_ENTRIES = 10_000; const lastConnectionFailure = new Map(); +// Per-provider network-error dedup: several combo targets on the SAME provider can +// fail the same single network event (a VPN blip) in the same request. Without this, +// each target counts once and one transient blip opens the whole-provider breaker +// while the provider is healthy. A genuinely dead proxy persists ACROSS requests +// (past the window) and still accumulates to its threshold. +const NETWORK_ERROR_DEDUP_MS = 10_000; +const MAX_NETWORK_ERROR_DEDUP_ENTRIES = 1000; +const lastNetworkErrorByProvider = new Map(); + function pruneConnectionFailureDedupeEntries(): void { while (lastConnectionFailure.size > MAX_CONNECTION_FAILURE_DEDUP_ENTRIES) { const oldestKey = lastConnectionFailure.keys().next().value; @@ -732,6 +742,7 @@ export function hasPerModelQuota( if (getCanonicalLockProvider(provider) === "antigravity") return true; if (getCanonicalLockProvider(provider) === "codex") return true; if (provider === "gemini" || provider === "github") return true; + if (provider === "antigravity" || provider === "agy") return true; if (getPassthroughProviders().has(provider)) return true; if (isCompatibleProvider(provider)) return true; return false; @@ -972,9 +983,30 @@ export function recordProviderFailure( provider: string | null | undefined, log?: { warn?: (...args: unknown[]) => void }, connectionId?: string | null, - profile?: ProviderBreakerProfile | null + profile?: ProviderBreakerProfile | null, + opts?: { isQueueTimeout?: boolean; isNetworkError?: boolean } ): void { if (!provider) return; + // OmniRoute's own rate-limit queue timeout is backpressure we applied, not a + // provider failure — the provider never saw the request, so it must not count + // toward the provider breaker. + if (opts?.isQueueTimeout) return; + + // Network-layer errors (proxy_unreachable) get a separate SAME-PROVIDER dedup, so a + // single transient network event is not counted once per combo target (see the + // declaration). A dead proxy persists across requests and still accumulates. + if (opts?.isNetworkError) { + const now = Date.now(); + const last = lastNetworkErrorByProvider.get(provider); + if (last && now - last < NETWORK_ERROR_DEDUP_MS) return; + lastNetworkErrorByProvider.delete(provider); + lastNetworkErrorByProvider.set(provider, now); + while (lastNetworkErrorByProvider.size > MAX_NETWORK_ERROR_DEDUP_ENTRIES) { + const oldestKey = lastNetworkErrorByProvider.keys().next().value; + if (typeof oldestKey !== "string") break; + lastNetworkErrorByProvider.delete(oldestKey); + } + } // Deduplicate rapid-fire failures from the same connection if (connectionId) { @@ -1001,6 +1033,47 @@ export function recordProviderFailure( } } +/** + * Record a successful request for a provider. + * Symmetric counterpart of recordProviderFailure: + * - Resets cooldown failureCount (exponential backoff) for all non-OPEN states. + * - HALF_OPEN -> CLOSED (probe success), CLOSED/DEGRADED -> decay failureCount. + * + * When the breaker is OPEN (provider is failing), this is a no-op -- the + * cooldown stays intact and the breaker keeps its cooldown period. + * + * Matches execute()'s behavior: _onSuccess() is called for all non-OPEN states. + */ +export function recordProviderSuccess( + provider: string | null | undefined, + connectionId?: string | null +): void { + if (!provider || provider === "unknown") return; + + const breaker = getProviderBreaker(provider); + if (!breaker) return; + const breakerState = breaker.getStatus().state; + + // When breaker is OPEN, the provider is failing -- do not reset cooldown + // even if one request slipped through (dispatched before the open). + // The cooldown resets when the breaker reaches HALF_OPEN and the probe + // succeeds below. + if (breakerState === "OPEN") return; + + // Reset cooldown failureCount (exponential backoff) -- symmetric with + // recordProviderCooldown which increments it on each failure. + resetCooldownFailureCount(provider, connectionId ?? undefined); + + // Clear failure-dedup window so the next genuine failure is not suppressed. + if (connectionId) { + lastConnectionFailure.delete(`${provider}:${connectionId}`); + } + + // Transition breaker on success, matching execute()'s behavior: + // HALF_OPEN -> CLOSED (probe success), CLOSED/DEGRADED -> decay failureCount. + breaker._onSuccess(); +} + /** * Reset the shared provider breaker. */ 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/admission/config.ts b/open-sse/services/admission/config.ts index dfe9b07a01..1f3aecf78e 100644 --- a/open-sse/services/admission/config.ts +++ b/open-sse/services/admission/config.ts @@ -21,6 +21,7 @@ export interface ValidatedConfig { adaptation: AdaptationParams; maxRequestCost: number; costConfig: ReturnType; + virtualLanes: boolean; } function requirePositiveInt( @@ -162,6 +163,7 @@ export function validateConfig(input: AdaptiveAdmissionConfig): ValidatedConfig windowMs, maxRequestCost: costConfig.maxRequestCost, costConfig, + virtualLanes: input.virtualLanes === true, adaptation: resolveAdaptationParams(input, minLimit, maxLimit, windowMs), }; } diff --git a/open-sse/services/admission/controller.ts b/open-sse/services/admission/controller.ts index 1051a64782..8a6db9be74 100644 --- a/open-sse/services/admission/controller.ts +++ b/open-sse/services/admission/controller.ts @@ -27,6 +27,13 @@ import { type ShadowDecision, } from "./types.ts"; +/** + * Idle TTL for per-connection virtual admission lanes (#9654). + */ +const ADMISSION_LANE_TTL_MS = 60_000; +/** Bounded per-connection lane map to prevent unbounded memory growth (#9654). */ +const ADMISSION_LANE_MAX_SESSIONS = 1_000; + type VirtualDisposition = "active" | "queued" | "rejected" | "none"; const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); @@ -95,6 +102,13 @@ export class AdaptiveAdmissionController { private adaptation: AdaptationState; private queue: FairCostQueue; private virtualQueue: FairCostQueue<{ recordId: string }>; + /** Per-connection virtual admission lanes (#9654). */ + private readonly virtualLanes = new Map; + lastUsedMs: number; + }>(); + /** Eviction timer for idle lanes; re-armed when a lane is created. */ + private laneEvictionTimer: unknown = undefined; private readonly active = new Map(); private activeCost = 0n; private virtualActiveCost = 0; @@ -148,6 +162,14 @@ export class AdaptiveAdmissionController { const drained = this.queue.drain(); this.queue = new FairCostQueue(next.maxQueueCount, next.maxQueueCost); + // Drain per-connection virtual lane queues (#9654). + for (const [, lane] of this.virtualLanes) { + for (const entry of lane.queue.drain()) { + drained.push(entry); + } + } + this.virtualLanes.clear(); + this.clearLaneEviction(); for (const entry of drained) { if (next.mode !== "enforce") { this.clearEntryTimer(entry); @@ -191,6 +213,10 @@ export class AdaptiveAdmissionController { virtualActiveCount: saturateSnapshotNumber(this.virtualActiveCount), virtualQueuedCost: saturateSnapshotNumber(this.virtualQueue.totalCost), virtualQueuedCount: saturateSnapshotNumber(this.virtualQueue.size), + laneCount: saturateSnapshotNumber(this.virtualLanes.size), + laneQueuedCost: saturateSnapshotNumber(this.laneTotalQueuedCost()), + laneQueuedCount: saturateSnapshotNumber(this.laneTotalQueuedCount()), + laneTenants: this.laneTenantSnapshot(), admittedCount: saturateSnapshotNumber(this.admittedCount), rejectedCount: saturateSnapshotNumber(this.rejectedCount), wouldAdmitCount: saturateSnapshotNumber(this.wouldAdmitCount), @@ -223,6 +249,7 @@ export class AdaptiveAdmissionController { /** Deterministic window tick for tests / injected clocks. */ tick(): void { this.sampleIntegral(); + this.evictIdleLanes(); closeAdaptationWindow(this.adaptation, this.config.adaptation, this.clock.now()); // Real queue first, then virtual: raised limits must promote shadow-queued work // before newer arrivals are classified against the updated budget. @@ -289,6 +316,19 @@ export class AdaptiveAdmissionController { ); this.rejectedCount += 1; } + // Drain per-connection virtual lane queues (#9654). + for (const [, lane] of this.virtualLanes) { + for (const entry of lane.queue.drain()) { + this.clearEntryTimer(entry); + this.detachAbort(entry); + entry.payload.reject( + createAdmissionRejectError("ADMISSION_SHUTDOWN", "admission controller shut down") + ); + this.rejectedCount += 1; + } + } + this.virtualLanes.clear(); + this.clearLaneEviction(); } private resolveCost(request: AdmissionRequest): number { @@ -440,9 +480,23 @@ export class AdaptiveAdmissionController { }, }; - if (!this.queue.enqueue(entry)) { + // Per-connection virtual admission lanes (#9654): when enabled via + // OMNIROUTE_CHAT_VIRTUAL_LANES=1, requests with a tenantKey are enqueued into + // a per-session lane queue instead of the shared queue, so one connection's + // burst does not 503 other sessions. Lanes are bounded by + // ADMISSION_LANE_MAX_SESSIONS and idle-evicted after ADMISSION_LANE_TTL_MS. + // Default: OFF — preserves the shared FairCostQueue round-robin behavior. + if (entry.tenantKey !== "_default" && this.config.virtualLanes) { + const lane = this.getOrCreateLane(entry.tenantKey); + if (!lane.queue.enqueue(entry)) { + this.removeEmptyLane(entry.tenantKey); + return this.reject("ADMISSION_QUEUE_FULL", "admission lane queue is full"); + } + this.armLaneEviction(); + } else if (!this.queue.enqueue(entry)) { return this.reject("ADMISSION_QUEUE_FULL", "admission queue is full"); } + this.dispatch(); entry.timerId = this.clock.setTimer( () => { @@ -466,7 +520,17 @@ export class AdaptiveAdmissionController { } private expireEntry(id: string, code: AdmissionRejectCode, message: string): void { - const entry = this.queue.removeById(id); + let entry = this.queue.removeById(id); + if (!entry) { + // Search per-connection lane queues (#9654). + for (const [, lane] of this.virtualLanes) { + entry = lane.queue.removeById(id); + if (entry) { + this.removeEmptyLane(entry.tenantKey); + break; + } + } + } if (!entry) return; this.clearEntryTimer(entry); this.detachAbort(entry); @@ -490,7 +554,6 @@ export class AdaptiveAdmissionController { private dispatch(): void { if (this.shutDown || this.config.mode !== "enforce") return; - while (this.queue.size > 0) { const limit = this.adaptation.currentLimit; const available = BigInt(limit) - this.activeCost; @@ -515,6 +578,165 @@ export class AdaptiveAdmissionController { } entry.payload.resolve(this.admit(entry.cost)); } + this.dispatchLanes(); + } + + /** Round-robin dispatch across per-connection virtual lane queues (#9654). */ + private dispatchLanes(): void { + if (this.shutDown || this.config.mode !== "enforce") return; + if (this.virtualLanes.size === 0) return; + + const keys = Array.from(this.virtualLanes.keys()); + for (const key of keys) { + const lane = this.virtualLanes.get(key); + if (!lane) continue; + // Dispatch as many entries from this lane as capacity allows, + // then break to give other lanes a fair share. + while (lane.queue.size > 0) { + const limit = this.adaptation.currentLimit; + const available = BigInt(limit) - this.activeCost; + if (available <= 0n) return; + const entry = lane.queue.dequeue(Number(available)); + if (!entry) break; // head doesn't fit + this.clearEntryTimer(entry); + this.detachAbort(entry); + if (entry.payload.signal?.aborted) { + entry.payload.reject( + createAdmissionRejectError("ADMISSION_ABORTED", "request aborted while queued") + ); + this.rejectedCount += 1; + continue; + } + if (this.clock.now() >= entry.deadlineMs) { + entry.payload.reject( + createAdmissionRejectError("ADMISSION_DEADLINE", "admission wait deadline exceeded") + ); + this.rejectedCount += 1; + continue; + } + entry.payload.resolve(this.admit(entry.cost)); + break; // yield to next lane for fairness + } + this.removeEmptyLane(key); + } + } + + private getOrCreateLane(tenantKey: string): { queue: FairCostQueue; lastUsedMs: number } { + let lane = this.virtualLanes.get(tenantKey); + if (!lane) { + // Evict oldest lane if at capacity (LRU). + if (this.virtualLanes.size >= ADMISSION_LANE_MAX_SESSIONS) { + const oldestKey = this.oldestLaneKey(); + if (oldestKey) { + this.deleteLane(oldestKey); + } + } + // Per-lane queue uses the same maxQueueCount/maxQueueCost as the shared + // queue. Total memory is bounded by ADMISSION_LANE_MAX_SESSIONS (1000) + // × per-lane queue caps — each lane's FairCostQueue rejects when full. + lane = { + queue: new FairCostQueue(this.config.maxQueueCount, this.config.maxQueueCost), + lastUsedMs: this.clock.now(), + }; + this.virtualLanes.set(tenantKey, lane); + } + lane.lastUsedMs = this.clock.now(); + return lane; + } + + private removeEmptyLane(tenantKey: string): void { + const lane = this.virtualLanes.get(tenantKey); + if (lane && lane.queue.size === 0) { + this.virtualLanes.delete(tenantKey); + } + } + + /** Drain and reject all pending entries in a lane before removing it from the map. */ + private deleteLane(tenantKey: string): void { + const lane = this.virtualLanes.get(tenantKey); + if (!lane) return; + for (const entry of lane.queue.drain()) { + this.clearEntryTimer(entry); + this.detachAbort(entry); + entry.payload.reject( + createAdmissionRejectError("ADMISSION_LANE_EVICTED", "connection lane evicted") + ); + this.rejectedCount += 1; + } + this.virtualLanes.delete(tenantKey); + } + + private oldestLaneKey(): string | undefined { + let oldest: string | undefined; + let oldestMs = Infinity; + for (const [key, lane] of this.virtualLanes) { + if (lane.lastUsedMs <= oldestMs) { + oldestMs = lane.lastUsedMs; + oldest = key; + } + } + return oldest; + } + + private evictIdleLanes(): void { + const now = this.clock.now(); + const keysToDelete: string[] = []; + for (const [key, lane] of this.virtualLanes) { + if (now - lane.lastUsedMs >= ADMISSION_LANE_TTL_MS) { + keysToDelete.push(key); + } + } + for (const key of keysToDelete) { + this.deleteLane(key); + } + if (this.virtualLanes.size > 0) { + this.armLaneEviction(); + } else { + this.clearLaneEviction(); + } + } + + private armLaneEviction(): void { + this.clearLaneEviction(); + this.laneEvictionTimer = this.clock.setTimer( + () => this.evictIdleLanes(), + ADMISSION_LANE_TTL_MS + ); + } + + private clearLaneEviction(): void { + if (this.laneEvictionTimer !== undefined) { + this.clock.clearTimer(this.laneEvictionTimer); + this.laneEvictionTimer = undefined; + } + } + + private laneTotalQueuedCost(): number { + let total = 0; + for (const [, lane] of this.virtualLanes) { + total = addSaturated(total, lane.queue.totalCost); + } + return total; + } + + private laneTotalQueuedCount(): number { + let count = 0; + for (const [, lane] of this.virtualLanes) { + count = addSaturated(count, lane.queue.size); + } + return count; + } + + private laneTenantSnapshot(): ReadonlyArray<{ tenantKey: string; queuedCount: number; queuedCost: number }> { + const arr: { tenantKey: string; queuedCount: number; queuedCost: number }[] = []; + for (const [tenantKey, lane] of this.virtualLanes) { + arr.push({ + tenantKey, + queuedCount: saturateSnapshotNumber(lane.queue.size), + queuedCost: saturateSnapshotNumber(lane.queue.totalCost), + }); + } + return arr; } private releaseVirtual(record: ActiveLeaseRecord): void { diff --git a/open-sse/services/admission/runtime.ts b/open-sse/services/admission/runtime.ts index ee0e10ec93..3d7af5d48f 100644 --- a/open-sse/services/admission/runtime.ts +++ b/open-sse/services/admission/runtime.ts @@ -39,6 +39,7 @@ export const DEFAULT_ADAPTIVE_ADMISSION_CONFIG: Readonly = { message: "Service temporarily unavailable", retryAfter: "1", }, + ADMISSION_LANE_EVICTED: { + status: 503, + code: "admission_lane_evicted", + message: "Connection lane evicted", + retryAfter: "1", + }, }; function isAdmissionRejectError( diff --git a/open-sse/services/admission/types.ts b/open-sse/services/admission/types.ts index 2a8e537a40..93a782321d 100644 --- a/open-sse/services/admission/types.ts +++ b/open-sse/services/admission/types.ts @@ -33,6 +33,7 @@ export type AdmissionRejectCode = | "ADMISSION_QUEUE_FULL" | "ADMISSION_DEADLINE" | "ADMISSION_ABORTED" + | "ADMISSION_LANE_EVICTED" | "ADMISSION_SHUTDOWN" | "ADMISSION_UNAVAILABLE"; @@ -79,6 +80,8 @@ export interface AdaptiveAdmissionConfig { maxIncreasePerWindow?: number; /** Optional cost quanta override used only when callers pass features instead of cost. */ cost?: Partial; + /** Per-connection virtual admission lanes (#9654). Default: false. */ + virtualLanes?: boolean; } export interface AdmissionRequest { @@ -137,6 +140,16 @@ export interface AdmissionSnapshot { virtualActiveCount: number; virtualQueuedCost: number; virtualQueuedCount: number; + /** Per-connection virtual lane metrics (#9654). */ + laneCount: number; + laneQueuedCost: number; + laneQueuedCount: number; + /** Per-tenant queue breakdown (opaque keys, never raw API keys). */ + laneTenants: ReadonlyArray<{ + tenantKey: string; + queuedCount: number; + queuedCost: number; + }>; admittedCount: number; rejectedCount: number; wouldAdmitCount: number; 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..5bd7638747 --- /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 16d491637a..bcc8987a75 100644 --- a/open-sse/services/adobeFireflyClient.ts +++ b/open-sse/services/adobeFireflyClient.ts @@ -24,17 +24,28 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { resolvePublicCred } from "../utils/publicCreds.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { + decodeAdobeJwtPayload, + findAllAdobeJwts, + isExactAdobeJwt, + stripAdobeJwts, +} from "./adobeFireflySecurity.ts"; +import { + parseAdobeModelsDiscovery as parseAdobeModelsDiscoveryContract, + type AdobeFireflyDiscoveredModel, +} from "./adobeFireflyModels.ts"; + +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"; export const ADOBE_FIREFLY_VIDEO_SUBMIT_URL = "https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async"; -export const ADOBE_FIREFLY_IMAGE_UPLOAD_URL = - "https://firefly-3p.ff.adobe.io/v2/storage/image"; +export const ADOBE_FIREFLY_IMAGE_UPLOAD_URL = "https://firefly-3p.ff.adobe.io/v2/storage/image"; export const ADOBE_FIREFLY_MODELS_DISCOVERY_URL = "https://firefly-3p.ff.adobe.io/v2/models/discovery"; -export const ADOBE_FIREFLY_CREDITS_BALANCE_URL = - "https://firefly.adobe.io/v1/credits/balance"; +export const ADOBE_FIREFLY_CREDITS_BALANCE_URL = "https://firefly.adobe.io/v1/credits/balance"; export const ADOBE_FIREFLY_IMS_REFRESH_URL = "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"; /** Scope set observed on live firefly.adobe.com IMS access tokens. */ @@ -46,60 +57,13 @@ export const ADOBE_FIREFLY_IMS_SCOPE = 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_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; -/** - * gpt-image family accepts subject refs, but the live SPA and colligo are reliable - * with 1–2 only. Sending 3–4+ (e.g. Store listing "5 screenshots") often hangs until - * poll timeout. Nano multi-ref composition supports more via usage "general". - */ -export const ADOBE_FIREFLY_GPT_IMAGE_MAX_REFS = 2; -export const ADOBE_FIREFLY_NANO_MAX_REFS = 4; -export const ADOBE_FIREFLY_GENERIC_IMAGE_MAX_REFS = 2; const FIREFLY_ORIGIN = "https://firefly.adobe.com"; const FIREFLY_REFERER = "https://firefly.adobe.com/"; -/** Cap reference uploads by Firefly image model family. */ -export function adobeFireflyMaxImageRefs(model: string): number { - const raw = String(model || "").toLowerCase(); - if (raw.includes("nano-banana") || raw.includes("nanobanana") || raw.includes("gemini-flash")) { - return ADOBE_FIREFLY_NANO_MAX_REFS; - } - if (raw.includes("gpt-image") || raw.includes("gptimage")) { - return ADOBE_FIREFLY_GPT_IMAGE_MAX_REFS; - } - return ADOBE_FIREFLY_GENERIC_IMAGE_MAX_REFS; -} - -/** - * 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" @@ -115,12 +79,7 @@ export type AdobeFireflyImageModelId = | "runway-gen4-image"; export type AdobeFireflyVideoModelId = - | "sora-2" - | "sora-2-pro" - | "veo-3.1" - | "veo-3.1-fast" - | "veo-3.1-ref" - | "kling-3"; + "sora-2" | "sora-2-pro" | "veo-3.1" | "veo-3.1-fast" | "veo-3.1-ref" | "kling-3"; export interface AdobeFireflyImageModelSpec { upstreamModelId: string; @@ -143,123 +102,127 @@ export interface AdobeFireflyVideoModelSpec { * 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 = - { - // 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_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 = - { - "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", - }, - }; +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": { @@ -380,23 +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); @@ -460,7 +406,11 @@ export function extractAdobeCredentialToken(raw: string): string { if (!value) return ""; if (/^bearer\s+/i.test(value)) { - const bare = value.replace(/^bearer\s+/i, "").trim().split(/\s+/)[0] || ""; + const bare = + value + .replace(/^bearer\s+/i, "") + .trim() + .split(/\s+/)[0] || ""; if (looksLikeAdobeJwt(bare)) return bare; } @@ -478,11 +428,13 @@ export function extractAdobeCredentialToken(raw: string): string { } // Authorization: Bearer eyJ... - const authMatch = value.match(/Authorization\s*:\s*Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i); + const authMatch = value.match( + /Authorization\s*:\s*Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i + ); 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)); @@ -531,14 +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(); @@ -591,8 +542,13 @@ export function normalizeAdobeAspectRatio(sizeOrRatio: unknown, fallback = "1:1" return fallback; } -export function normalizeAdobeOutputResolution(quality: unknown, size: unknown): "1K" | "2K" | "4K" { - const q = String(quality ?? "").trim().toLowerCase(); +export function normalizeAdobeOutputResolution( + quality: unknown, + size: unknown +): "1K" | "2K" | "4K" { + const q = String(quality ?? "") + .trim() + .toLowerCase(); if (q === "4k" || q === "ultra" || q === "high") return "4K"; if (q === "2k" || q === "hd" || q === "standard" || q === "medium") return "2K"; if (q === "1k" || q === "low") return "1K"; @@ -614,17 +570,33 @@ export function resolveAdobeImageModel(model: string): { .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-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"] }; + 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"] }; + 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"] }; + 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 ( @@ -636,10 +608,17 @@ export function resolveAdobeImageModel(model: string): { ) { // 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"] }; + 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"] }; + 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"] }; @@ -651,13 +630,19 @@ export function resolveAdobeImageModel(model: string): { 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"] }; + 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"] }; + return { + id: "runway-gen4-image", + spec: ADOBE_FIREFLY_IMAGE_MODELS["runway-gen4-image"], + }; } if (raw in ADOBE_FIREFLY_IMAGE_MODELS) { @@ -666,7 +651,10 @@ export function resolveAdobeImageModel(model: string): { } // Default to Nano Banana Pro (most common Firefly image path). - return { id: "nano-banana-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"] }; + return { + id: "nano-banana-pro", + spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"], + }; } export function resolveAdobeVideoModel(model: string): { @@ -686,10 +674,16 @@ export function resolveAdobeVideoModel(model: string): { 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"] }; + 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"] }; + 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"] }; @@ -713,11 +707,14 @@ export function resolveAdobeVideoModel(model: string): { * Explicit low/medium still honor the caller's choice. */ function gptDetailLevel(quality: unknown): number { - const q = String(quality ?? "high").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; + // Live firefly.adobe.com default for gpt-image is detailLevel 3 (medium). + const q = String(quality ?? "medium") + .trim() + .toLowerCase(); + 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: { @@ -754,7 +751,10 @@ 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, @@ -762,14 +762,9 @@ export function buildAdobeImagePayload(opts: { }; if (opts.sourceImageIds?.length) { // gpt-image subject references (mask path uses separate mask blob when present). - // Cap to wire-stable count — extra subject blobs stall colligo until poll 504. - const refIds = opts.sourceImageIds - .map((id) => String(id)) - .filter(Boolean) - .slice(0, ADOBE_FIREFLY_GPT_IMAGE_MAX_REFS); payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; - payload.referenceBlobs = refIds.map((id) => ({ - id, + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ + id: String(id), usage: "subject", })); payload.modelSpecificPayload = {}; @@ -792,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, @@ -802,21 +800,16 @@ export function buildAdobeImagePayload(opts: { if (Object.keys(genSettings).length) payload.generationSettings = genSettings; if (opts.sourceImageIds?.length) { - const maxRefs = - opts.modelSpec.family === "generic" - ? ADOBE_FIREFLY_GENERIC_IMAGE_MAX_REFS - : ADOBE_FIREFLY_NANO_MAX_REFS; - const refIds = opts.sourceImageIds - .map((id) => String(id)) - .filter(Boolean) - .slice(0, maxRefs); - payload.referenceBlobs = refIds.map((id) => ({ - id, + 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; @@ -844,7 +837,10 @@ export function buildAdobeVideoPayload(opts: { }): Record { const seedVal = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999); const aspect = opts.aspectRatio === "auto" ? "16:9" : opts.aspectRatio || "16:9"; - const duration = Math.max(1, Math.min(30, Math.floor(opts.duration || opts.modelSpec.defaultDuration))); + 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; @@ -931,13 +927,20 @@ export function buildAdobeVideoPayload(opts: { duration, fps: 24, prompt: promptJson, - generationMetadata: { module: sourceImageIds.length ? "image2video" : "text2video" }, + 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 }, + camera: { + angle: "none", + shotSize: "none", + motion: null, + promptStyle: null, + }, negativePrompt: negative, jobMode: "standard", debugGenerationEndpoint: "", @@ -1009,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( @@ -1047,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}`, @@ -1098,7 +1323,10 @@ export function buildAdobeUploadHeaders( cookie: extras?.cookie, prompt: extras?.prompt || "upload", }); - const ct = String(contentType || "image/png").trim().toLowerCase() || "image/png"; + const ct = + String(contentType || "image/png") + .trim() + .toLowerCase() || "image/png"; return { ...base, "content-type": ct.startsWith("image/") ? ct : "image/png", @@ -1110,11 +1338,18 @@ 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; const po = - b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options) + b.provider_options && + typeof b.provider_options === "object" && + !Array.isArray(b.provider_options) ? (b.provider_options as Record) : {}; @@ -1226,11 +1461,18 @@ 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 - if (!/^https?:\/\//i.test(trimmed) && /^[A-Za-z0-9+/=\s]+$/.test(trimmed) && trimmed.length > 64) { + if ( + !/^https?:\/\//i.test(trimmed) && + /^[A-Za-z0-9+/=\s]+$/.test(trimmed) && + trimmed.length > 64 + ) { const buffer = Buffer.from(trimmed.replace(/\s/g, ""), "base64"); if (buffer.length > 0 && buffer.length <= ADOBE_FIREFLY_MAX_UPLOAD_BYTES) { return { buffer, contentType: "image/png" }; @@ -1272,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); @@ -1292,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 @@ -1305,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(() => ""); @@ -1332,11 +1581,7 @@ export async function uploadAdobeFireflyImage(opts: { try { json = text ? JSON.parse(text) : {}; } catch { - throw new AdobeFireflyError( - "Adobe Firefly image upload returned non-JSON body", - 502, - "upload" - ); + throw new AdobeFireflyError("Adobe Firefly image upload returned non-JSON body", 502, "upload"); } const id = parseAdobeStorageUploadResponse(json); if (!id) { @@ -1361,9 +1606,14 @@ 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(8, opts.max ?? 4)); const sources = extractAdobeSourceImageSources(opts.body, max); @@ -1371,6 +1621,10 @@ export async function resolveAdobeSourceImageIds(opts: { 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) @@ -1411,6 +1665,7 @@ export async function resolveAdobeSourceImageIds(opts: { bytes: buffer, contentType, sessionCookie: opts.sessionCookie, + arpSessionId, prompt: opts.prompt, fetchImpl, log: opts.log, @@ -1473,13 +1728,29 @@ export function buildAdobeDiscoveryHeaders(accessToken: string): Record) : {}; - const links = data.links && typeof data.links === "object" ? (data.links as Record) : {}; + const links = + data.links && typeof data.links === "object" ? (data.links as Record) : {}; const result = links.result; if (typeof result === "string" && result) return result; if (result && typeof result === "object") { @@ -1531,9 +1803,7 @@ export function normalizeAdobePollUrl(rawUrl: string): string { const path = parsed.pathname || ""; const isJobPath = - path.includes("/jobs/result/") || - path.includes("/v2/status") || - path.includes("/status/"); + path.includes("/jobs/result/") || path.includes("/v2/status") || path.includes("/status/"); if (!isJobPath) return url; const jobId = path.split("/").filter(Boolean).pop() || ""; @@ -1548,14 +1818,12 @@ export function normalizeAdobePollUrl(rawUrl: string): string { } } -export function extractAdobeMediaUrl( - latest: unknown, - kind: "image" | "video" -): string | null { +export function extractAdobeMediaUrl(latest: unknown, kind: "image" | "video"): string | null { const body = latest && typeof latest === "object" ? (latest as Record) : {}; const outputs = Array.isArray(body.outputs) ? body.outputs : []; if (outputs.length > 0) { - const first = outputs[0] && typeof outputs[0] === "object" ? (outputs[0] as Record) : {}; + const first = + outputs[0] && typeof outputs[0] === "object" ? (outputs[0] as Record) : {}; const media = kind === "image" ? first.image && typeof first.image === "object" @@ -1569,7 +1837,10 @@ export function extractAdobeMediaUrl( } // Fallback recursive search for a presigned URL. - const found = findPresignedUrl(latest, kind === "image" ? [".png", ".jpg", ".jpeg", ".webp"] : [".mp4", ".webm"]); + const found = findPresignedUrl( + latest, + kind === "image" ? [".png", ".jpg", ".jpeg", ".webp"] : [".mp4", ".webm"] + ); return found; } @@ -1577,7 +1848,12 @@ function findPresignedUrl(obj: unknown, exts: string[]): string | null { if (!obj) return null; if (typeof obj === "string") { const s = obj.trim(); - if (/^https?:\/\//i.test(s) && (exts.some((e) => s.toLowerCase().includes(e)) || s.includes("presigned") || s.includes("X-Amz"))) { + if ( + /^https?:\/\//i.test(s) && + (exts.some((e) => s.toLowerCase().includes(e)) || + s.includes("presigned") || + s.includes("X-Amz")) + ) { return s; } return null; @@ -1633,8 +1909,7 @@ async function imsCheckToken(opts: { guestAllowed: boolean; fetchImpl: typeof fetch; }): Promise< - | { state: "ok"; token: string; data: ImsTokenResponse } - | { state: "failed"; status: number; error: string } + { ok: true; token: string; data: ImsTokenResponse } | { ok: false; status: number; error: string } > { const form = new URLSearchParams({ client_id: opts.clientId, @@ -1666,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}` @@ -1677,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 || {} }; } /** @@ -1731,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" || @@ -1754,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 || @@ -1791,7 +2066,11 @@ export async function resolveAdobeAccessToken( | { apiKey?: string; accessToken?: string; - providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null; + providerSpecificData?: { + cookie?: unknown; + access_token?: unknown; + accessToken?: unknown; + } | null; } | null | undefined, @@ -1861,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 && @@ -1949,54 +2232,11 @@ export async function fetchAdobeCreditsBalance( // ── Models discovery ──────────────────────────────────────────────────────── -export interface AdobeFireflyDiscoveredModel { - modelId: string; - modelVersion: string; - displayName: string; - modality: "image" | "video" | "audio" | "unknown"; - enabled: boolean; - healthStatus?: string; -} - /** * Parse POST /v2/models/discovery response into flat model/version rows. */ export function parseAdobeModelsDiscovery(body: unknown): AdobeFireflyDiscoveredModel[] { - const root = body && typeof body === "object" ? (body as Record) : {}; - const models = Array.isArray(root.models) ? root.models : []; - const out: AdobeFireflyDiscoveredModel[] = []; - - for (const m of models) { - if (!m || typeof m !== "object") continue; - const rec = m as Record; - const modelId = String(rec.modelId || "").trim(); - if (!modelId) continue; - const versions = - rec.modelVersions && typeof rec.modelVersions === "object" - ? (rec.modelVersions as Record) - : {}; - for (const [ver, spec] of Object.entries(versions)) { - if (!spec || typeof spec !== "object") continue; - const s = spec as Record; - if (s.enabled === false) continue; - const mods = Array.isArray(s.outputModality) - ? s.outputModality.map((x) => String(x).toLowerCase()) - : []; - let modality: AdobeFireflyDiscoveredModel["modality"] = "unknown"; - if (mods.includes("image")) modality = "image"; - else if (mods.includes("video")) modality = "video"; - else if (mods.includes("audio")) modality = "audio"; - out.push({ - modelId, - modelVersion: ver, - displayName: String(s.modelDisplayName || s.modelCaiDisplayName || ver), - modality, - enabled: s.enabled !== false, - healthStatus: typeof s.healthStatus === "string" ? s.healthStatus : undefined, - }); - } - } - return out; + return parseAdobeModelsDiscoveryContract(body); } export async function discoverAdobeFireflyModels( @@ -2009,7 +2249,11 @@ export async function discoverAdobeFireflyModels( body: JSON.stringify({ filters: { resolveSchema: true } }), }); if (resp.status === 401 || resp.status === 403) { - throw new AdobeFireflyError("Adobe Firefly model discovery: token invalid or expired", 401, "auth"); + throw new AdobeFireflyError( + "Adobe Firefly model discovery: token invalid or expired", + 401, + "auth" + ); } if (!resp.ok) { const text = await resp.text().catch(() => ""); @@ -2032,26 +2276,77 @@ 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; - const interval = opts.pollIntervalMs && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : DEFAULT_POLL_INTERVAL_MS; + const interval = + 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) { const accessError = pollResp.headers.get("x-access-error") || ""; if (accessError === "taste_exhausted") { - throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted"); + throw new AdobeFireflyError( + "Adobe Firefly quota exhausted for this account", + 429, + "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"); } @@ -2096,18 +2391,33 @@ export async function pollAdobeJob(opts: { ); } - opts.log?.info?.("ADOBE-FIREFLY", `${opts.kind} pending #${attempt} status=${statusVal || "unknown"}`); + opts.log?.info?.( + "ADOBE-FIREFLY", + `${opts.kind} pending #${attempt} status=${statusVal || "unknown"}` + ); await sleep(interval); } 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; @@ -2121,9 +2431,18 @@ export async function adobeFireflyGenerateImage(opts: { 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); @@ -2141,34 +2460,91 @@ export async function adobeFireflyGenerateImage(opts: { }); 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("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted"); + throw new AdobeFireflyError( + "Adobe Firefly quota exhausted for this account", + 429, + "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" ); @@ -2181,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" ); @@ -2207,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" ); @@ -2226,15 +2667,11 @@ export async function adobeFireflyGenerateImage(opts: { } pollUrl = normalizeAdobePollUrl(pollUrl); - const pollTimeoutMs = adobeFireflyImageTimeoutMs({ - timeoutMs: opts.timeoutMs, - refCount: 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, }); @@ -2256,10 +2693,24 @@ export async function adobeFireflyGenerateVideo(opts: { 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"); @@ -2289,32 +2740,86 @@ export async function adobeFireflyGenerateVideo(opts: { }); 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("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted"); + throw new AdobeFireflyError( + "Adobe Firefly quota exhausted for this account", + 429, + "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" ); @@ -2327,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" ); @@ -2352,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" ); @@ -2373,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/adobeFireflyModelSnapshot.ts b/open-sse/services/adobeFireflyModelSnapshot.ts new file mode 100644 index 0000000000..98514877f8 --- /dev/null +++ b/open-sse/services/adobeFireflyModelSnapshot.ts @@ -0,0 +1,8 @@ +/** + * Generated from Adobe Firefly POST /v2/models/discovery with resolveSchema=true. + * Source SHA-256: 74d7970aaab36f0484ef91133af312f825ac09fd066d7622d7afd3184eb393a9 + * Regenerate with scripts/dev/generate-adobe-firefly-snapshot.mjs; do not edit by hand. + * The generated literal stays compact to satisfy the repository's line-count gate. + */ +// prettier-ignore +export const ADOBE_FIREFLY_DISCOVERY_SNAPSHOT = [{"id":"flux-2","name":"Flux 2","modality":"image","upstreamModelId":"flux","upstreamModelVersion":"2","providerName":"Black Forest Labs","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"source","minItems":0,"maxItems":4,"maxFileSizeBytes":104857600}],"maxReferenceItems":4,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:flux_2_pro"},{"id":"flux-fluxpro","name":"Flux 1.1 Pro","modality":"image","upstreamModelId":"flux","upstreamModelVersion":"fluxPro","providerName":"Black Forest Labs","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"style","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600}],"maxReferenceItems":4,"supportedSizes":["1024x768","1440x1440","768x1024","576x1024","1024x576"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefall_3p:external:flux_1.1"},{"id":"flux-fluxultra","name":"Flux 1.1 Ultra","modality":"image","upstreamModelId":"flux","upstreamModelVersion":"fluxUltra","providerName":"Black Forest Labs","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"style","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600}],"maxReferenceItems":4,"supportedSizes":["1024x768","1440x1440","768x1024","576x1024","1024x576"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefall_3p:external:flux_pro_ultra1.1"},{"id":"flux-fluxkontextpro","name":"Flux Kontext Pro","modality":"image","upstreamModelId":"flux","upstreamModelVersion":"fluxKontextPro","providerName":"Black Forest Labs","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"source","minItems":0,"maxItems":4,"maxFileSizeBytes":104857600}],"maxReferenceItems":4,"supportedSizes":["1024x768","1440x1440","768x1024","576x1024","1024x576"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:flux_kontext_pro"},{"id":"flux-flex-2","name":"Flux 2 Flex","modality":"image","upstreamModelId":"flux","upstreamModelVersion":"flex-2","providerName":"Black Forest Labs","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"source","minItems":0,"maxItems":4,"maxFileSizeBytes":104857600}],"maxReferenceItems":4,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:flux_2"},{"id":"flux-fluxpro-2","name":"Flux 2 Pro","modality":"image","upstreamModelId":"flux","upstreamModelVersion":"fluxPro-2","providerName":"Black Forest Labs","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"source","minItems":0,"maxItems":4,"maxFileSizeBytes":104857600}],"maxReferenceItems":4,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:flux_2_pro"},{"id":"flux-fluxkontextmax","name":"Flux Kontext Max","modality":"image","upstreamModelId":"flux","upstreamModelVersion":"fluxKontextMax","providerName":"Black Forest Labs","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"source","minItems":0,"maxItems":4,"maxFileSizeBytes":104857600}],"maxReferenceItems":4,"supportedSizes":["1024x768","1440x1440","768x1024","576x1024","1024x576"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:flux_kontext_max"},{"id":"flux-fluxfillpro","name":"Flux 1.1 Pro Fill","modality":"image","upstreamModelId":"flux","upstreamModelVersion":"fluxFillPro","providerName":"Black Forest Labs","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["inpainting"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"mask","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600}],"maxReferenceItems":4,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:flux_bagel"},{"id":"seedream-seedream-v4","name":"Seedream 4.0","modality":"image","upstreamModelId":"seedream","upstreamModelVersion":"seedream_v4","providerName":"ByteDance","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600}],"maxReferenceItems":1,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":1,"outputCountMax":4,"promptMaxLength":2000,"backingModel":"firefly_3p:external:seedream_v4"},{"id":"seedream-seedream-v5-lite","name":"Seedream 5.0 Lite","modality":"image","upstreamModelId":"seedream","upstreamModelVersion":"seedream_v5_lite","providerName":"ByteDance","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","n","modelSpecificPayload","generationMetadata","output","size","generationSettings","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"source","minItems":0,"maxItems":10,"maxFileSizeBytes":104857600}],"maxReferenceItems":10,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":1,"outputCountMax":4,"promptMaxLength":2000,"backingModel":"firefly_3p:external:seedream_v5_lite"},{"id":"kling-kling-v3","name":"Kling Video v3","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_v3","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["editing"],"schemaProperties":["modelId","modelVersion","prompt","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760}],"maxReferenceItems":5,"supportedSizes":[],"supportedAspectRatios":["16:9","9:16","1:1"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_v3_ti2v"},{"id":"kling-kling-v3-v2v-edit","name":"Kling Video V3 V2V Edit","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_v3_v2v_edit","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["editing","style_reference"],"schemaProperties":["modelId","modelVersion","prompt","modelSpecificPayload","generationMetadata","output","size","keepAudio","characterOrientation","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"style","minItems":1,"maxItems":1,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":1,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":1,"maxItems":1,"maxFileSizeBytes":209715200}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_v3_v2v_edit"},{"id":"kling-kling-o3","name":"Kling Video O3","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_v3_omni","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["editing","style_reference"],"schemaProperties":["modelId","modelVersion","prompt","modelSpecificPayload","generationMetadata","output","size","generationSettings","generateAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":["16:9","9:16","1:1"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_tir2v"},{"id":"kling-kling-o3-v2v-create","name":"Kling Video O3 V2V Create","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_v3_omni_v2v_create","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["editing","style_reference"],"schemaProperties":["modelId","modelVersion","prompt","modelSpecificPayload","generationMetadata","output","size","generationSettings","keepAudio","duration","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"style","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":1,"maxItems":1,"maxFileSizeBytes":209715200}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":["auto","16:9","9:16","1:1"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_v2v_create"},{"id":"kling-kling-o3-v2v-edit","name":"Kling Video O3 V2V Edit","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_v3_omni_v2v_edit","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["editing","style_reference"],"schemaProperties":["modelId","modelVersion","prompt","modelSpecificPayload","generationMetadata","output","size","keepAudio","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"style","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":1,"maxItems":1,"maxFileSizeBytes":209715200}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_v2v_edit"},{"id":"kling-kling-v2-5-turbo-pro-i2v","name":"Kling Video 2.5 Turbo","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_v2_5_turbo_pro_i2v","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[5,10],"durationMin":null,"durationMax":null,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_v2_5_turbo_pro"},{"id":"kling-kling-v3-standard-t2v","name":"Kling Video v3 Standard Text to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_v3_standard_t2v","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":[],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":["16:9","9:16","1:1"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_v3_ti2v"},{"id":"kling-kling-v3-standard-i2v","name":"Kling Video v3 Standard Image to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_v3_standard_i2v","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":209715200}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_v3_ti2v"},{"id":"kling-kling-v3-pro-t2v","name":"Kling Video v3 Pro Text to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_v3_pro_t2v","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":[],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":["16:9","9:16","1:1"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_v3_ti2v"},{"id":"kling-kling-v3-pro-i2v","name":"Kling Video v3 Pro Image to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_v3_pro_i2v","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":209715200}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_v3_ti2v"},{"id":"kling-kling-o3-pro-t2v","name":"Kling Video O3 Pro Text to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_o3_pro_t2v","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":[],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":["16:9","9:16","1:1"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_ti2v"},{"id":"kling-kling-o3-pro-i2v","name":"Kling Video O3 Pro Image to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_o3_pro_i2v","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_ti2v"},{"id":"kling-kling-o3-pro-reference-to-video","name":"Kling Video O3 Pro Reference to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_o3_pro_reference_to_video","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":209715200}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":["16:9","9:16","1:1"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_r2v"},{"id":"kling-kling-o3-pro-v2v-reference","name":"Kling Video O3 Pro Reference Video to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_o3_pro_v2v_reference","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":209715200}],"maxReferenceItems":4,"supportedSizes":[],"supportedAspectRatios":["auto","16:9","9:16","1:1"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_v2v"},{"id":"kling-kling-o3-pro-v2v-edit","name":"Kling Video O3 Pro Edit Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_o3_pro_v2v_edit","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":209715200}],"maxReferenceItems":4,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_v2v"},{"id":"kling-kling-o3-standard-t2v","name":"Kling Video O3 Standard Text to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_o3_standard_t2v","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":[],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":["16:9","9:16","1:1"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_ti2v"},{"id":"kling-kling-o3-standard-i2v","name":"Kling Video O3 Standard Image to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_o3_standard_i2v","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_ti2v"},{"id":"kling-kling-o3-standard-reference-to-video","name":"Kling Video O3 Standard Reference to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_o3_standard_reference_to_video","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":209715200}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":["16:9","9:16","1:1"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_r2v"},{"id":"kling-kling-o3-standard-v2v-reference","name":"Kling Video O3 Standard Reference Video to Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_o3_standard_v2v_reference","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":209715200}],"maxReferenceItems":4,"supportedSizes":[],"supportedAspectRatios":["auto","16:9","9:16","1:1"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_v2v"},{"id":"kling-kling-o3-standard-v2v-edit","name":"Kling Video O3 Standard Edit Video","modality":"video","upstreamModelId":"kling","upstreamModelVersion":"kling_o3_standard_v2v_edit","providerName":"Kuaishou","releaseReadiness":"alpha","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","negativePrompt","promptAdherence","output","size","generationSettings","generateAudio","keepAudio","duration","multiPrompt","shotType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":3,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":209715200}],"maxReferenceItems":4,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":15,"durationDefault":5,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:kling_o3_v2v"},{"id":"gemini-flash-nano-banana","name":"Gemini 2.5 (Nano Banana)","modality":"image","upstreamModelId":"gemini-flash","upstreamModelVersion":"nano-banana","providerName":"Google","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","resolution","generationSettings","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"general","minItems":0,"maxItems":4,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":4,"maxFileSizeBytes":104857600}],"maxReferenceItems":4,"supportedSizes":["1024x1024","1536x672","896x1152","1152x896","1248x832","832x1248","864x1184","1184x864","768x1344","1344x768"],"supportedAspectRatios":["1:1","3:2","2:3","3:4","4:3","4:5","5:4","9:16","16:9","21:9"],"supportedResolutions":["1K"],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":131000,"backingModel":"firefly_3p:external:gemini_flash"},{"id":"gemini-flash-nano-banana-2","name":"Gemini 3.0 (Nano Banana Pro)","modality":"image","upstreamModelId":"gemini-flash","upstreamModelVersion":"nano-banana-2","providerName":"Google","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","resolution","generationSettings","groundSearch","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"general","minItems":0,"maxItems":14,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":14,"maxFileSizeBytes":104857600}],"maxReferenceItems":14,"supportedSizes":["1024x1024","1264x848","848x1264","1200x896","896x1200","1152x928","928x1152","768x1376","1376x768","1584x672","2048x2048","2528x1696","1696x2528","2400x1792","1792x2400","2304x1856","1856x2304","1536x2752","2752x1536","3168x1344","4096x4096","5056x3392","3392x5056","4800x3584","3584x4800","4608x3712","3712x4608","3072x5504","5504x3072","6336x2688"],"supportedAspectRatios":["1:1","3:2","2:3","3:4","4:3","4:5","5:4","9:16","16:9","21:9"],"supportedResolutions":["1K","2K","4K"],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":262000,"backingModel":"firefly_3p:external:gemini_flash_2"},{"id":"gemini-flash-nano-banana-3","name":"Gemini 3.1 (with Nano Banana 2)","modality":"image","upstreamModelId":"gemini-flash","upstreamModelVersion":"nano-banana-3","providerName":"Google","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","resolution","generationSettings","groundSearch","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"general","minItems":0,"maxItems":14,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":14,"maxFileSizeBytes":104857600}],"maxReferenceItems":14,"supportedSizes":["512x512","1024x1024","1264x848","848x1264","1200x896","896x1200","1152x928","928x1152","768x1376","1376x768","1584x672","2048x2048","2528x1696","1696x2528","2400x1792","1792x2400","2304x1856","1856x2304","1536x2752","2752x1536","3168x1344","4096x4096","5056x3392","3392x5056","4800x3584","3584x4800","4608x3712","3712x4608","3072x5504","5504x3072","6336x2688"],"supportedAspectRatios":["1:1","3:2","2:3","3:4","4:3","4:5","5:4","9:16","16:9","21:9","1:8","8:1","1:4","4:1"],"supportedResolutions":["512","1K","2K","4K"],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":524000,"backingModel":"firefly_3p:external:nano_banana_3"},{"id":"gemini-omni-omni-flash","name":"Gemini Omni Flash","modality":"video","upstreamModelId":"gemini-omni","upstreamModelVersion":"omni-flash","providerName":"Google","releaseReadiness":"beta","healthStatus":"CRITICAL","inputMediaUseCases":["style_reference","editing"],"schemaProperties":["modelId","modelVersion","prompt","n","generationMetadata","output","generationSettings","duration","referenceBlobs"],"requiredProperties":["duration","generationMetadata","modelId","prompt"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":4,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":104857600},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600}],"maxReferenceItems":4,"supportedSizes":[],"supportedAspectRatios":["16:9","9:16"],"supportedResolutions":[],"supportedDurations":[],"durationMin":3,"durationMax":10,"durationDefault":null,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:gemini_omni_flash"},{"id":"veo-3.1-generate","name":"Veo 3.1","modality":"video","upstreamModelId":"veo","upstreamModelVersion":"3.1-generate","providerName":"Google","releaseReadiness":"ga","healthStatus":"CRITICAL","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","generateAudio","duration","generationType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"subject","minItems":0,"maxItems":3,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":104857600}],"maxReferenceItems":3,"supportedSizes":["1280x720","720x1280","1920x1080","1080x1920"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[4,6,8],"durationMin":null,"durationMax":null,"durationDefault":8,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":20000,"backingModel":"firefly_3p:external:veo_3"},{"id":"veo-3.1-fast-generate","name":"Veo 3.1 Fast","modality":"video","upstreamModelId":"veo","upstreamModelVersion":"3.1-fast-generate","providerName":"Google","releaseReadiness":"ga","healthStatus":"DEGRADED","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","generateAudio","duration","generationType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"subject","minItems":0,"maxItems":3,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":3,"maxFileSizeBytes":104857600}],"maxReferenceItems":3,"supportedSizes":["1280x720","720x1280","1920x1080","1080x1920"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[4,6,8],"durationMin":null,"durationMax":null,"durationDefault":8,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":20000,"backingModel":"firefly_3p:external:veo_3_fast"},{"id":"veo-3.1-lite-generate","name":"Veo 3.1 Lite","modality":"video","upstreamModelId":"veo","upstreamModelVersion":"3.1-lite-generate","providerName":"Google","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","generateAudio","duration","generationType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":104857600}],"maxReferenceItems":null,"supportedSizes":["1280x720","720x1280","1920x1080","1080x1920"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[4,6,8],"durationMin":null,"durationMax":null,"durationDefault":8,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":20000,"backingModel":"firefly_3p:external:veo_3_1_lite"},{"id":"luma-2.0-ray","name":"Ray2","modality":"video","upstreamModelId":"luma","upstreamModelVersion":"2.0-ray","providerName":"Luma","releaseReadiness":"ga","healthStatus":"DEGRADED","inputMediaUseCases":["style_reference","editing","reframing"],"schemaProperties":["modelId","modelVersion","prompt","n","modelSpecificPayload","generationMetadata","output","size","duration","mode","generationType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600}],"maxReferenceItems":3,"supportedSizes":["960x540","540x960","540x540","720x540","540x720","1260x540","540x1260","1280x720","720x1280","720x720","960x720","720x960","1680x720","720x1680","1920x1080","1080x1920","1080x1080","1440x1080","1080x1440","2520x1080","1080x2520","3840x2160","2160x3840","2160x2160","2880x2160","2160x2880","5040x2160","2160x5040"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[5,9],"durationMin":null,"durationMax":null,"durationDefault":5,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:luma"},{"id":"luma-2.0-ray-flash","name":"Ray2 Flash","modality":"video","upstreamModelId":"luma","upstreamModelVersion":"2.0-ray-flash","providerName":"Luma","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference","editing","reframing"],"schemaProperties":["modelId","modelVersion","prompt","n","modelSpecificPayload","generationMetadata","output","size","duration","mode","generationType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600}],"maxReferenceItems":3,"supportedSizes":["960x540","540x960","540x540","720x540","540x720","1260x540","540x1260","1280x720","720x1280","720x720","960x720","720x960","1680x720","720x1680","1920x1080","1080x1920","1080x1080","1440x1080","1080x1440","2520x1080","1080x2520","3840x2160","2160x3840","2160x2160","2880x2160","2160x2880","5040x2160","2160x5040"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[5,9],"durationMin":null,"durationMax":null,"durationDefault":5,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:luma_flash"},{"id":"luma-3.0-ray","name":"Ray3","modality":"video","upstreamModelId":"luma","upstreamModelVersion":"3.0-ray","providerName":"Luma","releaseReadiness":"ga","healthStatus":"DEGRADED","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","modelSpecificPayload","generationMetadata","output","size","duration","mode","generationType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"image","usageType":"subject","minItems":0,"maxItems":1,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600}],"maxReferenceItems":3,"supportedSizes":["640x360","360x640","360x360","480x360","360x480","840x360","360x840","960x540","540x960","540x540","720x540","540x720","1260x540","540x1260","1280x720","720x1280","720x720","960x720","720x960","1680x720","720x1680","1920x1080","1080x1920","1080x1080","1440x1080","1080x1440","2520x1080","1080x2520","3840x2160","2160x3840","2160x2160","2880x2160","2160x2880","5040x2160","2160x5040"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[5,10],"durationMin":null,"durationMax":null,"durationDefault":5,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:luma_ray_3"},{"id":"luma-3.0-ray-hdr","name":"Ray3 HDR","modality":"video","upstreamModelId":"luma","upstreamModelVersion":"3.0-ray-hdr","providerName":"Luma","releaseReadiness":"ga","healthStatus":"DEGRADED","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","modelSpecificPayload","generationMetadata","output","size","duration","mode","generationType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600}],"maxReferenceItems":3,"supportedSizes":["640x360","360x640","360x360","480x360","360x480","840x360","360x840","960x540","540x960","540x540","720x540","540x720","1260x540","540x1260","1280x720","720x1280","720x720","960x720","720x960","1680x720","720x1680","1920x1080","1080x1920","1080x1080","1440x1080","1080x1440","2520x1080","1080x2520","3840x2160","2160x3840","2160x2160","2880x2160","2160x2880","5040x2160","2160x5040"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[5],"durationMin":null,"durationMax":null,"durationDefault":5,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:luma_ray_hdr_3"},{"id":"luma-3.14-ray","name":"Ray3.14","modality":"video","upstreamModelId":"luma","upstreamModelVersion":"3.14-ray","providerName":"Luma","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","modelSpecificPayload","generationMetadata","output","size","duration","mode","generationType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600}],"maxReferenceItems":3,"supportedSizes":["1280x720","720x1280","720x720","960x720","720x960","1680x720","720x1680","1920x1080","1080x1920","1080x1080","1440x1080","1080x1440","2520x1080","1080x2520","3840x2160","2160x3840","2160x2160","2880x2160","2160x2880","5040x2160","2160x5040"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[5,10],"durationMin":null,"durationMax":null,"durationDefault":5,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:luma_ray_3_14"},{"id":"luma-3.14-ray-hdr","name":"Ray3.14 HDR","modality":"video","upstreamModelId":"luma","upstreamModelVersion":"3.14-ray-hdr","providerName":"Luma","releaseReadiness":"ga","healthStatus":"DEGRADED","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","modelSpecificPayload","generationMetadata","output","size","duration","mode","generationType","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":10485760},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600}],"maxReferenceItems":3,"supportedSizes":["1280x720","720x1280","720x720","960x720","720x960","1680x720","720x1680","1920x1080","1080x1920","1080x1080","1440x1080","1080x1440","2520x1080","1080x2520","3840x2160","2160x3840","2160x2160","2880x2160","2160x2880","5040x2160","2160x5040"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[5],"durationMin":null,"durationMax":null,"durationDefault":5,"outputCountMin":1,"outputCountMax":1,"promptMaxLength":5000,"backingModel":"firefly_3p:external:luma_ray_hdr_3_14"},{"id":"gpt-4o-image","name":"GPT Image","modality":"image","upstreamModelId":"gpt-4o-image","upstreamModelVersion":"default","providerName":"OpenAI","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["editing","inpainting","style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","modelSpecificPayload","generationMetadata","output","size","generationSettings","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"source","minItems":0,"maxItems":16,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"mask","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":16,"maxFileSizeBytes":104857600}],"maxReferenceItems":17,"supportedSizes":["1024x1024","1536x1024","1024x1536"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":32000,"backingModel":"firefall_3p:external:gpt4o"},{"id":"gpt-image-2","name":"GPT Image 2","modality":"image","upstreamModelId":"gpt-image","upstreamModelVersion":"2","providerName":"OpenAI","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["editing","inpainting","style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","modelSpecificPayload","generationMetadata","output","size","generationSettings","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"source","minItems":0,"maxItems":16,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"mask","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":16,"maxFileSizeBytes":104857600}],"maxReferenceItems":17,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":32000,"backingModel":"firefly_3p:external:gpt_image_2"},{"id":"gpt-image-1.5","name":"GPT Image 1.5","modality":"image","upstreamModelId":"gpt-image","upstreamModelVersion":"1.5","providerName":"OpenAI","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["editing","inpainting","style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","modelSpecificPayload","generationMetadata","output","size","generationSettings","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"source","minItems":0,"maxItems":16,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"mask","minItems":0,"maxItems":1,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":16,"maxFileSizeBytes":104857600}],"maxReferenceItems":17,"supportedSizes":["1024x1024","1536x1024","1024x1536"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":32000,"backingModel":"firefly_3p:external:gpt_image_1_5"},{"id":"runway-gen4-image","name":"Runway Gen-4 Image","modality":"image","upstreamModelId":"runway","upstreamModelVersion":"gen4_image","providerName":"Runway","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","n","seeds","modelSpecificPayload","generationMetadata","output","size","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"general","minItems":0,"maxItems":null,"maxFileSizeBytes":104857600}],"maxReferenceItems":null,"supportedSizes":["1920x1080","1080x1920","1024x1024","1360x768","1080x1080","1168x880","1440x1080","1080x1440","1808x768","2112x912"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":null,"durationMax":null,"durationDefault":null,"outputCountMin":1,"outputCountMax":4,"promptMaxLength":1000,"backingModel":"firefly_3p:external:runway_gen4_image"},{"id":"runway-gen4-turbo","name":"Runway Gen-4 Video","modality":"video","upstreamModelId":"runway","upstreamModelVersion":"gen4_turbo","providerName":"Runway","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","output","size","duration","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":1,"maxFileSizeBytes":16777216}],"maxReferenceItems":null,"supportedSizes":["1280x720","720x1280","1104x832","832x1104","960x960","1584x672"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[5,10],"durationMin":null,"durationMax":null,"durationDefault":10,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":1000,"backingModel":"firefly_3p:external:runway_gen4_video_turbo"},{"id":"runway-gen4.5","name":"Runway Gen-4.5 Video","modality":"video","upstreamModelId":"runway","upstreamModelVersion":"gen4.5","providerName":"Runway","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["style_reference"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","output","size","duration","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":1,"maxFileSizeBytes":16777216},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":1,"maxFileSizeBytes":16777216}],"maxReferenceItems":1,"supportedSizes":["1280x720","720x1280","1104x832","832x1104","960x960","1584x672"],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[5,8,10],"durationMin":null,"durationMax":null,"durationDefault":10,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":1000,"backingModel":"firefly_3p:external:runway_gen4_5_video"},{"id":"runway-aleph-2","name":"Runway Aleph 2","modality":"video","upstreamModelId":"runway","upstreamModelVersion":"aleph_2","providerName":"Runway","releaseReadiness":"ga","healthStatus":"HEALTHY","inputMediaUseCases":["editing"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","output","size","duration","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"video","usageType":"source","minItems":1,"maxItems":1,"maxFileSizeBytes":33554432},{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":1,"maxFileSizeBytes":16777216}],"maxReferenceItems":null,"supportedSizes":[],"supportedAspectRatios":[],"supportedResolutions":[],"supportedDurations":[],"durationMin":2,"durationMax":10,"durationDefault":null,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":1000,"backingModel":"firefly_3p:external:runway_video_aleph_2"},{"id":"seedance-seedance-2.0","name":"Seedance 2.0","modality":"video","upstreamModelId":"seedance","upstreamModelVersion":"seedance_2.0","providerName":"ByteDance","releaseReadiness":"alpha","healthStatus":"CRITICAL","inputMediaUseCases":["editing","style_reference"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","output","size","generationSettings","generateAudio","duration","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":9,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":9,"maxFileSizeBytes":104857600},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":3,"maxFileSizeBytes":52428800},{"mediaType":"audio","usageType":"source","minItems":0,"maxItems":3,"maxFileSizeBytes":52428800}],"maxReferenceItems":9,"supportedSizes":["1920x1080","1280x720","640x480"],"supportedAspectRatios":["auto","21:9","16:9","4:3","1:1","3:4","9:16"],"supportedResolutions":[],"supportedDurations":[],"durationMin":4,"durationMax":15,"durationDefault":null,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:seedance_2_0"},{"id":"seedance-seedance-2.0-fast","name":"Seedance 2.0 Fast","modality":"video","upstreamModelId":"seedance","upstreamModelVersion":"seedance_2.0_fast","providerName":"ByteDance","releaseReadiness":"alpha","healthStatus":"DEGRADED","inputMediaUseCases":["editing","style_reference"],"schemaProperties":["modelId","modelVersion","prompt","seeds","modelSpecificPayload","generationMetadata","output","size","generationSettings","generateAudio","duration","referenceBlobs"],"requiredProperties":["generationMetadata","modelId"],"referenceInputs":[{"mediaType":"image","usageType":"frame","minItems":0,"maxItems":2,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"style","minItems":0,"maxItems":9,"maxFileSizeBytes":104857600},{"mediaType":"image","usageType":"element","minItems":0,"maxItems":9,"maxFileSizeBytes":104857600},{"mediaType":"video","usageType":"source","minItems":0,"maxItems":3,"maxFileSizeBytes":52428800},{"mediaType":"audio","usageType":"source","minItems":0,"maxItems":3,"maxFileSizeBytes":52428800}],"maxReferenceItems":9,"supportedSizes":["1280x720","640x480"],"supportedAspectRatios":["auto","21:9","16:9","4:3","1:1","3:4","9:16"],"supportedResolutions":[],"supportedDurations":[],"durationMin":4,"durationMax":15,"durationDefault":null,"outputCountMin":null,"outputCountMax":null,"promptMaxLength":2500,"backingModel":"firefly_3p:external:seedance_2_0_fast"}] as const; diff --git a/open-sse/services/adobeFireflyModels.ts b/open-sse/services/adobeFireflyModels.ts index 56290b6875..4fd560990c 100644 --- a/open-sse/services/adobeFireflyModels.ts +++ b/open-sse/services/adobeFireflyModels.ts @@ -1,328 +1,590 @@ /** - * Adobe Firefly model catalog: live discovery + static fallback from browser capture. + * Adobe Firefly model discovery and normalized media capabilities. * - * Live: POST firefly-3p.ff.adobe.io/v2/models/discovery (needs valid IMS token). - * Fallback: curated rows from adobe/get_models.txt (2026-07 Firefly SPA capture) so - * Media/Models still list usable ids when discovery fails or credentials are missing. + * The live discovery schema is authoritative. The generated snapshot is used only + * when a request cannot perform authenticated discovery (for example /v1/models). */ -import { - type AdobeFireflyDiscoveredModel, - discoverAdobeFireflyModels, - resolveAdobeAccessToken, -} from "./adobeFireflyClient.ts"; +import { ADOBE_FIREFLY_DISCOVERY_SNAPSHOT } from "./adobeFireflyModelSnapshot.ts"; + +export type AdobeFireflyModality = "image" | "video" | "audio" | "unknown"; + +export interface AdobeFireflyDiscoveredModel { + modelId: string; + modelVersion: string; + displayName: string; + modality: AdobeFireflyModality; + enabled: boolean; + providerName?: string; + releaseReadiness?: string; + healthStatus?: string; + inputMediaUseCases: string[]; + requestSchema?: Record; + backingModel?: string; +} + +export interface AdobeFireflyReferenceInputCapability { + mediaType: string; + usageType: string; + minItems: number; + maxItems: number | null; + maxFileSizeBytes: number | null; +} + +export interface AdobeFireflyMediaCapabilities { + inputMediaUseCases: string[]; + schemaProperties: string[]; + requiredProperties: string[]; + referenceInputs: AdobeFireflyReferenceInputCapability[]; + maxReferenceItems: number | null; + supportedSizes: string[]; + supportedAspectRatios: string[]; + supportedResolutions: string[]; + supportedDurations: number[]; + durationMin: number | null; + durationMax: number | null; + durationDefault: number | null; + outputCountMin: number | null; + outputCountMax: number | null; + promptMaxLength: number | null; + releaseReadiness: string; + healthStatus: string; +} export interface AdobeFireflyCatalogModel { - /** OpenAI-style id without provider prefix, e.g. nano-banana-pro or flux-fluxPro */ + /** Stable API id without the provider prefix. */ id: string; name: string; modality: "image" | "video"; - /** Upstream wire modelId for generate-async */ upstreamModelId: string; - /** Upstream wire modelVersion for generate-async */ upstreamModelVersion: string; - inputModalities?: string[]; + providerName: string; + backingModel: string; + inputModalities: string[]; + capabilities: AdobeFireflyMediaCapabilities; } -/** - * Static fallback built from adobe/get_models.txt discovery response. - * Friendly aliases first (Media page defaults), then popular upstream families. - */ -export const ADOBE_FIREFLY_FALLBACK_MODELS: AdobeFireflyCatalogModel[] = [ - // ── Friendly aliases (handler resolveAdobeImageModel / resolveAdobeVideoModel) ── - { - id: "nano-banana-pro", - name: "Gemini 3.0 (Nano Banana Pro)", - modality: "image", - upstreamModelId: "gemini-flash", - upstreamModelVersion: "nano-banana-2", - inputModalities: ["text", "image"], - }, - { - id: "nano-banana", - name: "Gemini 2.5 (Nano Banana)", - modality: "image", - upstreamModelId: "gemini-flash", - upstreamModelVersion: "nano-banana", - inputModalities: ["text", "image"], - }, - { - id: "nano-banana-2", - name: "Gemini 3.1 (Nano Banana 2)", - modality: "image", - upstreamModelId: "gemini-flash", - upstreamModelVersion: "nano-banana-3", - inputModalities: ["text", "image"], - }, - { - id: "gpt-image-2", - name: "GPT Image 2", - modality: "image", - upstreamModelId: "gpt-image", - upstreamModelVersion: "2", - inputModalities: ["text", "image"], - }, - { - id: "gpt-image", - name: "GPT Image 2", - modality: "image", - upstreamModelId: "gpt-image", - upstreamModelVersion: "2", - inputModalities: ["text", "image"], - }, - { - id: "gpt-image-1.5", - name: "GPT Image 1.5", - modality: "image", - upstreamModelId: "gpt-image", - upstreamModelVersion: "1.5", - inputModalities: ["text", "image"], - }, - { - id: "sora-2", - name: "Sora 2", - modality: "video", - upstreamModelId: "sora", - upstreamModelVersion: "sora-2", - }, - { - id: "sora-2-pro", - name: "Sora 2 Pro", - modality: "video", - upstreamModelId: "sora", - upstreamModelVersion: "sora-2-pro", - }, - { - id: "veo-3.1", - name: "Veo 3.1", - modality: "video", - upstreamModelId: "veo", - upstreamModelVersion: "3.1-generate", - }, - { - id: "veo-3.1-fast", - name: "Veo 3.1 Fast", - modality: "video", - upstreamModelId: "veo", - upstreamModelVersion: "3.1-fast-generate", - }, - { - id: "veo-3.1-ref", - name: "Veo 3.1 Reference", - modality: "video", - upstreamModelId: "veo", - upstreamModelVersion: "3.1-generate", - }, - { - id: "kling-3", - name: "Kling Video v3 Standard Image to Video", - modality: "video", - upstreamModelId: "kling", - upstreamModelVersion: "kling_v3_standard_i2v", - }, - // ── Additional image families from discovery capture ── - { - id: "flux-2", - name: "Flux 2", - modality: "image", - upstreamModelId: "flux", - upstreamModelVersion: "2", - inputModalities: ["text", "image"], - }, - { - id: "flux-pro", - name: "Flux 1.1 Pro", - modality: "image", - upstreamModelId: "flux", - upstreamModelVersion: "fluxPro", - inputModalities: ["text", "image"], - }, - { - id: "flux-ultra", - name: "Flux 1.1 Ultra", - modality: "image", - upstreamModelId: "flux", - upstreamModelVersion: "fluxUltra", - inputModalities: ["text", "image"], - }, - { - id: "seedream-4", - name: "Seedream 4.0", - modality: "image", - upstreamModelId: "seedream", - upstreamModelVersion: "seedream_v4", - inputModalities: ["text", "image"], - }, - { - id: "seedream-5-lite", - name: "Seedream 5.0 Lite", - modality: "image", - upstreamModelId: "seedream", - upstreamModelVersion: "seedream_v5_lite", - inputModalities: ["text", "image"], - }, - { - id: "runway-gen4-image", - name: "Runway Gen-4 Image", - modality: "image", - upstreamModelId: "runway-gen4-image", - upstreamModelVersion: "gen4_image", - inputModalities: ["text", "image"], - }, - // ── Additional video families ── - { - id: "kling-v3-t2v", - name: "Kling Video v3 Standard Text to Video", - modality: "video", - upstreamModelId: "kling", - upstreamModelVersion: "kling_v3_standard_t2v", - }, - { - id: "kling-v3-pro-i2v", - name: "Kling Video v3 Pro Image to Video", - modality: "video", - upstreamModelId: "kling", - upstreamModelVersion: "kling_v3_pro_i2v", - }, - { - id: "luma-ray3", - name: "Ray3", - modality: "video", - upstreamModelId: "luma", - upstreamModelVersion: "3.0-ray", - }, - { - id: "runway-gen4-turbo", - name: "Runway Gen-4 Video", - modality: "video", - upstreamModelId: "runway", - upstreamModelVersion: "gen4_turbo", - }, -]; +export interface AdobeFireflyImageModelSpec extends AdobeFireflyCatalogModel { + modality: "image"; + /** Payload dialect observed for this model family. */ + family: "gemini" | "gpt-image" | "generic"; +} -/** Stable slug for upstream modelId + modelVersion (catalog id when not a friendly alias). */ +export interface AdobeFireflyVideoModelSpec extends AdobeFireflyCatalogModel { + modality: "video"; + defaultDuration: number; + defaultResolution: string; +} + +interface MergedObjectSchema { + properties: Record>; + required: string[]; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.map((item) => String(item)).filter((item) => item.length > 0) + : []; +} + +function finiteInteger(value: unknown): number | null { + return Number.isInteger(value) ? (value as number) : null; +} + +/** Merge object properties/required keys contributed through JSON Schema allOf. */ +export function mergeAdobeObjectSchema(schema: unknown): MergedObjectSchema { + const merged: MergedObjectSchema = { properties: {}, required: [] }; + const visit = (value: unknown) => { + const node = asRecord(value); + const properties = asRecord(node.properties); + for (const [key, property] of Object.entries(properties)) { + merged.properties[key] = asRecord(property); + } + merged.required.push(...asStringArray(node.required)); + if (Array.isArray(node.allOf)) node.allOf.forEach(visit); + }; + visit(schema); + merged.required = [...new Set(merged.required)]; + return merged; +} + +function schemaBranches(schema: unknown): Record[] { + const root = asRecord(schema); + if (Object.keys(root).length === 0) return []; + return [ + root, + ...(Array.isArray(root.anyOf) ? root.anyOf.map(asRecord) : []), + ...(Array.isArray(root.oneOf) ? root.oneOf.map(asRecord) : []), + ]; +} + +function enumStrings(schema: unknown): string[] { + return [ + ...new Set( + schemaBranches(schema) + .flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : [])) + .filter((value): value is string => typeof value === "string") + ), + ]; +} + +function integerBranch(schema: unknown): Record { + return schemaBranches(schema).find((branch) => branch.type === "integer") || {}; +} + +/** Stable, collision-resistant public id for an exact upstream model/version pair. */ export function slugifyAdobeModel(modelId: string, modelVersion: string): string { - const mid = String(modelId || "") - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - const ver = String(modelVersion || "") - .trim() - .toLowerCase() - .replace(/[^a-z0-9.]+/g, "-") - .replace(/^-|-$/g, ""); - if (!ver || ver === "default" || ver === mid) return mid || "model"; - return `${mid}-${ver}`; + const slug = (value: string, allowDot = false) => + String(value || "") + .trim() + .toLowerCase() + .replace(allowDot ? /[^a-z0-9.]+/g : /[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + const family = slug(modelId); + // Adobe still uses `kling_v3_omni*` internally, while discovery exposes these + // products to users as Kling O3. Never leak the obsolete/internal "omni" name + // into the public API catalog; the untouched upstream version stays in the spec. + const publicVersion = + family === "kling" ? modelVersion.replace(/^kling_v3_omni/i, "kling_o3") : modelVersion; + const version = slug(publicVersion, true); + if (!version || version === "default" || version === family) return family || "model"; + return `${family}-${version}`; } -/** Map discovery rows → catalog entries (image/video only). */ -export function mapDiscoveredToCatalog( - rows: AdobeFireflyDiscoveredModel[] -): AdobeFireflyCatalogModel[] { - const out: AdobeFireflyCatalogModel[] = []; - const seen = new Set(); +/** Parse POST /v2/models/discovery without discarding its resolved request schema. */ +export function parseAdobeModelsDiscovery(body: unknown): AdobeFireflyDiscoveredModel[] { + const root = asRecord(body); + const families = Array.isArray(root.models) ? root.models : []; + const rows: AdobeFireflyDiscoveredModel[] = []; - // Prefer friendly aliases when upstream matches known fallback rows. - for (const fb of ADOBE_FIREFLY_FALLBACK_MODELS) { - const hit = rows.find( - (r) => - r.modelId === fb.upstreamModelId && - r.modelVersion === fb.upstreamModelVersion && - (r.modality === fb.modality || r.modality === "unknown") - ); - if (hit && !seen.has(fb.id)) { - seen.add(fb.id); - out.push({ - ...fb, - name: hit.displayName || fb.name, + for (const familyValue of families) { + const family = asRecord(familyValue); + const modelId = String(family.modelId || "").trim(); + if (!modelId) continue; + for (const [modelVersion, versionValue] of Object.entries(asRecord(family.modelVersions))) { + const version = asRecord(versionValue); + if (version.enabled === false) continue; + const outputModalities = asStringArray(version.outputModality).map((item) => + item.toLowerCase() + ); + const modality: AdobeFireflyModality = outputModalities.includes("image") + ? "image" + : outputModalities.includes("video") + ? "video" + : outputModalities.includes("audio") + ? "audio" + : "unknown"; + rows.push({ + modelId, + modelVersion, + displayName: String( + version.modelDisplayName || version.modelCaiDisplayName || modelVersion + ), + modality, + enabled: version.enabled !== false, + providerName: + typeof family.acModelFamilyProviderDisplayName === "string" + ? family.acModelFamilyProviderDisplayName + : undefined, + releaseReadiness: + typeof version.releaseReadiness === "string" ? version.releaseReadiness : undefined, + healthStatus: typeof version.healthStatus === "string" ? version.healthStatus : undefined, + inputMediaUseCases: asStringArray(version.inputMediaUseCase), + requestSchema: asRecord(version.requestSchema), + backingModel: + typeof version.bksGenerationModel === "string" ? version.bksGenerationModel : undefined, + }); + } + } + return rows; +} + +function normalizeCapabilities(row: AdobeFireflyDiscoveredModel): AdobeFireflyMediaCapabilities { + const schema = mergeAdobeObjectSchema(row.requestSchema); + const referenceSchema = asRecord(schema.properties.referenceBlobs); + const referenceInputs: AdobeFireflyReferenceInputCapability[] = []; + const mediaCapabilities = Array.isArray(referenceSchema["x-capabilities"]) + ? referenceSchema["x-capabilities"] + : []; + for (const mediaValue of mediaCapabilities) { + const media = asRecord(mediaValue); + const maxFileSizeBytes = finiteInteger(media.maxFileSizeBytes); + const usageConstraints = Array.isArray(media.usageConstraints) ? media.usageConstraints : []; + for (const usageValue of usageConstraints) { + const usage = asRecord(usageValue); + if (usage.deprecated === true) continue; + const usageType = String(usage.usageType || ""); + const mediaType = String(media.mediaType || ""); + if (!usageType || !mediaType) continue; + referenceInputs.push({ + mediaType, + usageType, + minItems: finiteInteger(usage.minItems) ?? 0, + maxItems: finiteInteger(usage.maxItems), + maxFileSizeBytes, }); } } - for (const r of rows) { - if (r.modality !== "image" && r.modality !== "video") continue; - const id = slugifyAdobeModel(r.modelId, r.modelVersion); - if (seen.has(id)) continue; - // Skip if already covered by a friendly alias with same upstream - if ( - out.some( - (o) => - o.upstreamModelId === r.modelId && o.upstreamModelVersion === r.modelVersion + const supportedSizes = [ + ...new Set( + schemaBranches(schema.properties.size) + .flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : [])) + .map(asRecord) + .filter((size) => finiteInteger(size.width) !== null && finiteInteger(size.height) !== null) + .map((size) => `${size.width}x${size.height}`) + ), + ]; + const supportedAspectRatios = [ + ...new Set( + schemaBranches(schema.properties.generationSettings).flatMap((branch) => + enumStrings(asRecord(asRecord(branch.properties).aspectRatio)) ) - ) { - continue; - } - seen.add(id); - out.push({ - id, - name: r.displayName || id, - modality: r.modality, - upstreamModelId: r.modelId, - upstreamModelVersion: r.modelVersion, - inputModalities: r.modality === "image" ? ["text", "image"] : ["text"], - }); - } - - return out; -} - -export function getAdobeFireflyFallbackCatalog(modality?: "image" | "video"): AdobeFireflyCatalogModel[] { - if (!modality) return [...ADOBE_FIREFLY_FALLBACK_MODELS]; - return ADOBE_FIREFLY_FALLBACK_MODELS.filter((m) => m.modality === modality); -} - -/** - * Live discovery when credentials resolve; otherwise static fallback from get_models capture. - */ -export async function resolveAdobeFireflyCatalog(opts: { - credentials?: { - apiKey?: string; - accessToken?: string; - providerSpecificData?: Record | null; - } | null; - modality?: "image" | "video"; - fetchImpl?: typeof fetch; -}): Promise<{ models: AdobeFireflyCatalogModel[]; source: "api" | "fallback" }> { - const fetchImpl = opts.fetchImpl || fetch; - try { - if (opts.credentials) { - const token = await resolveAdobeAccessToken(opts.credentials, fetchImpl); - const discovered = await discoverAdobeFireflyModels(token, fetchImpl); - let catalog = mapDiscoveredToCatalog(discovered); - if (opts.modality) catalog = catalog.filter((m) => m.modality === opts.modality); - if (catalog.length > 0) return { models: catalog, source: "api" }; - } - } catch { - // fall through to static catalog - } + ), + ]; + const duration = integerBranch(schema.properties.duration); + const outputCount = integerBranch(schema.properties.n); + const prompt = + schemaBranches(schema.properties.prompt).find((branch) => branch.type === "string") || {}; return { - models: getAdobeFireflyFallbackCatalog(opts.modality), - source: "fallback", + inputMediaUseCases: [...row.inputMediaUseCases], + schemaProperties: Object.keys(schema.properties), + requiredProperties: [...schema.required], + referenceInputs, + maxReferenceItems: finiteInteger(referenceSchema.maxItems), + supportedSizes, + supportedAspectRatios, + supportedResolutions: enumStrings(schema.properties.resolution), + supportedDurations: [ + ...new Set( + schemaBranches(schema.properties.duration) + .flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : [])) + .filter((value): value is number => Number.isInteger(value)) + ), + ], + durationMin: finiteInteger(duration.minimum), + durationMax: finiteInteger(duration.maximum), + durationDefault: finiteInteger(duration.default), + outputCountMin: finiteInteger(outputCount.minimum), + outputCountMax: finiteInteger(outputCount.maximum), + promptMaxLength: finiteInteger(prompt.maxLength), + releaseReadiness: row.releaseReadiness || "", + healthStatus: row.healthStatus || "", }; } -/** Registry-shaped models for imageRegistry / videoRegistry. */ -export function toRegistryImageModels( - models: AdobeFireflyCatalogModel[] = getAdobeFireflyFallbackCatalog("image") -): Array<{ id: string; name: string; inputModalities?: string[] }> { - return models - .filter((m) => m.modality === "image") - .map((m) => ({ - id: m.id, - name: m.name.startsWith("Firefly ") ? m.name : `Firefly ${m.name}`, - inputModalities: m.inputModalities || ["text", "image"], - })); +function isCallableGenerationModel(row: AdobeFireflyDiscoveredModel): boolean { + if (row.modality !== "image" && row.modality !== "video") return false; + if (!mergeAdobeObjectSchema(row.requestSchema).properties.prompt) return false; + const excluded = new Set(["upscaling", "sharpening", "denoising"]); + return !row.inputMediaUseCases.some((value) => excluded.has(value.toLowerCase())); } -export function toRegistryVideoModels( - models: AdobeFireflyCatalogModel[] = getAdobeFireflyFallbackCatalog("video") -): Array<{ id: string; name: string }> { - return models - .filter((m) => m.modality === "video") - .map((m) => ({ - id: m.id, - name: m.name.startsWith("Firefly ") ? m.name : `Firefly ${m.name}`, - })); +function deriveInputModalities(capabilities: AdobeFireflyMediaCapabilities): string[] { + return ["text", ...new Set(capabilities.referenceInputs.map((reference) => reference.mediaType))]; +} + +function semanticCatalogKey(model: AdobeFireflyCatalogModel): string { + return JSON.stringify({ + backingModel: model.backingModel, + name: model.name, + modality: model.modality, + capabilities: model.capabilities, + }); +} + +/** Normalize and de-duplicate callable image/video rows from live discovery. */ +export function mapDiscoveredToCatalog( + rows: AdobeFireflyDiscoveredModel[] +): AdobeFireflyCatalogModel[] { + const output: AdobeFireflyCatalogModel[] = []; + const seen = new Set(); + for (const row of rows) { + if (!isCallableGenerationModel(row)) continue; + const capabilities = normalizeCapabilities(row); + const model: AdobeFireflyCatalogModel = { + id: slugifyAdobeModel(row.modelId, row.modelVersion), + name: row.displayName, + modality: row.modality as "image" | "video", + upstreamModelId: row.modelId, + upstreamModelVersion: row.modelVersion, + providerName: row.providerName || "", + backingModel: row.backingModel || "", + inputModalities: deriveInputModalities(capabilities), + capabilities, + }; + const key = semanticCatalogKey(model); + if (seen.has(key)) continue; + seen.add(key); + output.push(model); + } + return output; +} + +function snapshotCatalog(): AdobeFireflyCatalogModel[] { + return ADOBE_FIREFLY_DISCOVERY_SNAPSHOT.map((model) => { + const capabilities: AdobeFireflyMediaCapabilities = { + inputMediaUseCases: [...model.inputMediaUseCases], + schemaProperties: [...model.schemaProperties], + requiredProperties: [...model.requiredProperties], + referenceInputs: model.referenceInputs.map((reference) => ({ ...reference })), + maxReferenceItems: model.maxReferenceItems, + supportedSizes: [...model.supportedSizes], + supportedAspectRatios: [...model.supportedAspectRatios], + supportedResolutions: [...model.supportedResolutions], + supportedDurations: [...model.supportedDurations], + durationMin: model.durationMin, + durationMax: model.durationMax, + durationDefault: model.durationDefault, + outputCountMin: model.outputCountMin, + outputCountMax: model.outputCountMax, + promptMaxLength: model.promptMaxLength, + releaseReadiness: model.releaseReadiness, + healthStatus: model.healthStatus, + }; + return { + id: model.id, + name: model.name, + modality: model.modality, + upstreamModelId: model.upstreamModelId, + upstreamModelVersion: model.upstreamModelVersion, + providerName: model.providerName, + backingModel: model.backingModel, + inputModalities: deriveInputModalities(capabilities), + capabilities, + }; + }); +} + +export const ADOBE_FIREFLY_FALLBACK_MODELS: AdobeFireflyCatalogModel[] = snapshotCatalog(); + +export function getAdobeFireflyFallbackCatalog( + modality?: "image" | "video" +): AdobeFireflyCatalogModel[] { + return ADOBE_FIREFLY_FALLBACK_MODELS.filter((model) => !modality || model.modality === modality); +} + +function imageFamily(model: AdobeFireflyCatalogModel): AdobeFireflyImageModelSpec["family"] { + if (model.upstreamModelId === "gemini-flash") return "gemini"; + if (model.upstreamModelId === "gpt-image" || model.upstreamModelId === "gpt-4o-image") { + return "gpt-image"; + } + return "generic"; +} + +export const ADOBE_FIREFLY_IMAGE_MODELS: Record = + Object.fromEntries( + getAdobeFireflyFallbackCatalog("image").map((model) => [ + model.id, + { ...model, modality: "image" as const, family: imageFamily(model) }, + ]) + ); + +function defaultDuration(model: AdobeFireflyCatalogModel): number { + const caps = model.capabilities; + return caps.durationDefault ?? caps.supportedDurations[0] ?? caps.durationMin ?? 5; +} + +function defaultResolution(model: AdobeFireflyCatalogModel): string { + if (model.capabilities.supportedSizes.some((value) => value.includes("1920x1080"))) { + return "1080p"; + } + return "720p"; +} + +export const ADOBE_FIREFLY_VIDEO_MODELS: Record = + Object.fromEntries( + getAdobeFireflyFallbackCatalog("video").map((model) => [ + model.id, + { + ...model, + modality: "video" as const, + defaultDuration: defaultDuration(model), + defaultResolution: defaultResolution(model), + }, + ]) + ); + +const LEGACY_MODEL_ALIASES: Record = { + "nano-banana": "gemini-flash-nano-banana", + "nano-banana-pro": "gemini-flash-nano-banana-2", + "nano-banana-2": "gemini-flash-nano-banana-3", + "gpt-image": "gpt-image-2", + "gpt-image-2": "gpt-image-2", + "gpt-image-1.5": "gpt-image-1.5", + "flux-2": "flux-2", + "flux-pro": "flux-fluxpro", + "flux-ultra": "flux-fluxultra", + "seedream-4": "seedream-seedream-v4", + "seedream-5-lite": "seedream-seedream-v5-lite", + "runway-gen4-image": "runway-gen4-image", + "veo-3.1": "veo-3.1-generate", + "veo-3.1-fast": "veo-3.1-fast-generate", + "luma-ray3": "luma-3.0-ray", + "runway-gen4-turbo": "runway-gen4-turbo", + // Backward compatibility only; the catalog advertises the exact discovered id. + "kling-3": "kling-kling-v3-standard-i2v", +}; + +// Preserve established API aliases when (and only when) they resolve to a model +// that is present in the verified discovery snapshot. These keys are not listed. +for (const [alias, target] of Object.entries(LEGACY_MODEL_ALIASES)) { + const imageTarget = ADOBE_FIREFLY_IMAGE_MODELS[target]; + if (imageTarget) ADOBE_FIREFLY_IMAGE_MODELS[alias] = imageTarget; + const videoTarget = ADOBE_FIREFLY_VIDEO_MODELS[target]; + if (videoTarget) ADOBE_FIREFLY_VIDEO_MODELS[alias] = videoTarget; +} + +/** Backward-compatible request ids. Kept out of every advertised model catalog. */ +export const ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES = Object.freeze( + Object.entries(LEGACY_MODEL_ALIASES) + .filter(([, target]) => Boolean(ADOBE_FIREFLY_IMAGE_MODELS[target])) + .map(([alias]) => alias) +); + +function normalizeRequestedId(model: string): string { + return String(model || "") + .trim() + .toLowerCase() + .replace(/^adobe-firefly\//, "") + .replace(/^firefly\//, ""); +} + +function resolveCatalogId(model: string): string { + const requested = normalizeRequestedId(model); + return LEGACY_MODEL_ALIASES[requested] || requested; +} + +export function resolveAdobeImageModel(model: string): { + id: string; + spec: AdobeFireflyImageModelSpec; +} { + const id = resolveCatalogId(model); + const spec = ADOBE_FIREFLY_IMAGE_MODELS[id]; + if (!spec) { + throw new Error( + `Unknown Adobe Firefly image model: ${normalizeRequestedId(model) || "(empty)"}` + ); + } + return { id, spec }; +} + +export function resolveAdobeVideoModel(model: string): { + id: string; + spec: AdobeFireflyVideoModelSpec; +} { + const id = resolveCatalogId(model); + const spec = ADOBE_FIREFLY_VIDEO_MODELS[id]; + if (!spec) { + throw new Error( + `Unknown Adobe Firefly video model: ${normalizeRequestedId(model) || "(empty)"}` + ); + } + return { id, spec }; +} + +export function toRegistryImageModels(): Array<{ + id: string; + name: string; + inputModalities: string[]; + imageRequired?: boolean; + supportedSizes: string[]; + mediaCapabilities: Record; +}> { + const generated = getAdobeFireflyFallbackCatalog("image").map((model) => ({ + id: model.id, + name: `Firefly ${model.name}`, + inputModalities: model.inputModalities, + supportedSizes: model.capabilities.supportedSizes, + mediaCapabilities: toAdobeMediaCapabilitiesApi(model), + })); + // Upscaling uses a distinct Firefly endpoint and is not returned by the image + // generation discovery schema. Keep its two supported Topaz models visible in + // the same provider catalog so image clients can select them deliberately. + return [ + ...generated, + { + id: "topaz-standard", + name: "Firefly Topaz Upscale (Standard)", + inputModalities: ["image"], + imageRequired: true, + supportedSizes: [], + mediaCapabilities: { input_media_use_cases: ["upscaling"] }, + }, + { + id: "topaz-bloom", + name: "Firefly Topaz Bloom (Creative Upscale)", + inputModalities: ["image"], + imageRequired: true, + supportedSizes: [], + mediaCapabilities: { input_media_use_cases: ["upscaling"] }, + }, + ]; +} + +export function toRegistryVideoModels(): Array<{ + id: string; + name: string; + supportedSizes: string[]; + mediaCapabilities: Record; +}> { + return getAdobeFireflyFallbackCatalog("video").map((model) => ({ + id: model.id, + name: `Firefly ${model.name}`, + supportedSizes: model.capabilities.supportedSizes, + mediaCapabilities: toAdobeMediaCapabilitiesApi(model), + })); +} + +/** JSON-safe extension emitted by /v1/models. */ +export function toAdobeMediaCapabilitiesApi( + model: AdobeFireflyCatalogModel +): Record { + const caps = model.capabilities; + return { + upstream_model_id: model.upstreamModelId, + upstream_model_version: model.upstreamModelVersion, + provider_name: model.providerName, + release_readiness: caps.releaseReadiness, + health_status: caps.healthStatus, + input_media_use_cases: caps.inputMediaUseCases, + reference_inputs: caps.referenceInputs.map((reference) => ({ + media_type: reference.mediaType, + usage_type: reference.usageType, + min_items: reference.minItems, + max_items: reference.maxItems, + max_file_size_bytes: reference.maxFileSizeBytes, + })), + max_reference_items: caps.maxReferenceItems, + supported_sizes: caps.supportedSizes, + supported_aspect_ratios: caps.supportedAspectRatios, + supported_resolutions: caps.supportedResolutions, + supported_durations: caps.supportedDurations, + duration_min: caps.durationMin, + duration_max: caps.durationMax, + duration_default: caps.durationDefault, + output_count_min: caps.outputCountMin, + output_count_max: caps.outputCountMax, + prompt_max_length: caps.promptMaxLength, + }; +} + +export function getAdobeReferenceUploadLimit( + model: AdobeFireflyCatalogModel, + mediaType: string +): number { + if (model.capabilities.maxReferenceItems !== null) { + return Math.max(1, Math.min(32, model.capabilities.maxReferenceItems)); + } + const declaredTotal = model.capabilities.referenceInputs + .filter((reference) => reference.mediaType === mediaType) + .reduce((total, reference) => total + (reference.maxItems ?? 0), 0); + return Math.max(1, Math.min(32, declaredTotal || 1)); } 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/alibabaFreeTier.ts b/open-sse/services/alibabaFreeTier.ts new file mode 100644 index 0000000000..1592d3d0d1 --- /dev/null +++ b/open-sse/services/alibabaFreeTier.ts @@ -0,0 +1,164 @@ +/** + * @file alibabaFreeTier.ts + * @description Alibaba Model Studio free-tier drain detection, billing mode, and persisted model lockouts. + * + * @changes + * - [2026-07-25] [Composer] - Delegate free-eligible filtering to probe-based discovery module + * - [2026-07-24] [Composer] - Add free-vs-paid billing mode and permanent free-tier model drain handling + */ + +import { isModelLocked, lockModel } from "./accountFallback.ts"; + +export type AlibabaBillingMode = "free" | "paid"; + +type AlibabaConnectionLike = { + id: string; + providerSpecificData?: Record | null; +}; + +/** ~10 years — free-tier drains are permanent until the operator clears connection state. */ +export const ALIBABA_FREE_DRAINED_LOCK_MS = 10 * 365 * 24 * 60 * 60 * 1000; + +const ALIBABA_FREE_QUOTA_EXHAUSTED_PATTERNS = [ + /\bfree quota has been exhausted\b/i, + /\bfree tier of the model has been exhausted\b/i, + /\buse free tier only\b/i, +] as const; + +const ALIBABA_MODEL_STUDIO_PROVIDER_IDS = new Set(["alibaba", "alibaba-cn", "ali"]); + +export { + filterAlibabaFreeEligibleModels, + isAlibabaFreeTierCapableModel, +} from "./alibabaFreeTierDiscovery.ts"; + +export { + filterAlibabaFreeVisionEligibleModels, + filterAlibabaFreeMultimodalEligibleModels, + filterAlibabaFreeAudioEligibleModels, + isAlibabaFreeTierVisionCapableModel, + isAlibabaFreeTierMultimodalCapableModel, + isAlibabaFreeTierAudioCapableModel, +} from "./alibabaFreeTierQuotaFetcher.ts"; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function isAlibabaModelStudioProvider(provider: string | null | undefined): boolean { + if (!provider) return false; + const normalized = provider.toLowerCase(); + return ALIBABA_MODEL_STUDIO_PROVIDER_IDS.has(normalized); +} + +export function isAlibabaFreeQuotaExhaustedError(errorText: string): boolean { + const text = String(errorText || ""); + if (!text) return false; + return ALIBABA_FREE_QUOTA_EXHAUSTED_PATTERNS.some((pattern) => pattern.test(text)); +} + +export function getAlibabaBillingMode( + providerSpecificData: Record | null | undefined +): AlibabaBillingMode { + const raw = asRecord(providerSpecificData).alibabaBillingMode; + return raw === "free" ? "free" : "paid"; +} + +export function getAlibabaFreeDrainedModels( + providerSpecificData: Record | null | undefined +): string[] { + const raw = asRecord(providerSpecificData).alibabaFreeDrainedModels; + if (!Array.isArray(raw)) return []; + return raw.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +export function isAlibabaModelFreeDrained( + provider: string | null | undefined, + providerSpecificData: Record | null | undefined, + model: string | null | undefined +): boolean { + if (!isAlibabaModelStudioProvider(provider) || !model) return false; + return getAlibabaFreeDrainedModels(providerSpecificData).includes(model); +} + +export function mergeAlibabaFreeDrainedModels( + providerSpecificData: Record | null | undefined, + model: string +): Record { + const base = asRecord(providerSpecificData); + const existing = new Set(getAlibabaFreeDrainedModels(base)); + existing.add(model); + return { + ...base, + alibabaFreeDrainedModels: [...existing], + }; +} + +export function shouldUseLiveAlibabaFreeModelDiscovery( + providerSpecificData: Record | null | undefined +): boolean { + return getAlibabaBillingMode(providerSpecificData) === "free"; +} + +export function filterAlibabaFreeTierModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined +): string[] { + const drained = new Set(getAlibabaFreeDrainedModels(providerSpecificData)); + return modelIds.filter((id) => !drained.has(id)); +} + +export function rehydrateAlibabaFreeDrainedModelLocks( + provider: string, + connectionId: string, + providerSpecificData: Record | null | undefined +): void { + if ( + !isAlibabaModelStudioProvider(provider) || + getAlibabaBillingMode(providerSpecificData) !== "free" + ) { + return; + } + for (const model of getAlibabaFreeDrainedModels(providerSpecificData)) { + if (!isModelLocked(provider, connectionId, model)) { + lockModel( + provider, + connectionId, + model, + "free_quota_exhausted", + ALIBABA_FREE_DRAINED_LOCK_MS + ); + } + } +} + +export async function isAlibabaFreeTierModelRoutable( + provider: string, + connectionId: string, + model: string +): Promise { + if (!isAlibabaModelStudioProvider(provider) || !model) return true; + if (isModelLocked(provider, connectionId, model)) return false; + try { + const { getProviderConnections } = await import("../../src/lib/db/providers.ts"); + const { buildAlibabaFreeTierFilterContext, isAlibabaFreeTierCapableModel } = + await import("./alibabaFreeTierDiscovery.ts"); + const connections = await getProviderConnections({ provider }); + const connection = connections.find((entry) => entry.id === connectionId); + if (!connection) return true; + const providerSpecificData = connection.providerSpecificData as Record; + rehydrateAlibabaFreeDrainedModelLocks(provider, connectionId, providerSpecificData); + if (getAlibabaBillingMode(providerSpecificData) === "free") { + const filterContext = buildAlibabaFreeTierFilterContext( + connections as unknown as readonly AlibabaConnectionLike[], + connectionId + ); + if (!isAlibabaFreeTierCapableModel(model, filterContext)) return false; + } + return !isAlibabaModelFreeDrained(provider, providerSpecificData, model); + } catch { + return !isModelLocked(provider, connectionId, model); + } +} diff --git a/open-sse/services/alibabaFreeTierAllowlist.ts b/open-sse/services/alibabaFreeTierAllowlist.ts new file mode 100644 index 0000000000..ef2a28c57f --- /dev/null +++ b/open-sse/services/alibabaFreeTierAllowlist.ts @@ -0,0 +1,202 @@ +/** + * @file alibabaFreeTierAllowlist.ts + * @description Offline fallback allowlist for Alibaba Model Studio free-tier text models. + * + * Prefer live console quota sync (`alibabaFreeTierQuotaFetcher.ts`). This pack is used + * only when no recent console snapshot exists on the connection. + * + * @changes + * - [2026-07-28] [Composer] - Load dated JSON pack from DATA_DIR/config with embedded fallback + * - [2026-07-25] [Composer] - Hardcode text free/paid model lists from operator console quota export + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type AlibabaFreeTierAllowlistPack = { + asOf: string; + validUntil?: string; + capable: string[]; + noFreeTier: string[]; +}; + +export const ALIBABA_FREE_TIER_TEXT_CAPABLE_MODELS = [ + "deepseek-v3.2", + "deepseek-v4-pro", + "glm-5.2", + "qwen-flash", + "qwen-flash-2025-07-28", + "qwen-flash-character", + "qwen-max", + "qwen-mt-flash", + "qwen-mt-lite", + "qwen-mt-plus", + "qwen-mt-turbo", + "qwen-plus-2025-04-28", + "qwen-plus-2025-07-14", + "qwen-plus-2025-07-28", + "qwen-plus-2025-09-11", + "qwen-plus-character", + "qwen-plus-latest", + "qwen3-14b", + "qwen3-235b-a22b", + "qwen3-235b-a22b-instruct-2507", + "qwen3-235b-a22b-thinking-2507", + "qwen3-30b-a3b", + "qwen3-30b-a3b-instruct-2507", + "qwen3-30b-a3b-thinking-2507", + "qwen3-32b", + "qwen3-8b", + "qwen3-coder-30b-a3b-instruct", + "qwen3-coder-480b-a35b-instruct", + "qwen3-coder-flash", + "qwen3-coder-flash-2025-07-28", + "qwen3-coder-next", + "qwen3-coder-plus", + "qwen3-coder-plus-2025-07-22", + "qwen3-coder-plus-2025-09-23", + "qwen3-max", + "qwen3-max-2025-09-23", + "qwen3-max-2026-01-23", + "qwen3-max-preview", + "qwen3-next-80b-a3b-instruct", + "qwen3-next-80b-a3b-thinking", + "qwen3.5-122b-a10b", + "qwen3.5-27b", + "qwen3.5-397b-a17b", + "qwen3.5-flash", + "qwen3.5-flash-2026-02-23", + "qwen3.5-plus", + "qwen3.5-plus-2026-02-15", + "qwen3.5-plus-2026-04-20", + "qwen3.6-27b", + "qwen3.6-35b-a3b", + "qwen3.6-flash", + "qwen3.6-flash-2026-04-16", + "qwen3.6-max-preview", + "qwen3.6-plus", + "qwen3.6-plus-2026-04-02", + "qwen3.7-flash", + "qwen3.7-flash-2026-07-15", + "qwen3.7-max-2026-05-17", + "qwen3.7-max-2026-05-20", + "qwen3.7-max-2026-06-08", + "qwen3.7-max-preview", + "qwen3.7-plus-2026-05-26", + "qwq-plus", +] as const; + +export const ALIBABA_NO_FREE_TIER_TEXT_MODELS = [ + "deepseek-v4-flash", + "glm-5.1", + "glm-5.2-fast-preview", + "kimi-k2.7-code", + "qwen-plus", + "qwen-plus-2025-01-25", + "qwen-plus-character-ja", + "qwen-turbo", + "qwen3.5-35b-a3b", + "qwen3.7-max", + "qwen3.7-plus", +] as const; + +const EMBEDDED_ALLOWLIST_PACK: AlibabaFreeTierAllowlistPack = { + asOf: "2026-07-25", + capable: [...ALIBABA_FREE_TIER_TEXT_CAPABLE_MODELS], + noFreeTier: [...ALIBABA_NO_FREE_TIER_TEXT_MODELS], +}; + +let cachedPack: AlibabaFreeTierAllowlistPack | null | undefined; + +export function resetAlibabaFreeTierAllowlistCache(): void { + cachedPack = undefined; +} + +function resolveAllowlistPaths(): string[] { + const paths: string[] = []; + const envPath = process.env.ALIBABA_FREE_TIER_ALLOWLIST_PATH?.trim(); + if (envPath) paths.push(envPath); + + const dataDir = process.env.DATA_DIR?.trim() || path.join(os.homedir(), ".omniroute"); + paths.push(path.join(dataDir, "alibaba-free-tier-allowlist.json")); + paths.push(path.join(process.cwd(), "config", "alibaba-free-tier-allowlist.json")); + return paths; +} + +function parseAllowlistPack(raw: unknown): AlibabaFreeTierAllowlistPack | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const record = raw as Record; + const capable = Array.isArray(record.capable) + ? record.capable.filter( + (entry): entry is string => typeof entry === "string" && entry.length > 0 + ) + : []; + const noFreeTier = Array.isArray(record.noFreeTier) + ? record.noFreeTier.filter( + (entry): entry is string => typeof entry === "string" && entry.length > 0 + ) + : []; + const asOf = typeof record.asOf === "string" && record.asOf.trim() ? record.asOf.trim() : null; + if (!asOf || capable.length === 0) return null; + + return { + asOf, + validUntil: + typeof record.validUntil === "string" && record.validUntil.trim() + ? record.validUntil.trim() + : undefined, + capable, + noFreeTier, + }; +} + +export function isAlibabaFreeTierAllowlistPackValid( + pack: AlibabaFreeTierAllowlistPack, + nowMs: number = Date.now() +): boolean { + if (!pack.validUntil) return true; + const expiresAt = Date.parse(pack.validUntil); + if (!Number.isFinite(expiresAt)) return true; + return expiresAt >= nowMs; +} + +export function loadAlibabaFreeTierAllowlistPack(): AlibabaFreeTierAllowlistPack | null { + if (cachedPack !== undefined) return cachedPack; + + for (const candidatePath of resolveAllowlistPaths()) { + try { + if (!fs.existsSync(candidatePath)) continue; + const parsed = parseAllowlistPack(JSON.parse(fs.readFileSync(candidatePath, "utf8"))); + if (parsed && isAlibabaFreeTierAllowlistPackValid(parsed)) { + cachedPack = parsed; + return cachedPack; + } + } catch { + // Try next path. + } + } + + cachedPack = null; + return cachedPack; +} + +function resolveActiveAllowlistPack(): AlibabaFreeTierAllowlistPack { + return loadAlibabaFreeTierAllowlistPack() ?? EMBEDDED_ALLOWLIST_PACK; +} + +export function getAlibabaBuiltinFreeTierTextCapableModels(): readonly string[] { + return resolveActiveAllowlistPack().capable; +} + +export function getAlibabaBuiltinNoFreeTierTextModels(): readonly string[] { + return resolveActiveAllowlistPack().noFreeTier; +} + +export function isAlibabaBuiltinFreeTierTextModel(modelId: string): boolean { + return getAlibabaBuiltinFreeTierTextCapableModels().includes(modelId); +} + +export function isAlibabaBuiltinNoFreeTierTextModel(modelId: string): boolean { + return getAlibabaBuiltinNoFreeTierTextModels().includes(modelId); +} diff --git a/open-sse/services/alibabaFreeTierDiscovery.ts b/open-sse/services/alibabaFreeTierDiscovery.ts new file mode 100644 index 0000000000..0c5bb01f6f --- /dev/null +++ b/open-sse/services/alibabaFreeTierDiscovery.ts @@ -0,0 +1,359 @@ +/** + * @file alibabaFreeTierDiscovery.ts + * @description Probe-based Alibaba Model Studio free-tier eligibility discovery. + * + * DashScope /models does not expose free-quota metadata. We classify models with + * minimal chat probes and persist results on the connection: + * - AllocationQuota.FreeTierOnly → model offers free tier (drained on this account) + * - 200 on native promo families (qwen/glm/deepseek/…) → free tier with remaining quota + * - 200 on third-party paid families (kimi/moonshot) → no free tier (paid billing only) + * + * @changes + * - [2026-07-25] [Composer] - Always union built-in text free-tier allowlist into capable/block checks + * - [2026-07-25] [Composer] - Delegate text filter context to canonical shared eligibility builder + * - [2026-07-25] [Composer] - Strict allowlist for alibabafree combos (no optimistic qwen/glm guessing) + * - [2026-07-25] [Composer] - Merge free-tier state across provider connections for wildcard filtering + * - [2026-07-25] [Composer] - Add probe-based free-tier model discovery for Alibaba connections + */ + +import { + getAlibabaBillingMode, + getAlibabaFreeDrainedModels, + isAlibabaFreeQuotaExhaustedError, + isAlibabaModelStudioProvider, + mergeAlibabaFreeDrainedModels, + type AlibabaBillingMode, +} from "./alibabaFreeTier.ts"; +import { + getAlibabaBuiltinFreeTierTextCapableModels, + getAlibabaBuiltinNoFreeTierTextModels, +} from "./alibabaFreeTierAllowlist.ts"; +import { + buildAlibabaFreeTierTextFilterContext, + getAlibabaFreeTierQuotaLastSyncAt, +} from "./alibabaFreeTierQuotaFetcher.ts"; + +export type AlibabaFreeTierProbeVerdict = + "capable_available" | "capable_drained" | "not_capable" | "unknown"; + +export type AlibabaFreeTierProbeResult = { + modelId: string; + verdict: AlibabaFreeTierProbeVerdict; + status: number; + errorCode?: string; +}; + +/** Third-party models listed on DashScope that bill paid-only on international (no free quota toggle). */ +const ALIBABA_THIRD_PARTY_PAID_PREFIXES = [/^kimi-/i, /^moonshot-/i] as const; + +/** Native families that participate in DashScope new-user free-quota promos. */ +const ALIBABA_NATIVE_FREE_TIER_PREFIXES = [ + /^qwen/i, + /^qwq/i, + /^glm-/i, + /^deepseek-/i, + /^minimax-/i, + /^MiniMax-/i, +] as const; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function normalizeModelIdList(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +export function getAlibabaFreeTierCapableModels( + providerSpecificData: Record | null | undefined +): string[] { + const raw = asRecord(providerSpecificData).alibabaFreeTierCapableModels; + const drained = getAlibabaFreeDrainedModels(providerSpecificData); + return [...new Set([...normalizeModelIdList(raw), ...drained])]; +} + +export function getAlibabaNoFreeTierModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaNoFreeTierModels); +} + +export function isAlibabaThirdPartyPaidModelFamily(modelId: string): boolean { + return ALIBABA_THIRD_PARTY_PAID_PREFIXES.some((pattern) => pattern.test(modelId)); +} + +export function isAlibabaNativeFreeTierModelFamily(modelId: string): boolean { + return ALIBABA_NATIVE_FREE_TIER_PREFIXES.some((pattern) => pattern.test(modelId)); +} + +export function parseAlibabaFreeTierProbeError(bodyText: string): { + code: string; + message: string; +} { + try { + const parsed = JSON.parse(bodyText) as { + error?: { code?: string; type?: string; message?: string }; + }; + const error = parsed?.error; + const code = String(error?.code || error?.type || ""); + const message = String(error?.message || bodyText || ""); + return { code, message }; + } catch { + return { code: "", message: bodyText }; + } +} + +export function classifyAlibabaFreeTierProbe( + modelId: string, + status: number, + bodyText: string +): AlibabaFreeTierProbeResult { + const { code, message } = parseAlibabaFreeTierProbeError(bodyText); + const combined = `${code} ${message}`; + + if (status >= 200 && status < 300) { + if (isAlibabaThirdPartyPaidModelFamily(modelId)) { + return { modelId, verdict: "not_capable", status, errorCode: code || undefined }; + } + if (isAlibabaNativeFreeTierModelFamily(modelId)) { + return { modelId, verdict: "capable_available", status, errorCode: code || undefined }; + } + return { modelId, verdict: "unknown", status, errorCode: code || undefined }; + } + + if ( + status === 403 && + (code === "AllocationQuota.FreeTierOnly" || isAlibabaFreeQuotaExhaustedError(combined)) + ) { + return { modelId, verdict: "capable_drained", status, errorCode: code || undefined }; + } + + if (status === 403 || status === 400) { + return { modelId, verdict: "not_capable", status, errorCode: code || undefined }; + } + + return { modelId, verdict: "unknown", status, errorCode: code || undefined }; +} + +export function mergeAlibabaFreeTierProbeResults( + providerSpecificData: Record | null | undefined, + results: readonly AlibabaFreeTierProbeResult[], + billingMode: AlibabaBillingMode = getAlibabaBillingMode(providerSpecificData) +): Record { + const base = asRecord(providerSpecificData); + const capable = new Set(getAlibabaFreeTierCapableModels(base)); + const noFreeTier = new Set(getAlibabaNoFreeTierModels(base)); + let next = base; + + for (const result of results) { + switch (result.verdict) { + case "capable_available": + capable.add(result.modelId); + noFreeTier.delete(result.modelId); + break; + case "capable_drained": + capable.add(result.modelId); + noFreeTier.delete(result.modelId); + if (billingMode === "free") { + next = mergeAlibabaFreeDrainedModels(next, result.modelId); + } + break; + case "not_capable": + noFreeTier.add(result.modelId); + capable.delete(result.modelId); + break; + default: + break; + } + } + + return { + ...next, + alibabaFreeTierCapableModels: [...capable], + alibabaNoFreeTierModels: [...noFreeTier], + alibabaFreeTierProbeLastRunAt: new Date().toISOString(), + }; +} + +type AlibabaConnectionLike = { + id: string; + providerSpecificData?: Record | null; +}; + +/** + * Build a merged free-tier filter context for one Alibaba connection. + * + * Free-tier eligibility (capable / no-free-tier) is account-agnostic — one synced + * console snapshot applies to every `alibabaBillingMode: free` key. Only drained + * models stay per-connection (quota exhaustion is key-specific). + */ +export function buildAlibabaFreeTierFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string +): Record { + return buildAlibabaFreeTierTextFilterContext(connections, connectionId); +} + +export function isAlibabaFreeTierCapableModel( + modelId: string, + providerSpecificData: Record | null | undefined, + options: { strictAllowlist?: boolean } = {} +): boolean { + const noFreeTier = new Set([ + ...getAlibabaNoFreeTierModels(providerSpecificData), + ...getAlibabaBuiltinNoFreeTierTextModels(), + ]); + if (noFreeTier.has(modelId)) return false; + + const drained = new Set(getAlibabaFreeDrainedModels(providerSpecificData)); + if (drained.has(modelId)) return false; + + const capable = new Set([ + ...normalizeModelIdList(asRecord(providerSpecificData).alibabaFreeTierCapableModels), + ...getAlibabaBuiltinFreeTierTextCapableModels(), + ]); + if (capable.has(modelId)) return true; + + // Console quota API is authoritative when present — never guess past it. + if (getAlibabaFreeTierQuotaLastSyncAt(providerSpecificData) || options.strictAllowlist) { + return false; + } + + // Optimistic inclusion for native families until a probe proves otherwise. + if (isAlibabaNativeFreeTierModelFamily(modelId) && !isAlibabaThirdPartyPaidModelFamily(modelId)) { + return true; + } + + return false; +} + +export function filterAlibabaFreeEligibleModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined, + options: { strictAllowlist?: boolean } = {} +): string[] { + const drained = new Set(getAlibabaFreeDrainedModels(providerSpecificData)); + return modelIds.filter((id) => { + if (drained.has(id)) return false; + return isAlibabaFreeTierCapableModel(id, providerSpecificData, options); + }); +} + +type ProbeConnection = { + id: string; + apiKey?: string | null; + providerSpecificData?: Record | null; +}; + +export async function probeAlibabaFreeTierModel( + connection: ProbeConnection, + modelId: string, + chatCompletionsUrl: string +): Promise { + if (!connection.apiKey) { + return { modelId, verdict: "unknown", status: 0 }; + } + + try { + const response = await fetch(chatCompletionsUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${connection.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: modelId, + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, + }), + }); + const bodyText = await response.text(); + return classifyAlibabaFreeTierProbe(modelId, response.status, bodyText); + } catch { + return { modelId, verdict: "unknown", status: 0 }; + } +} + +export async function probeAlibabaFreeTierModels( + connection: ProbeConnection, + modelIds: readonly string[], + chatCompletionsUrl: string, + options: { concurrency?: number } = {} +): Promise { + const concurrency = Math.max(1, Math.min(options.concurrency ?? 4, 8)); + const results: AlibabaFreeTierProbeResult[] = []; + let index = 0; + + async function worker() { + while (index < modelIds.length) { + const current = modelIds[index]; + index += 1; + results.push(await probeAlibabaFreeTierModel(connection, current, chatCompletionsUrl)); + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, modelIds.length) }, () => worker())); + return results; +} + +export async function refreshAlibabaFreeTierModelClassification( + provider: string, + connection: ProbeConnection, + modelIds: readonly string[], + chatCompletionsUrl: string +): Promise | null> { + if ( + !isAlibabaModelStudioProvider(provider) || + getAlibabaBillingMode(connection.providerSpecificData) !== "free" + ) { + return null; + } + + if (getAlibabaFreeTierQuotaLastSyncAt(connection.providerSpecificData)) { + return null; + } + + const capable = new Set(getAlibabaFreeTierCapableModels(connection.providerSpecificData)); + const noFreeTier = new Set(getAlibabaNoFreeTierModels(connection.providerSpecificData)); + const pending = modelIds.filter((id) => !capable.has(id) && !noFreeTier.has(id)); + if (pending.length === 0) return null; + + const probeResults = await probeAlibabaFreeTierModels(connection, pending, chatCompletionsUrl, { + concurrency: 4, + }); + return mergeAlibabaFreeTierProbeResults(connection.providerSpecificData, probeResults); +} + +export function scheduleAlibabaFreeTierProbeRefresh( + provider: string, + connection: ProbeConnection, + models: ReadonlyArray<{ id?: string | null }>, + chatCompletionsUrl: string +): void { + const modelIds = models + .map((model) => (typeof model?.id === "string" ? model.id.trim() : "")) + .filter((id) => id.length > 0); + if (modelIds.length === 0) return; + + void (async () => { + try { + const merged = await refreshAlibabaFreeTierModelClassification( + provider, + connection, + modelIds, + chatCompletionsUrl + ); + if (!merged) return; + const { updateProviderConnection } = await import("../../src/lib/db/providers.ts"); + await updateProviderConnection(connection.id, { providerSpecificData: merged }); + } catch (error) { + console.warn("[alibaba-free-tier] background probe refresh failed", { + connectionId: connection.id, + error: error instanceof Error ? error.message : String(error), + }); + } + })(); +} diff --git a/open-sse/services/alibabaFreeTierQuotaClassify.ts b/open-sse/services/alibabaFreeTierQuotaClassify.ts new file mode 100644 index 0000000000..efa23ab5da --- /dev/null +++ b/open-sse/services/alibabaFreeTierQuotaClassify.ts @@ -0,0 +1,648 @@ +/** + * @file alibabaFreeTierQuotaClassify.ts + * @description Parsing + classification + eligibility-filtering logic for Alibaba Model + * Studio free-tier quota entries. Extracted from alibabaFreeTierQuotaFetcher.ts (which + * exceeded the file-size cap) to isolate the pure parsing/classification helpers from + * the HTTP/console-fetch flow. Behavior is unchanged. + */ + +import { getAlibabaBillingMode } from "./alibabaFreeTier.ts"; +import { + getAlibabaBuiltinFreeTierTextCapableModels, + getAlibabaBuiltinNoFreeTierTextModels, +} from "./alibabaFreeTierAllowlist.ts"; +import { toNumberOrNull } from "@/shared/utils/numeric"; +import { + isDashscopeAudioModelId, + isDashscopeMultimodalModelId, + isDashscopeTextModelId, + isDashscopeVisionModelId, +} from "./dashscopeTextModels.ts"; +import { + asRecord, + getAlibabaFreeTierQuotaLastSyncAt, + isAlibabaLiveQuotaSyncAt, + normalizeModelIdList, + toTrimmedString, + type AlibabaFreeTierQuotaClassification, + type AlibabaFreeTierQuotaEntry, +} from "./alibabaFreeTierQuotaTypes.ts"; + +export function isAlibabaQuotaValidityExpired( + entry: AlibabaFreeTierQuotaEntry, + nowMs: number = Date.now() +): boolean { + if ( + typeof entry.quotaValidityPeriod !== "number" || + !Number.isFinite(entry.quotaValidityPeriod) + ) { + return false; + } + return entry.quotaValidityPeriod < nowMs; +} + +function parseQuotaEntry(value: unknown): AlibabaFreeTierQuotaEntry | null { + const record = asRecord(value); + const model = toTrimmedString(record.model); + if (!model) return null; + + return { + model, + freeTierOnly: record.freeTierOnly === true, + quotaStatus: toTrimmedString(record.quotaStatus) || "UNKNOWN", + quotaTotal: toNumberOrNull(record.quotaTotal) ?? undefined, + quotaInitTotal: toNumberOrNull(record.quotaInitTotal) ?? undefined, + quotaTotalPercentage: toNumberOrNull(record.quotaTotalPercentage) ?? undefined, + quotaValidityPeriod: toNumberOrNull(record.quotaValidityPeriod) ?? undefined, + }; +} + +export function parseAlibabaFreeTierQuotaEntries(payload: unknown): AlibabaFreeTierQuotaEntry[] { + const root = asRecord(payload); + const dataV2 = asRecord(asRecord(root.data).DataV2 ?? root.DataV2); + const inner = asRecord(dataV2.data); + const payloadData = asRecord(inner.data ?? inner); + const quotas = payloadData.freeTierQuotas; + + if (!Array.isArray(quotas)) return []; + return quotas + .map((entry) => parseQuotaEntry(entry)) + .filter((entry): entry is AlibabaFreeTierQuotaEntry => entry !== null); +} + +export function classifyAlibabaFreeTierQuotaEntry( + entry: AlibabaFreeTierQuotaEntry, + nowMs: number = Date.now() +): "available" | "capable_unknown" | "drained" | "not_capable" { + if (isAlibabaQuotaValidityExpired(entry, nowMs)) { + return "not_capable"; + } + + if (!entry.freeTierOnly) { + return "not_capable"; + } + + if (entry.quotaStatus === "VALID") { + if (typeof entry.quotaTotal === "number") { + return entry.quotaTotal > 0 ? "available" : "drained"; + } + return "capable_unknown"; + } + + if (entry.quotaStatus === "UNKNOWN") { + return "capable_unknown"; + } + + return "not_capable"; +} + +export function classifyAlibabaVisionFreeTierQuotaEntry( + entry: AlibabaFreeTierQuotaEntry, + nowMs: number = Date.now() +): "available" | "drained" | "not_capable" { + if (isAlibabaQuotaValidityExpired(entry, nowMs)) { + return "not_capable"; + } + + if (!isDashscopeVisionModelId(entry.model)) { + return "not_capable"; + } + + if (entry.quotaStatus === "VALID") { + if (typeof entry.quotaTotal === "number") { + return entry.quotaTotal > 0 ? "available" : "drained"; + } + if (typeof entry.quotaInitTotal === "number") { + return entry.quotaInitTotal > 0 ? "available" : "drained"; + } + return "not_capable"; + } + + return "not_capable"; +} + +export function classifyAlibabaVisionFreeTierQuotaEntries( + entries: readonly AlibabaFreeTierQuotaEntry[] +): AlibabaFreeTierQuotaClassification { + return classifyAlibabaFreeTierQuotaEntriesByModelFilter(entries, isDashscopeVisionModelId, { + useVisionRules: true, + }); +} + +function classifyAlibabaFreeTierQuotaEntriesByModelFilter( + entries: readonly AlibabaFreeTierQuotaEntry[], + modelFilter: (modelId: string) => boolean, + options: { useVisionRules?: boolean } = {} +): AlibabaFreeTierQuotaClassification { + const capableModels: string[] = []; + const noFreeTierModels: string[] = []; + const drainedModels: string[] = []; + + for (const entry of entries) { + if (!modelFilter(entry.model)) continue; + + const verdict = options.useVisionRules + ? classifyAlibabaVisionFreeTierQuotaEntry(entry) + : classifyAlibabaFreeTierQuotaEntry(entry); + + switch (verdict) { + case "available": + capableModels.push(entry.model); + break; + case "capable_unknown": + capableModels.push(entry.model); + break; + case "drained": + if (options.useVisionRules) { + drainedModels.push(entry.model); + } else { + capableModels.push(entry.model); + drainedModels.push(entry.model); + } + break; + case "not_capable": + noFreeTierModels.push(entry.model); + break; + default: + break; + } + } + + return { + capableModels: [...new Set(capableModels)], + noFreeTierModels: [...new Set(noFreeTierModels)], + drainedModels: [...new Set(drainedModels)], + entries: entries.filter((entry) => modelFilter(entry.model)), + }; +} + +export function classifyAlibabaMultimodalFreeTierQuotaEntries( + entries: readonly AlibabaFreeTierQuotaEntry[] +): AlibabaFreeTierQuotaClassification { + return classifyAlibabaFreeTierQuotaEntriesByModelFilter(entries, isDashscopeMultimodalModelId); +} + +export function classifyAlibabaAudioFreeTierQuotaEntries( + entries: readonly AlibabaFreeTierQuotaEntry[] +): AlibabaFreeTierQuotaClassification { + return classifyAlibabaFreeTierQuotaEntriesByModelFilter(entries, isDashscopeAudioModelId); +} + +export function classifyAlibabaFreeTierQuotaEntries( + entries: readonly AlibabaFreeTierQuotaEntry[], + options: { textOnly?: boolean } = {} +): AlibabaFreeTierQuotaClassification { + const capableModels: string[] = []; + const noFreeTierModels: string[] = []; + const drainedModels: string[] = []; + + for (const entry of entries) { + if (options.textOnly && !isDashscopeTextModelId(entry.model)) continue; + + const verdict = classifyAlibabaFreeTierQuotaEntry(entry); + switch (verdict) { + case "available": + case "capable_unknown": + capableModels.push(entry.model); + break; + case "drained": + capableModels.push(entry.model); + drainedModels.push(entry.model); + break; + case "not_capable": + noFreeTierModels.push(entry.model); + break; + default: + break; + } + } + + return { + capableModels: [...new Set(capableModels)], + noFreeTierModels: [...new Set(noFreeTierModels)], + drainedModels: [...new Set(drainedModels)], + entries: [...entries], + }; +} + +function unionModelIdLists(lists: readonly (readonly string[])[]): string[] { + return [...new Set(lists.flat())]; +} + +/** Eligibility is account-agnostic; only drained/quota exhaustion is per-connection. */ +const ALIBABA_SHARED_FREE_TIER_ELIGIBILITY_KEYS = [ + "alibabaFreeTierCapableModels", + "alibabaNoFreeTierModels", + "alibabaFreeTierVisionCapableModels", + "alibabaNoFreeTierVisionModels", + "alibabaFreeTierMultimodalCapableModels", + "alibabaNoFreeTierMultimodalModels", + "alibabaFreeTierAudioCapableModels", + "alibabaNoFreeTierAudioModels", + "alibabaFreeTierQuotaEntries", + "alibabaFreeTierVisionQuotaEntries", + "alibabaFreeTierMultimodalQuotaEntries", + "alibabaFreeTierAudioQuotaEntries", + "alibabaFreeTierQuotaLastSyncAt", + "alibabaFreeTierDiscoverySource", +] as const; + +export function extractAlibabaSharedFreeTierEligibility( + providerSpecificData: Record +): Record { + const source = asRecord(providerSpecificData); + const shared: Record = {}; + for (const key of ALIBABA_SHARED_FREE_TIER_ELIGIBILITY_KEYS) { + if (source[key] !== undefined) { + shared[key] = source[key]; + } + } + return shared; +} + +export function applyAlibabaSharedFreeTierEligibility( + targetPsd: Record, + shared: Record +): Record { + return { ...targetPsd, ...shared }; +} + +export type AlibabaConnectionLike = { + id: string; + providerSpecificData?: Record | null; +}; + +export type AlibabaFreeTierEligibilityFields = { + capableKey: string; + noFreeTierKey: string; + drainedKey: string; +}; + +export function pickCanonicalAlibabaFreeTierConnection( + connections: readonly AlibabaConnectionLike[], + fields: AlibabaFreeTierEligibilityFields +): AlibabaConnectionLike | undefined { + const freeConnections = connections.filter( + (connection) => getAlibabaBillingMode(connection.providerSpecificData) === "free" + ); + const synced = freeConnections.filter((connection) => + Boolean(getAlibabaFreeTierQuotaLastSyncAt(connection.providerSpecificData)) + ); + if (synced.length === 0) return undefined; + + const withEligibility = synced.filter((connection) => { + const psd = asRecord(connection.providerSpecificData); + const capable = normalizeModelIdList(psd[fields.capableKey]); + const blocked = normalizeModelIdList(psd[fields.noFreeTierKey]); + return capable.length > 0 || blocked.length > 0; + }); + + const pool = withEligibility.length > 0 ? withEligibility : synced; + return pool.reduce((best, current) => { + if (!best) return current; + const bestTime = getAlibabaFreeTierQuotaLastSyncAt(best.providerSpecificData) || ""; + const currentTime = getAlibabaFreeTierQuotaLastSyncAt(current.providerSpecificData) || ""; + return currentTime.localeCompare(bestTime) > 0 ? current : best; + }, undefined); +} + +function resolveAlibabaFreeTierEligibilityLists( + connections: readonly AlibabaConnectionLike[], + fields: AlibabaFreeTierEligibilityFields +): { capable: string[]; noFreeTier: string[]; hasQuotaSync: boolean; quotaSyncAt?: string } { + const freeConnections = connections.filter( + (connection) => getAlibabaBillingMode(connection.providerSpecificData) === "free" + ); + const hasQuotaSync = freeConnections.some((connection) => + Boolean(getAlibabaFreeTierQuotaLastSyncAt(connection.providerSpecificData)) + ); + const canonical = pickCanonicalAlibabaFreeTierConnection(freeConnections, fields); + + if (canonical) { + const psd = asRecord(canonical.providerSpecificData); + return { + capable: normalizeModelIdList(psd[fields.capableKey]), + noFreeTier: normalizeModelIdList(psd[fields.noFreeTierKey]), + hasQuotaSync, + quotaSyncAt: getAlibabaFreeTierQuotaLastSyncAt(psd) || "provider-canonical", + }; + } + + return { + capable: unionModelIdLists( + freeConnections.map((connection) => + normalizeModelIdList(asRecord(connection.providerSpecificData)[fields.capableKey]) + ) + ), + noFreeTier: unionModelIdLists( + freeConnections.map((connection) => + normalizeModelIdList(asRecord(connection.providerSpecificData)[fields.noFreeTierKey]) + ) + ), + hasQuotaSync, + }; +} + +function buildAlibabaCategoryFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string, + fields: { + capableKey: string; + noFreeTierKey: string; + drainedKey: string; + } +): Record { + const freeConnections = connections.filter( + (connection) => getAlibabaBillingMode(connection.providerSpecificData) === "free" + ); + const target = freeConnections.find((connection) => connection.id === connectionId); + const targetPsd = asRecord(target?.providerSpecificData); + const eligibility = resolveAlibabaFreeTierEligibilityLists(freeConnections, fields); + + const merged: Record = { + alibabaBillingMode: "free", + [fields.capableKey]: eligibility.capable, + [fields.noFreeTierKey]: eligibility.noFreeTier, + [fields.drainedKey]: normalizeModelIdList(targetPsd[fields.drainedKey]), + }; + + if (eligibility.hasQuotaSync) { + merged.alibabaFreeTierQuotaLastSyncAt = + eligibility.quotaSyncAt || getAlibabaFreeTierQuotaLastSyncAt(targetPsd) || "provider-merged"; + } + + return merged; +} + +export function buildAlibabaFreeVisionFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string +): Record { + return buildAlibabaCategoryFilterContext(connections, connectionId, { + capableKey: "alibabaFreeTierVisionCapableModels", + noFreeTierKey: "alibabaNoFreeTierVisionModels", + drainedKey: "alibabaFreeTierVisionDrainedModels", + }); +} + +export function buildAlibabaFreeMultimodalFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string +): Record { + return buildAlibabaCategoryFilterContext(connections, connectionId, { + capableKey: "alibabaFreeTierMultimodalCapableModels", + noFreeTierKey: "alibabaNoFreeTierMultimodalModels", + drainedKey: "alibabaFreeTierMultimodalDrainedModels", + }); +} + +export function buildAlibabaFreeAudioFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string +): Record { + return buildAlibabaCategoryFilterContext(connections, connectionId, { + capableKey: "alibabaFreeTierAudioCapableModels", + noFreeTierKey: "alibabaNoFreeTierAudioModels", + drainedKey: "alibabaFreeTierAudioDrainedModels", + }); +} + +const ALIBABA_TEXT_ELIGIBILITY_FIELDS: AlibabaFreeTierEligibilityFields = { + capableKey: "alibabaFreeTierCapableModels", + noFreeTierKey: "alibabaNoFreeTierModels", + drainedKey: "alibabaFreeDrainedModels", +}; + +export function buildAlibabaFreeTierTextFilterContext( + connections: readonly AlibabaConnectionLike[], + connectionId: string +): Record { + const freeConnections = connections.filter( + (connection) => getAlibabaBillingMode(connection.providerSpecificData) === "free" + ); + const target = freeConnections.find((connection) => connection.id === connectionId); + const targetPsd = asRecord(target?.providerSpecificData); + const eligibility = resolveAlibabaFreeTierEligibilityLists( + freeConnections, + ALIBABA_TEXT_ELIGIBILITY_FIELDS + ); + + const useBuiltinFallback = + !eligibility.hasQuotaSync || !isAlibabaLiveQuotaSyncAt(eligibility.quotaSyncAt ?? null); + + const merged: Record = { + alibabaBillingMode: "free", + alibabaFreeTierCapableModels: useBuiltinFallback + ? unionModelIdLists([eligibility.capable, getAlibabaBuiltinFreeTierTextCapableModels()]) + : eligibility.capable, + alibabaNoFreeTierModels: useBuiltinFallback + ? unionModelIdLists([eligibility.noFreeTier, getAlibabaBuiltinNoFreeTierTextModels()]) + : eligibility.noFreeTier, + alibabaFreeDrainedModels: normalizeModelIdList(targetPsd.alibabaFreeDrainedModels), + }; + + const syncAt = + eligibility.quotaSyncAt || + getAlibabaFreeTierQuotaLastSyncAt(targetPsd) || + (useBuiltinFallback ? "builtin-allowlist" : null); + if (syncAt) { + merged.alibabaFreeTierQuotaLastSyncAt = syncAt; + } + + return merged; +} + +export function getAlibabaFreeTierVisionCapableModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaFreeTierVisionCapableModels); +} + +export function getAlibabaFreeTierVisionDrainedModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaFreeTierVisionDrainedModels); +} + +export function getAlibabaNoFreeTierVisionModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaNoFreeTierVisionModels); +} + +export function isAlibabaFreeTierVisionCapableModel( + modelId: string, + providerSpecificData: Record | null | undefined +): boolean { + const noFreeTier = new Set(getAlibabaNoFreeTierVisionModels(providerSpecificData)); + if (noFreeTier.has(modelId)) return false; + + const drained = new Set(getAlibabaFreeTierVisionDrainedModels(providerSpecificData)); + if (drained.has(modelId)) return false; + + const capable = new Set(getAlibabaFreeTierVisionCapableModels(providerSpecificData)); + if (capable.has(modelId)) return true; + + if (getAlibabaFreeTierQuotaLastSyncAt(providerSpecificData)) { + return false; + } + + return isDashscopeVisionModelId(modelId); +} + +export function filterAlibabaFreeVisionEligibleModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined +): string[] { + return filterAlibabaFreeCategoryEligibleModels( + modelIds, + providerSpecificData, + isDashscopeVisionModelId, + getAlibabaFreeTierVisionCapableModels, + getAlibabaFreeTierVisionDrainedModels, + getAlibabaNoFreeTierVisionModels + ); +} + +function getAlibabaFreeTierMultimodalCapableModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList( + asRecord(providerSpecificData).alibabaFreeTierMultimodalCapableModels + ); +} + +function getAlibabaFreeTierMultimodalDrainedModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList( + asRecord(providerSpecificData).alibabaFreeTierMultimodalDrainedModels + ); +} + +function getAlibabaNoFreeTierMultimodalModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaNoFreeTierMultimodalModels); +} + +function getAlibabaFreeTierAudioCapableModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaFreeTierAudioCapableModels); +} + +function getAlibabaFreeTierAudioDrainedModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaFreeTierAudioDrainedModels); +} + +function getAlibabaNoFreeTierAudioModels( + providerSpecificData: Record | null | undefined +): string[] { + return normalizeModelIdList(asRecord(providerSpecificData).alibabaNoFreeTierAudioModels); +} + +function filterAlibabaFreeCategoryEligibleModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined, + modelTypeCheck: (modelId: string) => boolean, + getCapable: (psd: Record | null | undefined) => string[], + getDrained: (psd: Record | null | undefined) => string[], + getNoFreeTier: (psd: Record | null | undefined) => string[] +): string[] { + const drained = new Set(getDrained(providerSpecificData)); + return modelIds.filter((id) => { + if (!modelTypeCheck(id)) return false; + if (drained.has(id)) return false; + return isAlibabaFreeCategoryCapableModel( + id, + providerSpecificData, + modelTypeCheck, + getCapable, + getDrained, + getNoFreeTier + ); + }); +} + +function isAlibabaFreeCategoryCapableModel( + modelId: string, + providerSpecificData: Record | null | undefined, + modelTypeCheck: (modelId: string) => boolean, + getCapable: (psd: Record | null | undefined) => string[], + getDrained: (psd: Record | null | undefined) => string[], + getNoFreeTier: (psd: Record | null | undefined) => string[] +): boolean { + const noFreeTier = new Set(getNoFreeTier(providerSpecificData)); + if (noFreeTier.has(modelId)) return false; + + const drained = new Set(getDrained(providerSpecificData)); + if (drained.has(modelId)) return false; + + const capable = new Set(getCapable(providerSpecificData)); + if (capable.has(modelId)) return true; + + if (getAlibabaFreeTierQuotaLastSyncAt(providerSpecificData)) { + return false; + } + + return modelTypeCheck(modelId); +} + +export function isAlibabaFreeTierMultimodalCapableModel( + modelId: string, + providerSpecificData: Record | null | undefined +): boolean { + return isAlibabaFreeCategoryCapableModel( + modelId, + providerSpecificData, + isDashscopeMultimodalModelId, + getAlibabaFreeTierMultimodalCapableModels, + getAlibabaFreeTierMultimodalDrainedModels, + getAlibabaNoFreeTierMultimodalModels + ); +} + +export function isAlibabaFreeTierAudioCapableModel( + modelId: string, + providerSpecificData: Record | null | undefined +): boolean { + return isAlibabaFreeCategoryCapableModel( + modelId, + providerSpecificData, + isDashscopeAudioModelId, + getAlibabaFreeTierAudioCapableModels, + getAlibabaFreeTierAudioDrainedModels, + getAlibabaNoFreeTierAudioModels + ); +} + +export function filterAlibabaFreeMultimodalEligibleModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined +): string[] { + return filterAlibabaFreeCategoryEligibleModels( + modelIds, + providerSpecificData, + isDashscopeMultimodalModelId, + getAlibabaFreeTierMultimodalCapableModels, + getAlibabaFreeTierMultimodalDrainedModels, + getAlibabaNoFreeTierMultimodalModels + ); +} + +export function filterAlibabaFreeAudioEligibleModels( + modelIds: readonly string[], + providerSpecificData: Record | null | undefined +): string[] { + return filterAlibabaFreeCategoryEligibleModels( + modelIds, + providerSpecificData, + isDashscopeAudioModelId, + getAlibabaFreeTierAudioCapableModels, + getAlibabaFreeTierAudioDrainedModels, + getAlibabaNoFreeTierAudioModels + ); +} diff --git a/open-sse/services/alibabaFreeTierQuotaFetcher.ts b/open-sse/services/alibabaFreeTierQuotaFetcher.ts new file mode 100644 index 0000000000..4cf39a6e43 --- /dev/null +++ b/open-sse/services/alibabaFreeTierQuotaFetcher.ts @@ -0,0 +1,519 @@ +/** + * @file alibabaFreeTierQuotaFetcher.ts + * @description Fetch Alibaba Model Studio free-tier quota from the Bailian console API. + * + * DashScope inference keys cannot list free-tier eligibility. The console exposes + * `zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuotaAsyn` with per-model + * `freeTierOnly`, `quotaStatus`, and `quotaTotal` fields. + * + * Auth: browser session cookie (`login_aliyunid_ticket` or full Cookie header) stored + * on the connection as `providerSpecificData.alibabaConsoleCookie`. + * + * Parsing/classification/eligibility-filtering logic lives in + * `alibabaFreeTierQuotaClassify.ts` and shared types/primitives in + * `alibabaFreeTierQuotaTypes.ts` (split out to stay under the file-size cap); this file + * re-exports their public API so existing imports of this module keep working + * unchanged, and owns the HTTP/console-fetch flow itself. + * + * @changes + * - [2026-07-25] [Composer] - Merge built-in text free-tier allowlist into filter context + * - [2026-07-25] [Composer] - Propagate shared free-tier eligibility across all Alibaba free connections + * - [2026-07-25] [Composer] - Add multimodal and audio free-quota classification and console fetch paths + * - [2026-07-25] [Composer] - Add vision/media free-quota classification for alibabafreevision + * - [2026-07-25] [Composer] - Add console free-tier quota fetcher for Alibaba Model Studio + * - [2026-07-25] [Composer] - Use shared toNumberOrNull instead of local coercion helper + * - [2026-08-05] - Split classification/eligibility logic into alibabaFreeTierQuotaClassify.ts + * and alibabaFreeTierQuotaTypes.ts to stay under the file-size cap + */ + +import { getAlibabaBillingMode, isAlibabaModelStudioProvider } from "./alibabaFreeTier.ts"; +import { + asRecord, + getAlibabaFreeTierQuotaLastSyncAt, + normalizeModelIdList, + toTrimmedString, + type AlibabaFreeTierQuotaEntry, + type AlibabaFreeTierQuotaSnapshot, +} from "./alibabaFreeTierQuotaTypes.ts"; +import { + applyAlibabaSharedFreeTierEligibility, + classifyAlibabaAudioFreeTierQuotaEntries, + classifyAlibabaFreeTierQuotaEntries, + classifyAlibabaMultimodalFreeTierQuotaEntries, + classifyAlibabaVisionFreeTierQuotaEntries, + extractAlibabaSharedFreeTierEligibility, + parseAlibabaFreeTierQuotaEntries, +} from "./alibabaFreeTierQuotaClassify.ts"; + +// Re-export the shared types + the classification/eligibility public API so existing +// imports of this module (`from "./alibabaFreeTierQuotaFetcher.ts"`) keep working. +export type { + AlibabaFreeTierQuotaEntry, + AlibabaFreeTierQuotaClassification, + AlibabaFreeTierQuotaSnapshot, +} from "./alibabaFreeTierQuotaTypes.ts"; +export { getAlibabaFreeTierQuotaLastSyncAt, isAlibabaLiveQuotaSyncAt } from "./alibabaFreeTierQuotaTypes.ts"; +export { + isAlibabaQuotaValidityExpired, + parseAlibabaFreeTierQuotaEntries, + classifyAlibabaFreeTierQuotaEntry, + classifyAlibabaVisionFreeTierQuotaEntry, + classifyAlibabaVisionFreeTierQuotaEntries, + classifyAlibabaMultimodalFreeTierQuotaEntries, + classifyAlibabaAudioFreeTierQuotaEntries, + classifyAlibabaFreeTierQuotaEntries, + extractAlibabaSharedFreeTierEligibility, + applyAlibabaSharedFreeTierEligibility, + pickCanonicalAlibabaFreeTierConnection, + buildAlibabaFreeVisionFilterContext, + buildAlibabaFreeMultimodalFilterContext, + buildAlibabaFreeAudioFilterContext, + buildAlibabaFreeTierTextFilterContext, + getAlibabaFreeTierVisionCapableModels, + getAlibabaFreeTierVisionDrainedModels, + getAlibabaNoFreeTierVisionModels, + isAlibabaFreeTierVisionCapableModel, + filterAlibabaFreeVisionEligibleModels, + isAlibabaFreeTierMultimodalCapableModel, + isAlibabaFreeTierAudioCapableModel, + filterAlibabaFreeMultimodalEligibleModels, + filterAlibabaFreeAudioEligibleModels, + type AlibabaConnectionLike, + type AlibabaFreeTierEligibilityFields, +} from "./alibabaFreeTierQuotaClassify.ts"; + +const FREE_TIER_QUOTA_API = "zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuotaAsyn"; +const FREE_TIER_QUOTA_START_API = "zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota"; +const DEFAULT_TEXT_FE_PATH = "/costing-balance/free-quota"; +const DEFAULT_VISION_FE_PATH = + process.env.ALIBABA_FREE_TIER_VISION_FE_PATH?.trim() || "/costing-balance/free-quota-image-video"; +const DEFAULT_MULTIMODAL_FE_PATH = + process.env.ALIBABA_FREE_TIER_MULTIMODAL_FE_PATH?.trim() || + "/costing-balance/free-quota-multimodal"; +const DEFAULT_AUDIO_FE_PATH = + process.env.ALIBABA_FREE_TIER_AUDIO_FE_PATH?.trim() || "/costing-balance/free-quota-audio"; + +const CONSOLE_GATEWAYS = { + "global-sg": { + host: "https://bailian-singapore-cs.alibabacloud.com", + region: "ap-southeast-1", + action: "IntlBroadScopeAspnGateway", + product: "sfm_bailian", + }, + "china-beijing": { + host: "https://bailian.console.aliyun.com", + region: "cn-beijing", + action: "BroadScopeAspnGateway", + product: "sfm_bailian", + }, +} as const; + +type AlibabaProviderRegion = keyof typeof CONSOLE_GATEWAYS; + +function resolveAlibabaConsoleRegion( + providerSpecificData: Record | null | undefined +): AlibabaProviderRegion { + const region = toTrimmedString(asRecord(providerSpecificData).region); + return region === "china-beijing" ? "china-beijing" : "global-sg"; +} + +export function normalizeAlibabaConsoleCookie(raw: unknown): string | null { + const value = toTrimmedString(raw); + if (!value) return null; + + if (/login_aliyunid_ticket=/i.test(value) || value.includes(";")) { + return value; + } + + return `login_aliyunid_ticket=${value}`; +} + +export function getAlibabaConsoleCookie( + providerSpecificData: Record | null | undefined +): string | null { + const psd = asRecord(providerSpecificData); + return ( + normalizeAlibabaConsoleCookie(psd.alibabaConsoleCookie) || + normalizeAlibabaConsoleCookie(psd.cookie) || + null + ); +} + +export function getAlibabaConsoleSecToken( + providerSpecificData: Record | null | undefined +): string | null { + return toTrimmedString(asRecord(providerSpecificData).alibabaConsoleSecToken); +} + +export function hasAlibabaConsoleFreeTierAuth( + providerSpecificData: Record | null | undefined +): boolean { + return getAlibabaConsoleCookie(providerSpecificData) !== null; +} + +export function mergeAlibabaFreeTierQuotaClassification( + providerSpecificData: Record | null | undefined, + snapshot: AlibabaFreeTierQuotaSnapshot +): Record { + const base = asRecord(providerSpecificData); + + const coalesceList = (snapshotList: readonly string[], existingKey: string): string[] => + snapshotList.length > 0 ? [...snapshotList] : normalizeModelIdList(base[existingKey]); + + return { + ...base, + alibabaFreeTierCapableModels: coalesceList( + snapshot.text.capableModels, + "alibabaFreeTierCapableModels" + ), + alibabaNoFreeTierModels: coalesceList( + snapshot.text.noFreeTierModels, + "alibabaNoFreeTierModels" + ), + alibabaFreeDrainedModels: coalesceList(snapshot.text.drainedModels, "alibabaFreeDrainedModels"), + alibabaFreeTierVisionCapableModels: coalesceList( + snapshot.vision.capableModels, + "alibabaFreeTierVisionCapableModels" + ), + alibabaNoFreeTierVisionModels: coalesceList( + snapshot.vision.noFreeTierModels, + "alibabaNoFreeTierVisionModels" + ), + alibabaFreeTierVisionDrainedModels: coalesceList( + snapshot.vision.drainedModels, + "alibabaFreeTierVisionDrainedModels" + ), + alibabaFreeTierMultimodalCapableModels: coalesceList( + snapshot.multimodal.capableModels, + "alibabaFreeTierMultimodalCapableModels" + ), + alibabaNoFreeTierMultimodalModels: coalesceList( + snapshot.multimodal.noFreeTierModels, + "alibabaNoFreeTierMultimodalModels" + ), + alibabaFreeTierMultimodalDrainedModels: coalesceList( + snapshot.multimodal.drainedModels, + "alibabaFreeTierMultimodalDrainedModels" + ), + alibabaFreeTierAudioCapableModels: coalesceList( + snapshot.audio.capableModels, + "alibabaFreeTierAudioCapableModels" + ), + alibabaNoFreeTierAudioModels: coalesceList( + snapshot.audio.noFreeTierModels, + "alibabaNoFreeTierAudioModels" + ), + alibabaFreeTierAudioDrainedModels: coalesceList( + snapshot.audio.drainedModels, + "alibabaFreeTierAudioDrainedModels" + ), + alibabaFreeTierQuotaEntries: snapshot.entries, + alibabaFreeTierVisionQuotaEntries: snapshot.vision.entries, + alibabaFreeTierMultimodalQuotaEntries: snapshot.multimodal.entries, + alibabaFreeTierAudioQuotaEntries: snapshot.audio.entries, + alibabaFreeTierQuotaLastSyncAt: new Date().toISOString(), + alibabaFreeTierDiscoverySource: "console-quota-api", + }; +} + +export async function propagateAlibabaFreeTierEligibilityToSiblings( + provider: string, + sourceConnectionId: string, + mergedPsd: Record +): Promise { + const shared = extractAlibabaSharedFreeTierEligibility(mergedPsd); + if (!shared.alibabaFreeTierQuotaLastSyncAt) return; + + const { getProviderConnections, updateProviderConnection } = + await import("../../src/lib/db/providers.ts"); + const connections = await getProviderConnections({ provider }); + + for (const connection of connections) { + if (connection.id === sourceConnectionId) continue; + const psd = connection.providerSpecificData as Record | null | undefined; + if (getAlibabaBillingMode(psd) !== "free") continue; + + const updated = applyAlibabaSharedFreeTierEligibility( + asRecord(connection.providerSpecificData), + shared + ); + await updateProviderConnection(connection.id as string, { providerSpecificData: updated }); + } +} + +function buildGatewayUrl(region: AlibabaProviderRegion, api: string): string { + const gateway = CONSOLE_GATEWAYS[region]; + const params = new URLSearchParams({ + action: gateway.action, + product: gateway.product, + api, + _v: "undefined", + }); + return `${gateway.host}/data/api.json?${params.toString()}`; +} + +function buildCornerstoneParam( + region: AlibabaProviderRegion, + fePath: string = DEFAULT_TEXT_FE_PATH +): Record { + const gateway = CONSOLE_GATEWAYS[region]; + const normalizedPath = fePath.startsWith("/") ? fePath : `/${fePath}`; + return { + feTraceId: crypto.randomUUID(), + feURL: `https://modelstudio.console.alibabacloud.com/${gateway.region}?tab=costing-balance#${normalizedPath}`, + protocol: "V2", + console: "ONE_CONSOLE", + productCode: "p_efm", + switchAgent: 416572, + switchUserType: 3, + domain: "modelstudio.console.alibabacloud.com", + consoleSite: "MODELSTUDIO_ALBABACLOUD", + userNickName: "", + userPrincipalName: "", + xsp_lang: "en-US", + }; +} + +function buildRequestBody( + region: AlibabaProviderRegion, + api: string, + taskId?: string | null, + fePath: string = DEFAULT_TEXT_FE_PATH +): URLSearchParams { + const gateway = CONSOLE_GATEWAYS[region]; + const request: Record = {}; + if (taskId) { + request.queryFreeTierQuotaRequest = { taskId }; + } else { + request.queryFreeTierQuotaRequest = {}; + } + request.cornerstoneParam = buildCornerstoneParam(region, fePath); + + const body = new URLSearchParams({ + params: JSON.stringify({ + Api: api, + V: "1.0", + Data: request, + }), + region: gateway.region, + }); + + return body; +} + +async function postConsoleFreeTierQuota( + region: AlibabaProviderRegion, + api: string, + cookie: string, + secToken: string | null, + taskId?: string | null, + fePath: string = DEFAULT_TEXT_FE_PATH +): Promise { + const body = buildRequestBody(region, api, taskId, fePath); + if (secToken) { + body.set("sec_token", secToken); + } + + const response = await fetch(buildGatewayUrl(region, api), { + method: "POST", + headers: { + Accept: "*/*", + "Content-Type": "application/x-www-form-urlencoded", + Cookie: cookie, + Origin: "https://modelstudio.console.alibabacloud.com", + Referer: "https://modelstudio.console.alibabacloud.com/", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36", + }, + body: body.toString(), + signal: AbortSignal.timeout(15_000), + }); + + return response.json(); +} + +function extractTaskId(payload: unknown): string | null { + const root = asRecord(payload); + const dataV2 = asRecord(asRecord(root.data).DataV2 ?? root.DataV2); + const inner = asRecord(dataV2.data); + const payloadData = asRecord(inner.data ?? inner); + return toTrimmedString(payloadData.taskId); +} + +function hasQuotaPayload(payload: unknown): boolean { + return parseAlibabaFreeTierQuotaEntries(payload).length > 0; +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchAlibabaFreeTierQuotaEntriesForPath( + providerSpecificData: Record | null | undefined, + fePath: string +): Promise { + const cookie = getAlibabaConsoleCookie(providerSpecificData); + if (!cookie) return null; + + const region = resolveAlibabaConsoleRegion(providerSpecificData); + const secToken = getAlibabaConsoleSecToken(providerSpecificData); + + let payload = await postConsoleFreeTierQuota( + region, + FREE_TIER_QUOTA_START_API, + cookie, + secToken, + null, + fePath + ); + + if (!hasQuotaPayload(payload)) { + payload = await postConsoleFreeTierQuota( + region, + FREE_TIER_QUOTA_API, + cookie, + secToken, + null, + fePath + ); + } + + if (!hasQuotaPayload(payload)) { + const taskId = extractTaskId(payload); + if (!taskId) return null; + + for (let attempt = 0; attempt < 8; attempt += 1) { + if (attempt > 0) { + await delay(400); + } + payload = await postConsoleFreeTierQuota( + region, + FREE_TIER_QUOTA_API, + cookie, + secToken, + taskId, + fePath + ); + if (hasQuotaPayload(payload)) break; + } + } + + const entries = parseAlibabaFreeTierQuotaEntries(payload); + return entries.length > 0 ? entries : null; +} + +export async function fetchAlibabaFreeTierQuotaEntries( + providerSpecificData: Record | null | undefined +): Promise { + return fetchAlibabaFreeTierQuotaEntriesForPath(providerSpecificData, DEFAULT_TEXT_FE_PATH); +} + +export async function fetchAlibabaFreeTierVisionQuotaEntries( + providerSpecificData: Record | null | undefined +): Promise { + return fetchAlibabaFreeTierQuotaEntriesForPath(providerSpecificData, DEFAULT_VISION_FE_PATH); +} + +export async function fetchAlibabaFreeTierMultimodalQuotaEntries( + providerSpecificData: Record | null | undefined +): Promise { + return fetchAlibabaFreeTierQuotaEntriesForPath(providerSpecificData, DEFAULT_MULTIMODAL_FE_PATH); +} + +export async function fetchAlibabaFreeTierAudioQuotaEntries( + providerSpecificData: Record | null | undefined +): Promise { + return fetchAlibabaFreeTierQuotaEntriesForPath(providerSpecificData, DEFAULT_AUDIO_FE_PATH); +} + +function mergeUniqueQuotaEntries( + ...entryGroups: Array +): AlibabaFreeTierQuotaEntry[] { + const merged: AlibabaFreeTierQuotaEntry[] = []; + const seen = new Set(); + for (const group of entryGroups) { + for (const entry of group) { + if (seen.has(entry.model)) continue; + seen.add(entry.model); + merged.push(entry); + } + } + return merged; +} + +export async function buildAlibabaFreeTierQuotaSnapshot( + providerSpecificData: Record | null | undefined +): Promise { + const textEntries = await fetchAlibabaFreeTierQuotaEntries(providerSpecificData); + if (!textEntries) return null; + + const visionEntries = + (await fetchAlibabaFreeTierVisionQuotaEntries(providerSpecificData)) || textEntries; + const multimodalEntries = + (await fetchAlibabaFreeTierMultimodalQuotaEntries(providerSpecificData)) || textEntries; + const audioEntries = + (await fetchAlibabaFreeTierAudioQuotaEntries(providerSpecificData)) || textEntries; + + const text = classifyAlibabaFreeTierQuotaEntries(textEntries, { textOnly: true }); + const vision = classifyAlibabaVisionFreeTierQuotaEntries(visionEntries); + const multimodal = classifyAlibabaMultimodalFreeTierQuotaEntries(multimodalEntries); + const audio = classifyAlibabaAudioFreeTierQuotaEntries(audioEntries); + + return { + text, + vision, + multimodal, + audio, + entries: mergeUniqueQuotaEntries(textEntries, visionEntries, multimodalEntries, audioEntries), + }; +} + +export async function refreshAlibabaFreeTierQuotaClassification( + provider: string, + providerSpecificData: Record | null | undefined +): Promise | null> { + if ( + !isAlibabaModelStudioProvider(provider) || + getAlibabaBillingMode(providerSpecificData) !== "free" + ) { + return null; + } + if (!hasAlibabaConsoleFreeTierAuth(providerSpecificData)) { + return null; + } + + const snapshot = await buildAlibabaFreeTierQuotaSnapshot(providerSpecificData); + if (!snapshot) return null; + + return mergeAlibabaFreeTierQuotaClassification(providerSpecificData, snapshot); +} + +type QuotaConnection = { + id: string; + providerSpecificData?: Record | null; +}; + +export function scheduleAlibabaFreeTierQuotaRefresh( + provider: string, + connection: QuotaConnection +): void { + if (!hasAlibabaConsoleFreeTierAuth(connection.providerSpecificData)) return; + + void (async () => { + try { + const merged = await refreshAlibabaFreeTierQuotaClassification( + provider, + connection.providerSpecificData + ); + if (!merged) return; + const { updateProviderConnection } = await import("../../src/lib/db/providers.ts"); + await updateProviderConnection(connection.id, { providerSpecificData: merged }); + await propagateAlibabaFreeTierEligibilityToSiblings(provider, connection.id, merged); + } catch (error) { + console.warn("[alibaba-free-tier] console quota refresh failed", { + connectionId: connection.id, + error: error instanceof Error ? error.message : String(error), + }); + } + })(); +} diff --git a/open-sse/services/alibabaFreeTierQuotaTypes.ts b/open-sse/services/alibabaFreeTierQuotaTypes.ts new file mode 100644 index 0000000000..7d13f04b94 --- /dev/null +++ b/open-sse/services/alibabaFreeTierQuotaTypes.ts @@ -0,0 +1,58 @@ +/** + * @file alibabaFreeTierQuotaTypes.ts + * @description Shared types + small primitive helpers for the Alibaba free-tier quota + * fetcher/classifier split (extracted from alibabaFreeTierQuotaFetcher.ts to keep that + * file under the file-size cap; behavior is unchanged). + */ + +export type AlibabaFreeTierQuotaEntry = { + model: string; + freeTierOnly: boolean; + quotaStatus: string; + quotaTotal?: number; + quotaInitTotal?: number; + quotaTotalPercentage?: number; + quotaValidityPeriod?: number; +}; + +export type AlibabaFreeTierQuotaClassification = { + capableModels: string[]; + noFreeTierModels: string[]; + drainedModels: string[]; + entries: AlibabaFreeTierQuotaEntry[]; +}; + +export type AlibabaFreeTierQuotaSnapshot = { + text: AlibabaFreeTierQuotaClassification; + vision: AlibabaFreeTierQuotaClassification; + multimodal: AlibabaFreeTierQuotaClassification; + audio: AlibabaFreeTierQuotaClassification; + entries: AlibabaFreeTierQuotaEntry[]; +}; + +export function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function toTrimmedString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export function normalizeModelIdList(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +export function getAlibabaFreeTierQuotaLastSyncAt( + providerSpecificData: Record | null | undefined +): string | null { + return toTrimmedString(asRecord(providerSpecificData).alibabaFreeTierQuotaLastSyncAt); +} + +/** True when the connection has a live console/API quota snapshot (not builtin fallback). */ +export function isAlibabaLiveQuotaSyncAt(syncAt: string | null | undefined): boolean { + if (!syncAt || syncAt === "builtin-allowlist") return false; + return Number.isFinite(Date.parse(syncAt)); +} 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 new file mode 100644 index 0000000000..5e9426c1e9 --- /dev/null +++ b/open-sse/services/antigravityProjectPersistence.ts @@ -0,0 +1,132 @@ +/** + * @file antigravityProjectPersistence.ts + * @description Persist Antigravity Cloud Code projectId discovered at runtime and prefer + * healthy accounts during dynamic multi-account selection. + * + * @changes + * - [2026-07-24] [Composer] - Persist runtime loadCodeAssist projectId; filter broken accounts + */ + +import { updateProviderConnection } from "@/lib/db/providers"; + +export type AntigravityProjectConnectionLike = { + projectId?: string | null; + providerSpecificData?: unknown; + errorCode?: string | null; +}; + +export function extractAntigravityProjectIdFromPayload( + data: Record | null | undefined +): string | null { + if (!data || typeof data !== "object") return null; + + const raw = data.cloudaicompanionProject; + if (typeof raw === "string" && raw.trim()) return raw.trim(); + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + const id = (raw as Record).id; + if (typeof id === "string" && id.trim()) return id.trim(); + } + return null; +} + +export function getStoredAntigravityProjectId( + connection: Pick +): string | null { + const column = typeof connection.projectId === "string" ? connection.projectId.trim() : ""; + if (column) return column; + + const psd = connection.providerSpecificData as Record | undefined; + const fromPsd = typeof psd?.projectId === "string" ? psd.projectId.trim() : ""; + return fromPsd || null; +} + +const persistInFlight = new Set(); + +export function persistDiscoveredAntigravityProjectId( + connectionId: string | null | undefined, + projectId: string, + existingProviderSpecificData?: Record | null +): void { + const trimmed = projectId.trim(); + if (!connectionId || !trimmed) return; + + const dedupeKey = `${connectionId}:${trimmed}`; + if (persistInFlight.has(dedupeKey)) return; + persistInFlight.add(dedupeKey); + + const providerSpecificData = { + ...(existingProviderSpecificData || {}), + projectId: trimmed, + }; + + void updateProviderConnection(connectionId, { + projectId: trimmed, + errorCode: null, + lastError: null, + lastErrorType: null, + providerSpecificData, + }) + .catch(() => {}) + .finally(() => { + persistInFlight.delete(dedupeKey); + }); +} + +export function markAntigravityMissingCloudCodeProject( + connectionId: string | null | undefined +): void { + if (!connectionId) return; + + void updateProviderConnection(connectionId, { + errorCode: "missing_project_id", + lastError: + "Missing Google projectId for Antigravity account. Reconnect OAuth after completing Gemini Code Assist onboarding.", + lastErrorType: "oauth_missing_project_id", + }).catch(() => {}); +} + +/** + * When dynamic routing spans multiple Antigravity accounts, prefer connections that + * already have a stored Cloud Code projectId. Accounts confirmed missing a project + * (422) are skipped when alternatives exist. If every account lacks a stored project, + * keep the full pool so request-time loadCodeAssist discovery can still recover (#2334). + */ +export function preferAntigravityConnectionsWithStoredProject( + connections: T[] +): T[] { + if (connections.length <= 1) return connections; + + const hasStoredProject = (connection: T): boolean => { + const record = connection as Record; + if (typeof record.projectId === "string" && record.projectId.trim()) return true; + let psd = record.providerSpecificData; + if (typeof psd === "string") { + try { + psd = JSON.parse(psd); + } catch { + return false; + } + } + if (!psd || typeof psd !== "object") return false; + const projectId = (psd as Record).projectId; + return typeof projectId === "string" && projectId.trim().length > 0; + }; + + const withoutKnownMissing = connections.filter( + (connection) => + (connection as Record).errorCode !== "missing_project_id" || + hasStoredProject(connection) + ); + const pool = withoutKnownMissing.length > 0 ? withoutKnownMissing : connections; + + const withStored = pool.filter(hasStoredProject); + if (withStored.length > 0 && withStored.length < pool.length) { + return withStored; + } + return pool; +} + +/** Test helper — reset in-flight dedupe guards. */ +export function clearAntigravityProjectPersistenceInFlight(): void { + persistInFlight.clear(); +} diff --git a/open-sse/services/autoCombo/builtinCatalog.ts b/open-sse/services/autoCombo/builtinCatalog.ts index f2f12f6328..1f759d5c28 100644 --- a/open-sse/services/autoCombo/builtinCatalog.ts +++ b/open-sse/services/autoCombo/builtinCatalog.ts @@ -1,6 +1,7 @@ import type { AutoVariant } from "./autoPrefix"; import { VALID_VARIANTS } from "./autoPrefix"; -import { parseAutoSuffix } from "./suffixComposition"; +import type { PreparedVirtualAutoComboInputs } from "./virtualFactory"; +import { parseAutoSuffix, type AutoCategory, type AutoTier } from "./suffixComposition"; import { isValidModelFamily, AUTO_FAMILY_IDS } from "./modelFamily"; export { AUTO_FAMILY_IDS }; @@ -112,13 +113,99 @@ export function isPaidTierAutoId(autoId: string): boolean { return parsed.valid && parsed.tier === "pro"; } -export async function createBuiltinAutoCombo(modelStr: string, suffix: string) { - const { createVirtualAutoCombo } = await import("./virtualFactory.ts"); +/** + * Resolved spec for a built-in `auto/*` id: either a flat variant (legacy) or + * a category/tier overlay (#4235 Phase B). Category `vision`/`multimodal` adds + * a candidate filter so the virtual combo only scores vision-capable models. + */ +export type BuiltinAutoSpec = + | { variant: AutoVariant | undefined } + | { category: AutoCategory; tier?: AutoTier }; + +/** + * Vision-flavored flat ids that MUST resolve to the `vision` category (candidate + * filter by capability), not to a flat variant: the vision-bridge guardrail and + * its self-loop depend on `auto/best-vision` picking a model that can actually + * see images. Mapping it to `smart` scored ALL candidates and resolved to + * text-only models (e.g. deepseek-v4-flash-free), breaking every describe call. + */ +const VISION_CATEGORY_AUTO_IDS: Record = { + "auto/best-vision": { category: "vision" }, + "auto/pro-vision": { category: "vision", tier: "pro" }, +}; + +/** + * Pure resolver for a built-in `auto/*` id. Extracted from + * `createBuiltinAutoCombo` so the catalog mapping is unit-testable without + * materializing a virtual combo (which requires the DB). + */ +export function resolveBuiltinAutoSpec(modelStr: string, suffix: string): BuiltinAutoSpec { + const visionSpec = VISION_CATEGORY_AUTO_IDS[modelStr]; + if (visionSpec) return visionSpec; const resolved = resolveAutoVariant(modelStr, suffix); if (resolved.recognized) { - const spec = modelStr === "auto/best-free" ? { tier: "free" as const } : undefined; - const virtualCombo = await createVirtualAutoCombo(resolved.variant, spec); + return { variant: resolved.variant }; + } + + const parsed = parseAutoSuffix(suffix); + if (parsed.valid) { + return { + category: parsed.category as AutoCategory, + ...(parsed.tier ? { tier: parsed.tier } : {}), + }; + } + + return { variant: undefined }; +} + +export async function prepareBuiltinAutoComboInputs(): Promise { + const { prepareVirtualAutoComboInputs } = await import("./virtualFactory.ts"); + return prepareVirtualAutoComboInputs({ includeResolvedCapabilities: true }); +} + +export async function createBuiltinAutoCombo( + modelStr: string, + suffix: string, + prepared?: PreparedVirtualAutoComboInputs +) { + const { createVirtualAutoCombo, createVirtualAutoComboFromPrepared } = + await import("./virtualFactory.ts"); + const materialize = ( + variant: AutoVariant | undefined, + spec?: Parameters[1] + ) => + prepared + ? createVirtualAutoComboFromPrepared(prepared, variant, spec) + : createVirtualAutoCombo(variant, spec); + + const spec = resolveBuiltinAutoSpec(modelStr, suffix); + + if ("category" in spec) { + // #4235 Phase B category/tier path (incl. vision ids like auto/best-vision). + const virtualCombo = await materialize(undefined, { + category: spec.category, + ...(spec.tier ? { tier: spec.tier } : {}), + }); + virtualCombo.name = modelStr; + virtualCombo.id = modelStr; + return virtualCombo; + } + + if ("variant" in spec && spec.variant !== undefined) { + const virtualCombo = await materialize(spec.variant, { + ...(modelStr === "auto/best-free" ? { tier: "free" as const } : {}), + }); + virtualCombo.name = modelStr; + virtualCombo.id = modelStr; + return virtualCombo; + } + + // Advertised `auto/*` ids whose template maps to no variant (auto/chat, + // auto/best-chat, auto/pro-chat) still materialize via the default + // (unconstrained) virtual combo rather than throwing "Unknown built-in". + if (Object.prototype.hasOwnProperty.call(AUTO_TEMPLATE_VARIANTS, modelStr)) { + const virtualCombo = await materialize(undefined); virtualCombo.name = modelStr; virtualCombo.id = modelStr; return virtualCombo; @@ -127,7 +214,7 @@ export async function createBuiltinAutoCombo(modelStr: string, suffix: string) { // #4235 Phase B: `auto/[:]` (e.g. auto/coding:fast, auto/vision). const parsed = parseAutoSuffix(suffix); if (parsed.valid) { - const virtualCombo = await createVirtualAutoCombo(undefined, { + const virtualCombo = await materialize(undefined, { category: parsed.category, tier: parsed.tier, }); @@ -140,7 +227,7 @@ export async function createBuiltinAutoCombo(modelStr: string, suffix: string) { // auto/gemma, auto/llama, auto/gemini) — spans whatever installed backends // currently expose that model family, degrading gracefully as backends rotate. if (isValidModelFamily(suffix)) { - const virtualCombo = await createVirtualAutoCombo(undefined, { family: suffix }); + const virtualCombo = await materialize(undefined, { family: suffix }); virtualCombo.name = modelStr; virtualCombo.id = modelStr; return virtualCombo; diff --git a/open-sse/services/autoCombo/modePacks.ts b/open-sse/services/autoCombo/modePacks.ts index 7344496d10..5c0bfd54cc 100644 --- a/open-sse/services/autoCombo/modePacks.ts +++ b/open-sse/services/autoCombo/modePacks.ts @@ -24,6 +24,7 @@ export const MODE_PACKS: Record = { tierAffinity: 0, specificityMatch: 0, contextAffinity: 0.01, + sessionAvailability: 0.05, resetWindowAffinity: 0, connectionDensity: 0.05, }, @@ -39,6 +40,7 @@ export const MODE_PACKS: Record = { tierAffinity: 0, specificityMatch: 0, contextAffinity: 0.0, + sessionAvailability: 0.05, resetWindowAffinity: 0, connectionDensity: 0.05, }, @@ -54,6 +56,7 @@ export const MODE_PACKS: Record = { tierAffinity: 0, specificityMatch: 0, contextAffinity: 0.0, + sessionAvailability: 0.05, resetWindowAffinity: 0, connectionDensity: 0.05, }, @@ -69,6 +72,7 @@ export const MODE_PACKS: Record = { tierAffinity: 0, specificityMatch: 0, contextAffinity: 0.0, + sessionAvailability: 0.05, resetWindowAffinity: 0, connectionDensity: 0.05, }, @@ -85,6 +89,7 @@ export const MODE_PACKS: Record = { tierAffinity: 0, specificityMatch: 0, contextAffinity: 0.0, + sessionAvailability: 0.05, resetWindowAffinity: 0, connectionDensity: 0.05, }, @@ -105,6 +110,7 @@ export const MODE_PACKS: Record = { tierAffinity: 0, specificityMatch: 0, contextAffinity: 0.03, + sessionAvailability: 0.05, resetWindowAffinity: 0, connectionDensity: 0.05, }, diff --git a/open-sse/services/autoCombo/scoring.ts b/open-sse/services/autoCombo/scoring.ts index a8758df00c..f64ab607f2 100644 --- a/open-sse/services/autoCombo/scoring.ts +++ b/open-sse/services/autoCombo/scoring.ts @@ -20,6 +20,7 @@ export interface ScoringFactors { specificityMatch: number; contextAffinity: number; cacheAffinity?: number; + sessionAvailability?: number; resetWindowAffinity: number; connectionDensity: number; } @@ -36,6 +37,7 @@ export interface ScoringWeights { specificityMatch: number; contextAffinity: number; cacheAffinity?: number; + sessionAvailability?: number; resetWindowAffinity: number; connectionDensity: number; } @@ -52,6 +54,7 @@ export const DEFAULT_WEIGHTS: ScoringWeights = { specificityMatch: 0.05, contextAffinity: 0.05, cacheAffinity: 0, + sessionAvailability: 0.05, resetWindowAffinity: 0, connectionDensity: 0.05, }; @@ -101,6 +104,7 @@ export interface ProviderCandidate { contextAffinity?: number; /** Score [0..1] for the account selected by the stable prompt-cache key. */ cacheAffinity?: number; + sessionAvailability?: number; /** Score [0..1] for quota reset-window preference; sooner selected reset windows score higher. */ resetWindowAffinity?: number; connectionPoolSize?: number; @@ -135,6 +139,7 @@ export function calculateScore(factors: ScoringFactors, weights: ScoringWeights) (weights.specificityMatch ?? 0) * factors.specificityMatch + (weights.contextAffinity ?? 0) * factors.contextAffinity + (weights.cacheAffinity ?? 0) * (factors.cacheAffinity ?? 0) + + (weights.sessionAvailability ?? 0) * (factors.sessionAvailability ?? 1) + (weights.resetWindowAffinity ?? 0) * factors.resetWindowAffinity + (weights.connectionDensity ?? 0) * factors.connectionDensity ); @@ -260,6 +265,7 @@ export function calculateFactors( specificityMatch: calculateSpecificityMatch(candidate, manifestHint), contextAffinity: clamp01(candidate.contextAffinity ?? 0.5), cacheAffinity: clamp01(candidate.cacheAffinity ?? 0), + sessionAvailability: clamp01(candidate.sessionAvailability ?? 1), resetWindowAffinity: clamp01(candidate.resetWindowAffinity ?? 0.5), connectionDensity: clamp01(((candidate.connectionPoolSize ?? 1) - 1) / 10), }; diff --git a/open-sse/services/autoCombo/suffixComposition.ts b/open-sse/services/autoCombo/suffixComposition.ts index fc5e11a2e6..2299de1d8b 100644 --- a/open-sse/services/autoCombo/suffixComposition.ts +++ b/open-sse/services/autoCombo/suffixComposition.ts @@ -20,6 +20,7 @@ import type { AutoVariant } from "./autoPrefix"; import { classifyTier } from "../tierResolver"; import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; import { isVisionModelId } from "@/shared/constants/visionModels"; +import { isVisionBridgeForcedModel } from "@/shared/constants/visionBridgeDefaults"; export type AutoCategory = "coding" | "reasoning" | "vision" | "chat" | "multimodal"; export type AutoTier = "fast" | "cheap" | "floor" | "free" | "reliable" | "pro"; @@ -94,6 +95,9 @@ export function tierToWeightVariant(tier?: AutoTier): AutoVariant | "reliability interface PoolCandidate { provider: string; model: string; + resolvedSupportsVision?: boolean; + resolvedReasoning?: boolean; + resolvedSupportsThinking?: boolean; } /** @@ -109,16 +113,29 @@ export function buildAutoCandidateFilter( if (category === "vision" || category === "multimodal") { checks.push((c) => { + if (c.resolvedSupportsVision !== undefined) { + return c.resolvedSupportsVision || isVisionModelId(c.model); + } try { const caps = getResolvedModelCapabilities({ provider: c.provider, model: c.model }); - return caps.supportsVision === true || isVisionModelId(c.model); + const capable = + caps.supportsVision === true || isVisionModelId(c.model); + if (!capable) return false; + // #vison-pool: registry entries whose catalog OVERSTATES vision support + // (opencode-go/opencode-zen/tokenrouter — the backend models are text-only) + // are forced through the vision bridge by isVisionBridgeForcedModel. + // They must never be selected as the vision-capable candidate itself. + return !isVisionBridgeForcedModel(`${c.provider}/${c.model}`); } catch { - return isVisionModelId(c.model); + return isVisionModelId(c.model) && !isVisionBridgeForcedModel(`${c.provider}/${c.model}`); } }); } if (category === "reasoning") { checks.push((c) => { + if (c.resolvedReasoning !== undefined && c.resolvedSupportsThinking !== undefined) { + return c.resolvedReasoning || c.resolvedSupportsThinking; + } try { const caps = getResolvedModelCapabilities({ provider: c.provider, model: c.model }); return caps.reasoning === true || caps.supportsThinking === true; diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index e8e96bfb0f..01f4e99894 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -9,7 +9,11 @@ import { NOAUTH_PROVIDERS } from "@/shared/constants/providers"; import { hasUsableWebSessionCredential } from "@/shared/providers/webSessionCredentials"; import { defaultLogger as log } from "@omniroute/open-sse/utils/logger"; import { getTokenLimit } from "../contextManager"; -import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; +import { + createModelCapabilityResolutionSnapshot, + getResolvedModelCapabilities, + type ModelCapabilityResolutionSnapshot, +} from "@/lib/modelCapabilities"; import { buildAutoCandidateFilter, tierToWeightVariant, @@ -65,6 +69,12 @@ export interface VirtualAutoComboCandidate { model: string; modelStr: string; // e.g., 'openai/gpt-4o' costPer1MTokens: number; // from providerRegistry + /** Build-local capability snapshot. Runtime calls rebuild it; catalog entries reuse it. */ + resolvedContextLength?: number | null; + resolvedMaxOutputTokens?: number | null; + resolvedSupportsVision?: boolean; + resolvedReasoning?: boolean; + resolvedSupportsThinking?: boolean; } type VirtualAutoCombo = AutoComboConfig & { @@ -106,6 +116,15 @@ type VirtualAutoCombo = AutoComboConfig & { }; }; +/** + * Build-local candidate snapshots shared by the built-in entries in one model-catalog build. + * Runtime routing does not retain or reuse this object across requests. + */ +export interface PreparedVirtualAutoComboInputs { + readonly regularCandidates: readonly VirtualAutoComboCandidate[]; + readonly familyCandidates: readonly VirtualAutoComboCandidate[]; +} + function toExpiryMs(value: unknown): number | null { if (value === null || value === undefined || value === "") return null; @@ -289,7 +308,14 @@ function getNoAuthCandidates( */ const DEFAULT_ADVERTISED_MAX_OUTPUT_TOKENS = 8192; -export function computeAdvertisedLimits(candidates: Array<{ provider: string; model: string }>): { +type AdvertisedLimitCandidate = { + provider: string; + model: string; + resolvedContextLength?: number | null; + resolvedMaxOutputTokens?: number | null; +}; + +export function computeAdvertisedLimits(candidates: AdvertisedLimitCandidate[]): { contextLength: number | null; maxOutputTokens: number | null; } { @@ -300,14 +326,20 @@ export function computeAdvertisedLimits(candidates: Array<{ provider: string; mo let contextLength: number | null = null; let maxOutputTokens: number | null = null; for (const candidate of candidates) { - const limit = getTokenLimit(candidate.provider, candidate.model); - if (Number.isFinite(limit) && limit > 0) { + const limit = + candidate.resolvedContextLength !== undefined + ? candidate.resolvedContextLength + : getTokenLimit(candidate.provider, candidate.model); + if (typeof limit === "number" && Number.isFinite(limit) && limit > 0) { contextLength = contextLength === null ? limit : Math.max(contextLength, limit); } - const output = getResolvedModelCapabilities({ - provider: candidate.provider, - model: candidate.model, - }).maxOutputTokens; + const output = + candidate.resolvedMaxOutputTokens !== undefined + ? candidate.resolvedMaxOutputTokens + : getResolvedModelCapabilities({ + provider: candidate.provider, + model: candidate.model, + }).maxOutputTokens; if (typeof output === "number" && Number.isFinite(output) && output > 0) { maxOutputTokens = maxOutputTokens === null ? output : Math.max(maxOutputTokens, output); } @@ -318,12 +350,82 @@ export function computeAdvertisedLimits(candidates: Array<{ provider: string; mo return { contextLength, maxOutputTokens }; } -export async function createVirtualAutoCombo( - variant: AutoVariant | undefined, - spec?: AutoComboSpec, - apiKeyId?: string, - autoChannel?: string -): Promise { +const PREPARED_CAPABILITY_YIELD_INTERVAL = 16; + +type PreparedCapabilityValues = { + resolvedContextLength: number | null; + resolvedMaxOutputTokens: number | null; + resolvedSupportsVision: boolean; + resolvedReasoning: boolean; + resolvedSupportsThinking: boolean; +}; + +type PreparedCapabilityState = { + /** Nested provider → model memo; collision-free for arbitrary model ids. */ + byTarget: Map>; + resolvedSinceYield: number; + /** Build-local bulk maps; one per catalog prepare, never retained at runtime. */ + resolutionSnapshot: ModelCapabilityResolutionSnapshot; +}; + +function yieldVirtualAutoPreparationTurn(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +async function attachPreparedCapabilityValues( + candidates: readonly VirtualAutoComboCandidate[], + state: PreparedCapabilityState +): Promise { + const prepared: VirtualAutoComboCandidate[] = []; + for (const candidate of candidates) { + let byModel = state.byTarget.get(candidate.provider); + if (!byModel) { + byModel = new Map(); + state.byTarget.set(candidate.provider, byModel); + } + let values = byModel.get(candidate.model); + if (!values) { + const contextLength = getTokenLimit( + candidate.provider, + candidate.model, + state.resolutionSnapshot + ); + const capabilities = getResolvedModelCapabilities( + { + provider: candidate.provider, + model: candidate.model, + }, + state.resolutionSnapshot + ); + const maxOutputTokens = capabilities.maxOutputTokens; + values = { + resolvedContextLength: + Number.isFinite(contextLength) && contextLength > 0 ? contextLength : null, + resolvedMaxOutputTokens: + typeof maxOutputTokens === "number" && + Number.isFinite(maxOutputTokens) && + maxOutputTokens > 0 + ? maxOutputTokens + : null, + resolvedSupportsVision: capabilities.supportsVision === true, + resolvedReasoning: capabilities.reasoning === true, + resolvedSupportsThinking: capabilities.supportsThinking === true, + }; + byModel.set(candidate.model, values); + state.resolvedSinceYield++; + if (state.resolvedSinceYield >= PREPARED_CAPABILITY_YIELD_INTERVAL) { + state.resolvedSinceYield = 0; + await yieldVirtualAutoPreparationTurn(); + } + } + prepared.push({ ...candidate, ...values }); + } + return prepared; +} + +export async function prepareVirtualAutoComboInputs( + options: { includeResolvedCapabilities?: boolean } = {} +): Promise { const [connections, disabledNoAuthConnections, settings] = await Promise.all([ getCachedProviderConnections({ isActive: true }) as Promise, // #6557: no-auth providers (opencode/mimocode/etc.) don't get an isActive @@ -405,50 +507,79 @@ export async function createVirtualAutoCombo( } } - candidatePool.push( - ...getNoAuthCandidates( - new Set(validConnections.map((conn) => conn.provider)), - blockedProviders, - disabledNoAuthProviders, - noAuthProviderSpecificData, - hiddenModelsMap, - // #6453/#8183 (operator decision 2026-07-24): auto/ combos are an - // identity selector, not a reliability-curated pool — bypass the no-auth - // allowlist gate so any backend that genuinely serves the family (e.g. - // auggie for auto/glm) is admitted. Category/tier and flat-variant pools - // (spec.family unset) keep the allowlist gate intact. - Boolean(spec?.family) - ) - ); - // #7623: honor existing model lockouts + connection cooldown/terminal state so // auto/* never advertises models the dispatch path would immediately skip. const connectionsById = new Map(); for (const conn of [...connections, ...disabledNoAuthConnections]) { connectionsById.set(conn.id, conn); } - const resilienceFilteredPool = filterResilienceBlockedCandidates( - candidatePool, - connectionsById - ); - if (resilienceFilteredPool !== candidatePool) { - candidatePool.length = 0; - candidatePool.push(...resilienceFilteredPool); + + const connectedProviders = new Set(validConnections.map((conn) => conn.provider)); + const buildPreparedPool = (bypassNoAuthAllowlist: boolean) => { + let pool = [ + ...candidatePool, + ...getNoAuthCandidates( + connectedProviders, + blockedProviders, + disabledNoAuthProviders, + noAuthProviderSpecificData, + hiddenModelsMap, + bypassNoAuthAllowlist + ), + ]; + + const resilienceFilteredPool = filterResilienceBlockedCandidates(pool, connectionsById); + if (resilienceFilteredPool !== pool) pool = resilienceFilteredPool; + + // #6512 (follow-up to #6328/#6495): when the operator opts into `hidePaidModels`, + // exclude paid-only backends from EVERY `auto/*` candidate pool. + const paidFilteredPool = filterPaidOnlyCandidates(pool, settings.hidePaidModels === true); + if (paidFilteredPool !== pool) pool = paidFilteredPool; + return pool; + }; + + const regularCandidates = buildPreparedPool(false); + // #6453/#8183: family selectors bypass the reliability-curated no-auth allowlist. + const familyCandidates = buildPreparedPool(true); + if (!options.includeResolvedCapabilities) { + return { regularCandidates, familyCandidates }; } - // #6512 (follow-up to #6328/#6495): when the operator opts into `hidePaidModels`, - // exclude paid-only backends from EVERY `auto/*` candidate pool — not just the - // `/v1/models` listing — so auto-routing never picks a model that will 402/403. - // If this empties the pool the existing graceful empty-pool path below handles it - // (consistent with the opt-in intent). Default OFF → pool unchanged. - const paidFilteredPool = filterPaidOnlyCandidates( - candidatePool, - settings.hidePaidModels === true + // One uninterrupted bulk read of all three capability tables for this prepare only. + // Do not yield between the three loads; later cooperative yields remain fine because + // catalog generation guards already prevent publishing across intervening writes. + const capabilityState: PreparedCapabilityState = { + byTarget: new Map(), + resolvedSinceYield: 0, + resolutionSnapshot: createModelCapabilityResolutionSnapshot(), + }; + return { + regularCandidates: await attachPreparedCapabilityValues(regularCandidates, capabilityState), + familyCandidates: await attachPreparedCapabilityValues(familyCandidates, capabilityState), + }; +} + +function clonePreparedCandidates( + candidates: readonly VirtualAutoComboCandidate[] +): VirtualAutoComboCandidate[] { + return candidates.map((candidate) => ({ + ...candidate, + ...(candidate.allowedConnectionIds + ? { allowedConnectionIds: [...candidate.allowedConnectionIds] } + : {}), + })); +} + +export async function createVirtualAutoComboFromPrepared( + prepared: PreparedVirtualAutoComboInputs, + variant: AutoVariant | undefined, + spec?: AutoComboSpec, + apiKeyId?: string, + autoChannel?: string +): Promise { + let candidatePool = clonePreparedCandidates( + spec?.family ? prepared.familyCandidates : prepared.regularCandidates ); - if (paidFilteredPool !== candidatePool) { - candidatePool.length = 0; - candidatePool.push(...paidFilteredPool); - } // #7819 (Level 2): per-API-key candidate exclusions. Fail-open — an absent // apiKeyId/autoChannel (every caller before #7819) or a DB lookup failure @@ -513,9 +644,7 @@ export async function createVirtualAutoCombo( ? buildAutoCandidateFilter(spec.category, spec.tier) : null; if (candidateFilter) { - const narrowed = candidatePool.filter((c) => - candidateFilter({ provider: c.provider, model: c.model }) - ); + const narrowed = candidatePool.filter((candidate) => candidateFilter(candidate)); const label = spec?.family ? `auto/${spec.family}` : `auto/${spec?.category ?? ""}${spec?.tier ? `:${spec.tier}` : ""}`; @@ -683,3 +812,13 @@ export async function createVirtualAutoCombo( advertisedMaxOutputTokens: advertisedLimits.maxOutputTokens, }; } + +export async function createVirtualAutoCombo( + variant: AutoVariant | undefined, + spec?: AutoComboSpec, + apiKeyId?: string, + autoChannel?: string +): Promise { + const prepared = await prepareVirtualAutoComboInputs(); + return createVirtualAutoComboFromPrepared(prepared, variant, spec, apiKeyId, autoChannel); +} diff --git a/open-sse/services/backgroundTaskDetector.ts b/open-sse/services/backgroundTaskDetector.ts index 20ac799c5d..8cbcbd3e9e 100644 --- a/open-sse/services/backgroundTaskDetector.ts +++ b/open-sse/services/backgroundTaskDetector.ts @@ -190,15 +190,31 @@ export function getBackgroundTaskReason( const messages = toMessageArray(typedBody.messages ?? typedBody.input ?? []); if (!Array.isArray(messages) || messages.length === 0) return null; - // Find system message + // Derive system content from messages array (OpenAI format) or top-level + // system field (Anthropic format). const systemMsg = messages.find( (message: BackgroundMessage) => message.role === "system" || message.role === "developer" ); - if (!systemMsg) return null; - - const systemContent = - typeof systemMsg.content === "string" ? systemMsg.content.toLowerCase() : ""; - + let systemContent = ""; + if (systemMsg && typeof systemMsg.content === "string") { + systemContent = systemMsg.content.toLowerCase(); + } else if (!systemMsg) { + // Anthropic top-level system field: string or array of text blocks + const raw = (typedBody as Record).system; + if (typeof raw === "string") { + systemContent = raw.toLowerCase(); + } else if (Array.isArray(raw)) { + systemContent = raw + .map((part) => + part && typeof (part as { text?: unknown }).text === "string" + ? (part as { text: string }).text + : "" + ) + .filter(Boolean) + .join(" ") + .toLowerCase(); + } + } if (!systemContent) return null; // Check against detection patterns diff --git a/open-sse/services/bottleneckPatch.ts b/open-sse/services/bottleneckPatch.ts new file mode 100644 index 0000000000..42312f33a3 --- /dev/null +++ b/open-sse/services/bottleneckPatch.ts @@ -0,0 +1,82 @@ +/** + * Monkey-patch for Bottleneck v2.19.5 doExpire bug. + * + * Bug (Job.js:162): + * `this._states.jobStatus(this.options.id === "RUNNING")` + * compares job ID to "RUNNING" (always false) instead of checking status. + * Should be: `this._states.jobStatus(this.options.id) === "RUNNING"` + * + * Impact: when a job's execution time exceeds `expiration`, doExpire fires but + * fails to advance the job from RUNNING to EXECUTING. The _assertStatus throws + * in a setTimeout (uncaught), and the job is permanently stuck in RUNNING state. + * Bottleneck's internal _running counter never decrements -> capacity leak. + * + * This patch intercepts Bottleneck's _run method to fix job.doExpire before + * the expiration timeout fires. + */ + +import Bottleneck from "bottleneck"; + +/** Bottleneck Job instance (internal, not exported). */ +interface BottleneckJob { + options: { id?: string; expiration?: number }; + doExpire: (clearGlobalState: () => void, run: () => void, free: () => void) => void; + _states: { jobStatus: (id: string) => string | null; next: (id: string) => void }; +} + +let patched = false; + +export function applyBottleneckDoExpirePatch(): void { + if (patched) return; + patched = true; + + const proto = Bottleneck.prototype as Record; + const originalRun = proto._run as + ((index: string, job: BottleneckJob, wait: number) => unknown) | undefined; + if (typeof originalRun !== "function") { + console.warn("[bottleneck-patch] _run not found on prototype, patch skipped"); + return; + } + + proto._run = function patchedRun(this: unknown, index: string, job: BottleneckJob, wait: number) { + // Patch job.doExpire BEFORE calling originalRun. + // originalRun passes job.doExpire to setTimeout by reference -- once captured, + // reassigning the property later has no effect on the queued timer callback. + // + // Guard: _run is called twice for jobs with wait > 0 (first with the delay, + // then with wait=0 when the timer fires). Without the flag, fixedDoExpire + // would wrap itself recursively on the second call. + if (typeof job?.doExpire === "function" && !(job as Record)._doExpirePatched) { + (job as Record)._doExpirePatched = true; + const originalDoExpire = job.doExpire.bind(job); + // Bottleneck registers the job in _states under options.id (Job.js + // states.start(this.options.id)); a bare `job.id` does not exist and + // reading it makes the RUNNING check below always miss. options.id is + // stable on the job and is the key the state machine uses. + const jobId = job.options.id; + + job.doExpire = function fixedDoExpire( + clearGlobalState: () => void, + run: () => void, + free: () => void + ) { + // Fix: check job status, not compare ID to string "RUNNING" + const states = job._states; + const currentStatus = states?.jobStatus?.(jobId); + if (currentStatus === "RUNNING") { + states?.next?.(jobId); + console.warn( + `[bottleneck-patch] doExpire bug triggered: job ${jobId} stuck in RUNNING, ` + + `advanced to EXECUTING before expiry. This is the Bottleneck v2.19.5 capacity leak.` + ); + } + return originalDoExpire(clearGlobalState, run, free); + }; + } + + // Now call original _run which captures the (now-patched) job.doExpire. + return originalRun.call(this, index, job, wait); + }; + + console.log("[bottleneck-patch] Applied doExpire fix for Bottleneck v2.19.5"); +} diff --git a/open-sse/services/browserBackedChat.ts b/open-sse/services/browserBackedChat.ts index 433da826fb..ec7d6b2915 100644 --- a/open-sse/services/browserBackedChat.ts +++ b/open-sse/services/browserBackedChat.ts @@ -517,6 +517,7 @@ export async function httpBackedChat( headers, body, signal: signal ?? undefined, + sessionScope: req.poolKey, }); const fetchMs = Date.now() - fetchStart; diff --git a/open-sse/services/ccBridgeTransforms.ts b/open-sse/services/ccBridgeTransforms.ts index d13adbeacc..ff5da0fa7f 100644 --- a/open-sse/services/ccBridgeTransforms.ts +++ b/open-sse/services/ccBridgeTransforms.ts @@ -101,7 +101,7 @@ export interface InjectBillingHeaderOp { * - static-zero: emit "00000" (relay endpoints don't validate) */ cchAlgo: "sha256-first-user" | "xxhash64-body" | "static-zero"; - /** Override the embedded `cc_version=` value. Defaults to `2.1.219`. */ + /** Override the embedded `cc_version=` value. Defaults to CLAUDE_CODE_CLIENT_VERSION. */ version?: string; /** Override its captured build revision. Defaults to a computed compatibility suffix. */ buildRevision?: string; diff --git a/open-sse/services/chatgptWebCodexAdmin.ts b/open-sse/services/chatgptWebCodexAdmin.ts new file mode 100644 index 0000000000..582aa9fd4b --- /dev/null +++ b/open-sse/services/chatgptWebCodexAdmin.ts @@ -0,0 +1,18 @@ +/** + * Service-boundary re-exports for the chatgpt-web-codex admin/dashboard API + * routes (src/app/api/providers/**). + * + * `no-restricted-imports` (EXECUTOR_IMPORT_RESTRICTION, eslint.config.mjs) + * forbids `src/app/**` files from importing `open-sse/executors/**` directly + * — executor implementations must stay behind an open-sse handler or service + * boundary. This file is that boundary for the small set of + * chatgpt-web-codex helpers the provider CRUD/doctor routes need (secret + * encode/decode, storage-state finalization, connection health status). + */ +export { getChatGptWebCodexDoctorStatus } from "../executors/chatgpt-web-codex/doctor.ts"; +export { finalizeValidatedChatGptWebCodexSecrets } from "../executors/chatgpt-web-codex/storageState.ts"; +export { + decodeChatGptWebCodexSecrets, + encodeChatGptWebCodexSecrets, + type ChatGptWebCodexSecrets, +} from "../executors/chatgpt-web-codex/credentials.ts"; 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 | null +): string { + if (!rawName) return rawName; + + const exact = toolNameMap?.get(rawName); + if (typeof exact === "string") return exact; + + if (toolNameMap?.size) { + const lower = rawName.toLowerCase(); + for (const [sanitized, original] of toolNameMap.entries()) { + if (sanitized.toLowerCase() === lower || original.toLowerCase() === lower) { + return original; + } + } + } + + return REVERSE_MAP[rawName] ?? rawName; +} + export { TOOL_RENAME_MAP, REVERSE_MAP }; /** diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 37c270e25c..f0dc5efc3c 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -19,6 +19,7 @@ import { MODEL_ACCESS_DENIED_PATTERNS, recordModelLockoutFailure, recordProviderFailure, + recordProviderSuccess, selectLockoutCooldownMs, } from "./accountFallback.ts"; import { @@ -35,6 +36,10 @@ import { import { buildNoUpstreamResponseDiagnostics, buildRecoveryHint } from "./combo/pinRecovery.ts"; import { buildTargetTimeoutRunner } from "./combo/targetTimeoutRunner.ts"; import { recordComboRequest, recordComboShadowRequest, getComboMetrics } from "./comboMetrics.ts"; +import { + expandComboSystemPromptIfPresent, + resolveTargetFingerprint, +} from "./comboAgentMiddleware.ts"; import { resolveComboConfig, getDefaultComboConfig, @@ -69,6 +74,7 @@ import { notifyWebhookEvent } from "../../src/lib/webhookDispatcher"; import { type ProviderCandidate } from "./autoCombo/scoring.ts"; import { estimateTokens } from "./contextManager.ts"; import { getSessionConnection } from "./sessionManager.ts"; +import { getOAuthSessionAvailability } from "./oauthSessionOccupancy.ts"; import { applySessionStickiness, normalizeStickinessMessages, @@ -89,11 +95,7 @@ import { } from "./combo/promptCacheAffinity.ts"; import type { CompressionMode } from "./compression/types.ts"; import { getCachedProviderConnections } from "../../src/lib/db/readCache"; -import { - isProviderInCooldown, - recordProviderCooldown, - recordProviderSuccess, -} from "./providerCooldownTracker.ts"; +import { isProviderInCooldown, recordProviderCooldown } from "./providerCooldownTracker.ts"; import { resolveResilienceSettings, type ResilienceSettings, @@ -156,6 +158,7 @@ import { isStreamReadinessFailureErrorBody, isStreamEarlyEofErrorBody, isTokenLimitBreachErrorBody, + isLocalQueueCapacityErrorBody, toRecordedTarget, getExhaustedTargetSkipReason, clampPercent, @@ -174,6 +177,11 @@ export { isModelScoped400, }; import { applyComboTargetExhaustion } from "./combo/targetExhaustion.ts"; +import { + applyNativeCodexTurnPin, + getNativeCodexTurnPin, + pinNativeCodexTurn, +} from "./combo/nativeCodexTurnPin.ts"; import { pinIsDurablyUnhealthy, tryFusionDispatch, @@ -219,6 +227,11 @@ import { } from "./combo/quotaExhaustionCutoff.ts"; import { expandTargetsByFingerprints } from "./combo/fingerprintExpansion.ts"; import { resolveComboTargetPipeline } from "./combo/targetResolution.ts"; +import { + isQuotaExhaustionResponse, + recordQuotaExhaustionClassification, + withQuotaExhaustionClassification, +} from "./combo/quotaExhaustion.ts"; export { RESET_WINDOW_NAMES, QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty }; export { scoreAutoTargets, expandAutoComboCandidatePool }; @@ -445,6 +458,9 @@ export async function buildAutoCandidates( let quotaCutoffReason: string | undefined; const fetcher = getQuotaFetcher(provider); const connection = target.connectionId ? connectionById.get(target.connectionId) : undefined; + const authType = typeof connection?.authType === "string" ? connection.authType : null; + const sessionAvailability = + authType === "oauth" ? getOAuthSessionAvailability(target.connectionId, sessionId) : 1; // Gate the terminal-status cutoff behind the same opt-in as the quota-percent // cutoff (#4483): when quota cutoff is disabled, a connection in a terminal // testStatus must still fall through to normal connection-cooldown / model-lockout @@ -519,6 +535,7 @@ export async function buildAutoCandidates( accountTier: "standard" as const, quotaResetIntervalSecs: 86400, contextAffinity, + sessionAvailability, resetWindowAffinity, quotaCutoffBlocked, quotaCutoffReason, @@ -526,6 +543,7 @@ export async function buildAutoCandidates( statusPenaltyReason, connectionPoolSize: connectionPoolCounts.get(provider) ?? 1, connectionId: target.connectionId ?? undefined, + authType, }; }) ); @@ -572,6 +590,7 @@ export async function handleComboChat({ apiKeyAllowedConnections = null, nesting = null, hiddenModelsByProvider = getHiddenModelsByProvider(), + clientManagedResponsesContext = false, }: HandleComboChatOptions): Promise { const comboCtx = createComboContext({ body, combo, settings, relayOptions, log }); const { @@ -685,8 +704,14 @@ export async function handleComboChat({ }); if (runtimeUnitDispatch) return runtimeUnitDispatch; - // Route to round-robin handler if strategy matches - if (strategy === "round-robin") { + const activeNativeTurnPin = clientManagedResponsesContext + ? getNativeCodexTurnPin(body, combo.name) + : null; + + // Route new round-robin turns to the specialized handler. A native Codex + // continuation with an established provider/account pin must use the common + // target pipeline below so it cannot rotate between tool rounds. + if (strategy === "round-robin" && !activeNativeTurnPin) { return handleRoundRobinCombo({ body, combo, @@ -697,13 +722,15 @@ export async function handleComboChat({ allCombos, signal, hiddenModelsByProvider, + clientManagedResponsesContext, + relayOptions, }); } - const maxRetries = config.maxRetries ?? 1; + const maxRetries = activeNativeTurnPin ? 0 : (config.maxRetries ?? 1); const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000); const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0); - const maxSetRetries = config.maxSetRetries ?? 0; + const maxSetRetries = activeNativeTurnPin ? 0 : (config.maxSetRetries ?? 0); const setRetryDelayMs = resolveDelayMs(config.setRetryDelayMs, 2000); const targetResolution = await resolveComboTargetPipeline({ @@ -722,11 +749,25 @@ export async function handleComboChat({ handleSingleModelWithTimeout, buildAutoCandidates, hiddenModelsByProvider, + clientManagedResponsesContext, }); if ("earlyResponse" in targetResolution) return targetResolution.earlyResponse; const { stickyWeightedLimit, getWeightedStepKeyForTarget, preScreenMap } = targetResolution; const _sticky = targetResolution.sticky; let orderedTargets = targetResolution.orderedTargets; + if (activeNativeTurnPin) { + orderedTargets = applyNativeCodexTurnPin(orderedTargets, activeNativeTurnPin); + if (orderedTargets.length === 0) { + return errorResponse( + 409, + "The pinned native Codex turn target is no longer available; the turn cannot be moved to another provider" + ); + } + log.info( + "COMBO", + `Native Codex turn pinned to ${activeNativeTurnPin.modelStr} connection ${activeNativeTurnPin.connectionId.slice(0, 8)}` + ); + } // #5923 (Finding #4) — reset-window config for the shared per-target quota- // exhaustion cutoff below. The "auto" strategy already applies its own cutoff @@ -813,6 +854,26 @@ export async function handleComboChat({ // Accumulator for per-model error details across targets in the current set try. // Reset at the start of each set retry (same lifecycle as lastError/recordedAttempts). let comboErrors: Array<{ model: string; status: number; error: string }> = []; + // Quota trust spans set retries and recursive cooldown re-dispatches. Once any + // failure is non-quota, a nested caller must never treat this dispatch as quota-only. + let observedFailure = false; + let allObservedFailuresQuota = true; + const targetFailureTrust = new Map< + string, + { observedFailure: boolean; allObservedFailuresQuota: boolean } + >(); + const observeFailure = (quotaExhausted: boolean, targetExecutionKey?: string) => { + observedFailure = true; + allObservedFailuresQuota &&= quotaExhausted; + if (!targetExecutionKey) return; + const trust = targetFailureTrust.get(targetExecutionKey) ?? { + observedFailure: false, + allObservedFailuresQuota: true, + }; + trust.observedFailure = true; + trust.allObservedFailuresQuota &&= quotaExhausted; + targetFailureTrust.set(targetExecutionKey, trust); + }; // FASE 2.1: per-connection concurrency limit for quota-share. The gating in // selectQuotaShareTarget is fail-open and cannot hard-limit a single-connection @@ -900,6 +961,9 @@ export async function handleComboChat({ let anySuccess = false; const abortControllers = new Map(); const zeroLatencyOptimizationsEnabled = config.zeroLatencyOptimizationsEnabled === true; + const hasProtectedPriorityTarget = + strategy === "priority" && + orderedTargets.some((target) => target.fallbackOnlyOnQuotaExhaustion === true); const executeTarget = async ( i: number @@ -908,12 +972,20 @@ export async function handleComboChat({ const modelStr = target.modelStr; const rawModel = parseModel(modelStr).model || modelStr; const provider = target.provider; + const protectedPriorityTarget = + strategy === "priority" && target.fallbackOnlyOnQuotaExhaustion === true; + const stopProtectedPriorityTarget = (message: string) => { + observeFailure(false, target.executionKey); + return protectedPriorityTarget + ? { ok: false, response: errorResponse(503, message) } + : null; + }; const cb = getCircuitBreaker(provider); if (cb.getStatus().state === "OPEN") { log.info("COMBO", `Skipping ${modelStr} — circuit breaker OPEN for ${provider}`); if (i > 0) fallbackCount++; - return null; + return stopProtectedPriorityTarget(`Provider ${provider} circuit breaker is open`); } if ( @@ -923,7 +995,7 @@ export async function handleComboChat({ ) { log.info("COMBO", `Skipping ${modelStr} — provider ${provider} in global cooldown`); if (i > 0) fallbackCount++; - return null; + return stopProtectedPriorityTarget(`Provider ${provider} is in cooldown`); } // Use pre-screened profile if available, otherwise fetch on demand @@ -950,14 +1022,14 @@ export async function handleComboChat({ if (exhaustedSkip) { log.info("COMBO", exhaustedSkip); if (i > 0) fallbackCount++; - return null; + return stopProtectedPriorityTarget(`Target ${modelStr} is unavailable`); } // Pre-check: skip models locked by the resilience system (model-level lockout) if (provider && rawModel && isModelLocked(provider, target.connectionId || "", rawModel)) { log.info("COMBO", `Skipping ${modelStr} — model locked by resilience (cooldown active)`); if (i > 0) fallbackCount++; - return null; + return stopProtectedPriorityTarget(`Model ${modelStr} is locked`); } // #5923 (Finding #4) — honor the same opt-in quota-exhaustion cutoff the @@ -983,6 +1055,16 @@ export async function handleComboChat({ `Skipping ${modelStr} — quota exhaustion cutoff (${quotaCutoff.reason || "quota_exhausted"})` ); if (i > 0) fallbackCount++; + observeFailure(true, target.executionKey); + if (protectedPriorityTarget) { + const protectedTargetTrust = targetFailureTrust.get(target.executionKey); + if (!protectedTargetTrust?.allObservedFailuresQuota) { + return { + ok: false, + response: errorResponse(503, `Target ${modelStr} is unavailable`), + }; + } + } return null; } } @@ -1000,7 +1082,7 @@ export async function handleComboChat({ `Skipping ${modelStr} — no credentials available or model excluded` ); if (i > 0) fallbackCount++; - return null; + return stopProtectedPriorityTarget(`Model ${modelStr} is unavailable`); } } @@ -1011,7 +1093,7 @@ export async function handleComboChat({ if (gateResult.allowed === false) { logCredentialSkip(log, modelStr, gateResult.reason || "Credential gate blocked"); if (i > 0) fallbackCount++; - return null; + return stopProtectedPriorityTarget(`Credential gate blocked ${modelStr}`); } // Concurrency gate: fail-fast skip when connection is at max_concurrent capacity (e.g. Featherless 1/1) @@ -1025,7 +1107,7 @@ export async function handleComboChat({ `Skipping ${modelStr} — connection ${connectionId} is at max concurrency cap (${maxConcurrentCap})` ); if (i > 0) fallbackCount++; - return null; + return stopProtectedPriorityTarget(`Connection capacity reached for ${modelStr}`); } } @@ -1080,7 +1162,7 @@ export async function handleComboChat({ "COMBO", `Predictive TTFT Circuit Breaker: skipping ${modelStr} (avg ${m.avgLatencyMs}ms > max ${config.predictiveTtftMs}ms)` ); - return null; + return stopProtectedPriorityTarget(`Predictive latency check rejected ${modelStr}`); } } } @@ -1197,6 +1279,18 @@ export async function handleComboChat({ } } } + // #5501: server-side template expansion for the combo system_message — + // resolved per-target, scoped to combo-injected content only (never + // client-owned system messages). Gate: a non-empty combo system_message. + attemptBody = expandComboSystemPromptIfPresent(attemptBody, combo, { + modelId: modelStr, + providerId: provider !== "unknown" ? provider : "", + account: + typeof target.label === "string" && target.label.trim().length > 0 + ? target.label.trim() + : "", + fingerprint: resolveTargetFingerprint(target) ?? "", + }); const result = await handleSingleModelWithTimeout(attemptBody, modelStr, { ...targetForAttempt, effectiveComboStrategy: strategy, @@ -1278,7 +1372,22 @@ export async function handleComboChat({ error: `Quality: ${quality.reason}`, latencyMs: Date.now() - startTime, }); - return null; + observeFailure(false, target.executionKey); + return protectedPriorityTarget + ? { + ok: false, + response: errorResponse(502, "Upstream response failed quality validation"), + } + : null; + } + + if (clientManagedResponsesContext && effectiveConnectionId) { + pinNativeCodexTurn({ + body, + comboName: combo.name, + target, + connectionId: effectiveConnectionId, + }); } // Success decay: a healthy response walks the model's lockout failure @@ -1549,6 +1658,7 @@ export async function handleComboChat({ // FIX 5: a local per-API-key token-limit 429 must not cool shared accounts. const isTokenLimitBreach = result.status === 429 && isTokenLimitBreachErrorBody(errorBody); + const isLocalQueueCapacity = isLocalQueueCapacityErrorBody(errorBody); // Fix #1681: Status 499 means client disconnected — stop combo loop immediately. // There is no point trying fallback models when nobody is listening. @@ -1567,6 +1677,22 @@ export async function handleComboChat({ // so the combo would wrongly fall through to the next model after a 499. return { ok: false, response: result }; } + if (isLocalQueueCapacity) { + log.info( + "COMBO", + `Local rate-limit queue capacity reached for ${modelStr} — returning without upstream fallback` + ); + recordComboRequest(combo.name, modelStr, { + success: false, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy, + target: toRecordedTarget(target), + }); + recordedAttempts++; + if (i > 0) fallbackCount++; + return { ok: false, response: result }; + } // Combo fallback is target-level orchestration: a non-ok target response is // treated as local to that target and the combo continues to the next target. @@ -1591,7 +1717,7 @@ export async function handleComboChat({ : undefined, } : undefined; - const scopedFailure = isScopedFailure(result.status, errorText, structuredError); + const scopedFailure = isScopedFailure(result, errorText, structuredError); // #8375: input-bound request-scoped failures (context_length_exceeded) are // deterministic for the same input — retrying on other accounts of the same @@ -1629,7 +1755,7 @@ export async function handleComboChat({ result.status, errorText, 0, - null, + protectedPriorityTarget ? rawModel : null, provider, result.headers, profile, @@ -1672,6 +1798,7 @@ export async function handleComboChat({ rawModel, isTokenLimitBreach, allAccountsRateLimited: false, + requestScopedFailure: scopedFailure, sets: { exhaustedProviders, exhaustedConnections, transientRateLimitedProviders }, log, tag: "COMBO", @@ -1756,17 +1883,34 @@ export async function handleComboChat({ isProxyUnreachable: structuredError?.code === "proxy_unreachable", }) ) { - recordProviderFailure(provider, log, targetWithConnection.connectionId, profile); + const isQueueTimeout = + errorText.includes("RATE_LIMIT_QUEUE_TIMEOUT") || + errorText.includes("RATE_LIMIT_QUEUE_WEDGED"); + recordProviderFailure(provider, log, targetWithConnection.connectionId, profile, { + isQueueTimeout, + isNetworkError: structuredError?.code === "proxy_unreachable", + }); } + const quotaExhausted = await isQuotaExhaustionResponse( + result, + provider, + rawModel, + profile + ); + recordQuotaExhaustionClassification(result, quotaExhausted); + observeFailure(quotaExhausted, target.executionKey); + // Check if this is a transient error worth retrying on same model. // A token-limit 429 is terminal for the client — never retry it. const isTransient = !isStreamReadinessFailure && !isTokenLimitBreach && + !scopedFailure && [408, 429, 500, 502, 503, 504].includes(result.status); if (retry < maxRetries && isTransient && !providerExhausted) { if ( + !protectedPriorityTarget && provider && rawModel && isModelLocked(provider, targetWithConnection.connectionId || "", rawModel) @@ -1789,7 +1933,7 @@ export async function handleComboChat({ // once the model is cooling down, retrying it would waste an upstream // call and extend the cooldown via exponential backoff. let lockoutRecorded = false; - if (provider && rawModel && retry === 0 && !scopedFailure) { + if (!protectedPriorityTarget && provider && rawModel && retry === 0 && !scopedFailure) { const mlSettings = resolveModelLockoutSettings(settings); if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) { recordModelLockoutFailure( @@ -1829,6 +1973,22 @@ export async function handleComboChat({ } // Done retrying this model + const protectedTargetTrust = targetFailureTrust.get(target.executionKey); + if ( + protectedPriorityTarget && + (!protectedTargetTrust?.observedFailure || + !protectedTargetTrust.allObservedFailuresQuota) + ) { + recordComboRequest(combo.name, modelStr, { + success: false, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy, + target: toRecordedTarget(target), + }); + recordedAttempts++; + return { ok: false, response: result }; + } recordComboRequest(combo.name, modelStr, { success: false, latencyMs: Date.now() - startTime, @@ -1957,7 +2117,12 @@ export async function handleComboChat({ runningTasks.add(task); task.finally(() => runningTasks.delete(task)); - if (zeroLatencyOptimizationsEnabled && config.hedging && i + 1 < orderedTargets.length) { + if ( + zeroLatencyOptimizationsEnabled && + config.hedging && + !hasProtectedPriorityTarget && + i + 1 < orderedTargets.length + ) { const hedgeDelay = resolveDelayMs(config.hedgeDelayMs, 500); let timeoutResolve: () => void; const timeoutPromise = new Promise((r) => { @@ -2037,15 +2202,29 @@ export async function handleComboChat({ // All set retries exhausted — return the final error if (!lastStatus) { + if (recordedAttempts === 0) { + notifyWebhookEvent("request.failed", { + combo: combo.name, + reason: "ALL_TARGETS_SKIPPED", + latencyMs, + fallbackCount, + }); + return withQuotaExhaustionClassification( + errorResponseWithComboDiagnostics( + 503, + "Service temporarily unavailable: all targets were skipped by pre-dispatch filters", + buildComboDiag("all_targets_skipped"), + { code: "ALL_TARGETS_SKIPPED", type: "service_unavailable" } + ), + observedFailure ? allObservedFailuresQuota : null + ); + } notifyWebhookEvent("request.failed", { combo: combo.name, reason: "ALL_ACCOUNTS_INACTIVE", latencyMs, fallbackCount, }); - // Silent-stop fix: bump the failure counter so the session pin clears on the 3rd - // consecutive all-inactive cascade; buildRecoveryHint emits `switch-combo` with a - // next-step that points the user at /dashboard/providers. recordComboFailure(effectiveSessionId, combo.name); return errorResponseWithComboDiagnostics( 503, @@ -2128,7 +2307,10 @@ export async function handleComboChat({ if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) { const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter)); log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`); - return unavailableResponse(status, msg, earliestRetryAfter, retryHuman); + return withQuotaExhaustionClassification( + unavailableResponse(status, msg, earliestRetryAfter, retryHuman), + observedFailure ? allObservedFailuresQuota : null + ); } // Silent-stop fix: bump the failure counter (pin clears on 3rd consecutive) and emit @@ -2144,10 +2326,13 @@ export async function handleComboChat({ ); } const retryAfterSeconds = undefined; - return errorResponseWithComboDiagnostics( - status, - msg, - buildComboDiag(lastError ?? "all_models_failed", retryAfterSeconds) + return withQuotaExhaustionClassification( + errorResponseWithComboDiagnostics( + status, + msg, + buildComboDiag(lastError ?? "all_models_failed", retryAfterSeconds) + ), + observedFailure ? allObservedFailuresQuota : null ); } @@ -2212,7 +2397,10 @@ async function handleRoundRobinCombo({ settings, allCombos, signal, + nesting = null, hiddenModelsByProvider = getHiddenModelsByProvider(), + clientManagedResponsesContext, + relayOptions, }: HandleRoundRobinOptions): Promise { const config = settings ? resolveComboConfig(combo, settings) @@ -2259,7 +2447,9 @@ async function handleRoundRobinCombo({ ); const tagFilteredTargets = await applyRequestTagRouting(orderedTargets, body, log); const evalRankedTargets = orderTargetsByEvalScores(tagFilteredTargets, config.evalRouting, log); - const knownContextOverflow = getKnownContextOverflow(evalRankedTargets, body); + const knownContextOverflow = getKnownContextOverflow(evalRankedTargets, body, { + clientManagedResponsesContext, + }); if (knownContextOverflow) { return errorResponseWithComboDiagnostics( 400, @@ -2438,7 +2628,13 @@ async function handleRoundRobinCombo({ // stickiness engages on the /v1/responses surface, not just Chat Completions. normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }) ); - const rrAffinity = applyPromptCacheAffinity(filteredTargets, body, rrAffinityEnabled); + const rrAffinity = applyPromptCacheAffinity( + filteredTargets, + body, + rrAffinityEnabled, + "global", + relayOptions?.sessionId + ); if (rrAffinity.applied) { const stickyFirst = _rrSessionSticky.stuck ? _rrSessionSticky.targets[0] : null; filteredTargets = stickyFirst @@ -2601,6 +2797,18 @@ async function handleRoundRobinCombo({ } } + // #5501: combo system_message template expansion per target (same gate + // as the main iteration loop — round-robin branches here, not executeTarget). + attemptBody = expandComboSystemPromptIfPresent(attemptBody, combo, { + modelId: modelStr, + providerId: provider !== "unknown" ? provider : "", + account: + typeof target.label === "string" && target.label.trim().length > 0 + ? target.label.trim() + : "", + fingerprint: resolveTargetFingerprint(target) ?? "", + }); + const result = await handleSingleModel(attemptBody, modelStr, { ...targetForAttempt, effectiveComboStrategy: "round-robin", @@ -2799,6 +3007,23 @@ async function handleRoundRobinCombo({ // FIX 5: a local per-API-key token-limit 429 must not cool shared accounts. const isTokenLimitBreach = result.status === 429 && isTokenLimitBreachErrorBody(errorBody); + const isLocalQueueCapacity = isLocalQueueCapacityErrorBody(errorBody); + + if (isLocalQueueCapacity) { + log.info( + "COMBO-RR", + `Local rate-limit queue capacity reached for ${modelStr} — returning without upstream fallback` + ); + recordComboRequest(combo.name, modelStr, { + success: false, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy: "round-robin", + target: toRecordedTarget(target), + }); + recordedAttempts++; + return result; + } // Round-robin uses the same target-level fallback rule as other combo // strategies: non-ok target responses fall through to the next target. @@ -2823,7 +3048,7 @@ async function handleRoundRobinCombo({ : undefined, } : undefined; - const scopedFailure = isScopedFailure(result.status, errorText, structuredError); + const scopedFailure = isScopedFailure(result, errorText, structuredError); const fallbackResult = checkFallbackError( result.status, errorText, @@ -2862,6 +3087,7 @@ async function handleRoundRobinCombo({ rawModel: parseModel(modelStr).model || modelStr, isTokenLimitBreach, allAccountsRateLimited: isAllAccountsRateLimited, + requestScopedFailure: scopedFailure, sets: { exhaustedProviders, exhaustedConnections, transientRateLimitedProviders }, log, tag: "COMBO-RR", @@ -2895,6 +3121,7 @@ async function handleRoundRobinCombo({ const isTransient = !isStreamReadinessFailure && !isTokenLimitBreach && + !scopedFailure && [408, 429, 500, 502, 503, 504].includes(result.status); if (retry < maxRetries && isTransient && !providerExhausted) { continue; @@ -3005,6 +3232,19 @@ async function handleRoundRobinCombo({ } if (!lastStatus) { + if (recordedAttempts === 0) { + return new Response( + JSON.stringify({ + error: { + message: + "Service temporarily unavailable: all targets were skipped by pre-dispatch filters", + type: "service_unavailable", + code: "ALL_TARGETS_SKIPPED", + }, + }), + { status: 503, headers: { "Content-Type": "application/json" } } + ); + } return new Response( JSON.stringify({ error: { diff --git a/open-sse/services/combo/applyStrategyOrdering.ts b/open-sse/services/combo/applyStrategyOrdering.ts index a2eba3a555..38f07fbcdc 100644 --- a/open-sse/services/combo/applyStrategyOrdering.ts +++ b/open-sse/services/combo/applyStrategyOrdering.ts @@ -26,6 +26,7 @@ export interface ApplyStrategyOrderingDeps { body: Record; log: ComboLogger; apiKeyAllowedConnections: string[] | null; + sessionKey?: string | null; } /** @@ -45,7 +46,7 @@ export async function applyStrategyOrdering( initialOrderedTargets: ResolvedComboTarget[], deps: ApplyStrategyOrderingDeps ): Promise { - const { combo, config, body, log, apiKeyAllowedConnections } = deps; + const { combo, config, body, log, apiKeyAllowedConnections, sessionKey } = deps; let orderedTargets = initialOrderedTargets; if (strategy === "lkgp") { @@ -205,7 +206,7 @@ export async function applyStrategyOrdering( if (resolvePromptCacheAffinityKey(body)) { orderedTargets = await expandPromptCacheAffinityTargets(orderedTargets); } - const affinity = applyPromptCacheAffinity(orderedTargets, body); + const affinity = applyPromptCacheAffinity(orderedTargets, body, true, "global", sessionKey); orderedTargets = affinity.targets; log.info( "COMBO", diff --git a/open-sse/services/combo/comboPredicates.ts b/open-sse/services/combo/comboPredicates.ts index cc29f710fa..6a424d7439 100644 --- a/open-sse/services/combo/comboPredicates.ts +++ b/open-sse/services/combo/comboPredicates.ts @@ -12,6 +12,7 @@ import { isSelfInflictedUpstreamTimeout } from "../../handlers/chatCore/cooldown import { isLocalStreamLifecycleError } from "@/shared/utils/circuitBreaker"; import { CONTEXT_OVERFLOW_PATTERNS, MODEL_ACCESS_DENIED_PATTERNS } from "../accountFallback.ts"; import { isResourceNotFoundResponse } from "../errorClassifier.ts"; +import { getTrustedLocalRateLimitResponse } from "../rateLimitManager/errors.ts"; import type { ResolvedComboTarget } from "./types.ts"; // Status codes that should mark round-robin target semaphores as cooling down. @@ -190,13 +191,17 @@ export function shouldRecordProviderBreakerFailure(args: { ); } -const REQUEST_SCOPED_UPSTREAM_ERROR_CODES = new Set([ - "context_length_exceeded", - "upstream_empty_response", - "upstream_response_failed", +const REQUEST_SCOPED_UPSTREAM_ERROR_CODES: Record = { + context_length_exceeded: true, + upstream_empty_response: true, + upstream_response_failed: true, // Local combo per-target timer (targetTimeoutRunner) — not a connection health signal. - "combo_target_timeout", -]); + combo_target_timeout: true, + // Local limiter queue-capacity codes — not a provider/connection health signal. + rate_limit_queue_timeout: true, + rate_limit_queue_full: true, + rate_limit_queue_wedged: true, +}; /** Request/model-specific failures must not poison provider-wide resilience state. */ export function isRequestScopedUpstreamFailure(error?: { @@ -205,18 +210,23 @@ export function isRequestScopedUpstreamFailure(error?: { }): boolean { const code = typeof error?.code === "string" ? error.code.toLowerCase() : ""; const type = typeof error?.type === "string" ? error.type.toLowerCase() : ""; - return REQUEST_SCOPED_UPSTREAM_ERROR_CODES.has(code) || type === "context_length_exceeded"; + return ( + REQUEST_SCOPED_UPSTREAM_ERROR_CODES[code] === true || + type === "context_length_exceeded" || + type === "local_queue_capacity" + ); } /** Request-scoped classification that also has access to the HTTP body. */ export function isComboRequestScopedFailure( - status: number, + response: Response, errorText: string, error?: { code?: string | null; type?: string | null } ): boolean { return ( + getTrustedLocalRateLimitResponse(response) !== null || isRequestScopedUpstreamFailure(error) || - (status === 404 && isResourceNotFoundResponse(errorText)) + (response.status === 404 && isResourceNotFoundResponse(errorText)) ); } @@ -255,6 +265,7 @@ export function isInputBoundRequestFailure(error?: { export function shouldSkipConnDisable( result: { status: number; + response?: Response; errorCode?: string | null; errorType?: string | null; error?: unknown; @@ -270,6 +281,7 @@ export function shouldSkipConnDisable( // Client abort surfaced as a bare error (no statusCode → defaults to 502): // a local lifecycle event, not a provider failure (#4602 policy). isLocalStreamLifecycleError(result.error) || + (result.response ? getTrustedLocalRateLimitResponse(result.response) !== null : false) || result.errorCode === "plugin_block" || result.errorType === "plugin_block" || (is401 && hasExtraKeys) || @@ -354,6 +366,21 @@ export function isTokenLimitBreachErrorBody(errorBody: unknown): boolean { return (error as Record).code === "TOKEN_LIMIT_EXCEEDED"; } +/** Local limiter capacity is not an upstream/provider failure and must not cascade. */ +export function isLocalQueueCapacityErrorBody(errorBody: unknown): boolean { + if (!errorBody || typeof errorBody !== "object") return false; + const error = (errorBody as Record).error; + if (!error || typeof error !== "object") return false; + const code = String((error as Record).code || "").toUpperCase(); + const type = String((error as Record).type || "").toLowerCase(); + return ( + code === "RATE_LIMIT_QUEUE_TIMEOUT" || + code === "RATE_LIMIT_QUEUE_FULL" || + code === "RATE_LIMIT_QUEUE_WEDGED" || + type === "local_queue_capacity" + ); +} + export function toRecordedTarget(target: ResolvedComboTarget) { return { executionKey: target.executionKey, diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 176f41a99f..c125bc9052 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -18,6 +18,7 @@ import { getHiddenModelsByProvider } from "../../../src/lib/db/models"; import { getComboModelString, normalizeComboStep } from "../../../src/lib/combos/steps.ts"; import { getProviderByAlias, getProviderById } from "../../../src/shared/constants/providers.ts"; import { estimateTokens } from "../contextManager.ts"; +import { containsMediaKind } from "../../utils/mediaParts.ts"; import { getResolvedModelCapabilities } from "../modelCapabilities.ts"; import { parseModel, stripContextWindowSuffix } from "../model.ts"; import { dedupeTargetsByExecutionKey, isRecord } from "./comboData.ts"; @@ -114,6 +115,7 @@ function normalizeRuntimeStep( comboName: step.comboName, weight, label, + ...(step.fallbackOnlyOnQuotaExhaustion ? { fallbackOnlyOnQuotaExhaustion: true } : {}), }; } @@ -140,6 +142,9 @@ function normalizeRuntimeStep( // `prompt` is a per-step pipeline input and only exists on a model step — // #8894 widened the union with ComboProviderWildcardStep, which has no prompt. prompt: (step.kind === "model" ? step.prompt : null) || null, + ...(step.kind === "model" && step.fallbackOnlyOnQuotaExhaustion + ? { fallbackOnlyOnQuotaExhaustion: true } + : {}), } satisfies ResolvedComboTarget; } @@ -483,21 +488,8 @@ function estimateRequestInputTokens(body: Record): number { return Object.keys(estimatePayload).length > 0 ? estimateTokens(estimatePayload) : 0; } -function valueContainsImagePart(value: unknown, depth = 0): boolean { - if (depth > 8 || value === null || value === undefined) return false; - if (typeof value === "string") return value.startsWith("data:image/"); - if (Array.isArray(value)) return value.some((entry) => valueContainsImagePart(entry, depth + 1)); - if (!isRecord(value)) return false; - - const type = typeof value.type === "string" ? value.type.toLowerCase() : null; - if (type === "image" || type === "image_url" || type === "input_image") return true; - if ("image_url" in value || "input_image" in value) return true; - - const source = isRecord(value.source) ? value.source : null; - const mediaType = typeof source?.media_type === "string" ? source.media_type.toLowerCase() : ""; - if (mediaType.startsWith("image/")) return true; - - return Object.values(value).some((entry) => valueContainsImagePart(entry, depth + 1)); +function valueContainsImagePart(value: unknown): boolean { + return containsMediaKind([{ content: [value] }], "image"); } export function deriveRequestCompatibilityRequirements( @@ -620,6 +612,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. @@ -723,9 +719,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/dispatchPrelude.ts b/open-sse/services/combo/dispatchPrelude.ts index 65dd76e175..7caf82fe76 100644 --- a/open-sse/services/combo/dispatchPrelude.ts +++ b/open-sse/services/combo/dispatchPrelude.ts @@ -23,6 +23,10 @@ import { clampComboDepth, MAX_GLOBAL_ATTEMPTS, resolveDelayMs } from "./comboPre import { resolveComboRuntimeUnits, resolveComboTargets } from "./comboStructure.ts"; import { isComboModelVisible } from "./comboVisibility.ts"; import { buildFusionHandleSingleModel, extractFusionPanelSpec } from "./fusionPanel.ts"; +import { + expandComboSystemPromptIfPresent, + resolveTargetFingerprint, +} from "../comboAgentMiddleware.ts"; import { clampStickyWeightedTargetLimit, getStickyRoundRobinStartIndex, @@ -71,6 +75,7 @@ type PreludeBaseOptionArgs = { signal?: AbortSignal | null; apiKeyAllowedConnections?: string[] | null; hiddenModelsByProvider?: HiddenModelsByProvider; + clientManagedResponsesContext?: boolean; }; /** Rebuild handleComboChat's option bag verbatim for a recursive dispatch. */ @@ -87,6 +92,7 @@ function buildBaseOptions(a: PreludeBaseOptionArgs): HandleComboChatOptions { signal: a.signal, apiKeyAllowedConnections: a.apiKeyAllowedConnections, hiddenModelsByProvider: a.hiddenModelsByProvider, + clientManagedResponsesContext: a.clientManagedResponsesContext, }; } @@ -260,12 +266,20 @@ export async function tryPinnedModelDispatch(args: { // when allCombos is authoritative (non-empty) so we can resolve combo-refs; // the auto-combo redirect path passes an empty list and keeps prior behavior. const haveFullCombos = Array.isArray(allCombos) ? allCombos.length > 0 : !!allCombos; - const pinInCombo = resolveComboTargets( + // Eagerly resolve the combo's targets once (used for the pin-validity check AND + // #5501 template expansion). A non-authoritative allCombos (empty/missing) + // resolves to the combo's direct targets only — same semantics as the original + // `!haveFullCombos ||` short-circuit, without feeding `[]` to the nested resolver. + // #5501 also needs these targets eagerly for the combo system_message expansion; + // the release refactor threads `hiddenModelsByProvider` through the resolver so + // hidden models stay filtered on both the pin-validity and expansion paths. + const comboTargets = resolveComboTargets( combo, - haveFullCombos ? allCombos : null, + haveFullCombos ? allCombos : undefined, clampComboDepth(config.maxComboDepth), hiddenModelsByProvider - ).some((target) => target.modelStr === pinnedModel); + ); + const pinInCombo = !haveFullCombos || comboTargets.some((t) => t.modelStr === pinnedModel); // Honor the pin only if it is still a combo target AND its provider is not // DURABLY down. Without the health gate a pin keeps routing a session to a // dead/credits-exhausted/throttled account forever (strategy bypassed, no @@ -280,7 +294,21 @@ export async function tryPinnedModelDispatch(args: { ); let pinnedResult: Response | null = null; try { - pinnedResult = await handleSingleModelWithTimeout(body, pinnedModel, { + // #5501: the combo system_message also expands on the pinned context path — + // a session pin bypasses the main loop, so without this the template would + // go literal from the second in-session request on. Target context comes + // from the pinned model's resolved combo target when available. + const pinnedTarget = comboTargets.find((t) => t.modelStr === pinnedModel); + const pinnedBody = expandComboSystemPromptIfPresent(body, combo, { + modelId: pinnedModel, + providerId: pinnedTarget && pinnedTarget.provider !== "unknown" ? pinnedTarget.provider : "", + account: + typeof pinnedTarget?.label === "string" && pinnedTarget.label.trim().length > 0 + ? pinnedTarget.label.trim() + : "", + fingerprint: pinnedTarget ? resolveTargetFingerprint(pinnedTarget) ?? "" : "", + }); + pinnedResult = await handleSingleModelWithTimeout(pinnedBody, pinnedModel, { modelPinned: true, } as SingleModelTarget); } catch (pinErr) { 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/knownContextOverflow.ts b/open-sse/services/combo/knownContextOverflow.ts index 4416d1d451..db9cb2d602 100644 --- a/open-sse/services/combo/knownContextOverflow.ts +++ b/open-sse/services/combo/knownContextOverflow.ts @@ -61,7 +61,6 @@ export function getKnownContextLimit( return limits.length > 0 ? Math.min(...limits) : null; } - /** * Return a hard context-overflow decision only when every target has a known * context limit and every one of those limits is too small for the request. @@ -69,9 +68,23 @@ export function getKnownContextLimit( */ export function getKnownContextOverflow( targets: ResolvedComboTarget[], - body: Record + body: Record, + options: { clientManagedResponsesContext?: boolean } = {} ): KnownContextOverflow | null { if (targets.length === 0) return null; + // Native Codex Responses clients compact their own item history. Let the concrete + // Codex target enforce its effective context limit (including operator overrides) + // instead of rejecting early against a smaller catalog hint. Keep this scoped to + // pools made exclusively from native Codex-capable targets so other Responses + // clients/providers retain the hard preflight. + if ( + options.clientManagedResponsesContext === true && + targets.every( + (target) => target.provider === "codex" || target.provider === "chatgpt-web-codex" + ) + ) { + return null; + } const requirements = deriveRequestCompatibilityRequirements(body); if (requirements.requiredContextTokens <= 0) return null; diff --git a/open-sse/services/combo/nativeCodexTurnPin.ts b/open-sse/services/combo/nativeCodexTurnPin.ts new file mode 100644 index 0000000000..5ffa6bcf70 --- /dev/null +++ b/open-sse/services/combo/nativeCodexTurnPin.ts @@ -0,0 +1,124 @@ +import { createHash } from "node:crypto"; + +import type { ResolvedComboTarget } from "./types.ts"; + +type NativeTurnPin = { + comboName: string; + modelStr: string; + provider: string; + connectionId: string; + createdAt: number; + expiresAt: number; +}; + +const TTL_MS = 45 * 60_000; +const MAX_PINS = 1_000; +const pins = new Map(); + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function turnMetadata(body: Record): Record | undefined { + const metadata = record(body.client_metadata); + const raw = metadata?.["x-codex-turn-metadata"]; + if (typeof raw === "string") { + try { + return record(JSON.parse(raw)); + } catch { + return undefined; + } + } + return record(raw); +} + +export function nativeCodexTurnKey( + body: Record, + comboName: string +): string | null { + const metadata = turnMetadata(body); + const threadId = typeof metadata?.thread_id === "string" ? metadata.thread_id : ""; + const turnId = typeof metadata?.turn_id === "string" ? metadata.turn_id : ""; + if (!threadId || !turnId) return null; + return createHash("sha256").update(JSON.stringify({ comboName, threadId, turnId })).digest("hex"); +} + +function prune(now = Date.now()): void { + for (const [key, pin] of pins) if (pin.expiresAt <= now) pins.delete(key); + while (pins.size > MAX_PINS) { + const oldest = pins.keys().next().value as string | undefined; + if (!oldest) break; + pins.delete(oldest); + } +} + +export function getNativeCodexTurnPin( + body: Record, + comboName: string +): NativeTurnPin | null { + prune(); + const key = nativeCodexTurnKey(body, comboName); + return key ? (pins.get(key) ?? null) : null; +} + +export function pinNativeCodexTurn(args: { + body: Record; + comboName: string; + target: ResolvedComboTarget; + connectionId: string; +}): void { + const key = nativeCodexTurnKey(args.body, args.comboName); + if (!key || !args.connectionId) return; + const existing = pins.get(key); + if ( + existing && + (existing.modelStr !== args.target.modelStr || + existing.provider !== args.target.provider || + existing.connectionId !== args.connectionId) + ) { + throw new Error("Native Codex turn target changed after output was emitted"); + } + const now = Date.now(); + pins.set(key, { + comboName: args.comboName, + modelStr: args.target.modelStr, + provider: args.target.provider, + connectionId: args.connectionId, + createdAt: existing?.createdAt ?? now, + expiresAt: now + TTL_MS, + }); + prune(now); +} + +export function applyNativeCodexTurnPin( + targets: ResolvedComboTarget[], + pin: NativeTurnPin +): ResolvedComboTarget[] { + const target = targets.find( + (candidate) => candidate.modelStr === pin.modelStr && candidate.provider === pin.provider + ); + if (!target) return []; + return [ + { + ...target, + connectionId: pin.connectionId, + allowedConnectionIds: [pin.connectionId], + }, + ]; +} + +export function revokeNativeCodexTurnPinsForConnection(connectionId: string): number { + let revoked = 0; + for (const [key, pin] of pins) { + if (pin.connectionId !== connectionId) continue; + pins.delete(key); + revoked += 1; + } + return revoked; +} + +export function clearNativeCodexTurnPinsForTests(): void { + pins.clear(); +} diff --git a/open-sse/services/combo/promptCacheAffinity.ts b/open-sse/services/combo/promptCacheAffinity.ts index 070e50f84e..f7675f0675 100644 --- a/open-sse/services/combo/promptCacheAffinity.ts +++ b/open-sse/services/combo/promptCacheAffinity.ts @@ -6,10 +6,12 @@ import { import { getCachedProviderConnections } from "../../../src/lib/db/readCache"; import { parseModel } from "../model.ts"; import type { ResolvedComboTarget } from "./types.ts"; +import { getOAuthSessionAvailability } from "../oauthSessionOccupancy.ts"; interface PromptCacheAffinityTarget { executionKey: string; connectionId?: string | null; + authType?: string | null; } export type PromptCacheAffinitySource = "explicit" | "prefix"; @@ -126,6 +128,23 @@ function rendezvousScore(key: string, identity: string): bigint { return BigInt(`0x${digest.slice(0, 32)}`); } +const MAX_RENDEZVOUS_HIGH_BITS = (1n << 64n) - 1n; + +function normalizedRendezvousScore(key: string, identity: string): number { + return Number(rendezvousScore(key, identity) >> 64n) / Number(MAX_RENDEZVOUS_HIGH_BITS); +} + +function combinedAffinityScore( + key: string, + target: PromptCacheAffinityTarget, + sessionKey?: string | null +): number { + const cacheScore = normalizedRendezvousScore(key, promptCacheTargetIdentity(target)); + const availability = + target.authType === "oauth" ? getOAuthSessionAvailability(target.connectionId, sessionKey) : 1; + return cacheScore * 0.75 + availability * 0.25; +} + /** * Return a normalized cache-locality score for auto-combo scoring. The target * selected by rendezvous hashing receives 1; all other accounts receive 0. @@ -133,15 +152,16 @@ function rendezvousScore(key: string, identity: string): bigint { */ export function calculatePromptCacheAffinityScores( targets: PromptCacheAffinityTarget[], - body: Record | null | undefined + body: Record | null | undefined, + sessionKey?: string | null ): Map { const resolution = resolvePromptCacheAffinityKey(body); if (!resolution || targets.length === 0) return new Map(); let winnerIdentity = ""; - let winnerScore = -1n; + let winnerScore = -1; for (const target of targets) { const identity = promptCacheTargetIdentity(target); - const score = rendezvousScore(resolution.key, identity); + const score = combinedAffinityScore(resolution.key, target, sessionKey); if (score > winnerScore || (score === winnerScore && identity < winnerIdentity)) { winnerIdentity = identity; winnerScore = score; @@ -166,15 +186,13 @@ export async function expandPromptCacheAffinityTargets( ): Promise { const providers = Array.from( new Set( - targets - .filter((target) => !target.connectionId) - .map( - (target) => - target.provider || - parseModel(target.modelStr).provider || - parseModel(target.modelStr).providerAlias || - "unknown" - ) + targets.map( + (target) => + target.provider || + parseModel(target.modelStr).provider || + parseModel(target.modelStr).providerAlias || + "unknown" + ) ) ); const connectionsByProvider = new Map>>(); @@ -201,7 +219,18 @@ export function expandPromptCacheAffinityTargetsFromConnections( const expandedTargets: ResolvedComboTarget[] = []; for (const target of targets) { if (target.connectionId) { - expandedTargets.push(target); + const provider = + target.provider || + parseModel(target.modelStr).provider || + parseModel(target.modelStr).providerAlias || + "unknown"; + const connection = (connectionsByProvider.get(provider) || []).find( + (candidate) => candidate?.id === target.connectionId + ); + expandedTargets.push({ + ...target, + authType: typeof connection?.authType === "string" ? connection.authType : target.authType, + }); continue; } const parsed = parseModel(target.modelStr); @@ -227,9 +256,13 @@ export function expandPromptCacheAffinityTargetsFromConnections( continue; } for (const connectionId of scopedConnectionIds) { + const connection = (connectionsByProvider.get(provider) || []).find( + (candidate) => candidate?.id === connectionId + ); expandedTargets.push({ ...target, connectionId, + authType: typeof connection?.authType === "string" ? connection.authType : null, executionKey: `${target.executionKey}@${connectionId}`, }); } @@ -266,14 +299,33 @@ export function shouldProtectOriginalFirst( } /** - * Order eligible targets using rendezvous hashing. The original order is used - * as the final tie-breaker, so targets sharing one account identity remain - * stable without using modelStr as the affinity identity. + * Extract the base model identity from a target's executionKey or modelStr. + * This strips any per-connection suffix (@connectionId) to identify the model itself. + */ +function getBaseModelIdentity(target: ResolvedComboTarget): string { + // executionKey format: "stepId@connectionId" when expanded, or just "stepId" + const executionKey = target.executionKey || ""; + const baseExecutionKey = executionKey.split("@")[0]; + + // modelStr format: "provider/model" or "provider/model:version" + const modelStr = target.modelStr || ""; + + // Use executionKey as primary (preserves stepId grouping), fall back to modelStr + return baseExecutionKey || modelStr; +} + +/** + * Order eligible targets using rendezvous hashing. + * @param scope - "model": sort only within same-model groups, preserving inter-model order; + * "global": sort across all targets (original behavior). + * Defaults to "global" for backward compatibility. */ export function applyPromptCacheAffinity( targets: ResolvedComboTarget[], body: Record | null | undefined, - enabled: boolean = true + enabled: boolean = true, + scope: "model" | "global" = "global", + sessionKey?: string | null ): PromptCacheAffinityResult { const resolution = enabled ? resolvePromptCacheAffinityKey(body) : null; if (!resolution || targets.length <= 1) { @@ -289,20 +341,62 @@ export function applyPromptCacheAffinity( target, index, identity: promptCacheTargetIdentity(target), - score: rendezvousScore(resolution.key, promptCacheTargetIdentity(target)), + score: combinedAffinityScore(resolution.key, target, sessionKey), + baseModel: scope === "model" ? getBaseModelIdentity(target) : null, })); - ranked.sort((a, b) => { - if (a.score > b.score) return -1; - if (a.score < b.score) return 1; - const identityOrder = a.identity.localeCompare(b.identity); - return identityOrder !== 0 ? identityOrder : a.index - b.index; - }); + if (scope === "model") { + // Group by base model identity, preserving original group order + const groups = new Map(); + const groupOrder: string[] = []; - return { - targets: ranked.map((entry) => entry.target), - applied: true, - source: resolution.source, - fingerprint: resolution.fingerprint, - }; + for (const entry of ranked) { + // baseModel is guaranteed non-null when scope === "model" (see map above) + const baseModel = entry.baseModel as string; + if (!groups.has(baseModel)) { + groups.set(baseModel, []); + groupOrder.push(baseModel); + } + groups.get(baseModel)!.push(entry); + } + + // Sort within each group by score, then identity, then original index + const sortedGroups = groupOrder.map((baseModel) => { + const group = groups.get(baseModel)!; + return group.sort((a, b) => { + if (a.score > b.score) return -1; + if (a.score < b.score) return 1; + const identityOrder = a.identity.localeCompare(b.identity); + return identityOrder !== 0 ? identityOrder : a.index - b.index; + }); + }); + + // Flatten groups in original order + const sortedTargets = sortedGroups.flatMap((group) => group.map((entry) => entry.target)); + + // Check if the order actually changed (for applied flag) + const orderChanged = !targets.every((target, i) => target === sortedTargets[i]); + + return { + targets: sortedTargets, + applied: orderChanged, // Only true if the order actually changed + source: resolution.source, + fingerprint: resolution.fingerprint, + }; + } else { + // Original global sorting behavior + ranked.sort((a, b) => { + if (a.score > b.score) return -1; + if (a.score < b.score) return 1; + const identityOrder = a.identity.localeCompare(b.identity); + return identityOrder !== 0 ? identityOrder : a.index - b.index; + }); + + return { + targets: ranked.map((entry) => entry.target), + applied: true, + source: resolution.source, + fingerprint: resolution.fingerprint, + }; + } } diff --git a/open-sse/services/combo/providerWildcard.ts b/open-sse/services/combo/providerWildcard.ts index d27c168cd5..55763815c8 100644 --- a/open-sse/services/combo/providerWildcard.ts +++ b/open-sse/services/combo/providerWildcard.ts @@ -32,6 +32,26 @@ import { wildcardMatch } from "../wildcardRouter.ts"; import { getProviderModels } from "../../config/providerModels.ts"; import { getActiveSyncedCatalog } from "../../../src/lib/db/models/activeSyncedCatalog.ts"; +import { filterAlibabaFreeTierModels, isAlibabaModelStudioProvider } from "../alibabaFreeTier.ts"; +import { + filterAlibabaFreeEligibleModels, + buildAlibabaFreeTierFilterContext, +} from "../alibabaFreeTierDiscovery.ts"; +import { + buildAlibabaFreeAudioFilterContext, + buildAlibabaFreeMultimodalFilterContext, + buildAlibabaFreeVisionFilterContext, + filterAlibabaFreeAudioEligibleModels, + filterAlibabaFreeMultimodalEligibleModels, + filterAlibabaFreeVisionEligibleModels, +} from "../alibabaFreeTierQuotaFetcher.ts"; +import type { AlibabaConnectionLike } from "../alibabaFreeTierQuotaFetcher.ts"; +import { + isAlibabaFreeTierAudioComboName, + isAlibabaFreeTierMultimodalComboName, + isAlibabaFreeTierTextComboName, + isAlibabaFreeTierVisionComboName, +} from "../dashscopeTextModels.ts"; import type { ComboLike } from "./types.ts"; /** Sentinel pattern used for "all models of a provider". */ @@ -130,6 +150,61 @@ async function collectProviderModelIds(providerId: string): Promise { return getProviderModels(providerId).map((model) => model.id); } +async function filterAlibabaFreeDrainedModelIds( + providerId: string, + modelIds: string[], + connectionId: string | null, + comboName: string +): Promise { + if (!isAlibabaModelStudioProvider(providerId) || !connectionId) { + return modelIds; + } + try { + const { getProviderConnections } = await import("../../../src/lib/db/providers.ts"); + const connections = await getProviderConnections({ provider: providerId }); + const connection = connections.find((entry) => entry.id === connectionId); + if (!connection) return modelIds; + + if (isAlibabaFreeTierVisionComboName(comboName)) { + return filterAlibabaFreeVisionEligibleModels( + modelIds, + buildAlibabaFreeVisionFilterContext( + connections as unknown as readonly AlibabaConnectionLike[], + connectionId + ) + ); + } + if (isAlibabaFreeTierMultimodalComboName(comboName)) { + return filterAlibabaFreeMultimodalEligibleModels( + modelIds, + buildAlibabaFreeMultimodalFilterContext( + connections as unknown as readonly AlibabaConnectionLike[], + connectionId + ) + ); + } + if (isAlibabaFreeTierAudioComboName(comboName)) { + return filterAlibabaFreeAudioEligibleModels( + modelIds, + buildAlibabaFreeAudioFilterContext( + connections as unknown as readonly AlibabaConnectionLike[], + connectionId + ) + ); + } + return filterAlibabaFreeEligibleModels( + modelIds, + buildAlibabaFreeTierFilterContext( + connections as unknown as readonly AlibabaConnectionLike[], + connectionId + ), + { strictAllowlist: isAlibabaFreeTierTextComboName(comboName) } + ); + } catch { + return modelIds; + } +} + /** * Expand a single provider-wildcard spec into concrete model entry objects * that `normalizeComboStep` can process as normal model steps. @@ -141,9 +216,16 @@ async function expandWildcardSpec( spec: ProviderWildcardSpec, comboName: string ): Promise { - const modelIds = await collectProviderModelIds(spec.providerId); + let modelIds = await collectProviderModelIds(spec.providerId); if (modelIds.length === 0) return null; + modelIds = await filterAlibabaFreeDrainedModelIds( + spec.providerId, + modelIds, + spec.connectionId, + comboName + ); + const pattern = spec.modelPattern; const matchingIds = pattern === PROVIDER_WILDCARD_SENTINEL diff --git a/open-sse/services/combo/quotaExhaustion.ts b/open-sse/services/combo/quotaExhaustion.ts new file mode 100644 index 0000000000..50e2d71863 --- /dev/null +++ b/open-sse/services/combo/quotaExhaustion.ts @@ -0,0 +1,107 @@ +import { checkFallbackError, type ProviderProfile } from "../accountFallback.ts"; +import { classifyGeminiQuotaMetricFromText } from "../geminiRateLimitTracker.ts"; + +const TERMINAL_QUOTA_CODES = new Set([ + "billing_hard_limit_reached", + "credits_exhausted", + "insufficient_quota", + "quota_exhausted", +]); + +const trustedClassifications = new WeakMap(); + +type ParsedError = { + text: string; + structuredError: { code?: string; type?: string } | null; +}; + +async function parseError(response: Response): Promise { + let text = response.statusText; + let structuredError: ParsedError["structuredError"] = null; + try { + const body = (await response.clone().json()) as { + error?: string | { message?: unknown; code?: unknown; type?: unknown }; + message?: unknown; + }; + if (typeof body.error === "string") text = body.error; + else if (body.error && typeof body.error === "object") { + if (typeof body.error.message === "string") text = body.error.message; + structuredError = { + ...(body.error.code == null ? {} : { code: String(body.error.code) }), + ...(body.error.type == null ? {} : { type: String(body.error.type) }), + }; + } else if (typeof body.message === "string") text = body.message; + } catch { + try { + text = await response.clone().text(); + } catch { + // The status and trusted in-process classification remain available. + } + } + return { text, structuredError }; +} + +export function recordQuotaExhaustionClassification(response: Response, exhausted: boolean): void { + trustedClassifications.set(response, exhausted); +} + +export function withQuotaExhaustionClassification( + response: Response, + exhausted: boolean | null +): Response { + if (exhausted !== null) recordQuotaExhaustionClassification(response, exhausted); + return response; +} + +export async function isQuotaExhaustionResponse( + response: Response, + provider: string | null, + model: string | null, + profile: ProviderProfile | null = null +): Promise { + const trusted = trustedClassifications.get(response); + if (trusted !== undefined) return trusted; + + if (response.status !== 402 && response.status !== 429) return false; + + const { text, structuredError } = await parseError(response); + if (provider === "gemini" && response.status === 429) { + const metric = classifyGeminiQuotaMetricFromText(text); + if (metric === "rpm" || metric === "tpm") return false; + if (metric === "rpd") return true; + } + const normalizedCode = structuredError?.code?.toLowerCase(); + const normalizedType = structuredError?.type?.toLowerCase(); + if ( + (normalizedCode && TERMINAL_QUOTA_CODES.has(normalizedCode)) || + (normalizedType && TERMINAL_QUOTA_CODES.has(normalizedType)) + ) { + return true; + } + + if ( + /\b(?:billing hard limit reached|credits? exhausted|subscription quota exhausted)\b/i.test(text) + ) { + return true; + } + + if ( + provider?.startsWith("openai-compatible-") || + provider?.startsWith("openai-compatible-chat-") + ) { + return false; + } + + return ( + checkFallbackError( + response.status, + text, + 0, + model, + provider, + response.headers, + profile, + structuredError + ).reason === "quota_exhausted" + ); +} diff --git a/open-sse/services/combo/quotaScoring.ts b/open-sse/services/combo/quotaScoring.ts index 8f9dbbca92..4a853b1455 100644 --- a/open-sse/services/combo/quotaScoring.ts +++ b/open-sse/services/combo/quotaScoring.ts @@ -177,46 +177,113 @@ function normalizeWindowPercentUsed(value: unknown): number | null { return clamp01(numericValue); } +type QuotaWindowSnapshot = { percentUsed: number | null; resetAt: string | null }; + +/** + * Pick the first candidate that actually carries a reset instant, falling back + * to the first present candidate. A window can be structurally present but + * carry `resetAt: null` (e.g. Codex's `window7d` placeholder when the upstream + * only reported the primary limit); a plain `a || b` short-circuit would let + * that empty window shadow a sibling that does know when it resets — #9330. + */ +function pickWindowWithResetAt( + ...candidates: Array +): QuotaWindowSnapshot | null { + return candidates.find((candidate) => candidate?.resetAt) ?? candidates.find(Boolean) ?? null; +} + function getNamedQuotaWindow( quota: unknown, windowName: ResetWindowName -): { percentUsed: number | null; resetAt: string | null } | null { +): QuotaWindowSnapshot | null { if (!quota || !isRecord(quota)) return null; if (windowName === "session") return getQuotaWindow(quota, "window5h"); if (windowName === "weekly") { - return getQuotaWindow(quota, "window7d") || getQuotaWindow(quota, "windowWeekly"); + return pickWindowWithResetAt( + getQuotaWindow(quota, "window7d"), + getQuotaWindow(quota, "windowWeekly") + ); } if (windowName === "monthly") return getQuotaWindow(quota, "windowMonthly"); return null; } -function getWindowsMapQuotaWindow( - quota: unknown, - windowName: ResetWindowName -): { percentUsed: number | null; resetAt: string | null } | null { - if (!quota || !isRecord(quota) || !isRecord(quota.windows)) return null; - const candidates = Object.entries(quota.windows) - .map(([key, value]) => ({ key: key.toLowerCase(), value })) - .filter(({ key }) => key === windowName || key.startsWith(`${windowName} `)); - - if (candidates.length === 0) return null; - candidates.sort((a, b) => a.key.localeCompare(b.key)); - const window = candidates[0].value; +function toWindowSnapshot(window: unknown): QuotaWindowSnapshot | null { if (!isRecord(window)) return null; - return { percentUsed: normalizeWindowPercentUsed(window.percentUsed), resetAt: normalizeResetAt(window.resetAt), }; } +/** + * Every entry of the snapshot's `windows` map, name lower-cased. + * + * Deliberately reads `windows` only, never Codex's wider `allWindows`: for a + * Spark request `fetchCodexQuota` narrows `windows` to the Spark scope on + * purpose, and pulling the normal-scope entries back in would rank a request + * against a window it cannot spend. + */ +function getQuotaWindowEntries( + quota: unknown +): Array<{ key: string; window: QuotaWindowSnapshot }> { + if (!quota || !isRecord(quota) || !isRecord(quota.windows)) return []; + const entries: Array<{ key: string; window: QuotaWindowSnapshot }> = []; + for (const [key, value] of Object.entries(quota.windows)) { + const window = toWindowSnapshot(value); + if (window) entries.push({ key: key.toLowerCase(), window }); + } + return entries; +} + +function getWindowsMapQuotaWindow( + quota: unknown, + windowName: ResetWindowName +): QuotaWindowSnapshot | null { + const candidates = getQuotaWindowEntries(quota).filter( + ({ key }) => key === windowName || key.startsWith(`${windowName} `) + ); + + if (candidates.length === 0) return null; + candidates.sort((a, b) => a.key.localeCompare(b.key)); + // Prefer a candidate that knows when it resets (e.g. "weekly" vs a scoped + // "weekly (spark)" placeholder without a resetAt) — #9330. + return pickWindowWithResetAt( + ...candidates.filter(({ window }) => window.resetAt).map(({ window }) => window), + candidates[0].window + ); +} + function resolveQuotaWindowByName( quota: unknown, windowName: ResetWindowName -): { percentUsed: number | null; resetAt: string | null } | null { - return getNamedQuotaWindow(quota, windowName) || getWindowsMapQuotaWindow(quota, windowName); +): QuotaWindowSnapshot | null { + return pickWindowWithResetAt( + getNamedQuotaWindow(quota, windowName), + getWindowsMapQuotaWindow(quota, windowName) + ); +} + +/** + * Earliest reset instant across EVERY window a snapshot exposes, regardless of + * how the provider named it. + * + * Last-resort normalizer for #9330: providers routed through + * `genericQuotaFetcher.convertUsageToQuotaInfo` key their `windows` map by + * MODEL ID (Antigravity: "gemini-3-flash", "claude-sonnet-5", …), so none of + * the canonical "weekly" | "session" | "monthly" lookups match. Without this + * those accounts resolved to `Infinity` ("never resets") and were sorted behind + * a Codex account whose secondary window was 26 days out. + */ +function getEarliestWindowResetMs(quota: unknown): number { + let earliest = Infinity; + for (const { window } of getQuotaWindowEntries(quota)) { + const resetMs = parseResetTimeMs(window.resetAt); + if (Number.isFinite(resetMs)) earliest = Math.min(earliest, resetMs); + } + return earliest; } function getResetUrgency(resetAt: string | null | undefined, windowMs: number): number { @@ -276,6 +343,23 @@ export function scoreResetAwareQuota( return { score }; } +/** + * Absolute epoch-ms instant at which the configured quota window next resets, + * or `Infinity` when the snapshot exposes no parseable reset (which sorts the + * target last under the `reset-window` strategy). + * + * Resolution order — each step only runs when the previous one found nothing: + * 1. the configured windows, by canonical name (structural `window5h` / + * `window7d` / `windowWeekly` / `windowMonthly` fields, then a `windows` + * map keyed by "weekly" | "session" | "monthly"); + * 2. the earliest reset across every entry of the `windows` map, whatever the + * provider named them (Antigravity keys its map by model id — #9330); + * 3. the single-signal top-level `quota.resetAt`. + * + * Step 2 sits ahead of step 3 deliberately: `quota.resetAt` is populated from + * the most-USED window, which is not necessarily the one resetting soonest, and + * is left null entirely while every window is still at 0% used. + */ export function getResetWindowTimestampMs(quota: unknown, windows: ResetWindowName[]): number { if (!quota || !isRecord(quota) || quota.limitReached === true) return Infinity; @@ -288,6 +372,10 @@ export function getResetWindowTimestampMs(quota: unknown, windows: ResetWindowNa } } + if (!Number.isFinite(selectedResetMs)) { + selectedResetMs = getEarliestWindowResetMs(quota); + } + if (!Number.isFinite(selectedResetMs)) { selectedResetMs = parseResetTimeMs(normalizeResetAt(quota.resetAt)); } @@ -295,6 +383,26 @@ export function getResetWindowTimestampMs(quota: unknown, windows: ResetWindowNa return Number.isFinite(selectedResetMs) ? selectedResetMs : Infinity; } +/** + * Milliseconds remaining until the configured window resets — the uniform + * metric the `reset-window` strategy sorts on (ascending: soonest first). + * + * Normalizing to a duration (rather than comparing raw epoch timestamps) keeps + * every provider on one scale and collapses already-elapsed resets to 0, so a + * snapshot that is stale by three days ties with one that reset a second ago + * instead of jumping the queue by virtue of being older. `Infinity` means "no + * known reset" and sorts last. + */ +export function getResetWindowRemainingMs( + quota: unknown, + windows: ResetWindowName[], + now: number = Date.now() +): number { + const resetMs = getResetWindowTimestampMs(quota, windows); + if (!Number.isFinite(resetMs)) return Infinity; + return Math.max(0, resetMs - now); +} + function getResetWindowHorizonMs(windows: ResetWindowName[]): number { if (windows.includes("monthly")) return 30 * 24 * 60 * 60 * 1000; if (windows.includes("weekly")) return RESET_AWARE_WEEKLY_WINDOW_MS; diff --git a/open-sse/services/combo/quotaStrategies.ts b/open-sse/services/combo/quotaStrategies.ts index cff82c1369..ad47e5d2df 100644 --- a/open-sse/services/combo/quotaStrategies.ts +++ b/open-sse/services/combo/quotaStrategies.ts @@ -41,7 +41,7 @@ import { resolveResetWindowConfig, getResetAwareProvider, scoreResetAwareQuota, - getResetWindowTimestampMs, + getResetWindowRemainingMs, type QuotaFetchCacheConfig, } from "./quotaScoring.ts"; import { rankByHeadroom, type HeadroomSaturation } from "./headroomRanking.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) && @@ -536,27 +534,35 @@ export async function orderTargetsByResetWindow( apiKeyAllowedConnectionIds ); + // One `now` snapshot for the whole ranking: quota fetches run concurrently and + // can take seconds, so re-reading the clock per target would compare remaining + // times measured against different instants (#9330). + const now = Date.now(); const scoredTargets = await scoreQuotaAwareTargets({ comboName, config, connectionById, expandedTargets, log, - scoreQuota: (quota) => ({ resetMs: getResetWindowTimestampMs(quota, config.windows) }), + scoreQuota: (quota) => ({ + remainingMs: getResetWindowRemainingMs(quota, config.windows, now), + }), }); + // Ascending: the account whose quota resets SOONEST goes first. Targets with + // no known reset (Infinity) fall to the back, ordered by combo priority. scoredTargets.sort((a, b) => { - if (a.resetMs !== b.resetMs) return a.resetMs - b.resetMs; + if (a.remainingMs !== b.remainingMs) return a.remainingMs - b.remainingMs; return a.index - b.index; }); - const bestResetMs = scoredTargets[0]?.resetMs ?? Infinity; - if (!Number.isFinite(bestResetMs) || config.tieBandMs <= 0) { + const bestRemainingMs = scoredTargets[0]?.remainingMs ?? Infinity; + if (!Number.isFinite(bestRemainingMs) || config.tieBandMs <= 0) { return scoredTargets.map((entry) => entry.target); } const tiedTargets = scoredTargets.filter( - (entry) => entry.resetMs - bestResetMs <= config.tieBandMs + (entry) => entry.remainingMs - bestRemainingMs <= config.tieBandMs ); if (tiedTargets.length <= 1) return scoredTargets.map((entry) => entry.target); diff --git a/open-sse/services/combo/resolveAutoStrategy.ts b/open-sse/services/combo/resolveAutoStrategy.ts index 6e0ecad032..1d6d2bf156 100644 --- a/open-sse/services/combo/resolveAutoStrategy.ts +++ b/open-sse/services/combo/resolveAutoStrategy.ts @@ -1,4 +1,8 @@ -import { errorResponse, unavailableResponse, errorResponseWithComboDiagnostics } from "../../utils/error.ts"; +import { + errorResponse, + unavailableResponse, + errorResponseWithComboDiagnostics, +} from "../../utils/error.ts"; import { BudgetExceededError, selectProvider as selectAutoProvider } from "../autoCombo/engine.ts"; import { resolveRequestModePack, @@ -118,8 +122,7 @@ export async function resolveAutoStrategyOrder( // registry/capability rows honestly report toolCalling:false. const filtered = eligibleTargets.filter( (target) => - supportsToolCalling(target.modelStr) || - providerSupportsEmulatedToolCalling(target.provider) + supportsToolCalling(target.modelStr) || providerSupportsEmulatedToolCalling(target.provider) ); if (filtered.length > 0) { eligibleTargets = filtered; @@ -287,7 +290,11 @@ export async function resolveAutoStrategyOrder( resetWindowConfig, autoCandidateResilienceSettings ); - const cacheAffinityScores = calculatePromptCacheAffinityScores(candidates, body); + const cacheAffinityScores = calculatePromptCacheAffinityScores( + candidates, + body, + relayOptions?.sessionId + ); for (const candidate of candidates) { candidate.cacheAffinity = cacheAffinityScores.get(promptCacheTargetIdentity(candidate)) ?? 0; } diff --git a/open-sse/services/combo/runtimeUnits.ts b/open-sse/services/combo/runtimeUnits.ts index 90ad1c2cac..453ce38116 100644 --- a/open-sse/services/combo/runtimeUnits.ts +++ b/open-sse/services/combo/runtimeUnits.ts @@ -9,6 +9,7 @@ import { errorResponse } from "../../utils/error.ts"; import { recordComboRequest } from "../comboMetrics.ts"; import { resolveDelayMs } from "./comboPredicates.ts"; import { isRuntimeUnitAtConcurrencyCap } from "./runtimeUnitCapacity.ts"; +import { isQuotaExhaustionResponse, withQuotaExhaustionClassification } from "./quotaExhaustion.ts"; import { validateResponseQuality, releaseQualityClone } from "./validateQuality.ts"; import type { ResponseValidationConfig } from "./responseValidation.ts"; import type { @@ -197,8 +198,28 @@ export async function executeRuntimeUnitCombo(args: { const effectiveStrategy = args.effectiveComboStrategy ?? args.strategy; let lastResponse: Response | null = null; let fallbackCount = 0; + let observedFailure = false; + let allObservedFailuresQuota = true; + const targetFailureTrust = new Map< + string, + { observedFailure: boolean; allObservedFailuresQuota: boolean } + >(); + const observeFailure = async (response: Response, unit: ResolvedComboUnit): Promise => { + const quotaExhausted = await isQuotaExhaustionResponse( + response, + unit.kind === "model" ? unit.provider : null, + unit.kind === "model" ? unit.modelStr : null + ); + observedFailure = true; + allObservedFailuresQuota &&= quotaExhausted; + return quotaExhausted; + }; + const finalFailure = (response: Response): Response => + withQuotaExhaustionClassification(response, observedFailure ? allObservedFailuresQuota : null); for (const unit of orderedUnits) { + const protectedPriorityUnit = + effectiveStrategy === "priority" && unit.fallbackOnlyOnQuotaExhaustion === true; if ( await isRuntimeUnitAtConcurrencyCap( unit, @@ -211,16 +232,24 @@ export async function executeRuntimeUnitCombo(args: { "COMBO", `Skipping ${unit.kind} ${unitDisplayName(unit)} — concurrency cap reached` ); + lastResponse = errorResponse(503, `${unitDisplayName(unit)} is at concurrency capacity`); + await observeFailure(lastResponse, unit); + if (protectedPriorityUnit) return { response: finalFailure(lastResponse), unit }; fallbackCount += 1; continue; } for (let retry = 0; retry <= maxRetries; retry += 1) { - if (args.signal?.aborted) - return { response: errorResponse(499, "Client disconnected"), unit }; + if (args.signal?.aborted) { + lastResponse = errorResponse(499, "Client disconnected"); + await observeFailure(lastResponse, unit); + return { response: finalFailure(lastResponse), unit }; + } args.nesting.attemptBudget.count += 1; if (args.nesting.attemptBudget.count > args.nesting.attemptBudget.limit) { - return { response: errorResponse(503, "Maximum combo retry limit reached"), unit }; + lastResponse = errorResponse(503, "Maximum combo retry limit reached"); + await observeFailure(lastResponse, unit); + return { response: finalFailure(lastResponse), unit }; } if (retry > 0) { await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); @@ -276,9 +305,31 @@ export async function executeRuntimeUnitCombo(args: { }); return { response, unit }; } + lastResponse = errorResponse(502, "Upstream response failed quality validation"); + } + if (lastResponse) { + const quotaExhausted = await observeFailure(lastResponse, unit); + if (protectedPriorityUnit) { + const trust = targetFailureTrust.get(unit.executionKey) ?? { + observedFailure: false, + allObservedFailuresQuota: true, + }; + trust.observedFailure = true; + trust.allObservedFailuresQuota &&= quotaExhausted; + targetFailureTrust.set(unit.executionKey, trust); + } } if (![408, 429, 500, 502, 503, 504].includes(response.status)) break; } + const protectedTargetTrust = targetFailureTrust.get(unit.executionKey); + if ( + protectedPriorityUnit && + protectedTargetTrust?.observedFailure && + !protectedTargetTrust.allObservedFailuresQuota && + lastResponse + ) { + return { response: finalFailure(lastResponse), unit }; + } fallbackCount += 1; } recordComboRequest(args.combo.name, null, { @@ -288,7 +339,9 @@ export async function executeRuntimeUnitCombo(args: { strategy: effectiveStrategy, }); return { - response: lastResponse || errorResponse(503, "All nested combo units unavailable"), + response: finalFailure( + lastResponse || errorResponse(503, "All nested combo units unavailable") + ), unit: null, }; } diff --git a/open-sse/services/combo/targetExhaustion.ts b/open-sse/services/combo/targetExhaustion.ts index 85cc07dbb7..c4a880e79a 100644 --- a/open-sse/services/combo/targetExhaustion.ts +++ b/open-sse/services/combo/targetExhaustion.ts @@ -20,8 +20,13 @@ import { hasPerModelQuota, isProviderExhaustedReason, } from "../accountFallback.ts"; +import { + isAlibabaFreeQuotaExhaustedError, + isAlibabaModelStudioProvider, +} from "../alibabaFreeTier.ts"; import { RateLimitReason } from "../../config/constants.ts"; import { isProviderCircuitOpenResult, isRequestScopedUpstreamFailure } from "./comboPredicates.ts"; +import { isCloudflareFingerprintRejection } from "../errorClassifier.ts"; import type { ComboLogger, ResolvedComboTarget } from "./types.ts"; // Connection-level failure statuses: the provider connection itself is likely bad (upstream @@ -60,6 +65,7 @@ export type ApplyComboTargetExhaustionOptions = { rawModel: string; isTokenLimitBreach: boolean; allAccountsRateLimited: boolean; + requestScopedFailure: boolean; sets: ComboExhaustionSets; log: ComboLogger; tag: string; @@ -77,12 +83,53 @@ export function applyComboTargetExhaustion( target: ResolvedComboTarget, opts: ApplyComboTargetExhaustionOptions ): boolean { - const { result, sets, log, tag } = opts; + const { result, sets, log, tag, errorText, structuredError } = opts; const provider = target.provider; // #8133/#8137: auth-level failures (401/403) mean that connection's credentials are bad. // Split out to keep applyComboTargetExhaustion under the complexity ceiling. - if (AUTH_LEVEL_ERROR_STATUSES.includes(result.status) && provider && provider !== "unknown") { + // Cloudflare 1010 (a 403 carrying error_code 1010 / browser_signature_banned) is NOT an + // auth failure: the CDN in front of the upstream refused the client's TLS/UA signature, + // and a different client on the same key succeeds. Treating it as auth-level would mark + // every connection in the pool exhausted on the first 1010 and, with a multi-target combo, + // crystallize a misleading ALL_ACCOUNTS_INACTIVE after two such calls — see + // errorClassifier.isCloudflareFingerprintRejection. The signal may arrive via the + // upstream JSON's structuredError.message (nested "error_code":1010 / browser_signature_banned) + // when the raw errorText is generic, so inspect both. A normalized structuredError.code/type + // ("1010" / browser_signature_banned / fingerprint_rejection) is matched directly — it arrives + // without the error_code key that the text regex keys on. The comparison is case-insensitive + // (matching isCloudflareFingerprintRejection's lowercase) and exact: a numeric 10101 + // (port/count/request id) is a different token, never a 1010. + const fingerprintToken = [structuredError?.code, structuredError?.type].some((value) => + ["1010", "browser_signature_banned", "fingerprint_rejection"].includes( + value == null ? "" : String(value).toLowerCase() + ) + ); + // code/type can also carry the signal in a non-normalized form (e.g. a gateway stuffing + // "error_code: 1010" into the code field verbatim), so the shared text matcher sees every + // candidate string — the exact allowlist above is not the only path in. + const fingerprintText = isCloudflareFingerprintRejection( + [structuredError?.message, structuredError?.code, structuredError?.type, errorText] + .filter(Boolean) + .join(" ") + ); + if ( + AUTH_LEVEL_ERROR_STATUSES.includes(result.status) && + // Cloudflare 1010 is a 403-ONLY fingerprint rejection. A 401 that merely happens to + // mention "1010" or "fingerprint_rejection" in a port/count/model token must NOT skip + // auth-level exhaustion — only a 403 carrying the Cloudflare fingerprint signal does. + !(result.status === 403 && (fingerprintToken || fingerprintText)) && + provider && + provider !== "unknown" + ) { + // Alibaba free-tier drain is model-scoped — the connection and sibling models stay eligible. + if ( + result.status === 403 && + isAlibabaModelStudioProvider(provider) && + isAlibabaFreeQuotaExhaustedError(opts.errorText) + ) { + return false; + } markAuthLevelExhaustion(target, { result, sets, log, tag }); return true; } @@ -108,12 +155,25 @@ function isProviderQuotaExhausted( provider: string | null | undefined, opts: Pick< ApplyComboTargetExhaustionOptions, - "rawModel" | "fallbackResult" | "structuredError" | "errorText" | "allAccountsRateLimited" + | "rawModel" + | "fallbackResult" + | "structuredError" + | "errorText" + | "allAccountsRateLimited" + | "requestScopedFailure" > ): boolean { - const { rawModel, fallbackResult, structuredError, errorText, allAccountsRateLimited } = opts; + const { + rawModel, + fallbackResult, + structuredError, + errorText, + allAccountsRateLimited, + requestScopedFailure, + } = opts; return ( Boolean(provider && provider !== "unknown") && + !(requestScopedFailure || isRequestScopedUpstreamFailure(structuredError)) && !hasPerModelQuota(provider as string, rawModel) && (isProviderExhaustedReason(fallbackResult) || classifyErrorText(structuredError?.code || errorText) === RateLimitReason.QUOTA_EXHAUSTED || @@ -143,7 +203,17 @@ function markTransientOrConnectionLevel( target: ResolvedComboTarget, opts: ApplyComboTargetExhaustionOptions ): void { - const { result, errorText, rawModel, isTokenLimitBreach, sets, log, tag, structuredError } = opts; + const { + result, + errorText, + rawModel, + isTokenLimitBreach, + requestScopedFailure, + sets, + log, + tag, + structuredError, + } = opts; const provider = target.provider; if (result.status === 429 && !isTokenLimitBreach && provider && provider !== "unknown") { sets.transientRateLimitedProviders.add(provider); @@ -155,6 +225,7 @@ function markTransientOrConnectionLevel( log, tag, rawModel, + requestScopedFailure, structuredError, }); } @@ -198,16 +269,25 @@ function markConnectionLevelExhaustion( target: ResolvedComboTarget, opts: Pick< ApplyComboTargetExhaustionOptions, - "result" | "errorText" | "sets" | "log" | "tag" | "rawModel" | "structuredError" + | "result" + | "errorText" + | "sets" + | "log" + | "tag" + | "rawModel" + | "requestScopedFailure" + | "structuredError" > ): void { - const { result, errorText, sets, log, tag, rawModel, structuredError } = opts; + const { result, errorText, sets, log, tag, rawModel, requestScopedFailure, structuredError } = + opts; const provider = target.provider; if ( !provider || provider === "unknown" || !CONNECTION_LEVEL_ERROR_STATUSES.includes(result.status) || isProviderCircuitOpenResult(result, errorText) || + requestScopedFailure || isRequestScopedUpstreamFailure(structuredError) || // #5085: empty-content 502 is a healthy connection returning no body — model-level, not // connection-level. Don't exhaust the provider; let the remaining legs (incl. same-provider) diff --git a/open-sse/services/combo/targetResolution.ts b/open-sse/services/combo/targetResolution.ts index e6b4771239..6c4c162a97 100644 --- a/open-sse/services/combo/targetResolution.ts +++ b/open-sse/services/combo/targetResolution.ts @@ -113,6 +113,8 @@ export interface ResolveComboTargetPipelineDeps { */ buildAutoCandidates: ResolveAutoStrategyDeps["buildAutoCandidates"]; hiddenModelsByProvider?: HiddenModelsByProvider; + /** Native Responses clients (for example Codex CLI/Desktop) manage compaction themselves. */ + clientManagedResponsesContext?: boolean; } export interface ResolvedComboTargetPipeline { @@ -161,6 +163,12 @@ async function isTargetSelectableForWeighted( ) { return false; } + if (target.provider && rawModel && target.connectionId) { + const { isAlibabaFreeTierModelRoutable } = await import("../alibabaFreeTier.ts"); + if (!(await isAlibabaFreeTierModelRoutable(target.provider, target.connectionId, rawModel))) { + return false; + } + } return isModelAvailable ? await isModelAvailable(target.modelStr, target) : true; } @@ -446,6 +454,7 @@ async function orderByStrategy( body, log, apiKeyAllowedConnections: deps.apiKeyAllowedConnections, + sessionKey: deps.relayOptions?.sessionId, }); return { orderedTargets, autoUsedExplicitRouter: false }; } @@ -658,10 +667,25 @@ async function applyPromptCacheStage( promptCacheAffinityEnabled && resolvePromptCacheAffinityKey(body) ? await expandPromptCacheAffinityTargets(orderedTargets) : orderedTargets; + + // Determine affinity scope: restrict to model-level for deterministic strategies + // to preserve operator-defined model order; keep global for cross-model + // strategies. Per #8370, lkgp/auto/cache-optimized explicitly support promoting + // a previously-successful model ahead of the declared order, so they must stay + // cross-model ("global") rather than be locked into a single model step. + const modelOrderPreservingStrategies = new Set([ + "priority", + "weighted", + "fill-first", + "quota-share", + ]); + const isDeterministicStrategy = modelOrderPreservingStrategies.has(strategy); const promptCacheAffinity = applyPromptCacheAffinity( promptCacheAffinityTargets, body, - promptCacheAffinityEnabled + promptCacheAffinityEnabled, + isDeterministicStrategy ? "model" : "global", + deps.relayOptions?.sessionId ); if (!promptCacheAffinity.applied) return orderedTargets; const protectedOriginal = @@ -703,7 +727,9 @@ export async function resolveComboTargetPipeline( orderedTargets = await applyRequestTagRouting(orderedTargets, body, log); - const overflow = getKnownContextOverflow(orderedTargets, body); + const overflow = getKnownContextOverflow(orderedTargets, body, { + clientManagedResponsesContext: deps.clientManagedResponsesContext, + }); if (overflow) { return { earlyResponse: buildContextOverflowResponse(overflow, orderedTargets, log) }; } diff --git a/open-sse/services/combo/types.ts b/open-sse/services/combo/types.ts index ca00fedbce..9f9f31c4b4 100644 --- a/open-sse/services/combo/types.ts +++ b/open-sse/services/combo/types.ts @@ -110,12 +110,11 @@ export type HandleComboChatOptions = { apiKeyAllowedConnections?: string[] | null; nesting?: ComboNestingContext | null; hiddenModelsByProvider?: HiddenModelsByProvider; + /** Native Responses clients (for example Codex CLI/Desktop) manage compaction themselves. */ + clientManagedResponsesContext?: boolean; }; -export type HandleRoundRobinOptions = Omit< - HandleComboChatOptions, - "relayOptions" | "apiKeyAllowedConnections" ->; +export type HandleRoundRobinOptions = Omit; export type HistoricalLatencyStatsEntry = { totalRequests?: number; @@ -165,6 +164,7 @@ export type ResolvedComboTarget = { executionKey: string; modelStr: string; provider: string; + authType?: string | null; providerId: string | null; connectionId: string | null; allowedConnectionIds?: string[] | null; @@ -172,6 +172,7 @@ export type ResolvedComboTarget = { label: string | null; prompt?: string | null; failoverBeforeRetry?: unknown; + fallbackOnlyOnQuotaExhaustion?: boolean; trafficType?: "production" | "shadow"; /** * Fingerprint-based account pin resolved from a combo builder composite @@ -198,6 +199,7 @@ export type ResolvedComboRefTarget = { comboName: string; weight: number; label: string | null; + fallbackOnlyOnQuotaExhaustion?: boolean; }; export type ResolvedComboUnit = ResolvedComboTarget | ResolvedComboRefTarget; diff --git a/open-sse/services/combo/validateQuality.ts b/open-sse/services/combo/validateQuality.ts index 27f8e029f6..79f742b2c2 100644 --- a/open-sse/services/combo/validateQuality.ts +++ b/open-sse/services/combo/validateQuality.ts @@ -190,10 +190,26 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +/** + * Whether an `error` field carries a real failure signal. A key-presence check + * (`!= null`) false-positives on benign values some backends emit on every + * chunk (`{}`, `""`, `false`, `0`) — e.g. tool-call turns where a chunk with + * real tool_calls content also carries `"error": {}`. Only substantive values + * are treated as upstream failures. + */ +function isSubstantiveError(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim().length > 0; + if (typeof value === "object" && !Array.isArray(value)) { + return Object.keys(value as Record).length > 0; + } + return value === true; +} + function isStreamingUpstreamError(parsed: unknown, eventType: string): boolean { if (eventType === "response.failed" || eventType === "error") return true; if (!isRecord(parsed)) return false; - if (parsed.error != null) return true; + if (isSubstantiveError(parsed.error)) return true; const nestedResponse = isRecord(parsed.response) ? parsed.response : null; return nestedResponse?.status === "failed" && nestedResponse.error != null; diff --git a/open-sse/services/comboAgentMiddleware.ts b/open-sse/services/comboAgentMiddleware.ts index f9b2419020..7d968e7835 100644 --- a/open-sse/services/comboAgentMiddleware.ts +++ b/open-sse/services/comboAgentMiddleware.ts @@ -19,6 +19,8 @@ * All features are opt-in per combo and backward compatible with existing setups. */ +import { isFingerprintProvider } from "./combo/fingerprintExpansion.ts"; + interface ComboConfig { system_message?: string | null; tool_filter_regex?: string | null; @@ -221,3 +223,122 @@ export function applyComboAgentMiddleware( pinnedModel, }; } + +// ── System Prompt Template Expansion (#5501) ───────────────────────────────── + +export interface ComboSystemPromptTemplateContext { + modelId: string; + providerId: string; + account: string; + fingerprint: string; +} + +/** + * Replace allowlisted `{{TOKEN}}` placeholders in a single left-to-right scan. + * No regex (ReDoS-averse, cf. #3870) and no recursion: an expanded value is + * appended to the output and never re-scanned. Unknown tokens ({{FOO}}) and + * dangling "{{" stay literal. + */ +function expandStringTemplates(value: string, values: Record): string { + let out = ""; + let rest = value; + while (rest.length > 0) { + const start = rest.indexOf("{{"); + if (start === -1) { + out += rest; + break; + } + const end = rest.indexOf("}}", start + 2); + if (end === -1) { + out += rest; + break; + } + const token = rest.slice(start, end + 2); + out += rest.slice(0, start); + out += token in values ? values[token] : token; + rest = rest.slice(end + 2); + } + return out; +} + +/** + * Expand allowlisted placeholders in the combo-injected system prompt (#5501). + * + * Strictly scoped to the content the combo override produced — never + * client-owned system content: + * - Responses API body (has `instructions`) → expand `body.instructions`. + * - messages body → expand `body.messages[0]` when it is the injected combo + * system message (the override filters all system messages and injects its + * own at index 0 with string content). + * - otherwise → body unchanged. + */ +export function expandComboSystemPromptTemplates( + body: Record, + ctx: ComboSystemPromptTemplateContext +): Record { + const values: Record = { + "{{MODEL_ID}}": ctx.modelId, + "{{PROVIDER_ID}}": ctx.providerId, + "{{ACCOUNT}}": ctx.account, + "{{FINGERPRINT}}": ctx.fingerprint, + }; + const result = { ...body }; + if (typeof result.instructions === "string") { + result.instructions = expandStringTemplates(result.instructions, values); + return result; + } + const messages = result.messages; + if (Array.isArray(messages)) { + const first = messages[0] as Record | undefined; + if ( + first && + (first.role === "system" || first.role === "developer") && + typeof first.content === "string" + ) { + const next = [...messages]; + next[0] = { ...first, content: expandStringTemplates(first.content, values) }; + result.messages = next; + } + } + return result; +} + +/** + * Gate + expand: expand the combo `system_message` template placeholders only + * when the combo actually defines a non-empty `system_message`. Client-owned + * content passes through untouched (single gate shared by every dispatch path). + */ +export function expandComboSystemPromptIfPresent( + body: Record, + combo: { system_message?: string | null }, + ctx: ComboSystemPromptTemplateContext +): Record { + if (typeof combo.system_message === "string" && combo.system_message.trim()) { + return expandComboSystemPromptTemplates(body, ctx); + } + return body; +} + +/** + * Resolve the device fingerprint for a combo target (#5501, #6087). + * Only fingerprint-based providers carry fingerprints (see isFingerprintProvider). + * Priority: explicit pin (`pinnedFingerprint`, combo builder) → the `@fp:` + * suffix in `executionKey` (auto-rotation). + * Returns null when none is knowable (the first fingerprint of an auto-rotated + * set keeps the bare execution key — documented limitation). + */ +export function resolveTargetFingerprint(target: { + provider: string; + pinnedFingerprint?: string; + executionKey?: string; +}): string | null { + if (!isFingerprintProvider(target.provider)) return null; + if (target.pinnedFingerprint) return target.pinnedFingerprint; + const key = target.executionKey; + if (key) { + const marker = "@fp:"; + const idx = key.lastIndexOf(marker); + if (idx !== -1) return key.slice(idx + marker.length); + } + return null; +} diff --git a/open-sse/services/comboManifestMetrics.ts b/open-sse/services/comboManifestMetrics.ts deleted file mode 100644 index e620f1bd35..0000000000 --- a/open-sse/services/comboManifestMetrics.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getLogger } from "log-wrapper"; - -export function recordComboIntentWithSpecificity( - comboName: string, - specificityScore: number, - specificityLevel: string, - strategyModifier: string -): void { - getLogger().info( - { comboName, specificityScore, specificityLevel, strategyModifier }, - "combo manifest routing applied" - ); -} diff --git a/open-sse/services/compression/bodyAdapter.ts b/open-sse/services/compression/bodyAdapter.ts index 467575349f..be5e10ca14 100644 --- a/open-sse/services/compression/bodyAdapter.ts +++ b/open-sse/services/compression/bodyAdapter.ts @@ -373,9 +373,9 @@ export function adaptBodyForCompression( } // Compaction restore (#8560): rebuild input so Layer-3 history drops actually shrink - // Responses payloads. Also drop orphan function_call items whose outputs vanished. + // Responses payloads. Also drop orphan regular/custom call items whose outputs vanished. const nextInput: unknown[] = []; - const survivingCallIds = new Set(); + const survivingOutputKeys = new Set(); inputItems.forEach((item, index) => { if (mappedIndexSet.has(index)) { const compressedMessage = compressedMessagesByIndex.get(index); @@ -392,7 +392,7 @@ export function adaptBodyForCompression( restored.type === "apply_patch_call_output") && typeof restored.call_id === "string" ) { - survivingCallIds.add(restored.call_id); + survivingOutputKeys.add(`${restored.type}:${restored.call_id}`); } return; } @@ -422,7 +422,9 @@ export function adaptBodyForCompression( ); }); if (!hadMappedOutput) return true; - return survivingCallIds.has(item.call_id); + const outputType = + item.type === "custom_tool_call" ? "custom_tool_call_output" : "function_call_output"; + return survivingOutputKeys.has(`${outputType}:${item.call_id}`); }); const rest = { ...compressedBody }; 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/engines/llmlingua/onnxWorker.ts b/open-sse/services/compression/engines/llmlingua/onnxWorker.ts index 61dfb69018..1552158607 100644 --- a/open-sse/services/compression/engines/llmlingua/onnxWorker.ts +++ b/open-sse/services/compression/engines/llmlingua/onnxWorker.ts @@ -84,7 +84,68 @@ async function getCompressor(entry: LlmlinguaModelEntry, modelPath?: string): Pr logger: () => {}, }); - return promptCompressor; + return { compressor: promptCompressor, oai }; +} + +/** + * Chunk-overflow guard for the BERT position-embedding table. + * + * The library's chunkContext() splits input at `max_seq_length - 2` = 510 + * o200k (tiktoken) tokens, then decodes each chunk to text and re-tokenizes it + * with the model's wordpiece tokenizer for inference. The round-trip can + * EXPAND (510 tiktoken tokens → 516 wordpiece tokens observed), and the + * expanded sequence (plus [CLS]/[SEP]) overruns the model's + * max_position_embeddings=512 → onnxruntime fails with a broadcast error on + * `/bert/embeddings/Add_1` (512 by 516) and the whole call fail-opens. + * + * Fix: never hand the library a single text larger than MAX_SEG_TOKENS + * o200k tokens. The library then emits one chunk per call and the wordpiece + * round-trip stays safely under 512. Sentence-boundary backtracking keeps the + * cuts at natural breaks so compression quality is unaffected. + * + * Empirically measured on the TinyBERT meetingbank model: o200k→wordpiece + * expansion ≈ 1.09x, so cap 450 → max ~494 wordpiece (incl. [CLS]/[SEP]), + * while cap 470 → ~514 and overflows the position-embedding table. + */ +const MAX_SEG_TOKENS = 450; + +async function compressSegmented( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + compressor: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + oai: any, + text: string, + rate: number +): Promise { + const tokens = oai.encode(text); + if (tokens.length <= MAX_SEG_TOKENS) { + return compressor.compress(text, { rate }); + } + + const segments: string[] = []; + const END_TOKENS = new Set([".", "\n", "!", "?", ";"]); + let st = 0; + while (st < tokens.length) { + let ed = Math.min(st + MAX_SEG_TOKENS, tokens.length); + // Backtrack to the last sentence boundary inside the segment (≤ 80 tokens back). + for (let j = 0; j < Math.min(80, ed - st); j++) { + // js-tiktoken/lite exposes only encode/decode — decode a single-token slice. + const tok = oai.decode(tokens.slice(ed - 1 - j, ed - j)); + if (END_TOKENS.has(tok)) { + ed = ed - j; + break; + } + } + if (ed <= st) ed = Math.min(st + MAX_SEG_TOKENS, tokens.length); // no boundary — hard cut + segments.push(oai.decode(tokens.slice(st, ed))); + st = ed; + } + + const out: string[] = []; + for (const seg of segments) { + out.push(await compressor.compress(seg, { rate })); + } + return out.join("\n"); } if (parentPort) { @@ -104,9 +165,9 @@ if (parentPort) { }); } - const compressor = await pending; + const { compressor, oai } = await pending; const rate = typeof msg.compressionRate === "number" ? msg.compressionRate : 0.5; - const out: string = await compressor.compress(text, { rate }); + const out: string = await compressSegmented(compressor, oai, text, rate); parentPort!.postMessage({ id, ok: true, text: out }); } catch { diff --git a/open-sse/services/compression/languageDetector.ts b/open-sse/services/compression/languageDetector.ts index d44295d225..9e1851443c 100644 --- a/open-sse/services/compression/languageDetector.ts +++ b/open-sse/services/compression/languageDetector.ts @@ -7,6 +7,7 @@ const LANGUAGE_HINTS: Record = { es: [/\b(?:necesito|archivo|codigo|código|fallo|gracias|puedes)\b/i], de: [/\b(?:ich|datei|fehler|bitte|kannst|konfiguration|danke)\b/i], fr: [/\b(?:fichier|erreur|merci|peux|besoin)\b/i], + ru: [/\b(?:\u044d\u0442\u043e|\u0447\u0442\u043e|\u043a\u0430\u043a|\u0435\u0441\u043b\u0438|\u0447\u0442\u043e\u0431\u044b|\u043a\u043e\u0442\u043e\u0440\u044b\u0439|\u043c\u043e\u0436\u0435\u0442|\u043d\u0443\u0436\u043d\u043e|\u0435\u0441\u0442\u044c|\u0431\u044b\u043b\u043e|\u0431\u0443\u0434\u0435\u0442|\u043c\u043e\u0436\u043d\u043e|\u0434\u043e\u043b\u0436\u0435\u043d|\u0444\u0430\u0439\u043b|\u043e\u0448\u0438\u0431\u043a\u0430|\u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0430|\u0434\u0430\u043d\u043d\u044b\u0435)\b/i, /[\u0430-\u044f\u0451]/i], ja: [/[\u3040-\u30ff]/], id: [/\b(?:saya|kamu|anda|dengan|untuk|yang|tidak|bisa|terima\s+kasih|dari)\b/i], }; 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/rules/ru/context.json b/open-sse/services/compression/rules/ru/context.json new file mode 100644 index 0000000000..26534688ff --- /dev/null +++ b/open-sse/services/compression/rules/ru/context.json @@ -0,0 +1,38 @@ +{ + "language": "ru", + "category": "context", + "rules": [ + { + "name": "subject_omission", + "pattern": "^(?:Я |Мы |Вы )(?:можем|должны|будем|хотим|нужно)\\b\\s*", + "replacement": "", + "context": "all", + "category": "context", + "minIntensity": "full" + }, + { + "name": "known_fact_hedging", + "pattern": "(?<=\\.)\\s*(?:Возможно|Наверное|Может быть),\\s+", + "replacement": "", + "context": "assistant", + "category": "context", + "minIntensity": "full" + }, + { + "name": "redundant_clarification", + "pattern": "\\b(?:как я уже говорил|как уже упоминалось|как было сказано)\\b[,.!?\\s]*", + "replacement": "", + "context": "all", + "category": "context", + "minIntensity": "full" + }, + { + "name": "obvious_continuation", + "pattern": "\\b(?:далее|затем|после этого|в итоге)\\b[,.!?\\s]*", + "replacement": "", + "context": "all", + "category": "context", + "minIntensity": "ultra" + } + ] +} diff --git a/open-sse/services/compression/rules/ru/dedup.json b/open-sse/services/compression/rules/ru/dedup.json new file mode 100644 index 0000000000..6678979858 --- /dev/null +++ b/open-sse/services/compression/rules/ru/dedup.json @@ -0,0 +1,30 @@ +{ + "language": "ru", + "category": "dedup", + "rules": [ + { + "name": "thought_repetition", + "pattern": "([^.!?]+[.!?])\\s+\\1", + "replacement": "$1", + "context": "all", + "category": "dedup", + "minIntensity": "full" + }, + { + "name": "word_duplication", + "pattern": "\\b(\\w+)\\s+\\1\\b", + "replacement": "$1", + "context": "all", + "category": "dedup", + "minIntensity": "lite" + }, + { + "name": "synonymous_repetition", + "pattern": "\\b(проблема|ошибка)\\b[^.!?]*\\b(проблема|ошибка)\\b", + "replacement": "$1", + "context": "all", + "category": "dedup", + "minIntensity": "full" + } + ] +} diff --git a/open-sse/services/compression/rules/ru/filler.json b/open-sse/services/compression/rules/ru/filler.json new file mode 100644 index 0000000000..aa28ae6153 --- /dev/null +++ b/open-sse/services/compression/rules/ru/filler.json @@ -0,0 +1,86 @@ +{ + "language": "ru", + "category": "filler", + "rules": [ + { + "name": "pleasantries", + "pattern": "\\b(?:конечно|с радостью|рад помочь|могу помочь|обязательно|безусловно|разумеется)\\b[,.!?\\s]*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "polite_framing", + "pattern": "\\b(?:пожалуйста|если хотите|если можно|будьте добры|будьте любезны|прошу вас)\\b[,.!?\\s]*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "verbal_wrapping", + "pattern": "\\b(?:давайте разберём|давайте посмотрим|попробуем разобраться|постараюсь помочь)\\b[,.!?\\s]*", + "replacement": "", + "context": "assistant", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "hedging", + "pattern": "\\b(?:возможно|наверное|может быть|скорее всего|вероятно|видимо|похоже)\\b[,.!?\\s]*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "filler_adverbs", + "pattern": "\\b(?:в целом|на самом деле|в принципе|как правило|по сути|фактически|буквально)\\b[,.!?\\s]*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "empty_qualifiers", + "pattern": "\\b(?:удобный|хороший|эффективный|мощный|отличный|прекрасный)(?!\\s+(?:вариант|способ|решение|метод|инструмент))\\b", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "redundant_openers", + "pattern": "^(?:Привет|Здравствуйте|Добрый день|Доброе утро|Добрый вечер)\\s*[,.!?\\s]?\\s*", + "replacement": "", + "context": "user", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "excessive_gratitude", + "pattern": "\\b(?:Большое спасибо|Огромное спасибо|Спасибо заранее|Заранее благодарю|Очень признателен)\\b[,.!?\\s]*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "softeners", + "pattern": "\\b(?:немного|немножко|чуть-чуть|слегка|несколько|как-то)\\b\\s*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "assistant_fillers", + "pattern": "^(?:Вот|Ниже|Это|Здесь)\\s+(?:есть|находится)?\\s*", + "replacement": "", + "context": "assistant", + "category": "filler", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/ru/structural.json b/open-sse/services/compression/rules/ru/structural.json new file mode 100644 index 0000000000..78bf745f29 --- /dev/null +++ b/open-sse/services/compression/rules/ru/structural.json @@ -0,0 +1,101 @@ +{ + "language": "ru", + "category": "structural", + "rules": [ + { + "name": "problem_phrasing", + "pattern": "\\b(?:проблема заключается в том, что|дело в том, что|суть в том, что)\\b\\s*", + "replacement": "проблема: ", + "context": "all", + "category": "structural", + "minIntensity": "full" + }, + { + "name": "causality_verbose", + "pattern": "\\b(?:это приводит к тому, что|это означает, что|из этого следует, что)\\b\\s*", + "replacement": "→ ", + "context": "all", + "category": "structural", + "minIntensity": "full" + }, + { + "name": "purpose_phrases", + "pattern": "\\b(?:для того чтобы|с целью того чтобы)\\b\\s*", + "replacement": "чтобы ", + "context": "all", + "category": "structural", + "minIntensity": "lite" + }, + { + "name": "causality_phrases", + "pattern": "\\b(?:в связи с тем, что|по причине того, что|ввиду того, что)\\b\\s*", + "replacement": "из-за ", + "context": "all", + "category": "structural", + "minIntensity": "full" + }, + { + "name": "concession_phrases", + "pattern": "\\b(?:несмотря на то, что|хотя и)\\b\\s*", + "replacement": "хотя ", + "context": "all", + "category": "structural", + "minIntensity": "lite" + }, + { + "name": "note_phrases", + "pattern": "\\b(?:стоит отметить, что|следует иметь в виду, что|важно понимать, что|необходимо учитывать, что)\\b\\s*", + "replacement": "", + "context": "all", + "category": "structural", + "minIntensity": "full" + }, + { + "name": "redundant_directive", + "pattern": "\\b(?:важно помнить|не забывайте|помните о том)\\b\\s*", + "replacement": "", + "context": "all", + "category": "structural", + "minIntensity": "full" + }, + { + "name": "approximation", + "pattern": "\\b(?:примерно|приблизительно)\\b\\s*", + "replacement": "≈ ", + "context": "all", + "category": "structural", + "minIntensity": "full" + }, + { + "name": "forbidden_abbreviations_dots", + "pattern": "\\b(?:т\\.к\\.|т\\.е\\.|и т\\.д\\.|и т\\.п\\.|см\\.|напр\\.|и др\\.|в т\\.ч\\.)\\b", + "replacement": "", + "replacementMap": { + "т.к.": "так как", + "т.е.": "то есть", + "и т.д.": "", + "и т.п.": "", + "см.": "см", + "напр.": "например", + "и др.": "", + "в т.ч.": "" + }, + "context": "all", + "category": "structural", + "minIntensity": "lite" + }, + { + "name": "forbidden_abbreviations_dash", + "pattern": "\\b(?:кол-во|к-рый|св-во)\\b", + "replacement": "", + "replacementMap": { + "кол-во": "количество", + "к-рый": "который", + "св-во": "свойство" + }, + "context": "all", + "category": "structural", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/ru/ultra.json b/open-sse/services/compression/rules/ru/ultra.json new file mode 100644 index 0000000000..6c9d3e6f32 --- /dev/null +++ b/open-sse/services/compression/rules/ru/ultra.json @@ -0,0 +1,46 @@ +{ + "language": "ru", + "category": "ultra", + "rules": [ + { + "name": "ultra_compression_conjunctions", + "pattern": "\\b(?:однако|тем не менее|в то время как)\\b\\s*", + "replacement": "—", + "context": "all", + "category": "ultra", + "minIntensity": "ultra" + }, + { + "name": "ultra_compression_articles", + "pattern": "\\b(?:является|представляет собой)\\b\\s*", + "replacement": "—", + "context": "all", + "category": "ultra", + "minIntensity": "ultra" + }, + { + "name": "ultra_compression_verbs", + "pattern": "\\b(?:необходимо|требуется|нужно)\\b\\s*", + "replacement": "", + "context": "all", + "category": "ultra", + "minIntensity": "ultra" + }, + { + "name": "ultra_punctuation", + "pattern": "[,:;]\\s+", + "replacement": " ", + "context": "all", + "category": "ultra", + "minIntensity": "ultra" + }, + { + "name": "ultra_lowercase", + "pattern": "(?<=\\.)\\s+([А-ЯЁ])", + "replacement": " $1", + "context": "all", + "category": "ultra", + "minIntensity": "ultra" + } + ] +} 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..f57b6c10b7 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -215,6 +215,10 @@ export function selectCompressionPlan( ): DerivedPlan { let plan = resolveBasePlan(config, comboId, estimatedTokens, combos, header); + // The master switch is a hard kill. In particular, adaptive context-budget planning must + // never turn compression back on after resolveBasePlan() has selected the disabled plan. + if (!config.enabled) return plan; + // Adaptive context-budget floor/escalation (D-C4): after the base plan, replacing the // (now-bypassed) auto-trigger branch. Pure resolver; chatCore supplies the model limit. if (adaptiveEnabled(config) && config.contextBudget) { @@ -349,6 +353,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/conolAuth.ts b/open-sse/services/conolAuth.ts new file mode 100644 index 0000000000..9e44a5ff96 --- /dev/null +++ b/open-sse/services/conolAuth.ts @@ -0,0 +1,55 @@ +export const CONOL_SESSION_COOKIE_NAME = "__Secure-better-auth.session_token"; + +export interface ConolCredentialInput { + apiKey?: unknown; + accessToken?: unknown; + cookie?: unknown; + providerSpecificData?: unknown; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function readStoredValue(value: unknown): string { + const raw = readString(value); + if (!raw || !raw.startsWith("{")) return raw; + try { + const parsed = JSON.parse(raw) as Record; + return ( + readString(parsed.cookie) || + readString(parsed[CONOL_SESSION_COOKIE_NAME]) || + readString(parsed.sessionToken) + ); + } catch { + return raw; + } +} + +export function normalizeConolCookie(rawValue: string): string { + const raw = readStoredValue(rawValue).replace(/^Cookie:\s*/i, "").trim(); + if (!raw) return ""; + if (raw.includes("=")) return raw; + return `${CONOL_SESSION_COOKIE_NAME}=${raw}`; +} + +export function resolveConolCredentials(credentials?: ConolCredentialInput): { + cookie: string; +} { + const providerData = + credentials?.providerSpecificData && + typeof credentials.providerSpecificData === "object" && + !Array.isArray(credentials.providerSpecificData) + ? (credentials.providerSpecificData as Record) + : {}; + + const raw = + readStoredValue(providerData.cookie) || + readStoredValue(providerData[CONOL_SESSION_COOKIE_NAME]) || + readStoredValue(providerData.sessionToken) || + readStoredValue(credentials?.cookie) || + readStoredValue(credentials?.apiKey) || + readStoredValue(credentials?.accessToken); + + return { cookie: normalizeConolCookie(raw) }; +} diff --git a/open-sse/services/conolBrowserLogin.ts b/open-sse/services/conolBrowserLogin.ts new file mode 100644 index 0000000000..7444591c64 --- /dev/null +++ b/open-sse/services/conolBrowserLogin.ts @@ -0,0 +1,120 @@ +import { CONOL_SESSION_COOKIE_NAME } from "./conolAuth.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +const CONOL_HOME_URL = "https://conol.ai/home"; +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 = 1_000; + +interface BrowserCookieLike { + name: string; + value: string; + domain?: string; +} + +export interface ConolBrowserLoginResult { + success: boolean; + credentials?: { cookie: string }; + error?: string; +} + +type BrowserLauncher = Pick; + +function clampTimeout(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))); +} + +export function extractConolBrowserCredentials( + cookies: BrowserCookieLike[] +): { cookie: string } | null { + const session = cookies.find( + (candidate) => + candidate.name === CONOL_SESSION_COOKIE_NAME && + (!candidate.domain || candidate.domain === "conol.ai" || candidate.domain.endsWith(".conol.ai")) + ); + const value = session?.value?.trim() || ""; + if (!value || /[\r\n;]/.test(value)) return null; + return { cookie: `${CONOL_SESSION_COOKIE_NAME}=${value}` }; +} + +export async function launchConolLoginBrowser( + playwright: BrowserLauncher +): Promise { + const configuredPath = process.env.OMNIROUTE_LOGIN_BROWSER_PATH?.trim(); + const attempts: Array> = [ + ...(configuredPath ? [{ headless: false, executablePath: configuredPath }] : []), + { headless: false, channel: "chrome" }, + { headless: false, channel: "msedge" }, + { headless: false }, + ]; + + let lastError: unknown; + for (const options of attempts) { + try { + return await playwright.chromium.launch(options); + } catch (error) { + lastError = error; + } + } + throw lastError instanceof Error + ? lastError + : new Error("No compatible browser is available for sign-in"); +} + +export async function startConolBrowserLogin( + requestedTimeout?: unknown +): Promise { + const timeout = clampTimeout(requestedTimeout); + let playwright: typeof import("playwright"); + try { + playwright = await import("playwright"); + } catch { + return { + success: false, + error: "Browser sign-in is unavailable. Paste the Conol Cookie header instead.", + }; + } + + let browser: import("playwright").Browser | null = null; + try { + browser = await launchConolLoginBrowser(playwright); + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + locale: "en-US", + }); + const page = await context.newPage(); + await page.goto(CONOL_HOME_URL, { + waitUntil: "domcontentloaded", + timeout: Math.min(timeout, 60_000), + }); + + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const credentials = extractConolBrowserCredentials( + await context.cookies(["https://conol.ai"]) + ); + if (credentials) return { success: true, credentials }; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + return { + success: false, + error: "Conol sign-in timed out. Complete login in the opened browser and try again.", + }; + } catch (error) { + return { + success: false, + error: sanitizeErrorMessage(error instanceof Error ? error.message : error), + }; + } finally { + if (browser) { + try { + await browser.close(); + } catch { + // The user may close the login window before extraction completes. + } + } + } +} diff --git a/open-sse/services/conolModels.ts b/open-sse/services/conolModels.ts new file mode 100644 index 0000000000..47d87e5c15 --- /dev/null +++ b/open-sse/services/conolModels.ts @@ -0,0 +1,310 @@ +import { CONOL_SESSION_COOKIE_NAME, normalizeConolCookie } from "./conolAuth.ts"; + +export type ConolEffort = "minimal" | "low" | "medium" | "high" | "xhigh"; + +/** Ordered weakest → strongest. Used to clamp a requested effort onto a model. */ +export const CONOL_EFFORT_ORDER: readonly ConolEffort[] = [ + "minimal", + "low", + "medium", + "high", + "xhigh", +]; + +export interface ConolModel { + id: string; + name: string; + supportsVision?: boolean; + /** Efforts the upstream advertises for this model. Empty means "not tunable". */ + efforts?: ConolEffort[]; +} + +export interface ConolModelDiscovery { + agentServerId: string; + defaultModel: string; + models: ConolModel[]; + modelPresets: ConolModelPreset[]; +} + +export interface ConolModelPreset { + id: string; + text?: string; + multimodal?: string; +} + +/** Effort ladders observed on https://conol.ai/api/agent-servers (2026-07-30). */ +const EFFORTS_XHIGH: ConolEffort[] = ["low", "medium", "high", "xhigh"]; +const EFFORTS_STANDARD: ConolEffort[] = ["minimal", "low", "medium", "high"]; +const EFFORTS_NO_XHIGH: ConolEffort[] = ["low", "medium", "high"]; +const EFFORTS_HIGH_ONLY: ConolEffort[] = ["high", "xhigh"]; +const EFFORTS_PRO: ConolEffort[] = ["medium", "high", "xhigh"]; + +interface FallbackModelSeed { + id: string; + vision: boolean; + efforts: ConolEffort[]; +} + +const FALLBACK_MODEL_SEEDS: FallbackModelSeed[] = [ + { id: "claude-opus-5", vision: true, efforts: EFFORTS_XHIGH }, + { id: "claude-opus-4-8", vision: true, efforts: EFFORTS_XHIGH }, + { id: "claude-fable-5", vision: true, efforts: EFFORTS_XHIGH }, + { id: "claude-opus-4-7", vision: true, efforts: EFFORTS_XHIGH }, + { id: "claude-sonnet-5", vision: true, efforts: EFFORTS_NO_XHIGH }, + { id: "claude-sonnet-4-6", vision: true, efforts: EFFORTS_NO_XHIGH }, + { id: "claude-haiku-4-5", vision: true, efforts: EFFORTS_STANDARD }, + { id: "gpt-5.5", vision: true, efforts: EFFORTS_XHIGH }, + { id: "gpt-5.5-pro", vision: true, efforts: EFFORTS_PRO }, + { id: "gpt-5.6-sol", vision: true, efforts: EFFORTS_XHIGH }, + { id: "gpt-5.6-terra", vision: true, efforts: EFFORTS_XHIGH }, + { id: "gpt-5.6-luna", vision: true, efforts: EFFORTS_XHIGH }, + { id: "deepseek/deepseek-v4-pro", vision: false, efforts: EFFORTS_HIGH_ONLY }, + { id: "openrouter/fusion", vision: false, efforts: [] }, + { id: "z-ai/glm-5.2", vision: false, efforts: EFFORTS_STANDARD }, + { id: "z-ai/glm-5.1", vision: false, efforts: EFFORTS_STANDARD }, + { id: "tencent/hy3", vision: false, efforts: EFFORTS_STANDARD }, + { id: "moonshotai/kimi-k3", vision: true, efforts: EFFORTS_STANDARD }, + { id: "moonshotai/kimi-k2.7-code", vision: true, efforts: EFFORTS_STANDARD }, + { id: "qwen/qwen3.7-plus", vision: true, efforts: EFFORTS_STANDARD }, + { id: "qwen/qwen3.7-max", vision: false, efforts: EFFORTS_STANDARD }, + { id: "minimax/minimax-m3", vision: true, efforts: EFFORTS_STANDARD }, + { id: "stepfun/step-3.7-flash", vision: true, efforts: EFFORTS_STANDARD }, + { id: "google/gemini-3.5-flash", vision: true, efforts: EFFORTS_STANDARD }, + { id: "google/gemini-3.1-pro-preview", vision: true, efforts: EFFORTS_STANDARD }, + { id: "google/gemini-3.1-flash-lite", vision: true, efforts: EFFORTS_STANDARD }, + { id: "x-ai/grok-4.3", vision: true, efforts: EFFORTS_STANDARD }, + { id: "deepseek/deepseek-v4-flash", vision: false, efforts: EFFORTS_HIGH_ONLY }, + { id: "xiaomi/mimo-v2.5", vision: true, efforts: EFFORTS_STANDARD }, + { id: "xiaomi/mimo-v2.5-pro", vision: false, efforts: EFFORTS_STANDARD }, +]; + +/** Presets exposed by the web client's model picker (id → text/multimodal model). */ +export const CONOL_FALLBACK_MODEL_PRESETS: ConolModelPreset[] = [ + { id: "flash", text: "deepseek/deepseek-v4-flash", multimodal: "google/gemini-3.5-flash" }, + { id: "moderate", text: "deepseek/deepseek-v4-pro", multimodal: "claude-sonnet-5" }, + { id: "pro", text: "z-ai/glm-5.2", multimodal: "moonshotai/kimi-k3" }, + { id: "ultra", text: "claude-fable-5", multimodal: "claude-fable-5" }, +]; + +function modelName(id: string): string { + return id + .split("/") + .pop()! + .split("-") + .map((part) => { + const lower = part.toLowerCase(); + if (["gpt", "ai", "glm"].includes(lower)) return lower.toUpperCase(); + return part.length ? part[0]!.toUpperCase() + part.slice(1) : part; + }) + .join(" "); +} + +export const CONOL_FALLBACK_MODELS: ConolModel[] = FALLBACK_MODEL_SEEDS.map((seed) => ({ + id: seed.id, + name: modelName(seed.id), + supportsVision: seed.vision, + efforts: [...seed.efforts], +})); + +const CONOL_FALLBACK_EFFORTS = new Map( + FALLBACK_MODEL_SEEDS.map((seed) => [seed.id, seed.efforts]) +); + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function toEfforts(value: unknown): ConolEffort[] | null { + if (!Array.isArray(value)) return null; + const efforts = value + .map((entry) => readString(entry).toLowerCase()) + .filter((entry): entry is ConolEffort => + (CONOL_EFFORT_ORDER as readonly string[]).includes(entry) + ); + // Normalize to the canonical weakest→strongest order and de-duplicate. + return CONOL_EFFORT_ORDER.filter((effort) => efforts.includes(effort)); +} + +function toModel(value: unknown): ConolModel | null { + if (typeof value === "string") { + const id = value.trim(); + return id ? { id, name: modelName(id) } : null; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const item = value as Record; + const id = + readString(item.id) || + readString(item.modelId) || + readString(item.value) || + readString(item.name); + if (!id) return null; + const inputModalities = Array.isArray(item.inputModalities) + ? item.inputModalities.filter((modality): modality is string => typeof modality === "string") + : null; + const efforts = toEfforts(item.efforts); + return { + id, + name: readString(item.displayName) || readString(item.name) || modelName(id), + ...(inputModalities + ? { supportsVision: inputModalities.some((modality) => modality.toLowerCase() === "image") } + : {}), + ...(efforts ? { efforts } : {}), + }; +} + +function toModelPreset(value: unknown): ConolModelPreset | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const item = value as Record; + const id = readString(item.id); + if (!id) return null; + const text = readString(item.text); + const multimodal = readString(item.multimodal); + return { id, ...(text ? { text } : {}), ...(multimodal ? { multimodal } : {}) }; +} + +/** + * Clamp a requested effort onto the ladder a model actually advertises. + * Returns `null` when the model exposes no effort control at all. + */ +export function clampConolEffort( + requested: ConolEffort, + supported: readonly ConolEffort[] | undefined +): ConolEffort | null { + const ladder = + supported && supported.length + ? CONOL_EFFORT_ORDER.filter((effort) => supported.includes(effort)) + : []; + if (!ladder.length) return null; + if (ladder.includes(requested)) return requested; + + const requestedRank = CONOL_EFFORT_ORDER.indexOf(requested); + // Prefer the strongest supported effort at or below the request; otherwise the weakest above. + let below: ConolEffort | null = null; + for (const effort of ladder) { + if (CONOL_EFFORT_ORDER.indexOf(effort) <= requestedRank) below = effort; + } + return below ?? ladder[0]!; +} + +/** Effort ladder for a model id, using discovery data when available. */ +export function conolEffortsForModel( + modelId: string, + discovered?: readonly ConolModel[] +): ConolEffort[] { + const fromDiscovery = discovered?.find((model) => model.id === modelId)?.efforts; + if (fromDiscovery) return [...fromDiscovery]; + return [...(CONOL_FALLBACK_EFFORTS.get(modelId) ?? [])]; +} + +export function parseConolAgentServers(payload: unknown): ConolModelDiscovery { + const root = Array.isArray(payload) + ? payload + : payload && typeof payload === "object" + ? ((payload as Record).agentServers ?? + (payload as Record).servers ?? + []) + : []; + const servers = Array.isArray(root) ? root : []; + const server = servers.find( + (value) => value && typeof value === "object" && !Array.isArray(value) + ) as Record | undefined; + const capabilities = + server?.capabilities && + typeof server.capabilities === "object" && + !Array.isArray(server.capabilities) + ? (server.capabilities as Record) + : null; + const agents = Array.isArray(capabilities?.agents) ? capabilities.agents : []; + const defaultAgent = readString(capabilities?.defaultAgent); + const agent = (agents.find((value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return readString((value as Record).name) === defaultAgent; + }) ?? agents[0]) as Record | undefined; + + const seen = new Set(); + const rawModels = Array.isArray(agent?.models) + ? agent.models + : Array.isArray(server?.models) + ? server.models + : []; + const models = rawModels.map(toModel).filter((model): model is ConolModel => { + if (!model || seen.has(model.id)) return false; + seen.add(model.id); + return true; + }); + + const rawPresets = Array.isArray(agent?.modelPresets) ? agent.modelPresets : []; + const seenPresets = new Set(); + const modelPresets = rawPresets + .map(toModelPreset) + .filter((preset): preset is ConolModelPreset => { + if (!preset || seenPresets.has(preset.id)) return false; + seenPresets.add(preset.id); + return true; + }); + + return { + agentServerId: readString(server?.id), + defaultModel: readString(agent?.defaultModel) || readString(server?.defaultModel), + models, + modelPresets, + }; +} + +/** + * Effort applied when the caller does not pin one via the `-` model suffix. + * Clamped per-model, so models without an `xhigh` rung fall back to their strongest rung. + */ +export const CONOL_DEFAULT_EFFORT: ConolEffort = "xhigh"; + +export function resolveConolModelSelection(value: unknown): { + model: string; + effort: ConolEffort; + /** True when the effort came from an explicit `-` suffix rather than the default. */ + effortExplicit: boolean; +} { + let model = readString(value); + if (model.startsWith("conol-web/")) model = model.slice("conol-web/".length); + else if (model.startsWith("conol/")) model = model.slice("conol/".length); + else if (model.startsWith("cnl/")) model = model.slice("cnl/".length); + model ||= "claude-sonnet-5"; + + const effortMatch = model.match(/-(xhigh|high|medium|low|minimal)$/); + if (!effortMatch) return { model, effort: CONOL_DEFAULT_EFFORT, effortExplicit: false }; + return { + model: model.slice(0, -effortMatch[0].length), + effort: effortMatch[1] as ConolEffort, + effortExplicit: true, + }; +} + +export function resolveConolModelId(value: unknown): string { + return resolveConolModelSelection(value).model; +} + +export async function discoverConolModels(options: { + cookie: string; + fetchImpl?: typeof fetch; + signal?: AbortSignal; +}): Promise { + const cookie = normalizeConolCookie(options.cookie); + if (!cookie) throw new Error(`Missing ${CONOL_SESSION_COOKIE_NAME} cookie`); + + const response = await (options.fetchImpl ?? fetch)("https://conol.ai/api/agent-servers", { + method: "GET", + headers: { + accept: "application/json", + cookie, + referer: "https://conol.ai/home", + }, + signal: options.signal, + }); + if (!response.ok) { + throw new Error(`Conol model discovery returned HTTP ${response.status}`); + } + const discovered = parseConolAgentServers(await response.json()); + if (!discovered.models.length) { + throw new Error("Conol model discovery returned an empty catalog"); + } + return discovered; +} diff --git a/open-sse/services/conolSessionModel.ts b/open-sse/services/conolSessionModel.ts new file mode 100644 index 0000000000..90371e03ff --- /dev/null +++ b/open-sse/services/conolSessionModel.ts @@ -0,0 +1,148 @@ +/** + * Conol session model/effort configuration. + * + * `POST /api/sessions` ignores `agentModel`/`agentEffort` in its body — a freshly + * created session always starts on the account default and Conol reports the + * downgrade via `modelDowngraded` / `effectiveModel`. The web client therefore + * configures the session out-of-band against `POST /api/sessions/{id}/model`, + * which accepts three distinct payload shapes (verified 2026-07-30): + * + * 1. `{"modelPreset":"pro","hasImageHistory":false}` — picker preset + * 2. `{"agentModel":"claude-fable-5","agentEffort":null}` — pin an explicit model + * 3. `{"agentEffort":"xhigh"}` — pin the effort + * + * Shape 2 resets `agentEffort` to `null`, so the effort call must always follow + * the model call. All three return `{"ok":true}`. + */ +import { + clampConolEffort, + conolEffortsForModel, + type ConolEffort, + type ConolModel, +} from "./conolModels.ts"; + +export const CONOL_ORIGIN = "https://conol.ai"; + +/** Preset the web client sends on every new session before pinning a model. */ +export const CONOL_DEFAULT_MODEL_PRESET = "pro"; + +export type ConolModelPresetId = "flash" | "moderate" | "pro" | "ultra"; + +const KNOWN_PRESETS = new Set(["flash", "moderate", "pro", "ultra"]); + +export function isConolModelPreset(value: string): value is ConolModelPresetId { + return KNOWN_PRESETS.has(value as ConolModelPresetId); +} + +export interface ConolSessionModelPlan { + /** Preset priming call, sent once per session. */ + preset: { modelPreset: string; hasImageHistory: boolean }; + /** Explicit model pin. Always clears effort so the effort call can apply cleanly. */ + model: { agentModel: string; agentEffort: null }; + /** Effort pin, omitted when the model exposes no effort ladder. */ + effort: { agentEffort: ConolEffort } | null; +} + +export interface BuildConolSessionModelPlanOptions { + model: string; + effort: ConolEffort; + hasImageHistory: boolean; + /** Discovery catalog, when available, so effort ladders stay accurate. */ + catalog?: readonly ConolModel[]; + /** Overrides the default `pro` priming preset. */ + modelPreset?: string; +} + +/** + * Build the ordered preset → model → effort payloads for a session. + * Effort is clamped onto the ladder the target model actually advertises, so a + * default of `xhigh` degrades to `high` on models such as `claude-sonnet-5`. + */ +export function buildConolSessionModelPlan( + options: BuildConolSessionModelPlanOptions +): ConolSessionModelPlan { + const supported = conolEffortsForModel(options.model, options.catalog); + const effort = clampConolEffort(options.effort, supported); + return { + preset: { + modelPreset: options.modelPreset || CONOL_DEFAULT_MODEL_PRESET, + hasImageHistory: options.hasImageHistory, + }, + model: { agentModel: options.model, agentEffort: null }, + effort: effort ? { agentEffort: effort } : null, + }; +} + +export function conolSessionModelUrl(sessionId: string): string { + return `${CONOL_ORIGIN}/api/sessions/${encodeURIComponent(sessionId)}/model`; +} + +export interface ApplyConolSessionModelOptions { + sessionId: string; + plan: ConolSessionModelPlan; + /** Skip the preset priming call when the session was already primed. */ + skipPreset?: boolean; + buildHeaders: (sessionId: string) => Record; + fetchImpl?: typeof fetch; + signal?: AbortSignal | null; + onWarning?: (message: string) => void; +} + +export interface AppliedConolSessionModel { + presetApplied: boolean; + modelApplied: boolean; + effortApplied: ConolEffort | null; +} + +async function postSessionModel( + url: string, + body: unknown, + options: ApplyConolSessionModelOptions +): Promise { + const response = await (options.fetchImpl ?? fetch)(url, { + method: "POST", + headers: { ...options.buildHeaders(options.sessionId), "content-type": "application/json" }, + body: JSON.stringify(body), + signal: options.signal ?? undefined, + }); + // Drain so the socket can be reused; the payload is only `{"ok":true}`. + await response.body?.cancel().catch(() => undefined); + if (!response.ok) { + options.onWarning?.( + `Conol session model update failed (HTTP ${response.status}) for ${JSON.stringify(body)}` + ); + return false; + } + return true; +} + +/** + * Apply preset → model → effort in order. Ordering is load-bearing: the model + * call nulls the effort, so applying effort first would silently drop it. + * Failures are reported but non-fatal — the turn still runs on Conol's default. + */ +export async function applyConolSessionModel( + options: ApplyConolSessionModelOptions +): Promise { + const url = conolSessionModelUrl(options.sessionId); + const applied: AppliedConolSessionModel = { + presetApplied: false, + modelApplied: false, + effortApplied: null, + }; + + if (!options.skipPreset) { + applied.presetApplied = await postSessionModel(url, options.plan.preset, options); + } + + applied.modelApplied = await postSessionModel(url, options.plan.model, options); + + // Only pin effort if the model pin landed; otherwise the session is on an + // unknown model whose effort ladder we cannot reason about. + if (applied.modelApplied && options.plan.effort) { + const ok = await postSessionModel(url, options.plan.effort, options); + if (ok) applied.effortApplied = options.plan.effort.agentEffort; + } + + return applied; +} diff --git a/open-sse/services/conolUsage.ts b/open-sse/services/conolUsage.ts new file mode 100644 index 0000000000..1e3302a7a4 --- /dev/null +++ b/open-sse/services/conolUsage.ts @@ -0,0 +1,111 @@ +import { normalizeConolCookie } from "./conolAuth.ts"; + +interface UsageQuota { + used: number; + total: number; + remaining: number; + remainingPercentage: number; + resetAt: null; + unlimited: boolean; +} + +interface ConolBalance { + dailyCredits?: unknown; + subscriptionCredits?: unknown; + subscriptionAmount?: unknown; + extraCredits?: unknown; + total?: unknown; +} + +interface ConolUsageResult { + plan: string; + quotas: Record<"credits" | "daily" | "subscription" | "extra", UsageQuota>; + message: string | null; +} + +function numberValue(value: unknown): number { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function remainingQuota(remaining: number, total = remaining): UsageQuota { + const boundedTotal = Math.max(total, remaining); + const used = Math.max(0, boundedTotal - remaining); + return { + used, + total: boundedTotal, + remaining, + remainingPercentage: + boundedTotal > 0 ? Math.round((remaining / boundedTotal) * 1000) / 10 : 0, + resetAt: null, + unlimited: false, + }; +} + +export function buildConolUsageResult(balance: ConolBalance): ConolUsageResult { + const daily = numberValue(balance.dailyCredits); + const subscription = numberValue(balance.subscriptionCredits); + const subscriptionAmount = numberValue(balance.subscriptionAmount); + const extra = numberValue(balance.extraCredits); + const aggregate = numberValue(balance.total) || daily + subscription + extra; + + return { + plan: subscriptionAmount > 0 ? "Subscription" : "Free", + quotas: { + credits: remainingQuota(aggregate), + daily: remainingQuota(daily), + subscription: remainingQuota(subscription, subscriptionAmount || subscription), + extra: remainingQuota(extra), + }, + message: null, + }; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function readProviderValue(data: unknown, keys: readonly string[]): string { + if (!data || typeof data !== "object" || Array.isArray(data)) return ""; + const record = data as Record; + for (const key of keys) { + const value = readString(record[key]); + if (value) return value; + } + return ""; +} + +export async function getConolUsage( + apiKey: unknown, + providerSpecificData?: unknown +): Promise { + const raw = + readProviderValue(providerSpecificData, [ + "cookie", + "__Secure-better-auth.session_token", + "sessionToken", + ]) || readString(apiKey); + const cookie = normalizeConolCookie(raw); + if (!cookie) return { message: "Missing Conol session cookie" }; + + try { + const response = await fetch("https://conol.ai/api/billing/balance", { + method: "GET", + headers: { + accept: "application/json", + cookie, + referer: "https://conol.ai/home", + }, + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 401 || response.status === 403) { + return { message: "Conol session expired or is invalid" }; + } + if (!response.ok) { + return { message: `Conol balance request failed (HTTP ${response.status})` }; + } + return buildConolUsageResult((await response.json()) as ConolBalance); + } catch { + return { message: "Conol balance request failed" }; + } +} diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index cb34af4018..919c0d1e66 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -6,7 +6,10 @@ */ import { REGISTRY } from "../config/providerRegistry.ts"; -import { getModelContextLimit } from "../../src/lib/modelCapabilities.ts"; +import { + getModelContextLimit, + type ModelCapabilityResolutionSnapshot, +} from "../../src/lib/modelCapabilities.ts"; import { parseModel } from "./model.ts"; import { jsonLength } from "../utils/jsonSize.ts"; @@ -270,8 +273,12 @@ export function estimateTokens(text: unknown): number { * Get token limit for a provider/model combination * Priority: Env override > models.dev DB > Registry defaultContextLength > DEFAULT_LIMITS */ -export function getTokenLimit(provider: string, model: string | null = null): number { - return resolveTokenLimit(provider, model).limit; +export function getTokenLimit( + provider: string, + model: string | null = null, + snapshot?: ModelCapabilityResolutionSnapshot | null +): number { + return resolveTokenLimit(provider, model, snapshot).limit; } /** @@ -310,7 +317,8 @@ export function getComboTargetTokenLimit(options: { */ function resolveTokenLimit( provider: string, - model: string | null = null + model: string | null = null, + snapshot?: ModelCapabilityResolutionSnapshot | null ): { limit: number; specific: boolean } { // 1. Check environment variable override first const envOverride = getEnvOverride(provider); @@ -320,7 +328,7 @@ function resolveTokenLimit( // 2. Check models.dev synced DB for per-model context limit if (model) { - const dbLimit = getModelContextLimit(provider, model); + const dbLimit = getModelContextLimit(provider, model, snapshot); if (dbLimit && dbLimit > 0) return { limit: dbLimit, specific: true }; } diff --git a/open-sse/services/dashscopeTextModels.ts b/open-sse/services/dashscopeTextModels.ts new file mode 100644 index 0000000000..08418c5834 --- /dev/null +++ b/open-sse/services/dashscopeTextModels.ts @@ -0,0 +1,109 @@ +/** + * @file dashscopeTextModels.ts + * @description DashScope / Alibaba Model Studio text and vision model ID heuristics. + * + * @changes + * - [2026-07-25] [Composer] - Add alibabafree text combo name detection for strict allowlist routing + * - [2026-07-25] [Composer] - Add multimodal and audio model detection for free-tier combos + * - [2026-07-25] [Composer] - Add vision/media model detection for alibabafreevision + * - [2026-07-25] [Composer] - Extract DashScope text-model filter for open-sse consumers + */ + +const DASHSCOPE_TEXT_MODEL_PREFIXES = [ + "qwen", + "qwq-", + "deepseek-", + "glm-", + "kimi-", + "minimax-", +] as const; + +const DASHSCOPE_VISION_MODEL_PREFIXES = ["wan", "qwen-image", "happyhorse", "z-image"] as const; + +const DASHSCOPE_NON_TEXT_MODEL_TOKEN = + /(?:^|[-_.\/])(?:asr|audio|captioner|embedding|image|livetranslate|omni|ocr|realtime|rerank|s2s|speech|tts|video|vl)(?:$|[-_.\/])/i; + +const DASHSCOPE_VISION_MODEL_TOKEN = + /(?:^|[-_.\/])(?:i2v|t2v|r2v|vace|kf2v|videoedit|animate|image-edit)(?:$|[-_.\/])/i; + +export function isDashscopeTextModelId(value: unknown): boolean { + if (typeof value !== "string") return false; + const modelId = value.trim().toLowerCase(); + if (!modelId || DASHSCOPE_NON_TEXT_MODEL_TOKEN.test(modelId)) return false; + return DASHSCOPE_TEXT_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)); +} + +export function isDashscopeVisionModelId(value: unknown): value is string { + if (typeof value !== "string") return false; + const modelId = value.trim().toLowerCase(); + if (!modelId || isDashscopeTextModelId(modelId)) return false; + return ( + DASHSCOPE_VISION_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)) || + DASHSCOPE_VISION_MODEL_TOKEN.test(modelId) + ); +} + +const DASHSCOPE_AUDIO_PREFIXES = [ + "cosyvoice", + "fun-asr", + "qwen-audio", + "qwen-voice", + "voice-enrollment", +] as const; + +const DASHSCOPE_AUDIO_MODEL_TOKEN = + /(?:^|[-_.\/])(?:asr|tts|livetranslate|captioner|speech|voice-design|voice-enrollment)(?:$|[-_.\/])/i; + +const DASHSCOPE_MULTIMODAL_MODEL_TOKEN = /(?:^|[-_.\/])omni(?:$|[-_.\/])/i; + +export function isDashscopeAudioModelId(value: unknown): value is string { + if (typeof value !== "string") return false; + const modelId = value.trim().toLowerCase(); + if (!modelId) return false; + return ( + DASHSCOPE_AUDIO_PREFIXES.some((prefix) => modelId.startsWith(prefix)) || + DASHSCOPE_AUDIO_MODEL_TOKEN.test(modelId) + ); +} + +export function isDashscopeMultimodalModelId(value: unknown): value is string { + if (typeof value !== "string") return false; + const modelId = value.trim().toLowerCase(); + if (!modelId || isDashscopeAudioModelId(modelId) || isDashscopeVisionModelId(modelId)) { + return false; + } + return DASHSCOPE_MULTIMODAL_MODEL_TOKEN.test(modelId); +} + +export function isAlibabaFreeTierTextComboName(comboName: string | null | undefined): boolean { + if (!comboName) return false; + const normalized = comboName.trim().toLowerCase(); + if ( + isAlibabaFreeTierVisionComboName(normalized) || + isAlibabaFreeTierMultimodalComboName(normalized) || + isAlibabaFreeTierAudioComboName(normalized) + ) { + return false; + } + return normalized === "alibabafree" || normalized.endsWith("alibabafree"); +} + +export function isAlibabaFreeTierVisionComboName(comboName: string | null | undefined): boolean { + if (!comboName) return false; + const normalized = comboName.trim().toLowerCase(); + return normalized === "alibabafreevision" || normalized.endsWith("freevision"); +} + +export function isAlibabaFreeTierMultimodalComboName( + comboName: string | null | undefined +): boolean { + if (!comboName) return false; + const normalized = comboName.trim().toLowerCase(); + return normalized === "alibabafreemultimodal" || normalized.endsWith("freemultimodal"); +} + +export function isAlibabaFreeTierAudioComboName(comboName: string | null | undefined): boolean { + if (!comboName) return false; + const normalized = comboName.trim().toLowerCase(); + return normalized === "alibabafreeaudio" || normalized.endsWith("freeaudio"); +} diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 71bc81f5eb..daa3bab657 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -78,6 +78,7 @@ export const PROVIDER_ERROR_TYPES = { OAUTH_INVALID_TOKEN: "oauth_invalid_token", EMPTY_CONTENT: "empty_content", MODEL_NOT_FOUND: "model_not_found", + FINGERPRINT_REJECTION: "fingerprint_rejection", }; export const CONTEXT_OVERFLOW_SIGNALS = [ @@ -113,6 +114,31 @@ export function containsModelUnavailableMessage(errorMessage: string): boolean { return MODEL_NAMED_UNSUPPORTED_REGEX.test(String(errorMessage || "").toLowerCase()); } +// Cloudflare 1010 "Access denied ... blocked based on your browser's signature" — +// a fingerprint/browser-like rejection issued by the CDN in front of an upstream +// (e.g. opencode.ai/zen/v1), carrying error_code 1010 or error_name +// "browser_signature_banned". Distinct from an auth 403: the account is healthy, +// the CLIENT's TLS/UA signature was refused. +// +// IMPORTANT: the bare number 1010 is NOT matched on its own — a 403 body can +// legitimately contain "1010" as a port, count, request id, or model token +// ("model foo-1010 is not supported", "retry after 1010 seconds"). 1010 is only +// treated as a fingerprint rejection when it appears with an explicit Cloudflare +// key (`error_code` / `error-code`) or the unique `browser_signature_banned` / +// `fingerprint_rejection` tokens. `\\?` tolerates the escaped-quote form that +// appears when the upstream body is nested inside the gateway's error.message JSON. +const CLOUDFLARE_1010_REGEX = + /(?): 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/imageCombo.ts b/open-sse/services/imageCombo.ts new file mode 100644 index 0000000000..ada834ff50 --- /dev/null +++ b/open-sse/services/imageCombo.ts @@ -0,0 +1,199 @@ +/** + * Image Combo Strategy Execution + * + * Executes a full Combo strategy for image generation requests. Expands combo + * targets via resolveComboTargets(), filters to images-capable targets, runs + * each target via handleImageGeneration() using a priority strategy, provides + * per-credential resolution, and returns the first success or last failure. + * + * #9239 + */ +import { getComboByName, getCombos } from "@/lib/db/combos"; +import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts"; +import { getImageModelEntry, parseImageModel } from "@omniroute/open-sse/config/imageRegistry.ts"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; +import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; +import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts"; +import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; +import { generateRequestId } from "@/shared/utils/requestId"; +import { calculateModalCost } from "@/lib/usage/costCalculator"; +import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import * as logger from "@/sse/utils/logger"; + +/** + * Execute a full combo strategy for an image generation request. + * + * 1. Resolve combo targets via resolveComboTargets. + * 2. Filter to images-capable targets (those with an entry in the image registry). + * 3. Iterate targets in priority order; for each target, resolve credentials and + * call handleImageGeneration. Return the first success or the last failure. + * 4. Attach combo name, selected target, and fallback count to response headers. + */ +export async function executeImageCombo( + comboName: string, + body: Record, + auth: { + request: Request; + policy: { apiKeyInfo?: { id?: string; name?: string } | null }; + }, + startTime: number, + log: typeof logger +): Promise { + // 1. Resolve combo targets + const combo = await getComboByName(comboName); + if (!combo) { + // Model name is not a combo; the caller should handle this as a direct model + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Combo not found: ${comboName}` + ); + } + + const allCombos = await getCombos(); + const targets = resolveComboTargets(combo as never, allCombos as never); + if (!targets || targets.length === 0) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Combo "${comboName}" has no usable targets` + ); + } + + // 2. Filter to images-capable targets + const imageTargets = targets.filter((t) => { + if (!t.modelStr) return false; + const entry = getImageModelEntry(t.modelStr); + return entry !== null; + }); + + if (imageTargets.length === 0) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No images-capable targets in combo "${comboName}"` + ); + } + + // 3. Iterate targets in priority order (first healthy target wins) + let lastError: { status: number; error: string } | null = null; + let successResult: { data: unknown; provider: string; model: string } | null = null; + let fallbackCount = 0; + let selectedProvider = ""; + let selectedModel = ""; + + for (const target of imageTargets) { + const { provider: targetProvider, model: targetModel } = parseImageModel(target.modelStr); + if (!targetProvider) { + lastError = { status: 400, error: `Invalid image model: ${target.modelStr}` }; + fallbackCount += 1; + continue; + } + + // Resolve provider credentials + let credentials = null; + try { + credentials = await getProviderCredentialsWithQuotaPreflight(targetProvider); + } catch { + // DB unavailable — skip this target + lastError = { status: 502, error: `Failed to resolve credentials for ${targetProvider}` }; + fallbackCount += 1; + continue; + } + + if (!credentials) { + lastError = { status: 400, error: `No credentials for image provider: ${targetProvider}` }; + fallbackCount += 1; + continue; + } + + if (isAllRateLimitedCredentials(credentials)) { + lastError = { + status: 429, + error: `[${targetProvider}] All accounts rate limited`, + }; + fallbackCount += 1; + continue; + } + + // Execute image generation for this target + const result = await handleImageGeneration({ + body: { ...body, model: target.modelStr }, + credentials, + log, + signal: auth.request?.signal || null, + }); + + if (result.success) { + await clearRecoveredProviderState(credentials); + selectedProvider = targetProvider; + selectedModel = target.modelStr; + successResult = { + data: result.data, + provider: targetProvider, + model: target.modelStr, + }; + break; + } + + // Classify the failure + const status = result.status || 500; + const error = typeof result.error === "string" ? result.error : "Image generation failed"; + + // Terminal failures (400 bad model, 403 banned, etc.) — stop iterating + // Non-terminal failures (429, 5xx) — try next target + if (status === 400 || status === 403 || status === 401) { + return errorResponse( + status, + `[${targetProvider}] ${error}` + ); + } + + lastError = { status, error: `[${targetProvider}] ${error}` }; + fallbackCount += 1; + } + + // 4. Build response + if (successResult) { + const n = Math.max( + Number(body.n) || 1, + ( + successResult.data as { data?: { data?: unknown[] } } + ).data?.data?.length || 0 + ); + const costUsd = await calculateModalCost( + "image", + selectedProvider, + selectedModel, + { n } + ); + + const headers = new Headers({ "Content-Type": "application/json" }); + attachOmniRouteMetaHeaders(headers, { + provider: selectedProvider, + model: selectedModel, + costUsd, + latencyMs: Date.now() - startTime, + requestId: generateRequestId(), + strategy: "priority", + fallbackAttempts: fallbackCount, + }); + + return new Response( + JSON.stringify((successResult.data as { data: unknown }).data), + { status: 200, headers } + ); + } + + // All targets failed — return the last error + const errorPayload = toJsonErrorPayload( + lastError?.error || "All combo targets failed", + "Image combo targets all failed" + ); + return new Response(JSON.stringify(errorPayload), { + status: lastError?.status || 502, + headers: { "Content-Type": "application/json" }, + }); +} \ No newline at end of file diff --git a/open-sse/services/inAppLoginService.ts b/open-sse/services/inAppLoginService.ts index 0a71b99929..d2196fd6d0 100644 --- a/open-sse/services/inAppLoginService.ts +++ b/open-sse/services/inAppLoginService.ts @@ -13,7 +13,11 @@ */ import { EventEmitter } from "events"; -import { TOKEN_EXTRACTION_CONFIGS, TokenExtractionConfig, type TokenSource } from "./tokenExtractionConfig"; +import { + TOKEN_EXTRACTION_CONFIGS, + TokenExtractionConfig, + type TokenSource, +} from "./tokenExtractionConfig"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -28,6 +32,20 @@ interface ActiveLogin { aborted: boolean; } +export function captureConfiguredHeaders( + tokenSources: readonly TokenSource[], + requestHeaders: Record, + credentials: Record +): void { + for (const source of tokenSources) { + if (source.type !== "header" || credentials[source.name]) continue; + const value = requestHeaders[source.name.toLowerCase()]; + if (typeof value === "string" && value.trim()) { + credentials[source.name] = value.trim(); + } + } +} + // ─── Service ──────────────────────────────────────────────────────────────── export class InAppLoginService extends EventEmitter { @@ -46,19 +64,29 @@ export class InAppLoginService extends EventEmitter { } if (this.activeLogin) { - this.emit("status", { providerId, status: "error", message: "A login is already in progress" }); + this.emit("status", { + providerId, + status: "error", + message: "A login is already in progress", + }); return { success: false, error: "A login process is already in progress" }; } this.activeLogin = { providerId, aborted: false }; - this.emit("status", { providerId, status: "starting", message: `Opening ${config.displayName} login...` }); + this.emit("status", { + providerId, + status: "starting", + message: `Opening ${config.displayName} login...`, + }); try { const result = await this.runBrowserLogin(config, options?.timeout); this.emit("status", { providerId, status: result.success ? "complete" : "error", - message: result.success ? "Credentials extracted successfully" : (result.error || "Login failed"), + message: result.success + ? "Credentials extracted successfully" + : result.error || "Login failed", }); return result; } catch (error) { @@ -87,7 +115,10 @@ export class InAppLoginService extends EventEmitter { try { playwright = await import("playwright"); } catch { - return { success: false, error: "Playwright is not installed. Use Electron for native login." }; + return { + success: false, + error: "Playwright is not installed. Use Electron for native login.", + }; } if (this.activeLogin?.aborted) { @@ -106,19 +137,39 @@ export class InAppLoginService extends EventEmitter { locale: "en-US", }); const page = await context.newPage(); + const credentials: Record = {}; + + // Playwright normalizes request header names to lowercase. Capture only + // explicitly configured credentials and never replace the first token + // observed after login. + page.on("request", (request: { allHeaders(): Promise> }) => { + void request + .allHeaders() + .then((headers) => captureConfiguredHeaders(config.tokenSources, headers, credentials)) + .catch(() => { + // Some browser-internal requests do not expose their full headers. + }); + }); // Navigate to login URL - this.emit("status", { providerId, status: "navigating", message: `Loading ${config.loginUrl}` }); + this.emit("status", { + providerId, + status: "navigating", + message: `Loading ${config.loginUrl}`, + }); await page.goto(config.loginUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); // Poll for success URL + token extraction const maxPolls = Math.floor(maxTimeout / pollInterval); - const credentials: Record = {}; const startTime = Date.now(); for (let i = 0; i < maxPolls; i++) { if (this.activeLogin?.aborted) { - this.emit("status", { providerId, status: "cancelled", message: "Login cancelled by user" }); + this.emit("status", { + providerId, + status: "cancelled", + message: "Login cancelled by user", + }); return { success: false, error: "Login cancelled" }; } @@ -147,8 +198,7 @@ export class InAppLoginService extends EventEmitter { const domain = source.domain || undefined; const matched = cookies.find( (c: any) => - c.name === source.name && - (!domain || c.domain.includes(domain.replace(/^\./, ""))) + c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, ""))) ); if (matched && !credentials[source.name]) { credentials[source.name] = matched.value; @@ -160,7 +210,10 @@ export class InAppLoginService extends EventEmitter { for (const source of tokenSources) { if (source.type === "localStorage" && !credentials[source.key]) { try { - const value = await page.evaluate((key: string) => localStorage.getItem(key), source.key); + const value = await page.evaluate( + (key: string) => localStorage.getItem(key), + source.key + ); if (value && typeof value === "string") { credentials[source.key] = value; } @@ -170,7 +223,10 @@ export class InAppLoginService extends EventEmitter { } if (source.type === "sessionStorage" && !credentials[source.key]) { try { - const value = await page.evaluate((key: string) => sessionStorage.getItem(key), source.key); + const value = await page.evaluate( + (key: string) => sessionStorage.getItem(key), + source.key + ); if (value && typeof value === "string") { credentials[source.key] = value; } @@ -182,7 +238,11 @@ export class InAppLoginService extends EventEmitter { // Check if all required tokens are found const requiredKeys = tokenSources.map((s) => - s.type === "cookie" ? s.name : s.type === "localStorage" || s.type === "sessionStorage" ? s.key : s.name + s.type === "cookie" + ? s.name + : s.type === "localStorage" || s.type === "sessionStorage" + ? s.key + : s.name ); const allFound = requiredKeys.every((k) => credentials[k] !== undefined); diff --git a/open-sse/services/oauthSessionOccupancy.ts b/open-sse/services/oauthSessionOccupancy.ts new file mode 100644 index 0000000000..e9d48386c8 --- /dev/null +++ b/open-sse/services/oauthSessionOccupancy.ts @@ -0,0 +1,114 @@ +const DEFAULT_LEASE_MS = 10 * 60_000; + +interface SessionLease { + requests: number; + expiresAt: number; +} + +const occupancy = new Map>(); + +function prune(now = Date.now()): void { + for (const [connectionId, sessions] of occupancy) { + for (const [sessionKey, lease] of sessions) { + if (lease.expiresAt <= now) sessions.delete(sessionKey); + } + if (sessions.size === 0) occupancy.delete(connectionId); + } +} + +export function getForeignOAuthSessionCount( + connectionId: string | null | undefined, + sessionKey: string | null | undefined, + now = Date.now() +): number { + if (!connectionId) return 0; + prune(now); + const sessions = occupancy.get(connectionId); + if (!sessions) return 0; + let count = 0; + for (const key of sessions.keys()) { + if (!sessionKey || key !== sessionKey) count++; + } + return count; +} + +export function getOAuthSessionAvailability( + connectionId: string | null | undefined, + sessionKey: string | null | undefined, + now = Date.now() +): number { + return 1 / (1 + getForeignOAuthSessionCount(connectionId, sessionKey, now)); +} + +export function reserveOAuthSession( + connectionId: string, + sessionKey: string, + leaseMs = DEFAULT_LEASE_MS, + now = Date.now() +): () => void { + if (!connectionId || !sessionKey) return () => {}; + prune(now); + const sessions = occupancy.get(connectionId) ?? new Map(); + const current = sessions.get(sessionKey); + sessions.set(sessionKey, { + requests: (current?.requests ?? 0) + 1, + expiresAt: now + Math.max(1, leaseMs), + }); + occupancy.set(connectionId, sessions); + + let released = false; + return () => { + if (released) return; + released = true; + const activeSessions = occupancy.get(connectionId); + const active = activeSessions?.get(sessionKey); + if (!activeSessions || !active) return; + if (active.requests <= 1) activeSessions.delete(sessionKey); + else activeSessions.set(sessionKey, { ...active, requests: active.requests - 1 }); + if (activeSessions.size === 0) occupancy.delete(connectionId); + }; +} + +export function wrapResponseWithOAuthSessionRelease( + response: Response, + release: () => void +): Response { + if (!response.body) { + release(); + return response; + } + const reader = response.body.getReader(); + const body = new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); + if (done) { + release(); + controller.close(); + return; + } + controller.enqueue(value); + } catch (error) { + release(); + controller.error(error); + } + }, + async cancel(reason) { + release(); + try { + await reader.cancel(reason); + } catch { + // The upstream stream is already closing; the lease has still been released. + } + }, + }); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +export function _clearOAuthSessionOccupancyForTest(): void { + occupancy.clear(); +} diff --git a/open-sse/services/providerCooldownTracker.ts b/open-sse/services/providerCooldownTracker.ts index f5a14d2006..13fbd17af5 100644 --- a/open-sse/services/providerCooldownTracker.ts +++ b/open-sse/services/providerCooldownTracker.ts @@ -171,6 +171,11 @@ export function getRemainingCooldownMs( /** * Record a successful request for a provider/connection. * Resets the failure count (but keeps the entry for reference). + * + * @deprecated Use accountFallback.recordProviderSuccess instead -- it also + * transitions the circuit breaker from HALF_OPEN to CLOSED. This function + * only resets the cooldown failureCount without touching the breaker, which + * leaves the breaker stuck in HALF_OPEN after repeated failures. */ export function recordProviderSuccess(provider: string, connectionId: string | undefined): void { if (!provider || provider === "unknown") return; diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index cb4350b2f8..8ab1b814b6 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -9,16 +9,12 @@ */ import Bottleneck from "bottleneck"; +import { applyBottleneckDoExpirePatch } from "./bottleneckPatch.ts"; import { parseRetryAfterFromBody } from "./accountFallback.ts"; import { getAntigravityQuotaFamily } from "./antigravityQuotaFamily.ts"; import { getProviderCategory } from "../config/providerRegistry.ts"; import { getCodexRateLimitKey } from "../executors/codex.ts"; -import { - getProviderDefaultRateLimit, - setProviderQuotaOverrides, -} from "./providerDefaultRateLimit.ts"; -import { keyContainsConnection, RollingRpmGate } from "./rollingRpmGate.ts"; -import { toNumber } from "@/shared/utils/numeric"; +import { awaitProviderDefaultSlot, setProviderQuotaOverrides } from "./providerDefaultRateLimit.ts"; import { DEFAULT_RESILIENCE_SETTINGS, resolveResilienceSettings, @@ -31,6 +27,13 @@ import { toPlainHeaders, } from "./rateLimitManager/headers"; import { checkQueueAdmission } from "./rateLimitManager/admission"; +import { + markLocalRateLimitError, + RATE_LIMIT_EXECUTION_TIMEOUT_CODE, + RATE_LIMIT_QUEUE_WEDGED_CODE, +} from "./rateLimitManager/errors"; +import { LimiterWedgeWatchdog, WATCHDOG_INTERVAL_MS } from "./rateLimitManager/wedgeWatchdog"; +import { toNumber } from "@/shared/utils/numeric"; interface LearnedLimitEntry { provider: string; @@ -44,38 +47,17 @@ interface LearnedLimitEntry { interface LimiterUpdateSettings { maxConcurrent?: number | null; minTime: number; + reservoir?: number | null; + reservoirRefreshAmount?: number | null; + reservoirRefreshInterval?: number | null; } type JsonRecord = Record; -type QueueTimeoutReason = "local-queue" | "upstream-cooldown"; function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } -function createQueueTimeoutError( - provider: string, - model: string | null, - maxWaitMs: number, - reason: QueueTimeoutReason = "local-queue", - cause?: unknown -) { - const target = model ? `${provider}/${model}` : provider; - const message = - reason === "upstream-cooldown" - ? `Request dropped after waiting ${maxWaitMs}ms for an upstream rate-limit cooldown for ${target}. ` + - `The provider cooldown outlasted OmniRoute's local wait budget; this is not local queue saturation.` - : `Request dropped after exceeding the local rate-limit queue budget maxWaitMs (${maxWaitMs}ms) for ` + - `${target} — this is OmniRoute's request queue ` + - `(resilienceSettings.requestQueue.maxWaitMs), not an upstream timeout. Raise it in ` + - `Settings → Resilience if this is queue saturation rather than a slow provider.`; - const queueErr = new Error(message, cause === undefined ? undefined : { cause }) as Error & { - code?: string; - }; - queueErr.code = "RATE_LIMIT_QUEUE_TIMEOUT"; - return queueErr; -} - function isNodeTestRunnerChild(): boolean { return typeof process.env.NODE_TEST_CONTEXT === "string"; } @@ -105,7 +87,6 @@ const connectionRateLimitOverrides = new Map>(); // Store learned limits for persistence (debounced) const learnedLimits: Record = {}; const MAX_LEARNED_LIMITS = 200; -const INACTIVE_LIMITER_MS = 10 * 60 * 1000; const limiterLastUsed = new Map(); let persistTimer: ReturnType | null = null; const pendingAsyncOperations = new Set>(); @@ -116,17 +97,24 @@ let initialized = false; let currentRequestQueueSettings: RequestQueueSettings = DEFAULT_RESILIENCE_SETTINGS.requestQueue; -// Watchdog: detect Bottleneck limiters that are wedged (queue has work, but no -// jobs are dispatched). RPM admission happens before Bottleneck, so a queued -// Bottleneck job with no active work is a concurrency scheduler failure. -const lastDispatchAt = new Map(); -let nextJobTraceId = 1; +const limiterEffectiveSettings = new WeakMap(); +const preservedReplacementSettings = new Map(); +const limiterWatchdog = new LimiterWedgeWatchdog({ + limiters, + limiterLastUsed, + limiterEffectiveSettings, + preservedReplacementSettings, + trackBackground: (promise) => { + trackAsyncOperation(promise); + }, + log: logRateLimit, + warn: warnRateLimit, +}); let watchdogInterval: ReturnType | null = null; -const WATCHDOG_INTERVAL_MS = 30_000; -// Threshold has to exceed any legitimate gap caused by adaptive minTime while -// still catching the actual wedge case we observed (queue stalled for 3+ -// minutes with no progress). -const WEDGE_THRESHOLD_MS = 120_000; + +type LimiterFactory = (options: Bottleneck.ConstructorOptions) => Bottleneck; +const defaultLimiterFactory: LimiterFactory = (options) => new Bottleneck(options); +let limiterFactory: LimiterFactory = defaultLimiterFactory; /** * Env-var override for the auto-enable safety net. Highest priority — wins @@ -143,10 +131,19 @@ function isAutoEnableActive(settings: RequestQueueSettings): boolean { return settings.autoEnableApiKeyProviders; } -// Bottleneck handles concurrency and pacing. RPM is enforced by the rolling -// lease limiter above rather than by a fixed-window reservoir. +// Sentinels for "no rate limit" / effectively infinite capacity. The reservoir +// value uses Number.MAX_SAFE_INTEGER so the bucket can never realistically be +// exhausted; maxConcurrent uses a smaller-but-still-vast ceiling since +// Bottleneck tracks concurrent jobs in memory and an unbounded number would +// risk internal counter overflow under sustained pressure. +const EFFECTIVELY_INFINITE = Number.MAX_SAFE_INTEGER; const EFFECTIVELY_INFINITE_CONCURRENCY = 1000; +// Resolve an RPM override. 0 or missing means "infinite" (no rate cap). +function resolveRpm(override: number | undefined | null): number { + return typeof override === "number" && override > 0 ? override : EFFECTIVELY_INFINITE; +} + // Resolve a minTime override. 0 or missing means "no minimum gap". function resolveMinTime(override: number | undefined | null): number { return typeof override === "number" && override > 0 ? override : 0; @@ -158,62 +155,38 @@ function resolveMaxConcurrent(override: number | undefined | null): number { } function buildLimiterDefaults() { + // 0 or missing values mean "infinite" / no rate limit applies. This treats + // the global request-queue settings the same way per-connection overrides + // are interpreted (see resolveRpm / resolveMinTime / resolveMaxConcurrent). return { maxConcurrent: resolveMaxConcurrent(currentRequestQueueSettings.concurrentRequests), minTime: resolveMinTime(currentRequestQueueSettings.minTimeBetweenRequestsMs), + reservoir: resolveRpm(currentRequestQueueSettings.requestsPerMinute), + reservoirRefreshAmount: resolveRpm(currentRequestQueueSettings.requestsPerMinute), + reservoirRefreshInterval: 60 * 1000, }; } -/** - * Apply new settings to a Bottleneck limiter and re-arm its reservoir-refresh - * heartbeat. - * - * Bottleneck 2.19.5 (frozen upstream dependency, no release since 2019) has a - * bug in `LocalDatastore#_startHeartbeat()` - * (node_modules/bottleneck/lib/LocalDatastore.js:29,56): the guard - * `if (this.heartbeat == null && ...)` only (re)creates the periodic - * reservoir-refresh interval the FIRST time it runs. Every later call — - * including the one `updateSettings()` itself triggers internally — falls - * into the `else` branch and does `clearInterval(this.heartbeat)` WITHOUT - * resetting `this.heartbeat` back to `null`. Because the stale reference is - * left in place, every future `_startHeartbeat()` call keeps taking the same - * dead `else` branch: the periodic reservoir refresh is gone forever after - * the FIRST manual `updateSettings()` call on a limiter — every limiter here - * starts with a live heartbeat (buildLimiterDefaults() always sets - * reservoirRefreshInterval/reservoirRefreshAmount), so that "first call" is - * whichever of the 5 updateSettings() call sites in this file runs first. - * - * Work around it here instead of patching node_modules: null out the stale - * reference ourselves and re-invoke `_startHeartbeat()` so it takes the - * "start a fresh interval" branch again. Every `limiter.updateSettings(...)` - * call in this file MUST go through this helper, never Bottleneck's method - * directly. - */ -async function applyLimiterSettings( +function updateLimiterSettings( limiter: Bottleneck, updates: Bottleneck.ConstructorOptions -): Promise { - await limiter.updateSettings(updates); - const store = ( - limiter as unknown as { - _store?: { - heartbeat?: ReturnType | null; - _startHeartbeat?: () => void; - }; - } - )._store; - if (store && typeof store._startHeartbeat === "function") { - if (store.heartbeat != null) clearInterval(store.heartbeat); - store.heartbeat = null; - store._startHeartbeat(); +): Bottleneck { + const effective = limiterEffectiveSettings.get(limiter) ?? {}; + limiterEffectiveSettings.set(limiter, { ...effective, ...updates }); + return limiter.updateSettings(updates); +} + +function updateAllLimiterSettings() { + const defaults = buildLimiterDefaults(); + for (const limiter of limiters.values()) { + updateLimiterSettings(limiter, defaults); } } -async function updateAllLimiterSettings() { - const defaults = buildLimiterDefaults(); - await Promise.all( - Array.from(limiters.values(), (limiter) => applyLimiterSettings(limiter, defaults)) - ); +function clearPreservedReplacementSettings(connectionId: string): void { + for (const key of preservedReplacementSettings.keys()) { + if (key.includes(connectionId)) preservedReplacementSettings.delete(key); + } } function reconcileEnabledConnections( @@ -246,9 +219,8 @@ function reconcileEnabledConnections( nextEnabledConnections.add(connectionId); autoCount++; - // Route through getLimiter so the `queued`/`executing` listeners and - // lastDispatchAt heartbeat are wired up — otherwise the watchdog sees - // `stalledMs = now - 0` and falsely flags healthy idle limiters as wedged. + // Route through getLimiter so the queue-progress listeners are wired up. + // Otherwise a limiter created here could not be evaluated safely by the watchdog. getLimiter(provider, connectionId); } } @@ -269,82 +241,16 @@ function reconcileEnabledConnections( }; } -function watchdogTick() { - const now = Date.now(); - rpmGate.cleanupExpired(now); - // Clean up idle limiters that haven't been used recently - for (const [key, limiter] of Array.from(limiters)) { - const lastUsed = limiterLastUsed.get(key) ?? 0; - if (now - lastUsed > INACTIVE_LIMITER_MS) { - const counts = limiter.counts(); - if ( - counts.RECEIVED === 0 && - counts.QUEUED === 0 && - counts.RUNNING === 0 && - counts.EXECUTING === 0 - ) { - limiters.delete(key); - lastDispatchAt.delete(key); - limiterLastUsed.delete(key); - logRateLimit( - `🧹 [RATE-LIMIT] Evicting idle limiter: ${key} (inactive for ${Math.round((now - lastUsed) / 1000)}s)` - ); - trackAsyncOperation(limiter.disconnect()); - } - } - } - for (const [key, limiter] of Array.from(limiters)) { - const counts = limiter.counts(); - // RECEIVED-only work is still active and must not be evicted. Once a job - // is stably queued, Bottleneck reports it in QUEUED with RECEIVED=0; that - // is the state the wedge detector is designed to recover. - if (counts.RECEIVED > 0 || counts.QUEUED === 0) continue; - if (counts.RUNNING > 0 || counts.EXECUTING > 0) continue; - const lastDispatch = lastDispatchAt.get(key); - // No heartbeat yet → seed it and skip this tick. Prevents false wedge - // detection on a brand-new limiter or one created outside getLimiter. - if (lastDispatch === undefined) { - lastDispatchAt.set(key, now); - continue; - } - const stalledMs = now - lastDispatch; - if (stalledMs < WEDGE_THRESHOLD_MS) continue; - - warnRateLimit( - `🚨 [RATE-LIMIT] WEDGED: ${key} received=${counts.RECEIVED} queued=${counts.QUEUED} running=0 executing=0 stalled=${stalledMs}ms — force-resetting` - ); - // Live incident (log id 1784465227489-a2cbc0): disconnect() releases the - // heartbeat timer but does NOT reject the QUEUED jobs already sitting on - // this instance — withRateLimit's `limiter.schedule()` for those callers - // then just hangs forever (nothing will ever dequeue them; getLimiter() - // only hands out a FRESH instance to future callers), leaving the - // dispatch orphaned until the outer ~300s per-target timeout eventually - // aborts it. Real clients routinely give up (and retry) well before that - // — this specific incident's client aborted at ~60s having never reached - // the provider at all (queued=2 running=0 executing=0 the entire time). - // - // stop({ dropWaitingJobs: true }) rejects exactly the RECEIVED/QUEUED/ - // RUNNING jobs on THIS instance immediately (Bottleneck's own contract — - // see node_modules/bottleneck/bottleneck.d.ts StopOptions) so those - // withRateLimit() callers reject right away instead of hanging, letting - // combo's fallback/cooldown-wait engage within seconds instead of minutes. - // This is safe against the previously-documented "spurious 502 bursts" - // concern: the wedge condition checked above already requires - // RUNNING === 0 && EXECUTING === 0, so no job that's actually progressing - // can be caught by this — only ones already confirmed stuck. The instance - // is deleted from `limiters` synchronously (above) before this call, so - // no future getLimiter() call can ever hand out this now-stopped instance - // — the "permanently rejects future .schedule()" behavior stop() has is - // therefore moot; nothing will call .schedule() on it again. - evictWedgeLimiter(key, limiter); - } -} - let shutdownHandlersRegistered = false; export function startRateLimitWatchdog(): void { if (watchdogInterval) return; - watchdogInterval = setInterval(watchdogTick, WATCHDOG_INTERVAL_MS); + watchdogInterval = setInterval(() => { + const run = trackAsyncOperation(limiterWatchdog.run()); + void run.then(undefined, (error) => { + errorRateLimit("[RATE-LIMIT] Watchdog scan failed:", error); + }); + }, WATCHDOG_INTERVAL_MS); watchdogInterval.unref?.(); // Register SIGTERM/SIGINT shutdown handlers once, lazily, on first watchdog start. // Registering here (rather than at module load) avoids interfering with test runner @@ -362,54 +268,18 @@ export function stopRateLimitWatchdog(): void { watchdogInterval = null; } -export function __installLimiterForTests( - provider: string, - connectionId: string, - limiter: Bottleneck, - model = null -): void { - const key = getLimiterKey(provider, connectionId, model); - limiters.set(key, limiter); - lastDispatchAt.set(key, Date.now()); - limiterLastUsed.set(key, Date.now()); -} - -export function __runRateLimitWatchdogForTests(): void { - watchdogTick(); -} - -export function __getLimiterForTests(provider: string, connectionId: string, model = null) { - return getLimiter(provider, connectionId, model); -} - -export function __setLastDispatchAtForTests( - provider: string, - connectionId: string, - model: string | null, - timestamp: number -): void { - lastDispatchAt.set(getLimiterKey(provider, connectionId, model), timestamp); -} - -function evictWedgeLimiter(key: string, limiter: Bottleneck): void { - if (limiters.get(key) !== limiter) return; - evictLimiterAndDropQueued(key, limiter, "rate-limit-watchdog-wedge-reset"); -} - /** * Gracefully stop all limiters for process shutdown. - * ONLY call this from SIGTERM/SIGINT handlers — not during runtime resets. - * Calling .stop() during runtime (e.g. on 429 or connection disable) permanently - * rejects future .schedule() calls, causing 502 bursts. This function is the - * sole legitimate use of limiter.stop() in this module. + * Runtime wedge recovery also uses stop(), but only after synchronously + * removing that limiter from the cache so it can never accept new work. */ function shutdownLimiters(): void { for (const limiter of limiters.values()) { limiter.stop({ dropWaitingJobs: false }); } limiters.clear(); - lastDispatchAt.clear(); limiterLastUsed.clear(); + preservedReplacementSettings.clear(); } // Only register shutdown handlers when there are active limiters to shut down. @@ -441,6 +311,8 @@ function trackAsyncOperation(promise: Promise): Promise { export async function initializeRateLimits() { if (initialized) return; initialized = true; + // Fix Bottleneck v2.19.5 doExpire bug before any limiter is created. + applyBottleneckDoExpirePatch(); try { const { getCachedProviderConnections, getSettings } = await import("@/lib/localDb"); @@ -454,10 +326,13 @@ export async function initializeRateLimits() { // budget + concurrency cap (nvidia today). No-op for every provider without // an entry in either providerQuotaOverrides or PROVIDER_DEFAULT_RATE_LIMITS. setProviderQuotaOverrides(resilience.providerQuotaOverrides); + const { explicitCount, autoCount } = reconcileEnabledConnections( + connections as unknown[], + currentRequestQueueSettings + ); + updateAllLimiterSettings(); - // Load per-connection rate limit overrides before reconciliation can create - // any limiter. The RPM gate reads these overrides at admission time, and - // Bottleneck still needs the non-RPM connection settings immediately. + // Load per-connection rate limit overrides connectionRateLimitOverrides.clear(); for (const conn of connections as Array>) { const overrides = conn.rateLimitOverrides; @@ -466,12 +341,6 @@ export async function initializeRateLimits() { } } - const { explicitCount, autoCount } = reconcileEnabledConnections( - connections as unknown[], - currentRequestQueueSettings - ); - updateAllLimiterSettings(); - if (explicitCount > 0 || autoCount > 0) { logRateLimit( `🛡️ [RATE-LIMIT] Loaded ${explicitCount} explicit + ${autoCount} auto-enabled protection(s)` @@ -491,16 +360,21 @@ export async function initializeRateLimits() { export async function applyRequestQueueSettings(nextSettings: RequestQueueSettings) { currentRequestQueueSettings = { ...nextSettings }; + // Global policy changes invalidate snapshots from the previous generation. + preservedReplacementSettings.clear(); const { getCachedProviderConnections } = await import("@/lib/localDb"); const connections = await getCachedProviderConnections(); + // Also discard any snapshot created while the asynchronous DB read yielded. + preservedReplacementSettings.clear(); reconcileEnabledConnections(connections as unknown[], currentRequestQueueSettings); - await updateAllLimiterSettings(); + updateAllLimiterSettings(); } /** * Get or create a limiter for a given provider+connection combination */ export function enableRateLimitProtection(connectionId) { + if (!enabledConnections.has(connectionId)) clearPreservedReplacementSettings(connectionId); enabledConnections.add(connectionId); } @@ -509,14 +383,19 @@ export function enableRateLimitProtection(connectionId) { */ export function disableRateLimitProtection(connectionId) { enabledConnections.delete(connectionId); - // Drop queued jobs before evicting the limiter. Otherwise disconnect() leaves - // callers waiting on an instance that is no longer reachable from the cache. + clearPreservedReplacementSettings(connectionId); + // Ordinary administrative eviction uses disconnect(), not stop(), so + // in-flight requests can finish. Wedge recovery is the deliberate exception: + // it removes the limiter from the cache first, then stops it to settle jobs + // that were already proven stranded. for (const [key, limiter] of Array.from(limiters)) { - if (keyContainsConnection(key, connectionId)) { - evictLimiterAndDropQueued(key, limiter, "rate-limit-connection-disabled"); + if (key.includes(connectionId)) { + limiters.delete(key); + limiterWatchdog.forget(limiter); + limiterLastUsed.delete(key); + trackAsyncOperation(limiter.disconnect()); } } - rpmGate.clearConnection(connectionId); } /** @@ -542,13 +421,16 @@ export function refreshConnectionRateLimits(connectionId, overrides) { } else { connectionRateLimitOverrides.set(connectionId, overrides); } + clearPreservedReplacementSettings(connectionId); // Evict limiters referencing this connection so they get recreated on next use for (const [key, limiter] of Array.from(limiters)) { - if (keyContainsConnection(key, connectionId)) { - evictLimiterAndDropQueued(key, limiter, "rate-limit-settings-refresh"); + if (key.includes(connectionId)) { + limiters.delete(key); + limiterWatchdog.forget(limiter); + limiterLastUsed.delete(key); + trackAsyncOperation(limiter.disconnect()); } } - rpmGate.clearConnection(connectionId); } /** @@ -571,46 +453,51 @@ function getLimiterKey(provider, connectionId, model = null) { return `${provider}:${connectionId}`; } -const rpmGate = new RollingRpmGate({ - getGlobalRpm: () => currentRequestQueueSettings.requestsPerMinute, - getProviderWindow: getProviderDefaultRateLimit, - getConnectionRpm: (connectionId) => connectionRateLimitOverrides.get(connectionId)?.rpm, - getLimiterKey, - createQueueTimeoutError: (provider, model, maxWaitMs, reason) => - createQueueTimeoutError(provider, model, maxWaitMs, reason), -}); - function getLimiter(provider, connectionId, model = null) { const key = getLimiterKey(provider, connectionId, model); if (!limiters.has(key)) { - const defaults = buildLimiterDefaults(); - const overrides = connectionRateLimitOverrides.get(connectionId); - if (overrides) { - // 0 (or missing) means "no override — fall through to buildLimiterDefaults()". - if (typeof overrides.maxConcurrent === "number" && overrides.maxConcurrent > 0) { - defaults.maxConcurrent = overrides.maxConcurrent; + const preserved = preservedReplacementSettings.get(key); + let options: Bottleneck.ConstructorOptions; + if (preserved) { + preservedReplacementSettings.delete(key); + options = { ...preserved, id: key }; + } else { + const defaults = buildLimiterDefaults(); + const overrides = connectionRateLimitOverrides.get(connectionId); + if (overrides) { + // 0 (or missing) means "no override — fall through to buildLimiterDefaults()". + // Without this guard, an rpm of 0 sets reservoir=0, which Bottleneck treats + // as depleted and blocks all requests indefinitely. + if (typeof overrides.maxConcurrent === "number" && overrides.maxConcurrent > 0) { + defaults.maxConcurrent = overrides.maxConcurrent; + } + if (typeof overrides.minTime === "number" && overrides.minTime > 0) { + defaults.minTime = overrides.minTime; + } + if (typeof overrides.rpm === "number" && overrides.rpm > 0) { + defaults.reservoir = overrides.rpm; + defaults.reservoirRefreshAmount = overrides.rpm; + defaults.reservoirRefreshInterval = 60 * 1000; + } + // TODO: TPM/TPD integration requires separate token and request buckets. } - if (typeof overrides.minTime === "number" && overrides.minTime > 0) { - defaults.minTime = overrides.minTime; - } - // TODO: TPM/TPD integration — requires a token-bucket vs request-bucket - // separation. RPM is handled by the rolling lease gate below. - // When added, treat 0/missing the same way: fall through to system default. + options = { ...defaults, id: key }; } - const limiter = new Bottleneck({ - ...defaults, - id: key, - }); - // Heartbeat: timestamp every dispatch so the watchdog can tell a healthy - // queue (just dispatched a job) from a wedged one (queue has work but - // nothing has been dispatched in a while). - limiter.on("executing", () => { - lastDispatchAt.set(key, Date.now()); + const limiter = limiterFactory(options); + limiterEffectiveSettings.set(limiter, { ...options }); + limiter.on("queued", () => { + limiterWatchdog.noteQueued(key, limiter); }); + const markQueueProgress = () => { + limiterWatchdog.noteProgress(key, limiter); + }; + limiter.on("executing", markQueueProgress); + // A long-running job can leave older work queued. Start the idle grace + // from its completion, not from when that waiting work first arrived. + limiter.on("done", markQueueProgress); limiters.set(key, limiter); - lastDispatchAt.set(key, Date.now()); limiterLastUsed.set(key, Date.now()); } @@ -618,15 +505,6 @@ function getLimiter(provider, connectionId, model = null) { return limiters.get(key); } -function evictLimiterAndDropQueued(key: string, limiter: Bottleneck, reason: string): void { - if (limiters.get(key) === limiter) { - limiters.delete(key); - lastDispatchAt.delete(key); - limiterLastUsed.delete(key); - } - trackAsyncOperation(limiter.stop({ dropWaitingJobs: true, dropErrorMessage: reason })); -} - /** * Acquire a rate limit slot before making a request. * If rate limiting is disabled for this connection, returns immediately. @@ -651,20 +529,22 @@ export async function withRateLimit(provider, connectionId, model, fn, signal = throw err; } - const maxWaitMs = currentRequestQueueSettings.maxWaitMs; - const queueStartedAt = Date.now(); - const rpmLease = await rpmGate.acquire( + // Proactive sliding-window fallback for header-less providers with a declared cap + // (Fase 8.2). No-op unless PROVIDER_DEFAULT_RATE_LIMITS has an entry for `provider`. + await awaitProviderDefaultSlot( provider, connectionId, - model, signal, - maxWaitMs, - queueStartedAt + currentRequestQueueSettings.maxWaitMs ); + const limiter = getLimiter(provider, connectionId, model); - const key = getLimiterKey(provider, connectionId, model); - const jobId = `${key}:job-${nextJobTraceId++}`; - const scheduleOpts = { id: jobId }; + // Bottleneck's `expiration` starts only after a job leaves QUEUED. The + // legacy maxWaitMs setting therefore bounds limiter-managed execution; it + // is not a queue-wait deadline. + const executionExpirationMs = currentRequestQueueSettings.maxWaitMs; + const scheduleOpts = + executionExpirationMs && executionExpirationMs > 0 ? { expiration: executionExpirationMs } : {}; // Issue #6593: opt-in admission cap — fast-reject before Bottleneck's // schedule() (and before any downstream compression/prompt work runs) when @@ -675,129 +555,96 @@ export async function withRateLimit(provider, connectionId, model, fn, signal = model ? `${provider}/${model}` : provider ); if (admissionErr) { - rpmLease?.release(); logRateLimit( `🚧 [RATE-LIMIT] ${getLimiterKey(provider, connectionId, model)} — queue full, rejecting fast (maxQueueDepth=${currentRequestQueueSettings.maxQueueDepth})` ); throw admissionErr; } - let dispatched = false; - let queueExpired = false; - let dispatchCancelled = false; - let queueTimer: ReturnType | undefined; - const remainingWaitMs = - maxWaitMs > 0 ? Math.max(1, maxWaitMs - (Date.now() - queueStartedAt)) : 0; - const queueTimeoutPromise = - remainingWaitMs > 0 - ? new Promise((_, reject) => { - queueTimer = setTimeout(() => { - if (dispatched) return; - queueExpired = true; - logRateLimit( - `⏰ [RATE-LIMIT] ${key} — job exceeded ${Math.ceil(maxWaitMs / 1000)}s queue wait budget, dropping` - ); - reject(new Error("rate-limit-queue-timeout")); - }, remainingWaitMs); - }) - : null; - const scheduled = limiter.schedule(scheduleOpts, async () => { - if (queueExpired) { - throw createQueueTimeoutError(provider, model, maxWaitMs); - } - if (dispatchCancelled) { - const error = new Error("The operation was aborted before limiter dispatch"); - error.name = "AbortError"; - throw error; - } - if (signal?.aborted) { - const error = new Error("The operation was aborted before limiter dispatch"); - error.name = "AbortError"; - throw error; - } - dispatched = true; - if (queueTimer) clearTimeout(queueTimer); - return fn(); - }); - try { if (signal) { let abortListener: (() => void) | undefined; - const abortPromise = new Promise((_, reject) => { - const onAbort = () => { - const reason = signal.reason; - // Reject before evicting the queued job so the caller observes its - // abort reason instead of Bottleneck's internal drop error. - if (reason instanceof Error) { - reject(reason); - } else { - const err = new Error( - typeof reason === "string" ? reason : "The operation was aborted" - ); - err.name = "AbortError"; - if (reason !== undefined) { - (err as Error & { cause?: unknown }).cause = reason; - } - reject(err); - } - if (!dispatched) { - dispatchCancelled = true; - if (queueTimer) clearTimeout(queueTimer); - // Leave the cancelled job in Bottleneck so queued peers are not dropped. - // Its scheduled callback will consume one queue turn and exit before fn(). - } - }; - if (signal.aborted) { - onAbort(); + const { promise: abortPromise, reject: rejectAbort } = Promise.withResolvers(); + const onAbort = () => { + const reason = signal.reason; + // Preserve native Error reasons (including AbortController's + // read-only DOMException) instead of mutating or wrapping them. + if (reason instanceof Error) { + rejectAbort(reason); return; } + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + if (reason !== undefined) { + (err as Error & { cause?: unknown }).cause = reason; + } + rejectAbort(err); + }; + if (signal.aborted) { + onAbort(); + } else { abortListener = onAbort; signal.addEventListener("abort", abortListener, { once: true }); - }); + } try { - const races: Promise[] = [scheduled, abortPromise]; - if (queueTimeoutPromise) races.push(queueTimeoutPromise); - return await Promise.race(races); + return await Promise.race([limiter.schedule(scheduleOpts, fn), abortPromise]); } finally { if (abortListener) { signal.removeEventListener("abort", abortListener); } } } else { - return await (queueTimeoutPromise - ? Promise.race([scheduled, queueTimeoutPromise]) - : scheduled); + return await limiter.schedule(scheduleOpts, fn); } } catch (err) { - if (queueTimer) clearTimeout(queueTimer); - if (!dispatched) rpmLease?.release(); - if (err?.message === "rate-limit-upstream-429") { - const rateLimitErr = new Error( - `Request dropped while the ${provider} connection was under an upstream rate-limit cooldown`, - { cause: err } - ) as Error & { code?: string; status?: number }; - rateLimitErr.code = "RATE_LIMIT_UPSTREAM_429"; - rateLimitErr.status = 429; - throw rateLimitErr; + // Only Bottleneck-owned failures are rewritten. Application code can throw + // the same text and must retain its original identity and semantics. + if ( + err instanceof Bottleneck.BottleneckError && + /^This job timed out after \d+ ms\.$/.test(err.message) + ) { + const key = getLimiterKey(provider, connectionId, model); + logRateLimit( + `⏰ [RATE-LIMIT] ${key} — limiter-managed execution expired after ${Math.ceil((executionExpirationMs || 0) / 1000)}s` + ); + throw markLocalRateLimitError( + new Error( + `Request exceeded OmniRoute's local rate-limit execution expiration ` + + `(legacy resilienceSettings.requestQueue.maxWaitMs=${executionExpirationMs}ms) for ` + + `${model ? `${provider}/${model}` : provider}. Bottleneck applies this deadline only ` + + `after dispatch; it does not bound queue wait and is not an upstream-generated timeout.`, + { cause: err } + ), + RATE_LIMIT_EXECUTION_TIMEOUT_CODE + ); } - // The watchdog's stop({ dropWaitingJobs: true }) wedge-recovery (above) rejects - // queued jobs with this exact message. Rewrite it the same way as the timeout - // case — a clear, OmniRoute-owned, classifiable error — so combo's transient-error - // handling (which already treats a 502 as retryable) falls back to the next target - // immediately instead of surfacing Bottleneck's internal wording. - if (err?.message === "rate-limit-watchdog-wedge-reset") { + + if ( + err instanceof Bottleneck.BottleneckError && + err.message === "rate-limit-watchdog-wedge-reset" + ) { + const cleanup = limiterWatchdog.getEviction(limiter); + if (!cleanup) throw err; + + let cleanupError: unknown; + try { + await cleanup; + } catch (error) { + cleanupError = error; + errorRateLimit("[RATE-LIMIT] Wedge cleanup failed:", error); + } + + const key = getLimiterKey(provider, connectionId, model); + logRateLimit(`↪️ [RATE-LIMIT] ${key} — surfacing local wedge; caller will not be replayed`); const wedgeErr = new Error( `Request dropped: the local rate-limit queue for ${model ? `${provider}/${model}` : provider} ` + - `was detected as wedged (stalled with nothing executing) and force-reset. This is OmniRoute's ` + - `own queue recovering, not an upstream error.`, + `was detected as wedged (stalled with nothing executing) and force-reset. OmniRoute does ` + + `not replay dropped work automatically; combo routing may fall back to another target.`, { cause: err } - ) as Error & { code?: string }; - wedgeErr.code = "RATE_LIMIT_QUEUE_WEDGED"; - throw wedgeErr; - } - if (err?.message === "rate-limit-queue-timeout") { - throw createQueueTimeoutError(provider, model, maxWaitMs); + ) as Error & { cleanupError?: unknown }; + if (cleanupError !== undefined) wedgeErr.cleanupError = cleanupError; + throw markLocalRateLimitError(wedgeErr, RATE_LIMIT_QUEUE_WEDGED_CODE); } throw err; } @@ -842,12 +689,21 @@ export function updateFromHeaders(provider, connectionId, headers, status, model `🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s, dropping ${counts.QUEUED} queued request(s)` ); - rpmGate.block(provider, connectionId, model, retryAfterMs); - - // Evict from the cache before stopping so follow-up requests get a fresh - // instance. Stopping the unreachable instance rejects its queued jobs and - // releases its heartbeat without poisoning the replacement limiter. - evictLimiterAndDropQueued(limiterKey, limiter, "rate-limit-upstream-429"); + // Evict from the cache so follow-up learning from the same error body + // can materialize a fresh limiter immediately. Do NOT call limiter.stop() — + // it permanently rejects future .schedule() calls with "This limiter has been stopped". + // In-flight requests holding a reference to the evicted instance will fail (they + // were already going to fail — the 429 means the API rejected them), but future + // requests will get a fresh Bottleneck instance via getLimiter(). + // Call disconnect() (not stop()) to release Bottleneck's internal heartbeat timer + // without permanently poisoning the instance for any remaining in-flight jobs. + // Without disconnect() here, every 429 leaks a heartbeat timer until GC reclaims + // the abandoned Bottleneck; under sustained quota pressure that is a real leak. + limiters.delete(limiterKey); + limiterWatchdog.forget(limiter); + limiterLastUsed.delete(limiterKey); + preservedReplacementSettings.delete(limiterKey); + trackAsyncOperation(limiter.disconnect()); return; } @@ -856,41 +712,40 @@ export function updateFromHeaders(provider, connectionId, headers, status, model logRateLimit( `⚠️ [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — near capacity, slowing down` ); - trackAsyncOperation(applyLimiterSettings(limiter, { minTime: 200 })); + updateLimiterSettings(limiter, { + minTime: 200, // Add 200ms between requests + }); return; } // Normal response — update limiter from headers if (!isNaN(limit) && limit > 0) { + const resetMs = parseResetTime(resetStr) || 60000; + // Calculate optimal minTime from RPM limit const minTime = Math.max(0, Math.floor(60000 / limit) - 10); // Small buffer const updates: LimiterUpdateSettings = { minTime }; - const resetMs = parseResetTime(resetStr) || 60000; - // Keep adaptive pacing from response headers, but do not mutate an RPM - // reservoir. RPM admission is enforced by the rolling lease gate. + // If remaining is low (< 10% of limit), set reservoir to throttle immediately if (!isNaN(remaining)) { if (remaining < limit * 0.1) { - rpmGate.learnHeaderWindow( - provider, - connectionId, - model, - remaining, - resetMs, - Date.now() + resetMs - ); + updates.reservoir = remaining; + updates.reservoirRefreshAmount = limit; + updates.reservoirRefreshInterval = resetMs; logRateLimit( `⚠️ [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — ${remaining}/${limit} remaining, throttling` ); } else if (remaining > limit * 0.5) { // Plenty of headroom — relax the limiter updates.minTime = 0; - rpmGate.clearLearnedHeaderWindow(provider, connectionId, model); + updates.reservoir = null; + updates.reservoirRefreshAmount = null; + updates.reservoirRefreshInterval = null; } } - trackAsyncOperation(applyLimiterSettings(limiter, updates)); + updateLimiterSettings(limiter, updates); // Persist learned limits (debounced) recordLearnedLimit( @@ -1003,6 +858,14 @@ export async function __flushLearnedLimitsForTests() { } } +export function __setLimiterFactoryForTests(factory: LimiterFactory): void { + limiterFactory = factory; +} + +export async function __runLimiterWatchdogForTests(now = Date.now()): Promise { + await limiterWatchdog.run(now); +} + export async function __resetRateLimitManagerForTests() { if (persistTimer) { clearTimeout(persistTimer); @@ -1019,11 +882,11 @@ export async function __resetRateLimitManagerForTests() { } limiters.clear(); enabledConnections.clear(); - connectionRateLimitOverrides.clear(); - rpmGate.reset(); initialized = false; - lastDispatchAt.clear(); limiterLastUsed.clear(); + preservedReplacementSettings.clear(); + limiterFactory = defaultLimiterFactory; + limiterWatchdog.reset(); shutdownHandlersRegistered = false; for (const key of Object.keys(learnedLimits)) { @@ -1094,7 +957,7 @@ async function loadPersistedLimits() { const limiter = limiters.get(key); if (limiter && limit > 0) { const inferredMinTime = minTime || Math.max(0, Math.floor(60000 / limit) - 10); - await applyLimiterSettings(limiter, { minTime: inferredMinTime }); + updateLimiterSettings(limiter, { minTime: inferredMinTime }); count++; } } @@ -1125,10 +988,15 @@ export function updateFromResponseBody(provider, connectionId, responseBody, sta const { retryAfterMs, reason } = parseRetryAfterFromBody(responseBody); if (retryAfterMs && retryAfterMs > 0) { - getLimiter(provider, connectionId, model); + const limiter = getLimiter(provider, connectionId, model); logRateLimit( `🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — body-parsed retry: ${Math.ceil(retryAfterMs / 1000)}s (${reason})` ); - rpmGate.block(provider, connectionId, model, retryAfterMs); + + updateLimiterSettings(limiter, { + reservoir: 0, + reservoirRefreshAmount: 60, + reservoirRefreshInterval: retryAfterMs, + }); } } diff --git a/open-sse/services/rateLimitManager/admission.ts b/open-sse/services/rateLimitManager/admission.ts index d9ace7bf13..af3192e0d5 100644 --- a/open-sse/services/rateLimitManager/admission.ts +++ b/open-sse/services/rateLimitManager/admission.ts @@ -13,8 +13,10 @@ * @module services/rateLimitManager/admission */ +import { markLocalRateLimitError, RATE_LIMIT_QUEUE_FULL_CODE } from "./errors"; + export interface QueueFullError extends Error { - code: "RATE_LIMIT_QUEUE_FULL"; + code: typeof RATE_LIMIT_QUEUE_FULL_CODE; status: 429; } @@ -36,13 +38,8 @@ export function checkQueueAdmission( `queued request(s), at or above the configured admission cap maxQueueDepth (${maxQueueDepth}) ` + `— this is OmniRoute's request queue (resilienceSettings.requestQueue.maxQueueDepth), not an ` + `upstream rejection. Raise it in Settings → Resilience if this is expected burst traffic.` - ) as Error & { code?: string; status?: number }; - err.code = "RATE_LIMIT_QUEUE_FULL"; - // chatCore's generic catch-all fallback (open-sse/handlers/chatCore.ts) maps a - // status-less error to HTTP 502 — which also risks tripping the whole-provider - // circuit breaker (PROVIDER_BREAKER_FAILURE_STATUSES includes 502) for what is a - // purely local, in-process admission decision. Tag 429 explicitly so it is read - // via `error.status` before that fallback kicks in. - err.status = 429; - return err as QueueFullError; + ); + // The public code/status remain useful to callers, while the WeakMap brand + // is the provenance signal used by health and routing decisions. + return markLocalRateLimitError(err, RATE_LIMIT_QUEUE_FULL_CODE) as QueueFullError; } diff --git a/open-sse/services/rateLimitManager/errors.ts b/open-sse/services/rateLimitManager/errors.ts new file mode 100644 index 0000000000..167f92c834 --- /dev/null +++ b/open-sse/services/rateLimitManager/errors.ts @@ -0,0 +1,94 @@ +export const RATE_LIMIT_EXECUTION_TIMEOUT_CODE = "RATE_LIMIT_EXECUTION_TIMEOUT"; +export const RATE_LIMIT_QUEUE_FULL_CODE = "RATE_LIMIT_QUEUE_FULL"; +export const RATE_LIMIT_QUEUE_WEDGED_CODE = "RATE_LIMIT_QUEUE_WEDGED"; +export const LEGACY_RATE_LIMIT_QUEUE_TIMEOUT_CODE = "RATE_LIMIT_QUEUE_TIMEOUT"; + +export type LocalRateLimitErrorCode = + | typeof RATE_LIMIT_EXECUTION_TIMEOUT_CODE + | typeof RATE_LIMIT_QUEUE_FULL_CODE + | typeof RATE_LIMIT_QUEUE_WEDGED_CODE; + +export type TrustedLocalRateLimitErrorCode = + LocalRateLimitErrorCode | typeof LEGACY_RATE_LIMIT_QUEUE_TIMEOUT_CODE; + +export interface TrustedLocalRateLimitFailure { + code: TrustedLocalRateLimitErrorCode; + status: 429 | 503 | 504; +} + +const localRateLimitErrors = new WeakMap(); +const localRateLimitResponses = new WeakMap(); + +function getStatusForCode(code: TrustedLocalRateLimitErrorCode): 429 | 503 | 504 { + switch (code) { + case RATE_LIMIT_QUEUE_FULL_CODE: + return 429; + case RATE_LIMIT_EXECUTION_TIMEOUT_CODE: + return 504; + case RATE_LIMIT_QUEUE_WEDGED_CODE: + case LEGACY_RATE_LIMIT_QUEUE_TIMEOUT_CODE: + return 503; + } +} + +/** + * Brand an error created by OmniRoute's local limiter. The WeakMap identity, + * not the public code string, is the trusted provenance signal. + */ +export function markLocalRateLimitError( + error: T, + code: TrustedLocalRateLimitErrorCode +): T & { code: TrustedLocalRateLimitErrorCode; status: 429 | 503 | 504 } { + const failure = Object.freeze({ code, status: getStatusForCode(code) }); + localRateLimitErrors.set(error, failure); + const branded = error as T & { + code: TrustedLocalRateLimitErrorCode; + status: 429 | 503 | 504; + }; + branded.code = failure.code; + branded.status = failure.status; + return branded; +} + +export function getTrustedLocalRateLimitError(error: unknown): TrustedLocalRateLimitFailure | null { + if (!error || (typeof error !== "object" && typeof error !== "function")) return null; + return localRateLimitErrors.get(error as object) ?? null; +} + +/** + * Return the public fields for a trusted local failure without its low-level + * Bottleneck cause, which must remain server-side diagnostic context. + */ +export function getClientSafeLocalRateLimitError( + error: unknown +): (TrustedLocalRateLimitFailure & { message: string }) | null { + const failure = getTrustedLocalRateLimitError(error); + if (!failure) return null; + return { + ...failure, + message: error instanceof Error ? error.message : "Local rate-limit failure", + }; +} + +/** + * Transfer trusted local provenance from a branded error to its generated + * internal Response. Provider-controlled bodies and headers cannot set this. + */ +export function markTrustedLocalRateLimitResponse(response: Response, error: unknown): Response { + const failure = getTrustedLocalRateLimitError(error); + if (failure) localRateLimitResponses.set(response, failure); + return response; +} + +export function getTrustedLocalRateLimitResponse( + response: Response +): TrustedLocalRateLimitFailure | null { + return localRateLimitResponses.get(response) ?? null; +} + +/** Preserve trusted provenance when an internal response wrapper must allocate. */ +export function inheritTrustedLocalRateLimitResponse(source: Response, target: Response): Response { + const failure = localRateLimitResponses.get(source); + if (failure) localRateLimitResponses.set(target, failure); + return target; +} diff --git a/open-sse/services/rateLimitManager/wedgeWatchdog.ts b/open-sse/services/rateLimitManager/wedgeWatchdog.ts new file mode 100644 index 0000000000..624a413880 --- /dev/null +++ b/open-sse/services/rateLimitManager/wedgeWatchdog.ts @@ -0,0 +1,210 @@ +import Bottleneck from "bottleneck"; + +export const WATCHDOG_INTERVAL_MS = 30_000; + +const INACTIVE_LIMITER_MS = 10 * 60 * 1000; +const IDLE_CAPACITY_WEDGE_GRACE_MS = 10_000; + +interface IdleCapacitySnapshot { + lastProgress: number; + reservoir: number | null; +} + +interface LimiterWedgeWatchdogDependencies { + limiters: Map; + limiterLastUsed: Map; + limiterEffectiveSettings: WeakMap; + preservedReplacementSettings: Map; + trackBackground: (promise: Promise) => void; + log: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; +} + +/** + * Detects a Bottleneck queue that has remained idle despite immediately usable + * capacity. State is keyed by limiter identity so late events from an evicted + * instance cannot mutate the replacement's progress record. + */ +export class LimiterWedgeWatchdog { + private queueProgressAt = new WeakMap(); + private evictions = new WeakMap>(); + private currentRun: Promise | null = null; + + constructor(private readonly dependencies: LimiterWedgeWatchdogDependencies) {} + + noteQueued(key: string, limiter: Bottleneck): void { + if (this.dependencies.limiters.get(key) !== limiter) return; + if (!this.queueProgressAt.has(limiter)) this.queueProgressAt.set(limiter, Date.now()); + } + + noteProgress(key: string, limiter: Bottleneck): void { + if (this.dependencies.limiters.get(key) !== limiter) return; + if (limiter.counts().QUEUED > 0) { + this.queueProgressAt.set(limiter, Date.now()); + } else { + this.queueProgressAt.delete(limiter); + } + } + + forget(limiter: Bottleneck): void { + this.queueProgressAt.delete(limiter); + } + + getEviction(limiter: Bottleneck): Promise | undefined { + return this.evictions.get(limiter); + } + + run(now = Date.now()): Promise { + if (this.currentRun) return this.currentRun; + const run = this.tick(now); + this.currentRun = run; + void run.then( + () => { + if (this.currentRun === run) this.currentRun = null; + }, + () => { + if (this.currentRun === run) this.currentRun = null; + } + ); + return run; + } + + reset(): void { + this.queueProgressAt = new WeakMap(); + this.evictions = new WeakMap(); + this.currentRun = null; + } + + private async tick(now: number): Promise { + const { limiters, limiterLastUsed, log, trackBackground, warn } = this.dependencies; + + for (const [key, limiter] of Array.from(limiters)) { + const lastUsed = limiterLastUsed.get(key) ?? 0; + if (now - lastUsed <= INACTIVE_LIMITER_MS) continue; + + const counts = limiter.counts(); + if (counts.QUEUED > 0 || counts.RUNNING > 0 || counts.EXECUTING > 0) continue; + + limiters.delete(key); + this.queueProgressAt.delete(limiter); + limiterLastUsed.delete(key); + log( + `[RATE-LIMIT] Evicting idle limiter: ${key} ` + + `(inactive for ${Math.round((now - lastUsed) / 1000)}s)` + ); + trackBackground(limiter.disconnect()); + } + + for (const [key, limiter] of Array.from(limiters)) { + const snapshot = await this.getStableIdleCapacity(key, limiter, now); + if (!snapshot) continue; + + const counts = limiter.counts(); + const cleanup = this.evict(key, limiter, snapshot); + if (!cleanup) continue; + + warn( + `[RATE-LIMIT] WEDGED: ${key} queued=${counts.QUEUED} running=0 executing=0 ` + + `stalled=${now - snapshot.lastProgress}ms with idle capacity — force-resetting` + ); + await cleanup; + } + } + + private async getStableIdleCapacity( + key: string, + limiter: Bottleneck, + now: number + ): Promise { + const before = limiter.counts(); + if (before.QUEUED === 0) { + this.queueProgressAt.delete(limiter); + return null; + } + if (before.RUNNING > 0 || before.EXECUTING > 0) return null; + + const lastProgress = this.queueProgressAt.get(limiter); + if (lastProgress === undefined) { + this.queueProgressAt.set(limiter, now); + return null; + } + if (now - lastProgress < IDLE_CAPACITY_WEDGE_GRACE_MS) return null; + + let canRunNow: boolean; + let reservoir: number | null; + try { + // Every job this manager submits has Bottleneck's default weight of 1. + // check(1) is an eligibility query for exactly that shape, not a generic + // query about an arbitrary weighted queue head. + canRunNow = await limiter.check(1); + if (!canRunNow) return null; + reservoir = await limiter.currentReservoir(); + } catch { + return null; + } + if (this.dependencies.limiters.get(key) !== limiter) return null; + + const after = limiter.counts(); + if ( + after.QUEUED === 0 || + after.RUNNING > 0 || + after.EXECUTING > 0 || + this.queueProgressAt.get(limiter) !== lastProgress + ) { + return null; + } + return { lastProgress, reservoir }; + } + + private evict( + key: string, + limiter: Bottleneck, + snapshot: IdleCapacitySnapshot + ): Promise | null { + const { limiterEffectiveSettings, limiterLastUsed, limiters, preservedReplacementSettings } = + this.dependencies; + if (limiters.get(key) !== limiter) return null; + + const counts = limiter.counts(); + if ( + counts.QUEUED === 0 || + counts.RUNNING > 0 || + counts.EXECUTING > 0 || + this.queueProgressAt.get(limiter) !== snapshot.lastProgress + ) { + return null; + } + + const effectiveSettings = limiterEffectiveSettings.get(limiter) ?? {}; + preservedReplacementSettings.set(key, { + ...effectiveSettings, + id: key, + // Carry consumed capacity forward. Restarting the refresh interval from + // replacement creation is conservative and cannot grant an early burst. + reservoir: snapshot.reservoir, + }); + limiters.delete(key); + this.queueProgressAt.delete(limiter); + limiterLastUsed.delete(key); + + // Register this Promise before stop() runs. Every dropped caller awaits the + // same cleanup and is surfaced exactly once; none is replayed automatically. + const stopped = Promise.resolve().then(() => + limiter.stop({ + dropWaitingJobs: true, + dropErrorMessage: "rate-limit-watchdog-wedge-reset", + }) + ); + const cleanup = stopped + .then( + () => limiter.disconnect(), + async (stopError: unknown) => { + await limiter.disconnect(); + throw stopError; + } + ) + .then(() => true); + this.evictions.set(limiter, cleanup); + return cleanup; + } +} diff --git a/open-sse/services/reasoningCache.ts b/open-sse/services/reasoningCache.ts index 3d4a715fe0..dc81b14e69 100644 --- a/open-sse/services/reasoningCache.ts +++ b/open-sse/services/reasoningCache.ts @@ -22,6 +22,7 @@ import { getReasoningCacheStats, setReasoningCache, } from "../../src/lib/db/reasoningCache.ts"; +import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts"; // ──────────────── Provider/Model Detection ──────────────── @@ -63,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; @@ -93,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; @@ -194,6 +205,9 @@ export function cacheReasoningByKey( reasoning: string ): void { if (!key || !reasoning) return; + // ponytail: never store the internal replay placeholder — models echo it + // and it poisons the cache (upstream echo loop, OmniRoute #9573). + if (isInternalReasoningPlaceholder(reasoning)) return; if (reasoning.length > MAX_ENTRY_BYTES) { reasoning = reasoning.slice(0, MAX_ENTRY_BYTES); @@ -259,6 +273,8 @@ export function cacheReasoningFromAssistantMessage( ? message.reasoning : ""; if (!reasoning) return 0; + // ponytail: don't capture the echoed placeholder into the cache. + if (isInternalReasoningPlaceholder(reasoning)) return 0; const toolCallIds = Array.isArray(message.tool_calls) ? (message.tool_calls as ToolCallLike[]) @@ -299,6 +315,12 @@ export function lookupReasoning(toolCallId: string): string | null { const mem = memoryCache.get(toolCallId); if (mem) { if (Date.now() < mem.expiresAt) { + // ponytail: never replay the internal placeholder from memory. + if (isInternalReasoningPlaceholder(mem.reasoning)) { + memoryCache.delete(toolCallId); + misses++; + return null; + } hits++; return mem.reasoning; } @@ -314,6 +336,11 @@ export function lookupReasoning(toolCallId: string): string | null { // DB lookup failure is non-fatal; treat it as a cache miss. } if (dbResult) { + // ponytail: never promote/replay the internal placeholder from DB. + if (isInternalReasoningPlaceholder(dbResult.reasoning)) { + misses++; + return null; + } hits++; let promotedReasoning = dbResult.reasoning; if (promotedReasoning.length > MAX_ENTRY_BYTES) { 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/streamRecovery.ts b/open-sse/services/streamRecovery.ts index 58b464a81d..a0a397fd87 100644 --- a/open-sse/services/streamRecovery.ts +++ b/open-sse/services/streamRecovery.ts @@ -11,6 +11,13 @@ * without real sockets. The ReadableStream wiring lives in `createRecoverableStream`. */ import { STREAM_RECOVERY } from "../config/constants.ts"; +import { + createThroughputWatchdog, + ThroughputWatchdogError, + type ThroughputWatchdogOptions, +} from "./throughputWatchdog.ts"; + +export { ThroughputWatchdogError } from "./throughputWatchdog.ts"; /** Raised internally when an upstream stream ends without a terminal SSE marker. */ export class TruncatedStreamError extends Error { @@ -123,7 +130,9 @@ const RETRYABLE_ERROR_NAMES = new Set(["TimeoutError", "BodyTimeoutError"]); * the executor retry/failover loop, not here. */ export function isRetryableStreamError(error: unknown): boolean { - if (error instanceof TruncatedStreamError) return true; + if (error instanceof TruncatedStreamError || error instanceof ThroughputWatchdogError) { + return true; + } if (!error || typeof error !== "object") return false; const name = (error as { name?: unknown }).name; @@ -289,6 +298,10 @@ export interface RecoverableStreamOptions { maxContinuations?: number; /** Observability hook fired on each continuation attempt. */ onContinue?: (attempt: number, assistantSoFar: string) => void; + /** Opt-in active-stream output-quality watchdog. Disabled when omitted. */ + throughputWatchdog?: ThroughputWatchdogOptions; + /** Sanitized observability hook fired before the active attempt is aborted. */ + onWatchdogAbort?: (error: ThroughputWatchdogError) => void; } /** @@ -312,6 +325,7 @@ export function createRecoverableStream( let retries = 0; let finalized = false; let cancelled = false; + let throughputWatchdog = createThroughputWatchdog(options.throughputWatchdog); const runFinalize = () => { if (finalized) return; @@ -342,6 +356,7 @@ export function createRecoverableStream( if (!next) return false; reader = next.getReader(); holdback.discard(); // reuse the (still-uncommitted) buffer for the new attempt + throughputWatchdog = createThroughputWatchdog(options.throughputWatchdog); return true; }; @@ -519,6 +534,32 @@ export function createRecoverableStream( if (value === undefined) continue; + const watchdogDecision = throughputWatchdog.observe(value); + if (watchdogDecision.abort) { + const error = new ThroughputWatchdogError(); + options.onWatchdogAbort?.(error); + if (!holdback.committed && (await tryReopen(error))) continue; + if (holdback.committed) { + try { + await reader.cancel(error); + } catch { + // The active attempt may have closed while the watchdog was deciding. + } + if (await tryContinue(controller)) { + runFinalize(); + controller.close(); + return; + } + runFinalize(); + controller.error(error); + return; + } + flushHeld(controller); + runFinalize(); + controller.close(); + return; + } + if (holdback.committed) { emit(controller, value); return; diff --git a/open-sse/services/throughputWatchdog.ts b/open-sse/services/throughputWatchdog.ts new file mode 100644 index 0000000000..48aa9cabdb --- /dev/null +++ b/open-sse/services/throughputWatchdog.ts @@ -0,0 +1,175 @@ +/** + * Deterministic quality watchdog for active SSE streams. + * + * Unlike the idle timeout, this only makes a decision after a warm-up period and + * a complete rolling window. Heartbeats/metadata and tool/reasoning phases do not + * count as assistant output (and tool/reasoning phases suspend judgement). + */ + +export interface ThroughputWatchdogOptions { + enabled?: boolean; + warmupMs?: number; + windowMs?: number; + minUsefulBytesPerSecond?: number; + minUsefulBytes?: number; + now?: () => number; +} + +export interface ThroughputWatchdogDecision { + abort: boolean; + reason?: "throughput_too_low"; + usefulBytes: number; + rateBytesPerSecond: number; + protectedPhase: boolean; +} + +export class ThroughputWatchdogError extends Error { + readonly code = "STREAM_THROUGHPUT_TOO_LOW"; + + constructor(message = "Upstream stream throughput remained below the configured minimum") { + super(message); + this.name = "ThroughputWatchdogError"; + } +} + +type ParsedEvent = { usefulBytes: number; protectedPhase: boolean }; + +function parseEvent(event: string): ParsedEvent { + const lines = event.split(/\r?\n/); + const eventName = lines + .find((line) => /^event:\s*/i.test(line)) + ?.replace(/^event:\s*/i, "") + .trim(); + const data = lines + .filter((line) => /^data:\s*/i.test(line)) + .map((line) => line.replace(/^data:\s*/i, "").trim()) + .join("\n"); + if (!data || data === "[DONE]") return { usefulBytes: 0, protectedPhase: false }; + + let payload: unknown; + try { + payload = JSON.parse(data); + } catch { + return { usefulBytes: 0, protectedPhase: false }; + } + + const record = payload as Record; + const type = typeof record.type === "string" ? record.type : eventName; + if (type && /(reasoning|thinking|tool|function_call)/i.test(type)) { + return { usefulBytes: 0, protectedPhase: true }; + } + + const choices = Array.isArray(record.choices) ? record.choices : []; + let useful = ""; + let protectedPhase = false; + for (const choice of choices) { + const delta = (choice as Record).delta; + if (!delta || typeof delta !== "object") continue; + const deltaRecord = delta as Record; + if (Array.isArray(deltaRecord.tool_calls) || deltaRecord.function_call) { + protectedPhase = true; + } + for (const key of ["content", "text"]) { + if (typeof deltaRecord[key] === "string") useful += deltaRecord[key] as string; + } + if ( + typeof deltaRecord.reasoning_content === "string" || + typeof deltaRecord.reasoning === "string" + ) { + protectedPhase = true; + } + } + + const outputText = typeof record.delta === "string" ? record.delta : undefined; + if (outputText) useful += outputText; + const nestedDelta = record.delta; + if (nestedDelta && typeof nestedDelta === "object") { + const nested = nestedDelta as Record; + const nestedType = typeof nested.type === "string" ? nested.type : ""; + if (/(reasoning|thinking|tool|function_call)/i.test(nestedType)) { + protectedPhase = true; + } + if (typeof nested.text === "string") useful += nested.text; + } + const contentBlock = record.content_block; + if (contentBlock && typeof contentBlock === "object") { + const blockType = (contentBlock as Record).type; + if (typeof blockType === "string" && /(reasoning|thinking|tool_use)/i.test(blockType)) { + protectedPhase = true; + } + } + if (protectedPhase) useful = ""; + return { + usefulBytes: useful ? new TextEncoder().encode(useful).byteLength : 0, + protectedPhase, + }; +} + +export class ThroughputWatchdog { + private readonly enabled: boolean; + private readonly warmupMs: number; + private readonly windowMs: number; + private readonly minimumRate: number; + private readonly minimumBytes: number; + private readonly now: () => number; + private startedAt: number | null = null; + private buffer = ""; + private readonly decoder = new TextDecoder(); + private samples: Array<{ at: number; bytes: number }> = []; + private protectedPhase = false; + + constructor(options: ThroughputWatchdogOptions = {}) { + this.enabled = options.enabled === true; + this.warmupMs = Math.max(0, Math.floor(options.warmupMs ?? 30_000)); + this.windowMs = Math.max(1, Math.floor(options.windowMs ?? 30_000)); + this.minimumRate = Math.max(0, options.minUsefulBytesPerSecond ?? 1); + this.minimumBytes = Math.max(1, Math.floor(options.minUsefulBytes ?? 1)); + this.now = options.now ?? (() => Date.now()); + } + + observe(chunk: Uint8Array | string): ThroughputWatchdogDecision { + const at = this.now(); + if (this.startedAt === null) this.startedAt = at; + if (!this.enabled) return this.decision(false, 0); + this.buffer += typeof chunk === "string" ? chunk : this.decoder.decode(chunk, { stream: true }); + const events = this.buffer.split(/\r?\n\r?\n/); + this.buffer = events.pop() ?? ""; + let useful = 0; + for (const event of events) { + const parsed = parseEvent(event); + useful += parsed.usefulBytes; + if (parsed.protectedPhase) this.protectedPhase = true; + if (parsed.usefulBytes > 0) this.protectedPhase = false; + } + if (useful > 0) this.samples.push({ at, bytes: useful }); + const cutoff = at - this.windowMs; + this.samples = this.samples.filter((sample) => sample.at >= cutoff); + const windowBytes = this.samples.reduce((sum, sample) => sum + sample.bytes, 0); + const elapsed = at - (this.startedAt ?? at); + const rate = windowBytes / Math.max(1, this.windowMs / 1000); + const ready = elapsed >= this.warmupMs + this.windowMs; + const measurable = windowBytes === 0 || windowBytes >= this.minimumBytes; + const abort = ready && !this.protectedPhase && measurable && rate < this.minimumRate; + return this.decision(abort, windowBytes, rate); + } + + private decision( + abort: boolean, + usefulBytes: number, + rateBytesPerSecond = 0 + ): ThroughputWatchdogDecision { + return { + abort, + reason: abort ? "throughput_too_low" : undefined, + usefulBytes, + rateBytesPerSecond, + protectedPhase: this.protectedPhase, + }; + } +} + +export function createThroughputWatchdog( + options: ThroughputWatchdogOptions = {} +): ThroughputWatchdog { + return new ThroughputWatchdog(options); +} diff --git a/open-sse/services/tokenExtractionConfig.ts b/open-sse/services/tokenExtractionConfig.ts index 9f2d400f08..761210c661 100644 --- a/open-sse/services/tokenExtractionConfig.ts +++ b/open-sse/services/tokenExtractionConfig.ts @@ -230,9 +230,8 @@ const RAW_CONFIGS: TokenExtractionConfig[] = [ "Microsoft Copilot", "https://copilot.microsoft.com/", "https://copilot.microsoft.com", - [{ type: "cookie", name: "RPSCAuth", domain: ".microsoft.com" }], - "Log in with your Microsoft account at copilot.microsoft.com. The session auth cookie will be extracted.", - { cookieDomain: ".microsoft.com" } + [{ type: "header", name: "Authorization" }], + "Log in with your Microsoft account at copilot.microsoft.com. The bearer access token will be extracted from an authenticated request." ), // ── DuckDuckGo Web ──────────────────────────────────────── 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/services/usage.ts b/open-sse/services/usage.ts index 679d4f263d..e6979d172b 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -68,6 +68,8 @@ import { getXaiUsage } from "./usage/xai.ts"; import { getXaiOauthUsage } from "./usage/xaiOauth.ts"; import { getGrokCliUsage } from "./usage/grokCli.ts"; import { getFirecrawlUsage } from "./usage/firecrawl.ts"; +import { getCommandCodeUsage } from "./usage/command-code.ts"; +import { getConolUsage } from "./conolUsage.ts"; type JsonRecord = Record; type UsageProviderConnection = JsonRecord & { @@ -130,6 +132,10 @@ export const USAGE_FETCHER_PROVIDERS = [ "ha", // Firecrawl team credits (GET /v2/team/credit-usage) "firecrawl", + // Command Code credits + 5h/weekly windows (GET /alpha/billing/credits) + "command-code", + "conol-web", + "cnl", ] as const; export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number]; @@ -229,6 +235,11 @@ export async function getUsageForProvider( return await getHyperAgentUsage(apiKey || accessToken, providerSpecificData); case "firecrawl": return await getFirecrawlUsage(id || "", apiKey, connection); + case "command-code": + return await getCommandCodeUsage(apiKey || accessToken || ""); + case "conol-web": + case "cnl": + return await getConolUsage(apiKey || accessToken, providerSpecificData); default: return { message: `Usage API not implemented for ${provider}` }; } @@ -259,6 +270,7 @@ export const __testing = { getXaiUsage, getXaiOauthUsage, getFirecrawlUsage, + getCommandCodeUsage, getVertexUsage, getMiniMaxAuthErrorMessage, getMiniMaxErrorSummary, diff --git a/open-sse/services/usage/antigravity.ts b/open-sse/services/usage/antigravity.ts index 7b2f2ce13a..693771d681 100644 --- a/open-sse/services/usage/antigravity.ts +++ b/open-sse/services/usage/antigravity.ts @@ -272,21 +272,24 @@ async function fetchAntigravityUserQuotaCached( const promise = (async () => { try { - const response = await fetch( - "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", - { - method: "POST", - headers: getAntigravityContentHeaders(clientProfile, accessToken), - body: JSON.stringify({ project: projectId }), - signal: AbortSignal.timeout(10000), - } - ); + for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) { + const response = await fetch( + `${baseUrl}/v1internal:retrieveUserQuota`, + { + method: "POST", + headers: getAntigravityContentHeaders(clientProfile, accessToken), + body: JSON.stringify({ project: projectId }), + signal: AbortSignal.timeout(10000), + } + ); - if (!response.ok) return null; + if (!response.ok) continue; - const data = await response.json(); - _antigravityUserQuotaCache.set(cacheKey, { data, fetchedAt: Date.now() }); - return data; + const data = await response.json(); + _antigravityUserQuotaCache.set(cacheKey, { data, fetchedAt: Date.now() }); + return data; + } + return null; } catch { return null; } diff --git a/open-sse/services/usage/antigravityWeeklyQuota.ts b/open-sse/services/usage/antigravityWeeklyQuota.ts index 3aa4e78d18..a806eb4645 100644 --- a/open-sse/services/usage/antigravityWeeklyQuota.ts +++ b/open-sse/services/usage/antigravityWeeklyQuota.ts @@ -17,6 +17,7 @@ * `fetchAntigravityUserQuotaCached` pattern. */ +import { ANTIGRAVITY_RUNTIME_BASE_URLS } from "../../config/antigravityUpstream.ts"; import { toRecord, toNumber } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; import { getAntigravityContentHeaders } from "../antigravityHeaders.ts"; @@ -81,21 +82,24 @@ export async function fetchAntigravityUserQuotaSummaryCached( const promise = (async () => { try { - const response = await fetch( - "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", - { - method: "POST", - headers: getAntigravityContentHeaders(clientProfile, accessToken), - body: JSON.stringify({ project: projectId }), - signal: AbortSignal.timeout(10000), - } - ); + for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) { + const response = await fetch( + `${baseUrl}/v1internal:retrieveUserQuotaSummary`, + { + method: "POST", + headers: getAntigravityContentHeaders(clientProfile, accessToken), + body: JSON.stringify({ project: projectId }), + signal: AbortSignal.timeout(10000), + } + ); - if (!response.ok) return null; + if (!response.ok) continue; - const data = await response.json(); - _weeklyQuotaCache.set(cacheKey, { data, fetchedAt: Date.now() }); - return data; + const data = await response.json(); + _weeklyQuotaCache.set(cacheKey, { data, fetchedAt: Date.now() }); + return data; + } + return null; } catch { return null; } diff --git a/open-sse/services/usage/command-code.ts b/open-sse/services/usage/command-code.ts new file mode 100644 index 0000000000..4ad7e199e2 --- /dev/null +++ b/open-sse/services/usage/command-code.ts @@ -0,0 +1,233 @@ +/** + * usage/command-code.ts — Command Code (commandcode.ai) usage fetcher. + * + * Bearer `/alpha` endpoints (same surface the CLI `/usage` view uses): + * GET /alpha/whoami + * GET /alpha/billing/credits → remaining pools + windowLimits + * GET /alpha/billing/subscriptions → planId + billing period (soft) + * GET /alpha/usage/summary → period spend (soft) + * + * Surfaces five_hour / weekly rolling USD windows plus a credits pool quota + * for Provider Limits and genericQuotaFetcher preflight. + */ + +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { toNumber, toRecord } from "./scalars.ts"; +import { createQuotaFromUsage, parseResetTime, type UsageQuota } from "./quota.ts"; + +const COMMAND_CODE_API_BASE = + process.env.COMMANDCODE_API_URL?.trim() || "https://api.commandcode.ai"; +const FETCH_TIMEOUT_MS = 10_000; + +type JsonRecord = Record; + +const PLAN_LABELS: Record = { + "individual-goat": "Command Code · GOAT", + "individual-go": "Command Code · Go", + "individual-pro": "Command Code · Pro", + "individual-max-10x": "Command Code · Max 10×", + "individual-max-20x": "Command Code · Max 20×", + "team-pro": "Command Code · Team Pro", +}; + +function withCurrency(quota: UsageQuota, displayName: string): UsageQuota { + return { + ...quota, + currency: "USD", + displayName, + }; +} + +function humanizePlanId(planId: string | undefined): string { + if (!planId) return "Command Code"; + const mapped = PLAN_LABELS[planId]; + if (mapped) return mapped; + const title = planId + .replace(/^individual-/, "") + .replace(/^team-/, "Team ") + .split("-") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + return `Command Code · ${title || planId}`; +} + +function orgQuery(orgId: string | null | undefined): string { + if (!orgId) return ""; + return `?orgId=${encodeURIComponent(orgId)}`; +} + +async function fetchJson( + path: string, + apiKey: string +): Promise<{ ok: boolean; status: number; body: JsonRecord | null }> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const response = await fetch(`${COMMAND_CODE_API_BASE}${path}`, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + signal: controller.signal, + }); + const text = await response.text(); + let body: JsonRecord | null = null; + if (text) { + try { + body = toRecord(JSON.parse(text)); + } catch { + body = null; + } + } + return { ok: response.ok, status: response.status, body }; + } finally { + clearTimeout(timer); + } +} + +function creditRemaining(credits: JsonRecord): number { + return ( + Math.max(0, toNumber(credits.monthlyCredits, 0)) + + Math.max(0, toNumber(credits.purchasedCredits, 0)) + + Math.max(0, toNumber(credits.freeCredits, 0)) + ); +} + +function windowQuota(window: unknown, displayName: string): UsageQuota | null { + const w = toRecord(window); + const cap = toNumber(w.cap, 0); + if (!(cap > 0)) return null; + const used = toNumber(w.used, 0); + return withCurrency(createQuotaFromUsage(used, cap, w.resetAt), displayName); +} + +/** + * Command Code Usage — monthly credit pool + 5h/weekly rolling windows. + */ +export async function getCommandCodeUsage(apiKey: string) { + if (!apiKey) { + return { message: "Command Code API key not available. Add a key to view usage." }; + } + + try { + let orgId: string | null = null; + try { + const whoami = await fetchJson("/alpha/whoami", apiKey); + if (whoami.status === 401 || whoami.status === 403) { + return { + message: + "Command Code connected. The API key was rejected — reconnect or rotate the key.", + }; + } + if (whoami.ok && whoami.body) { + const org = toRecord(whoami.body.org); + const id = typeof org.id === "string" && org.id.trim() ? org.id.trim() : null; + orgId = id; + } + } catch { + // whoami is optional — continue without orgId + } + + const q = orgQuery(orgId); + const creditsRes = await fetchJson(`/alpha/billing/credits${q}`, apiKey); + + if (creditsRes.status === 401 || creditsRes.status === 403) { + return { + message: "Command Code connected. The API key was rejected — reconnect or rotate the key.", + }; + } + if (!creditsRes.ok || !creditsRes.body) { + return { + message: `Command Code connected. /alpha/billing/credits returned HTTP ${creditsRes.status}.`, + }; + } + + const creditsObj = toRecord(creditsRes.body.credits); + const windowLimits = toRecord(creditsRes.body.windowLimits); + const remaining = creditRemaining(creditsObj); + + let planId: string | undefined; + let periodStart: string | undefined; + let periodEnd: string | null = null; + + try { + const subRes = await fetchJson(`/alpha/billing/subscriptions${q}`, apiKey); + if (subRes.ok && subRes.body) { + const data = toRecord(subRes.body.data); + if (typeof data.planId === "string" && data.planId.trim()) { + planId = data.planId.trim(); + } + if (typeof data.currentPeriodStart === "string") { + periodStart = data.currentPeriodStart; + } + periodEnd = parseResetTime(data.currentPeriodEnd); + } + } catch { + // subscription enrichment is soft-fail + } + + let periodUsed = 0; + try { + const sinceQ = + periodStart != null ? `${q ? `${q}&` : "?"}since=${encodeURIComponent(periodStart)}` : q; + const summaryRes = await fetchJson(`/alpha/usage/summary${sinceQ}`, apiKey); + if (summaryRes.ok && summaryRes.body) { + const cost = toNumber(summaryRes.body.totalCost, Number.NaN); + if (Number.isFinite(cost) && cost >= 0) { + periodUsed = cost; + } else { + const monthly = toNumber(summaryRes.body.totalMonthlyCredits, Number.NaN); + if (Number.isFinite(monthly) && monthly >= 0) periodUsed = monthly; + } + } + } catch { + // summary enrichment is soft-fail + } + + const quotas: Record = {}; + + const fiveHour = windowQuota(windowLimits.fiveHour, "5-hour window"); + if (fiveHour) quotas.five_hour = fiveHour; + + const weekly = windowQuota(windowLimits.weekly, "Weekly window"); + if (weekly) quotas.weekly = weekly; + + const creditsTotal = periodUsed + remaining; + const creditsRemainingPct = + creditsTotal > 0 + ? Math.round((remaining / creditsTotal) * 1000) / 10 + : remaining > 0 + ? 100 + : 0; + quotas.credits = { + used: Math.max(0, periodUsed), + total: Math.max(0, creditsTotal), + remaining, + remainingPercentage: creditsRemainingPct, + resetAt: periodEnd, + unlimited: false, + currency: "USD", + displayName: "Credits", + grantedBalance: Math.max(0, toNumber(creditsObj.monthlyCredits, 0)), + toppedUpBalance: + Math.max(0, toNumber(creditsObj.purchasedCredits, 0)) + + Math.max(0, toNumber(creditsObj.freeCredits, 0)), + }; + + return { + plan: humanizePlanId(planId), + quotas, + windowExceeded: typeof windowLimits.exceeded === "string" ? windowLimits.exceeded : null, + limited: windowLimits.limited === true, + }; + } catch (error) { + return { + message: `Command Code usage error: ${sanitizeErrorMessage( + error instanceof Error ? error.message : String(error) + )}`, + }; + } +} diff --git a/open-sse/services/webSearchFallback.ts b/open-sse/services/webSearchFallback.ts index 7ae1749803..0cc33fdac7 100644 --- a/open-sse/services/webSearchFallback.ts +++ b/open-sse/services/webSearchFallback.ts @@ -1,7 +1,10 @@ import { FORMATS } from "../translator/formats.ts"; export const OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME = "omniroute_web_search"; -const WEB_SEARCH_TOOL_TYPES = new Set(["web_search", "web_search_preview"]); +// Prefix match — Anthropic sends date-suffixed variants (web_search_20250305, …). +// The other two detectors (openai-responses/helpers.ts, webSearchRouting.ts) already +// use /^web_search/ prefix matching; this aligns the fallback detector with them. +const WEB_SEARCH_TOOL_TYPES = /^web_search/; const SEARCH_CONTEXT_DEFAULTS: Record = { low: 5, medium: 8, @@ -27,13 +30,13 @@ function toRecord(value: unknown): JsonRecord { function isBuiltInWebSearchTool(tool: unknown): tool is JsonRecord { const toolRecord = toRecord(tool); const toolType = typeof toolRecord.type === "string" ? toolRecord.type : ""; - return WEB_SEARCH_TOOL_TYPES.has(toolType) && !toolRecord.function; + return WEB_SEARCH_TOOL_TYPES.test(toolType) && !toolRecord.function; } function isBuiltInWebSearchToolChoice(toolChoice: unknown): boolean { const choice = toRecord(toolChoice); const toolType = typeof choice.type === "string" ? choice.type : ""; - return WEB_SEARCH_TOOL_TYPES.has(toolType); + return WEB_SEARCH_TOOL_TYPES.test(toolType); } function buildFallbackDescription(tool: JsonRecord): string { 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 e083787014..2db1da2545 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -13,7 +13,7 @@ import { providerHonorsOpenAIFormatCacheControl, resolveConnectionCacheOverride, } from "../utils/cacheControlPolicy.ts"; -import { requiresAuthenticReasoningContent } from "../utils/reasoningContentInjector.ts"; +import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts"; import { coerceToolSchemas, injectEmptyReasoningContentForToolCalls, @@ -25,6 +25,7 @@ import { bootstrapTranslatorRegistry } from "./bootstrap.ts"; import { hasThinkingConfig, normalizeThinkingConfig } from "../services/provider.ts"; import { applyThinkingBudget } from "../services/thinkingBudget.ts"; import { applyReasoningRuleDirective } from "@/lib/reasoningRouting/policy"; +import { getModelPreserveVideoUrl } from "@/lib/db/models/modelPreserveVideoUrl"; import { getResolvedModelCapabilities, supportsReasoning } from "../services/modelCapabilities.ts"; import { normalizeRoles } from "../services/roleNormalizer.ts"; import { hoistLeadingSystemMessage } from "./helpers/strictSystemHoist.ts"; @@ -160,10 +161,37 @@ 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, + }) ); } +/** + * Upstreams that reject an ABSENT reasoning_content on replay turns, so the + * placeholder must survive the cache miss. + * + * #9573/#9610 removed the placeholder globally because the model echoed it as + * its own reasoning and stopped (empty turns). That holds for DeepSeek, where + * an absent field was verified to be accepted — but Xiaomi MiMo still 400s + * ("Param Incorrect: The reasoning_content in the thinking mode must be passed + * back to the API", 9router#1321/#1337), so omitting the field there trades one + * live bug for another. Keep the placeholder only for those providers; the echo + * that comes back is still stripped on the way in by + * isInternalReasoningPlaceholder(), so it never re-poisons cache or history. + */ +function requiresReasoningContentPresence(provider: unknown, model: unknown): boolean { + const normalizedProvider = String(provider ?? "") + .trim() + .toLowerCase(); + const normalizedModel = String(model ?? "") + .trim() + .toLowerCase(); + return normalizedProvider === "xiaomi-mimo" || /(^|\/)mimo/i.test(normalizedModel); +} + /** @param options.normalizeToolCallId - When true, use 9-char tool call ids (e.g. Mistral); when false, leave ids as-is */ /** @param options.preserveDeveloperRole - undefined/true: keep developer for OpenAI format (default); false: map to system */ /** @param options.preserveCacheControl - When true, preserve client-side cache_control markers (for Claude Code, etc.) */ @@ -208,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); @@ -293,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); @@ -327,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; } } } @@ -336,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, @@ -352,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, }); @@ -368,8 +426,11 @@ export function translateRequest( providerHonorsOpenAIFormatCacheControl(provider, connectionCacheOverride), // #4849 regression guard: keep client reasoning_content for replay providers. preserveReasoningContent: isReasoner, - // Moonshot's Chat API accepts its own OpenAI-compatible `video_url` block. - preserveVideoUrl: normalizedProvider === "moonshot" || normalizedProvider === "kimi", + // Per-provider/model preserveVideoUrl flag from compat overrides. + // Falls back to true for moonshot/kimi when unset (legacy behavior). + preserveVideoUrl: + getModelPreserveVideoUrl(normalizedProvider, normalizedModel) ?? + (normalizedProvider === "moonshot" || normalizedProvider === "kimi"), }); } @@ -422,7 +483,7 @@ export function translateRequest( if ( targetFormat === FORMATS.OPENAI && - !requiresAuthenticReasoning && + !requiresExplicitReasoningReplay && result.messages && Array.isArray(result.messages) ) { @@ -447,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()) { @@ -481,10 +542,11 @@ export function translateRequest( !hasNonEmptyReasoningContent(msg); if (!hasToolCalls && !hasToolUseBlocks && !shouldReplayReasoningOnly) { - // Strip empty reasoning_content on non-tool-call messages we are NOT - // replaying (e.g. non-DeepSeek targets); an empty string has no meaningful - // value to send and may confuse some upstreams. - if (msg.reasoning_content === "") { + // Strip empty or placeholder reasoning_content on non-tool-call messages + // we are NOT replaying. The placeholder is request scaffolding, never + // real reasoning — forwarding it makes the model continue its chain of + // thought FROM that text (echo → empty stop, #9573). + if (msg.reasoning_content === "" || isInternalReasoningPlaceholder(msg.reasoning_content)) { delete msg.reasoning_content; } continue; @@ -495,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, @@ -526,14 +610,22 @@ export function translateRequest( } // ── OpenAI-format message ── - // Skip if client already provided real reasoning_content + // Skip if client already provided real reasoning_content. The internal + // replay placeholder is NOT real reasoning: drop it and fall through to + // the cache lookup so it can be replaced with genuine cached reasoning. + // Forwarding it makes the model continue its chain of thought from that + // text (echo → empty stop), and the echo re-poisons cache + client + // history (#9573). if (hasNonEmptyReasoningContent(msg)) { - continue; + if (!isInternalReasoningPlaceholder(msg.reasoning_content)) { + continue; + } + delete msg.reasoning_content; } const cacheKey = hasToolCalls ? msg.tool_calls[0]?.id - : getAssistantMessageCacheKey(result, 0); + : getAssistantMessageCacheKey(result, messageIndex); if (cacheKey) { const cached = lookupReasoning(cacheKey); if (cached) { @@ -546,24 +638,26 @@ 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; } - // Cache miss fallback — use a non-empty placeholder. - // Empty string causes DeepSeek V4+ to reject with 400: - // "reasoning_content in the thinking mode must be passed back to the API." - // Note: injectEmptyReasoningContentForToolCalls may have pre-set - // reasoning_content="" before the cache lookup, so we check for - // both undefined AND empty string here. - // - // Applies to tool-call messages AND to plain (non-tool-call) assistant turns - // on DeepSeek replay targets (#1682). Without the placeholder on plain turns, - // a multi-turn text conversation whose reasoning_content the client stripped - // is forwarded to DeepSeek without the field and rejected with 400. + // Cache miss fallback — previously injected a non-empty placeholder + // (NON_ANTHROPIC_THINKING_PLACEHOLDER) to dodge an alleged DeepSeek V4 400 + // on missing reasoning_content. The placeholder is the root cause of this + // bug: the model echoes it as its own reasoning and stops (empty turns), + // and the echo re-poisons the cache + client history (#9573). Empirically, + // 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 reasoning replay) have their own paths above. if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) { - msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER; + if (requiresReasoningContentPresence(normalizedProvider, normalizedModel)) { + msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER; + } else { + delete msg.reasoning_content; + } } } } else if ( @@ -696,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/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index a69540e49d..b45bf7730a 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -12,7 +12,10 @@ import { } from "../../services/geminiThoughtSignatureStore.ts"; import { capMaxOutputTokens, capThinkingBudget } from "../../../src/lib/modelCapabilities.ts"; import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts"; -import { buildHistoricalToolResultContext } from "./openai-to-gemini/helpers.ts"; +import { + buildChangedToolNameMap, + buildHistoricalToolResultContext, +} from "./openai-to-gemini/helpers.ts"; /** * Direct Claude → Gemini request translator. @@ -302,12 +305,12 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { } } - const changedToolNameMap = new Map( - [...toolNameMap.entries()].filter( - ([sanitizedName, originalName]) => sanitizedName !== originalName - ) - ); - if (changedToolNameMap.size > 0) { + // Gemini lowercases tool names in its functionCall responses, so identity + // entries (Read → Read) still need a lowercase alias ("read" → "Read") for + // gemini-to-claude to restore the casing Claude Code registered (#9568 parity + // — that fix landed on the openai-to-gemini path only). + const changedToolNameMap = buildChangedToolNameMap(toolNameMap); + if (changedToolNameMap) { result._toolNameMap = changedToolNameMap; } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 67c4a9eb11..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); @@ -724,7 +781,7 @@ export function openaiResponsesToOpenAIRequest( const reasoningRec = toRecord(root.reasoning); const effort = toString(reasoningRec.effort); if (effort && result.reasoning_effort === undefined) { - result.reasoning_effort = normalizeResponsesReasoningEffort(effort, model); + result.reasoning_effort = normalizeResponsesReasoningEffort(effort, model ?? root.model); } if ( credentialRecord._copilotClient === true && @@ -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/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 14cc4d4e5e..cb1cedd713 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -464,12 +464,17 @@ function openaiToGeminiBase( // Gemini expects the signature on the functionCall part itself. // If we are in a mode where missing signatures cause 400s (and we couldn't find one), - // safely default to the bypass string to protect against 400s. + // safely default to the bypass string to protect against 400s. The bypass sentinel is + // an audit-trail risk (a magic validator-bypass string upstream could log/flag), so + // operators can disable it via ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS=0 — real signatures + // are always preferred; the sentinel only fills the gap when none is available. + const signatureBypassEnabled = + toolNameOptions.supportsSignatureBypass && + signaturelessToolCallMode !== "text" && + process.env.ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS !== "0"; const finalSignature = embeddedThoughtSignature || - (toolNameOptions.supportsSignatureBypass && signaturelessToolCallMode !== "text" - ? "skip_thought_signature_validator" - : undefined); + (signatureBypassEnabled ? "skip_thought_signature_validator" : undefined); parts.push({ ...(finalSignature ? { thoughtSignature: finalSignature } : {}), functionCall: { diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 17a5f6d1d6..03bae61bb1 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -110,6 +110,59 @@ function buildKiroToolSpecs(tools: KiroToolInput[]): { return { specs, docs: docs.join("\n\n---\n\n") }; } +/** + * Does this message carry Anthropic-style `tool_result` content blocks? Such a + * user message is part of an open tool-result batch rather than new user input. + */ +function carriesToolResults(msg): boolean { + return Array.isArray(msg?.content) && msg.content.some((c) => c.type === "tool_result"); +} + +/** + * Lookahead for issue #8903: is the text-only assistant message at `index` + * genuinely sandwiched inside a tool-result batch? + * + * True only when a later `tool` message (or a `tool_result` content block on a + * user message) still belongs to the same assistant turn — i.e. it appears + * before the conversation moves on with real user text or a new assistant + * tool-call turn. Consecutive text-only assistant messages are skipped so a + * `tool -> assistant -> assistant -> tool` run still counts as interleaved. + * + * Returning false for the ordinary `tool -> assistant(final reply)` shape is + * what keeps that reply on the normal flush path instead of being deferred. + */ +function hasFollowingToolResult(messages, index: number): boolean { + for (let j = index + 1; j < messages.length; j++) { + const next = messages[j]; + if (next.role === "tool") return true; + + if (next.role === "user") { + const blocks = Array.isArray(next.content) ? next.content : []; + // A user message carrying only tool_result blocks is still part of the + // batch; one with real text ends it. + if (blocks.some((c) => c.type === "tool_result")) { + const hasText = blocks.some((c) => (c.type === "text" || c.text) && c.text?.trim()); + if (!hasText) return true; + } + return false; + } + + if (next.role === "assistant") { + const isTextOnly = + (!next.tool_calls || next.tool_calls.length === 0) && + !(Array.isArray(next.content) && next.content.some((c) => c.type === "tool_use")); + // Skip further text-only assistant messages; a new tool-call turn ends + // the current batch. + if (isTextOnly) continue; + return false; + } + + // system or any other role ends the batch + return false; + } + return false; +} + /** * Convert OpenAI messages to Kiro format * Rules: system/tool/user -> user role, merge consecutive same roles @@ -121,6 +174,11 @@ function convertMessages(messages, tools, model) { let pendingUserContent = []; let pendingAssistantContent = []; let pendingToolResults = []; + // Text-only assistant turns that arrived in the middle of an open tool-result + // batch. They are held back so the batch stays contiguous, then emitted as + // their own assistant turn right after the batch flushes — see + // `interruptsOpenToolBatch` below (issue #8903). + let deferredAssistantContent: string[] = []; let pendingImages: Array<{ format: string; source: { bytes: string } }> = []; let currentRole = null; let toolsAttached = false; @@ -193,6 +251,19 @@ function convertMessages(messages, tools, model) { pendingUserContent = []; pendingToolResults = []; pendingImages = []; + + // The tool batch is now closed, so any assistant text that was held back + // to keep it contiguous can be emitted as its own turn (issue #8903). + // Without this the deferred text would sit in a queue nothing drains and + // be silently dropped from the transcript. + if (deferredAssistantContent.length > 0) { + history.push({ + assistantResponseMessage: { + content: deferredAssistantContent.join("\n\n").trim() || "(empty)", + }, + }); + deferredAssistantContent = []; + } } else if (currentRole === "assistant") { const content = pendingAssistantContent.join("\n\n").trim() || "(empty)"; const assistantMsg = { @@ -215,11 +286,64 @@ function convertMessages(messages, tools, model) { } // If role changes, flush pending + // + // Exception: a text-only assistant message must not split a batch of tool + // results that answers a single assistant turn. `tool` is normalized to + // `user` above, so `tool -> assistant -> tool` looks like two role changes + // and the interleaved flush would emit the first tool result and drop the + // rest, leaving advertised `toolUses` without matching `toolResults`. + // Bedrock rejects that transcript with 400 "Expected toolResult blocks" + // (issue #8903). Defer the assistant text instead so the tool batch stays + // contiguous; the text is re-emitted as its own assistant turn as soon as + // the batch flushes. + // + // The lookahead matters: without it, an ordinary trailing assistant reply + // (`tool -> assistant`, with no further tool message) would also be + // deferred and its text lost. Only a genuine sandwich qualifies. + const isTextOnlyAssistant = + msg.role === "assistant" && + (!msg.tool_calls || msg.tool_calls.length === 0) && + !(Array.isArray(msg.content) && msg.content.some((c) => c.type === "tool_use")); + const interruptsOpenToolBatch = + isTextOnlyAssistant && + currentRole === "user" && + pendingToolResults.length > 0 && + hasFollowingToolResult(messages, i); + + if (interruptsOpenToolBatch) { + const deferredText = + typeof msg.content === "string" + ? msg.content.trim() + : Array.isArray(msg.content) + ? msg.content + .filter((c) => c.type === "text" || c.text) + .map((c) => c.text || "") + .join("\n") + .trim() + : ""; + if (deferredText) deferredAssistantContent.push(deferredText); + continue; + } + + // Once assistant text has been deferred, the tool batch is logically over + // as soon as a message arrives that is not itself a tool result. Flush now + // so the pending batch + deferred assistant turn are emitted before the new + // user text, instead of that text merging into the tool-result turn and + // leaving the deferred reply stranded after it (issue #8903). + if ( + deferredAssistantContent.length > 0 && + currentRole === "user" && + msg.role !== "tool" && + !carriesToolResults(msg) + ) { + flushPending(); + currentRole = null; + } + if (role !== currentRole && currentRole !== null) { flushPending(); } currentRole = role; - if (role === "user") { // Extract content let content = ""; diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 3af3c48418..18fb6ca6fb 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -1,16 +1,12 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { isAbortFinishReason } from "../../utils/finishReason.ts"; -import { REVERSE_MAP } from "../../services/claudeCodeToolRemapper.ts"; +import { restoreClaudeToolName } from "../../services/claudeCodeToolRemapper.ts"; import { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature, } from "../../services/geminiThoughtSignatureStore.ts"; -function normalizeToolName(name: string): string { - return REVERSE_MAP[name] ?? name; -} - /** * Direct Gemini → Claude response translator. * Converts Gemini streaming chunks directly to Claude Messages API @@ -108,11 +104,13 @@ export function geminiToClaudeResponse(chunk, state) { } const fc = part.functionCall; const rawToolName = fc.name; - const mappedName = state.toolNameMap?.get(rawToolName); - // When the toolNameMap provides a match (e.g., lowercase "bash" → "Bash"), - // use it directly without passing through normalizeToolName(), which would - // reverse TitleCase back to lowercase via REVERSE_MAP (#9568). - const restoredToolName = mappedName || normalizeToolName(rawToolName); + // #9008: honor the request's original casing via toolNameMap before any + // REVERSE_MAP lowercase fallback (#7926). Blind REVERSE_MAP broke Claude + // Code (Read/WebSearch → read/websearch → "No such tool available"). + const restoredToolName = restoreClaudeToolName( + typeof rawToolName === "string" ? rawToolName : "", + state.toolNameMap instanceof Map ? state.toolNameMap : null + ); const idx = state.contentBlockIndex++; const toolId = fc.id || `toolu_${Date.now()}_${idx}`; diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 4533b30958..5aa3728f77 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 = @@ -790,6 +846,48 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { function openaiResponsesToOpenAIResponseStream(chunk, state) { if (!chunk) { + if ( + state.currentToolCallNeedsNormalization && + state.currentToolCallArgsBuffer && + state.currentToolCallName + ) { + const toolSchema = state.toolSchemas?.get(state.currentToolCallName); + const argsToEmit = stripEmptyOptionalToolArgs( + state.currentToolCallArgsBuffer, + state.currentToolCallName, + toolSchema + ); + const argsStr = + typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {}); + state.currentToolCallArgsBuffer = ""; + state.currentToolCallNeedsNormalization = false; + state.finishReasonSent = true; + state.finishReason = "tool_calls"; + const common = { + id: state.chatId, + object: "chat.completion.chunk", + created: state.created, + model: state.model || "gpt-4", + }; + return [ + { + ...common, + choices: [ + { + index: 0, + delta: { + tool_calls: [{ index: state.toolCallIndex, function: { arguments: argsStr } }], + }, + finish_reason: null, + }, + ], + }, + { + ...common, + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + }, + ]; + } // Flush: send final chunk with finish_reason if (!state.finishReasonSent && state.started) { state.finishReasonSent = true; @@ -874,6 +972,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId); const toolName = normalizeToolName(item.name); + state.currentToolName = toolName; // track for schema lookup at done time + state.currentToolCallName = toolName; + state.currentToolCallNeedsNormalization = toolName === "Agent"; if (!toolName) { // Some Responses providers briefly emit placeholder/empty tool names. // Defer emission until output_item.done in case the final name is populated there. @@ -917,28 +1018,11 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { if (!argsDelta) return null; state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta; - if (state.currentToolCallDeferred) return null; + if (state.currentToolCallDeferred || state.currentToolCallNeedsNormalization) return null; - return { - id: state.chatId, - object: "chat.completion.chunk", - created: state.created, - model: state.model || "gpt-4", - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: state.toolCallIndex, - function: { arguments: argsDelta }, - }, - ], - }, - finish_reason: null, - }, - ], - }; + // #9168: buffer arguments until output_item.done for schema-aware null normalization + // Previously emitted raw null values for optional enum fields (e.g. isolation: null). + return null; } // Function call done — emit args chunk from item.arguments when no deltas were received, @@ -951,6 +1035,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { const callId = item.call_id || state.currentToolCallId || fallbackToolCallId(); const toolName = normalizeToolName(item.name); const toolSchema = state.toolSchemas?.get(toolName); + const shouldNormalizeArguments = toolName === "Agent"; + state.currentToolCallNeedsNormalization = shouldNormalizeArguments; // Track this call_id so response.completed doesn't synthesize a duplicate if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set(); @@ -967,7 +1053,13 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { state.toolCallIndex++; - const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName, toolSchema); + const terminalArguments = + typeof item.arguments === "string" + ? item.arguments.length > 0 + ? item.arguments + : buffered + : (item.arguments ?? buffered); + const argsToEmit = stripEmptyOptionalToolArgs(terminalArguments, toolName, toolSchema); const argsStr = argsToEmit != null @@ -1006,10 +1098,49 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { state.toolCallIndex++; state.currentToolCallArgsBuffer = ""; // reset for next tool call state.currentToolCallId = null; + const needsNormalization = state.currentToolCallNeedsNormalization === true; + state.currentToolCallNeedsNormalization = false; + state.currentToolCallName = ""; - // Only emit if arguments exist in the done event AND they weren't already streamed via deltas - if (item.arguments != null && !buffered) { - const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName, toolSchema); + // Nullable omission sentinels must be normalized before any argument bytes reach the client. + // Other tool calls retain immediate argument streaming. + if ((needsNormalization || !buffered) && (item.arguments != null || buffered)) { + const terminalArguments = + typeof item.arguments === "string" + ? item.arguments.length > 0 + ? item.arguments + : buffered + : (item.arguments ?? buffered); + const argsToEmit = stripEmptyOptionalToolArgs(terminalArguments, toolName, toolSchema); + + const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit); + if (argsStr) { + return { + id: state.chatId, + object: "chat.completion.chunk", + created: state.created, + model: state.model || "gpt-4", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: currentIndex, + function: { arguments: argsStr }, + }, + ], + }, + finish_reason: null, + }, + ], + }; + } + } else if (buffered) { + // #9168: deltas were buffered — normalize against the original client schema + // and emit the cleaned arguments once, stripping optional null values that + // would otherwise reach the client raw. + const argsToEmit = stripEmptyOptionalToolArgs(buffered, toolName, toolSchema); const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit); if (argsStr) { @@ -1058,8 +1189,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { responseUsage.reasoning_tokens || 0; - // prompt_tokens = input_tokens + cache_read + cache_creation (all prompt-side tokens) - const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens; + const promptTokens = + inputTokens + + ("cache_read_input_tokens" in responseUsage ? cacheReadTokens + cacheCreationTokens : 0); state.usage = { prompt_tokens: promptTokens, diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts index 01999f9ac9..7cea183884 100644 --- a/open-sse/translator/response/openai-responses/pureHelpers.ts +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -60,8 +60,17 @@ function isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) { // no-default optional enum properties to accept `null`, meaning "omitted" (OpenAI's own // nullable-union idiom for Responses-API strict mode). Drop the key when the model // follows that idiom for a non-required, schema-declared property. -function isDroppableNullEntry(entry, propSchema, required, key) { - return entry === null && propSchema != null && !required.has(key); +function isDroppableNullEntry(entry, propSchema, required, key, toolName) { + if (entry !== null) return false; + if (toolName === "Agent") return true; + if (propSchema == null) return false; + const omissionSentinel = + typeof propSchema === "object" && + Array.isArray(propSchema.enum) && + propSchema.enum.includes(null) && + typeof propSchema.description === "string" && + propSchema.description.includes("null = omit this parameter"); + return !required.has(key) || omissionSentinel; } function stripEmptyOptionalToolArgsObject(value, toolName, schema) { @@ -75,7 +84,7 @@ function stripEmptyOptionalToolArgsObject(value, toolName, schema) { if ( matchesSchemaDefault(propSchema, entry) || isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) || - isDroppableNullEntry(entry, propSchema, required, key) + isDroppableNullEntry(entry, propSchema, required, key, toolName) ) { delete cleaned[key]; } diff --git a/open-sse/translator/response/openai-responses/toolSchemas.ts b/open-sse/translator/response/openai-responses/toolSchemas.ts index 8f5457ea72..692164897a 100644 --- a/open-sse/translator/response/openai-responses/toolSchemas.ts +++ b/open-sse/translator/response/openai-responses/toolSchemas.ts @@ -20,9 +20,11 @@ export function extractToolSchemaMap(body: unknown): Map | n const item = asRecord(tool); if (!item) continue; const fn = asRecord(item.function); - const name = (typeof fn?.name === "string" ? fn.name : typeof item.name === "string" ? item.name : "").trim(); + const name = ( + typeof fn?.name === "string" ? fn.name : typeof item.name === "string" ? item.name : "" + ).trim(); if (!name) continue; - const schema = asRecord(fn?.parameters ?? item.parameters); + const schema = asRecord(fn?.parameters ?? item.parameters ?? item.input_schema); if (schema) map.set(name, schema); } return map.size > 0 ? map : null; diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index cac48d5848..b8e4f103c0 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -9,11 +9,7 @@ import { isInternalReasoningPlaceholder, stripInternalReasoningPlaceholder, } from "../../utils/reasoningPlaceholder.ts"; -import { REVERSE_MAP } from "../../services/claudeCodeToolRemapper.ts"; - -function normalizeToolName(name: string): string { - return REVERSE_MAP[name] ?? name; -} +import { restoreClaudeToolName } from "../../services/claudeCodeToolRemapper.ts"; interface XmlToolCall { id: string; @@ -432,7 +428,12 @@ export function openaiToClaudeResponse(chunk, state) { content_block: { type: "tool_use", id: tc.id, - name: normalizeToolName(tc.name), + // #9008: prefer request-side original casing; REVERSE_MAP only when + // no map entry exists (#7926 XML TitleCase → lowercase clients). + name: restoreClaudeToolName( + tc.name, + state.toolNameMap instanceof Map ? state.toolNameMap : null + ), input: tc.args, }, }); diff --git a/open-sse/translator/webTools.ts b/open-sse/translator/webTools.ts index ecc291ce26..ac3efbee14 100644 --- a/open-sse/translator/webTools.ts +++ b/open-sse/translator/webTools.ts @@ -391,10 +391,15 @@ export function serializeToolsToPrompt(tools: unknown): string { if (lines.length === 0) return ""; 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, @@ -425,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, @@ -521,11 +549,25 @@ export function prepareToolMessages( if (!hasTools) return { hasTools: false, requestedTools, effectiveMessages: messages }; const toolPrompt = serializeToolsToPrompt(requestedTools); - return { - hasTools: true, - requestedTools, - effectiveMessages: [{ role: "system", content: toolPrompt }, ...messages], - }; + 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/types.d.ts b/open-sse/types.d.ts index 6d95d1e072..c2f35693d4 100644 --- a/open-sse/types.d.ts +++ b/open-sse/types.d.ts @@ -69,7 +69,7 @@ export interface ChatCoreParams { /** Connection ID for usage tracking */ connectionId: string; /** API key metadata for usage attribution */ - apiKeyInfo?: { id?: string; name?: string } | null; + apiKeyInfo?: { id?: string; name?: string; compressionEnabled?: boolean } | null; /** Client User-Agent header */ userAgent?: string; /** Callback when credentials are refreshed mid-request */ diff --git a/open-sse/utils/ccDiscoveryAliases.ts b/open-sse/utils/ccDiscoveryAliases.ts index fe491bfac0..70fda8989b 100644 --- a/open-sse/utils/ccDiscoveryAliases.ts +++ b/open-sse/utils/ccDiscoveryAliases.ts @@ -33,7 +33,7 @@ export const CC_DISCOVERY_COMBO_PREFIX = "claude/combo/"; // Ids that already live under the claude/anthropic namespace — never re-mirror them. const ALREADY_CLAUDE_RE = /^(?:claude|anthropic)(?:\/|$)/i; // Ids that already carry a reasoning-effort suffix — v1 only mirrors base ids. -const EFFORT_SUFFIX_RE = /-(?:xhigh|high|medium|low)$/i; +const CLAUDE_EFFORT_SUFFIX_RE = /-(?:xhigh|high|medium|low)$/i; const NO_THINKING_PREFIX = "no-think/"; // Built-in `auto`/`auto/*` combos are synthesized by createBuiltinAutoCombo, NOT // stored in the DB combos table — the request-path resolver (getComboByName) can't @@ -66,7 +66,14 @@ function isMirrorableId(id: string): boolean { if (id.length === 0) return false; if (ALREADY_CLAUDE_RE.test(id)) return false; if (id.startsWith(NO_THINKING_PREFIX)) return false; - return !EFFORT_SUFFIX_RE.test(id); + return !CLAUDE_EFFORT_SUFFIX_RE.test(id); +} + +/** Strip a `/` prefix to get the bare model name, matching the convention in + * claudeEffortVariants.ts / noThinkingAlias.ts. */ +function bareModelName(id: string): string { + const slash = id.lastIndexOf("/"); + return slash >= 0 ? id.slice(slash + 1) : id; } export function appendCcDiscoveryAliases( @@ -90,7 +97,10 @@ export function appendCcDiscoveryAliases( aliases.push({ ...model, id: aliasId, - root: id, + // Combo names may legally contain "/" (comboNameSchema allows it), so a combo's + // root must stay the full name verbatim — only real provider-qualified ids get + // the "/" stripped down to the bare model name. + root: isCombo ? id : bareModelName(id), display_name: `${label} (OmniRoute)`, } as T); } diff --git a/open-sse/utils/claudeEffortVariants.ts b/open-sse/utils/claudeEffortVariants.ts index a5c549fe55..78dcad34c0 100644 --- a/open-sse/utils/claudeEffortVariants.ts +++ b/open-sse/utils/claudeEffortVariants.ts @@ -64,6 +64,17 @@ export function formatClaudeEffortLabel(level: string): string { return level.charAt(0).toUpperCase() + level.slice(1); } +/** + * Whether `bareModelId` (no provider prefix, no effort suffix) is a real, + * effort-capable Claude-family model — the single source of truth used both to + * decide whether the catalog should advertise an effort variant AND whether + * dispatch-time stripping should unwind one back to this model. + */ +export function isKnownClaudeEffortBaseModel(bareModelId: string): boolean { + const spec = getModelSpec(bareModelId); + return spec?.supportsThinking === true && CLAUDE_NAME_RE.test(bareModelId); +} + /** * Whether the catalog should advertise reasoning-effort variants for this entry. * @@ -84,10 +95,7 @@ export function shouldExposeClaudeEffortVariants( if (CLAUDE_EFFORT_SUFFIX_RE.test(id)) return false; const name = bareModelName(id); - const spec = getModelSpec(name); - if (!spec) return false; - - return spec.supportsThinking === true && CLAUDE_NAME_RE.test(name); + return isKnownClaudeEffortBaseModel(name); } /** diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index 106c29ecba..d86fc7f4ce 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] @@ -344,6 +332,8 @@ function splitCursorEffortSuffix( /** * cursor-agent rewrites model ids before putting them on the wire: * "auto" → RequestedModel { model_id: "default" } + * "auto-cost" → RequestedModel { model_id: "default", + * parameters: [{id: "optimization", value: "cost"}] } * "composer-2-fast" → RequestedModel { model_id: "composer-2", * parameters: [{id: "fast", value: "true"}] } * "claude-opus-4-8-high" → RequestedModel { model_id: "claude-opus-4-8", @@ -354,7 +344,31 @@ function splitCursorEffortSuffix( * Other ids are passed through verbatim after spelling-variant normalization * (see normalizeCursorModelId). */ -export function resolveRequestedModel(modelId: string): { +/** Cursor Router optimization levels (OpenCodex `CURSOR_ROUTING_LEVELS`). */ +export const CURSOR_ROUTING_LEVELS = ["cost", "balance", "intelligence"] as const; +export type CursorRoutingLevel = (typeof CURSOR_ROUTING_LEVELS)[number]; + +/** + * ModelParameter id for Cursor's Cost/Balance/Intelligence control on wire model + * `default` (OpenCodex `CURSOR_ROUTING_LEVEL_PARAMETER_ID`). + */ +export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization"; + +export type ResolveRequestedModelOptions = { + /** + * When set and containing the normalized client model id, send that id + * verbatim on AgentRun (skip composer-fast / Claude / GPT splits). + * Live AvailableModels returns flattened effort-suffixed ids; stripping them + * to a missing base causes Cursor `AI Model Not Found`. Auto / auto-* still + * map to wire `default` (+ optimization) even when present in this set. + */ + liveCatalogIds?: ReadonlySet; +}; + +export function resolveRequestedModel( + modelId: string, + opts?: ResolveRequestedModelOptions +): { modelId: string; parameters: Array<{ id: string; value: string }>; } { @@ -362,6 +376,20 @@ export function resolveRequestedModel(modelId: string): { if (normalized === "auto") { return { modelId: "default", parameters: [] }; } + // OpenCodex-style router variants: auto-cost / auto-balance / auto-intelligence + // → wire `default` + ModelParameter { id: "optimization", value: }. + for (const level of CURSOR_ROUTING_LEVELS) { + if (normalized === `auto-${level}`) { + return { + modelId: "default", + parameters: [{ id: CURSOR_ROUTING_LEVEL_PARAMETER_ID, value: level }], + }; + } + } + // Live catalog is authoritative for exact ids (flattened effort variants). + if (opts?.liveCatalogIds?.has(normalized)) { + return { modelId: normalized, parameters: [] }; + } // Strip the "-fast" suffix and surface it as a parameter — only the composer // family observably needs this split today, but the protocol field is generic. if (normalized.startsWith("composer-") && normalized.endsWith("-fast")) { @@ -413,58 +441,17 @@ 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[]; + /** Exact live AvailableModels ids — see resolveRequestedModel liveCatalogIds. */ + liveCatalogIds?: ReadonlySet; }; -/** - * 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 @@ -488,17 +475,22 @@ export function openAIToolsToMcpDefs(tools: OpenAITool[]): McpToolDefinition[] { export function encodeAgentRunRequest(input: AgentRunInput): Buffer { const conversationId = input.conversationId || crypto.randomUUID(); const messageId = input.messageId || crypto.randomUUID(); - const { modelId, parameters } = resolveRequestedModel(input.modelId); + const { modelId, parameters } = resolveRequestedModel(input.modelId, { + liveCatalogIds: input.liveCatalogIds, + }); // 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 { + const content = body.content as unknown[]; + const hasOutput = content.some((block) => { // A malformed/partial provider response could carry a null (or non-object) // entry in `content`; guard before type-asserting so the detector never // throws on `null.type` (that would crash the whole non-stream classifier). @@ -229,16 +230,18 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null ) { return true; } - // Extended-thinking block: valid when it carries visible thinking text OR a - // non-empty `signature` (cryptographic proof the thinking step ran, so it is a - // valid completion even when the thinking text is ""). - if ( - b.type === "thinking" && - ((typeof b.thinking === "string" && (b.thinking as string).length > 0) || - (typeof b.signature === "string" && (b.signature as string).length > 0)) - ) { - return true; - } + // Extended-thinking block: valid structural output whenever the model + // entered the thinking phase, even with no visible thinking text and no + // `signature`. #9971: the Claude Code OAuth upstream can truncate long + // large-input+large-output generations around the ~3-min turn boundary, + // leaving a content-less thinking-only body whose final text (and, when + // cut mid-think, its signature) never arrived. The block's very presence + // is proof the turn produced output upstream, so it is a valid + // in-progress completion, NOT a genuinely empty terminal response. + // (Previously only a non-empty `thinking` text OR `signature` counted — + // #5108 — which misclassified these content-less bodies as empty_choices + // → 502.) + if (b.type === "thinking") return true; // Redacted thinking and tool_use are valid structural output. if (b.type === "redacted_thinking") return true; if (b.type === "tool_use" && typeof b.id === "string" && (b.id as string).length > 0) { @@ -246,7 +249,27 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null } return false; }); - return hasOutput ? null : "empty_choices"; + if (hasOutput) return null; + + // No per-block output. Two distinct situations remain: + // 1) A block IS present but invalid (e.g. text:"", a lone "(empty response)" + // sentinel, or only null entries) — the model genuinely produced no + // usable output. That is a MALFORMED-200 empty_choices regardless of + // stop_reason (parity with the OpenAI content:"" path). + // 2) `content: []` — no block at all. Only a genuinely *terminal* response + // (a final stop_reason with no output) is empty_choices. #9971: a + // truncated / non-terminal body — the Claude Code OAuth upstream cutting + // a long generation mid-turn, or a content-less thinking-only stream + // that never emitted a terminal event — carries content:[] with no + // reachable end, so flagging it would turn an upstream truncation into a + // false 502. Require a terminal stop_reason before calling a block-less + // response genuinely empty. + if (content.length === 0) { + const stopReason = typeof body.stop_reason === "string" ? body.stop_reason : ""; + const isTerminal = stopReason.length > 0; + return isTerminal ? "empty_choices" : null; + } + return "empty_choices"; } // ── Chat Completions shape ── diff --git a/open-sse/utils/estimateSize.ts b/open-sse/utils/estimateSize.ts index 8a6f5ef76d..9eb7f178fd 100644 --- a/open-sse/utils/estimateSize.ts +++ b/open-sse/utils/estimateSize.ts @@ -3,15 +3,20 @@ * Safe for circular references (WeakSet). Iterative frames only (no recursive call stack). * * Budgets: - * - ESTIMATE_SIZE_BYTE_LIMIT (256 KiB): early-exit once counted bytes exceed the limit + * - byteLimit param (default ESTIMATE_SIZE_BYTE_LIMIT, 256 KiB): early-exit + * once counted bytes exceed the limit — pass the caller's own threshold + * explicitly rather than relying on the default, since a caller comparing + * against a bigger configured limit would otherwise never see a size + * above 256 KiB. * - ESTIMATE_SIZE_NODE_BUDGET: max value visits (containers + primitives/elements) * * Arrays are walked by index frame (never pre-push/copy every element reference). * Plain objects yield own enumerable values incrementally (no Object.keys materialization). - * Node-budget exhaustion returns a value strictly above 256 KiB so callers fail closed. + * Node-budget exhaustion returns a value strictly above the effective byteLimit + * so callers fail closed. */ -/** Byte early-exit threshold (256 KiB). */ +/** Default byte early-exit threshold (256 KiB) when a caller doesn't pass its own. */ export const ESTIMATE_SIZE_BYTE_LIMIT = 262_144; /** @@ -74,14 +79,22 @@ function expandContainerFrame(stack: Frame[], frame: Exclude) 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 | undefined)?.url; + return typeof url === "string" ? url : undefined; +} + +function pushPart( + ctx: DetectCtx, + kind: MediaKind, + ref: string, + shape: MediaPart["shape"], + depth: number +): void { + ctx.out.push({ + kind, + ref, + messageIndex: ctx.messageIndex, + partIndex: ctx.partIndex, + nested: depth > 0, + shape, + }); + if (ctx.stopAtKind === kind) ctx.found = true; +} + +/** Strict image shapes with an extractable ref. Returns true when one was pushed. */ +function inspectImageShapes( + obj: Record, + type: string | undefined, + ctx: DetectCtx, + depth: number +): boolean { + if (type === "image_url" || type === "input_image") { + const url = urlFrom(obj.image_url); + if (url) { + pushPart(ctx, "image", url, type === "input_image" ? "input_image" : "image_url", depth); + return true; + } + } + if (type === "image") { + const source = obj.source as Record | undefined; + if (source?.type === "base64" && typeof source.data === "string") { + const media = typeof source.media_type === "string" ? source.media_type : "image/png"; + pushPart(ctx, "image", `data:${media};base64,${source.data}`, "image_base64", depth); + return true; + } + // Non-empty url required: an empty `source.url` is not an extractable image + // (mirrors the guardrail's historical `if (url)` guard). + if (source?.type === "url" && typeof source.url === "string" && source.url) { + pushPart(ctx, "image", source.url, "image_source_url", depth); + return true; + } + } + return false; +} + +/** + * Audio shapes. Returns true when a part was pushed (at most one per object). + * Callers must NOT early-return on audio: the same object can also carry + * image indicators or nest image parts inside its payload. + */ +function inspectAudioShapes( + obj: Record, + type: string | undefined, + mediaType: unknown, + ctx: DetectCtx, + depth: number +): boolean { + if (type === "input_audio") { + const audio = obj.input_audio as Record | undefined; + if (typeof audio?.data === "string") { + pushPart(ctx, "audio", audio.data, "input_audio", depth); + return true; + } + } + if (type === "audio_url") { + const url = urlFrom(obj.audio_url); + if (url) { + pushPart(ctx, "audio", url, "audio_url", depth); + return true; + } + } + if (typeof mediaType === "string" && mediaType.startsWith("audio/")) { + const data = (obj.source as Record).data; + if (typeof data === "string") { + pushPart(ctx, "audio", data, "audio_source", depth); + return true; + } + } + return false; +} + +/** + * Combo-parity image indicators: the legacy valueContainsImagePart + * (comboStructure) matched image-ish `type` names case-insensitively, bare + * `image_url`/`input_image` keys, and `source.media_type` image/* — all + * without needing an extractable ref. Emit an indicator part (ref + * best-effort, possibly "") so boolean callers keep seeing those requests as + * vision requests. Returns true when one was pushed. + */ +function inspectImageIndicators( + obj: Record, + type: string | undefined, + mediaType: unknown, + ctx: DetectCtx, + depth: number +): boolean { + const lowerType = type?.toLowerCase(); + const looksLikeImage = + lowerType === "image" || + lowerType === "image_url" || + lowerType === "input_image" || + "image_url" in obj || + "input_image" in obj; + const imageMediaType = + typeof mediaType === "string" && mediaType.toLowerCase().startsWith("image/"); + if (!looksLikeImage && !imageMediaType) return false; + pushPart(ctx, "image", urlFrom(obj.image_url ?? obj.input_image) ?? "", "image_indicator", depth); + return true; +} + +function inspect(value: unknown, ctx: DetectCtx, depth: number): void { + if (ctx.found || depth > MAX_DEPTH || value == null) return; + if (typeof value === "string") { + if (value.startsWith("data:image/")) pushPart(ctx, "image", value, "data_uri_string", depth); + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + inspect(entry, ctx, depth + 1); + if (ctx.found) return; + } + return; + } + if (typeof value !== "object") return; + const obj = value as Record; + const type = typeof obj.type === "string" ? obj.type : undefined; + + if (inspectImageShapes(obj, type, ctx, depth)) return; + + const mediaType = (obj.source as Record | undefined)?.media_type; + // Audio does not early-return: the same object can also carry image + // indicators (bare `image_url`/`input_image` keys the legacy combo filter + // matched) or nest image parts inside its payload. + inspectAudioShapes(obj, type, mediaType, ctx, depth); + if (ctx.found) return; + if (inspectImageIndicators(obj, type, mediaType, ctx, depth)) return; + for (const nested of Object.values(obj)) { + inspect(nested, ctx, depth + 1); + if (ctx.found) return; + } +} + +export function detectMediaParts( + messages: ReadonlyArray<{ role?: string; content?: unknown }> | undefined | null +): MediaPart[] { + const out: MediaPart[] = []; + if (!Array.isArray(messages)) return out; + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const content = messages[messageIndex]?.content; + if (!Array.isArray(content)) continue; + for (let partIndex = 0; partIndex < content.length; partIndex++) { + inspect(content[partIndex], { out, messageIndex, partIndex }, 0); + } + } + return out; +} + +/** + * Early-exit presence check: returns true as soon as the FIRST part of the + * requested kind is found, without collecting the full part list or finishing + * the traversal. Prefer this on hot paths (e.g. the combo compatibility + * filter runs on every request) over `detectMediaParts(...).some(...)`. + */ +export function containsMediaKind( + messages: ReadonlyArray<{ role?: string; content?: unknown }> | undefined | null, + kind: MediaKind +): boolean { + if (!Array.isArray(messages)) return false; + const out: MediaPart[] = []; + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const content = messages[messageIndex]?.content; + if (!Array.isArray(content)) continue; + for (let partIndex = 0; partIndex < content.length; partIndex++) { + const ctx: DetectCtx = { out, messageIndex, partIndex, stopAtKind: kind }; + inspect(content[partIndex], ctx, 0); + if (ctx.found) return true; + } + } + return false; +} diff --git a/open-sse/utils/noThinkingAlias.ts b/open-sse/utils/noThinkingAlias.ts index f83cefc69b..e783de2601 100644 --- a/open-sse/utils/noThinkingAlias.ts +++ b/open-sse/utils/noThinkingAlias.ts @@ -31,6 +31,15 @@ import { getModelSpec } from "@/shared/constants/modelSpecs"; export const NO_THINKING_PREFIX = "no-think/"; +// Ids that already carry a Claude reasoning-effort suffix (see +// claudeEffortVariants.ts's identical constant) — a no-think variant of an effort +// variant would combine two independent OmniRoute catalog conventions on the same +// id. Dispatch-time, applyNoThinkingAlias pre-sets reasoning_effort:"none" before +// applyClaudeEffortVariant's hasExplicitClaudeEffort() check runs, so the pre-set +// "none" is treated as explicit and the suffix's implied effort is silently +// discarded — semantically incoherent, so never advertise the combination. +const CLAUDE_EFFORT_SUFFIX_RE = /-(?:xhigh|high|medium|low)$/i; + /** True when `modelId` carries the no-thinking gateway prefix. */ export function isNoThinkingAlias(modelId: unknown): modelId is string { return typeof modelId === "string" && modelId.startsWith(NO_THINKING_PREFIX); @@ -108,6 +117,7 @@ export function shouldExposeNoThinkingAlias(model: CatalogModelEntry): boolean { if (typeof id !== "string" || id.length === 0) return false; if (model.owned_by === "combo") return false; // combos are virtual if (isNoThinkingAlias(id)) return false; // never double-alias + if (CLAUDE_EFFORT_SUFFIX_RE.test(id)) return false; // never combine with an effort-suffix id const name = bareModelName(id); const spec = getModelSpec(name); @@ -158,7 +168,8 @@ export function appendNoThinkingVariants( const rawId = model.id as string; const qualifiedId = aliasToCanonical ? normalizeProviderPrefix(rawId, aliasToCanonical) : rawId; const aliasId = toNoThinkingAlias(qualifiedId); - const variant: T = { ...model, id: aliasId, root: aliasId }; + const bareRoot = toNoThinkingAlias(bareModelName(qualifiedId)); + const variant: T = { ...model, id: aliasId, root: bareRoot }; if (typeof model.name === "string" && model.name) { variant.name = `${model.name} (no thinking)`; } diff --git a/open-sse/utils/passthroughTailProcessor.ts b/open-sse/utils/passthroughTailProcessor.ts index 23d04e8a27..ab45fb5474 100644 --- a/open-sse/utils/passthroughTailProcessor.ts +++ b/open-sse/utils/passthroughTailProcessor.ts @@ -31,6 +31,7 @@ export type PassthroughTailProcessorContext = { emitConvertedOutput: (output: string) => void; pushProviderPayload: (payload: unknown) => void; pushClientPayload: (payload: unknown) => void; + sanitizeUsagePayload: (payload: unknown) => boolean; setPassthroughResponsesId: (value: string) => void; setUsage: (value: unknown) => void; addTotalContentLength: (value: number) => void; @@ -284,6 +285,9 @@ export function processBufferedPassthroughLine( } const parsed = parsedPassthroughData as JsonRecord; + if (context.sanitizeUsagePayload(parsed)) { + output = `data: ${JSON.stringify(parsed)}\n\n`; + } const parsedType = typeof parsed.type === "string" ? parsed.type : ""; const isResponses = parsedType.startsWith("response."); const isClaude = context.isClaudeEventPayload(parsed); diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index a5080c6fbb..b8b9798bc6 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -13,7 +13,7 @@ import { proxyConfigToUrl, proxyUrlForLogs, } from "./proxyDispatcher.ts"; -import tlsClient from "./tlsClient.ts"; +import tlsClient, { type TlsFetchOptions } from "./tlsClient.ts"; import { isProxyReachable } from "@/lib/proxyHealth"; import { isControlPlaneProxyDirectFallbackEnabled, @@ -79,6 +79,32 @@ function isTlsFingerprintEnabled() { return process.env.ENABLE_TLS_FINGERPRINT === "true"; } +function tlsFingerprintProviderAllowed( + provider: string | null | undefined, + proxied: boolean +): boolean { + const configured = process.env.TLS_FINGERPRINT_PROVIDERS?.trim(); + // Preserve the legacy direct-only opt-in. The new proxied transport requires + // an explicit allowlist so enabling TLS cannot silently change proxy traffic. + if (!configured) return !proxied; + if (!provider) return false; + const normalizedProvider = provider.trim().toLowerCase(); + return configured + .split(",") + .some((candidate) => candidate.trim().toLowerCase() === normalizedProvider); +} + +type TlsClientLike = { + available: boolean; + fetch: (url: string, options?: TlsFetchOptions) => Promise; +}; +let activeTlsClient: TlsClientLike = tlsClient; + +/** Test seam for exercising wreq selection without replacing the module loader. */ +export function setTlsClientForTest(client: TlsClientLike | null): void { + activeTlsClient = client ?? tlsClient; +} + // #8376: transport-level connect-failure codes that mean "the configured upstream // proxy (or the target itself, for direct egress) is unreachable" — as opposed to an // ordinary upstream HTTP error. Read `.code` first (stable across undici/node @@ -122,9 +148,12 @@ function tagProxyUnreachable(err: T): T { return err; } -/** Per-request tracking of whether TLS fingerprint was used */ -type TlsFingerprintStore = { used: boolean }; -const tlsFingerprintContext = new AsyncLocalStorage(); +/** Per-request TLS identity and success telemetry. */ +type TlsFingerprintStore = { + used: boolean; + provider?: string | null; + sessionScope?: string; +}; /** * #5217 (Gap-secondary): a mutable sink that records the proxy actually applied @@ -227,20 +256,112 @@ function requestHasNonReplayableBody( return false; } +const TLS_ALLOWED_OPTION_KEYS: Record = { + body: true, + headers: true, + method: true, + redirect: true, + signal: true, +}; + +function isWreqBodySupported(body: unknown): boolean { + if (body == null || typeof body === "string") return true; + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return true; + if (body instanceof URLSearchParams) return true; + if (typeof Blob !== "undefined" && body instanceof Blob) return true; + if (typeof FormData !== "undefined" && body instanceof FormData) return true; + return false; +} + +function isTlsRequestEligible( + input: RequestInfo | URL, + options: FetchWithDispatcherOptions +): boolean { + if (typeof Request !== "undefined" && input instanceof Request) return false; + if (!isWreqBodySupported(options.body)) return false; + return Object.keys(options).every((key) => TLS_ALLOWED_OPTION_KEYS[key] === true); +} + +function isTlsFallbackReplaySafe( + input: RequestInfo | URL, + options: FetchWithDispatcherOptions +): boolean { + const method = ( + options.method ?? + (typeof Request !== "undefined" && input instanceof Request ? input.method : "GET") + ).toUpperCase(); + return ( + (method === "GET" || method === "HEAD" || method === "OPTIONS") && + !requestHasNonReplayableBody(input, options) + ); +} + +function getEffectiveSignal( + input: RequestInfo | URL, + options: FetchWithDispatcherOptions +): AbortSignal | null | undefined { + return ( + options.signal ?? + (typeof Request !== "undefined" && input instanceof Request ? input.signal : undefined) + ); +} + +function isWreqProxySupported(proxyUrl: string): boolean { + try { + const parsed = new URL(proxyUrl); + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + parsed.searchParams.get("family") === null + ); + } catch { + return false; + } +} + +function sanitizeTransportError( + error: unknown, + message: string, + fallbackCode: string +): Error & { code: string; errorCode?: string; statusCode?: number } { + const source = error && typeof error === "object" ? (error as Record) : {}; + const sanitized = new Error(message) as Error & { + code: string; + errorCode?: string; + statusCode?: number; + }; + sanitized.code = + typeof source.code === "string" && /^[A-Z0-9_:-]{1,64}$/.test(source.code) + ? source.code + : fallbackCode; + if ( + typeof source.errorCode === "string" && + /^[a-zA-Z0-9_:-]{1,64}$/.test(source.errorCode) + ) { + sanitized.errorCode = source.errorCode; + } + if (typeof source.statusCode === "number" && Number.isFinite(source.statusCode)) { + sanitized.statusCode = source.statusCode; + } + return sanitized; +} + /** Injectable dependencies for testability (Approach B DI). */ export type ProxyFetchDeps = { undiciFetch?: FetchWithDispatcher; nativeFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + findWorkingProxy?: (hostname: string, targetUrl: string) => Promise; }; type PatchState = { originalFetch: typeof globalThis.fetch; proxyContext: AsyncLocalStorage; + tlsFingerprintContext?: AsyncLocalStorage; isPatched: boolean; }; const isCloud = typeof caches !== "undefined" && typeof caches === "object"; const PATCH_STATE_KEY = Symbol.for("omniroute.proxyFetch.state"); +const DIRECT_PROXY_CONTEXT = Symbol.for("omniroute.proxyFetch.direct-context"); function getPatchState(): PatchState { const scopedGlobal = globalThis as typeof globalThis & { @@ -251,6 +372,7 @@ function getPatchState(): PatchState { scopedGlobal[PATCH_STATE_KEY] = { originalFetch: globalThis.fetch, proxyContext: new AsyncLocalStorage(), + tlsFingerprintContext: new AsyncLocalStorage(), isPatched: false, }; } @@ -258,9 +380,11 @@ function getPatchState(): PatchState { } const patchState = getPatchState(); +patchState.tlsFingerprintContext ??= new AsyncLocalStorage(); const originalFetch = patchState.originalFetch; const originalFetchWithDispatcher = originalFetch as FetchWithDispatcher; const proxyContext = patchState.proxyContext; +const tlsFingerprintContext = patchState.tlsFingerprintContext; function noProxyMatch(targetUrl) { const noProxy = process.env.NO_PROXY || process.env.no_proxy; @@ -381,6 +505,9 @@ export function resolveProxyForRequest(targetUrl) { } const contextProxy = proxyContext.getStore(); + if (contextProxy === DIRECT_PROXY_CONTEXT) { + return { source: "direct", proxyUrl: null }; + } if (contextProxy) { // #9551: NO_PROXY must bypass context-proxy too if (target && noProxyMatch(targetUrl)) { @@ -398,16 +525,15 @@ export function resolveProxyForRequest(targetUrl) { } /** - * A caller-initiated abort/timeout is not a proxy transport failure — it must - * not be misreported as one. Prefer `signal.aborted` because - * `AbortController.abort(reason)` may surface a custom Error rather than a - * standard AbortError/TimeoutError name. - * Ported from decolua/9router#2589 (`isCallerAbort`). + * A caller-initiated abort is identified only by the caller's effective signal. + * Dependency-internal TimeoutError/AbortError values are transport failures and + * retain the normal safe-method fallback behavior. */ -function isCallerAbort(error: unknown, signal: AbortSignal | null | undefined): boolean { - if (signal?.aborted === true) return true; - const name = (error as { name?: unknown } | null)?.name; - return name === "AbortError" || name === "TimeoutError"; +function isCallerAbort( + _error: unknown, + signal: AbortSignal | null | undefined +): boolean { + return signal?.aborted === true; } function getTargetUrl(input) { @@ -425,9 +551,13 @@ export async function runWithProxyContext( throw new TypeError("runWithProxyContext requires a callback function"); } - // Inherit existing context if no specific proxyConfig is provided + // Inherit existing context if no specific proxyConfig is provided. A direct + // sentinel must remain direct without being mistaken for a proxy config. const currentContext = proxyContext.getStore(); - const effectiveProxyConfig = proxyConfig || currentContext || null; + const inheritsDirect = currentContext === DIRECT_PROXY_CONTEXT && !proxyConfig; + const effectiveProxyConfig = + proxyConfig || (inheritsDirect ? null : currentContext) || null; + const contextValue = inheritsDirect ? DIRECT_PROXY_CONTEXT : effectiveProxyConfig; const resolvedProxyUrl = effectiveProxyConfig ? proxyConfigToUrl(effectiveProxyConfig) : null; @@ -435,8 +565,9 @@ export async function runWithProxyContext( // This fallback changes egress IP, so upgrades must not silently turn it on. const directFallbackOnUnreachable = opts?.directFallbackOnUnreachable === true && isControlPlaneProxyDirectFallbackEnabled(); - // Run fn with the proxy context cleared so the request egresses directly. - const runDirect = () => proxyContext.run(null, fn); + // Keep an explicit direct sentinel so resolveProxyForRequest cannot re-read + // HTTPS_PROXY/HTTP_PROXY after the control-plane route decision. + const runDirect = () => proxyContext.run(DIRECT_PROXY_CONTEXT, fn); // T14: Proxy Fast-Fail (non-blocking, #9100) // Perform a short TCP reachability check BEFORE issuing upstream requests. @@ -502,7 +633,7 @@ export async function runWithProxyContext( } } - return proxyContext.run(effectiveProxyConfig, async () => { + return proxyContext.run(contextValue, async () => { if (resolvedProxyUrl && effectiveProxyConfig !== currentContext) { // #9158: this fires on EVERY proxied request (innermost context wins). // Gate it behind the same env flag as the relay routing log so request @@ -604,25 +735,59 @@ async function patchedFetch( const { source, proxyUrl } = resolved; if (!proxyUrl) { - // TLS fingerprint spoofing for direct connections (no proxy configured) - if (isTlsFingerprintEnabled() && tlsClient.available) { + // TLS fingerprint spoofing for an already-resolved direct route. Explicit + // proxy:null prevents wreq from re-reading a global environment proxy. + const tlsStore = tlsFingerprintContext.getStore(); + let tlsDirectFallback = false; + if ( + isTlsFingerprintEnabled() && + activeTlsClient.available && + tlsFingerprintProviderAllowed(tlsStore?.provider, false) && + isTlsRequestEligible(input, options) + ) { try { - const store = tlsFingerprintContext.getStore(); - if (store) store.used = true; - return await tlsClient.fetch(targetUrl, { - ...options, + const response = await activeTlsClient.fetch(targetUrl, { + method: options.method, headers: options.headers, - signal: options.signal ?? undefined, + body: options.body as TlsFetchOptions["body"], + redirect: options.redirect, + signal: getEffectiveSignal(input, options), + proxy: null, + sessionScope: tlsStore?.sessionScope, }); + if (tlsStore) tlsStore.used = true; + return response; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn( - `[ProxyFetch] TLS fingerprint failed, falling back to native fetch: ${message}` - ); - const store = tlsFingerprintContext.getStore(); - if (store) store.used = false; + if (isCallerAbort(error, getEffectiveSignal(input, options))) throw error; + const sessionHadCookies = + !!error && + typeof error === "object" && + "sessionHadCookies" in error && + error.sessionHadCookies === true; + if (!isTlsFallbackReplaySafe(input, options) || sessionHadCookies) { + throw sanitizeTransportError( + error, + sessionHadCookies + ? "TLS fingerprint request failed; stateful session cannot be replayed" + : "TLS fingerprint request failed; request is not safe to replay", + "TLS_FINGERPRINT_FAILED" + ); + } + console.warn("[ProxyFetch] TLS fingerprint transport failed; using direct dispatcher"); + if (tlsStore) tlsStore.used = false; + tlsDirectFallback = true; } } + // Bun already provides a native fetch implementation with connection and + // stream handling. The custom undici dispatcher path is Node-oriented and + // can leave Bun server responses pending even though the upstream request + // itself succeeds. Preserve the dispatcher path for Node and TLS-fingerprint + // requests, but use Bun's native fetch for ordinary direct egress. + if (process.versions.bun) { + const _nativeFetch = + (deps.nativeFetch as FetchWithDispatcher | undefined) ?? originalFetchWithDispatcher; + return _nativeFetch(input, options); + } // Direct connection (no proxy) — use undici with custom dispatcher for timeout control. // Falls back to original native fetch if dispatcher initialization fails (#1054). // Retries once on transient dispatcher errors before falling back (fix: proxyfetch-undici-retry). @@ -695,7 +860,11 @@ async function patchedFetch( } // All attempts exhausted — try proxy fallback before native fetch - if (source === "direct" && isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")) { + if ( + !tlsDirectFallback && + source === "direct" && + isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED") + ) { let targetHostname = ""; try { targetHostname = new URL(targetUrl).hostname; @@ -703,7 +872,8 @@ async function patchedFetch( // ignore } if (targetHostname) { - const { findWorkingProxy } = await import("./proxyFallback.ts"); + const findWorkingProxy = + deps.findWorkingProxy ?? (await import("./proxyFallback.ts")).findWorkingProxy; const fallbackProxyUrl = await findWorkingProxy(targetHostname, targetUrl); if (fallbackProxyUrl) { try { @@ -854,6 +1024,51 @@ async function patchedFetch( throw lastRelayError; } + // The proxied TLS overlay is deliberately narrow: approved provider, exact + // http(s) proxy, no relay/family pinning, and only options wreq can preserve. + const tlsStore = tlsFingerprintContext.getStore(); + if ( + isTlsFingerprintEnabled() && + typeof tlsStore?.sessionScope === "string" && + tlsStore.sessionScope.trim().length > 0 && + activeTlsClient.available && + tlsFingerprintProviderAllowed(tlsStore?.provider, true) && + isTlsRequestEligible(input, options) && + isWreqProxySupported(proxyUrl) + ) { + try { + const response = await activeTlsClient.fetch(targetUrl, { + method: options.method, + headers: options.headers, + body: options.body as TlsFetchOptions["body"], + redirect: options.redirect, + signal: getEffectiveSignal(input, options), + proxy: proxyUrl, + sessionScope: tlsStore?.sessionScope, + }); + if (tlsStore) tlsStore.used = true; + return response; + } catch (error) { + if (isCallerAbort(error, getEffectiveSignal(input, options))) throw error; + const sessionHadCookies = + !!error && + typeof error === "object" && + "sessionHadCookies" in error && + error.sessionHadCookies === true; + if (!isTlsFallbackReplaySafe(input, options) || sessionHadCookies) { + throw sanitizeTransportError( + error, + sessionHadCookies + ? "TLS fingerprint request failed; stateful session cannot be replayed" + : "TLS fingerprint request failed; request is not safe to replay", + "TLS_FINGERPRINT_FAILED" + ); + } + console.warn("[ProxyFetch] TLS fingerprint transport failed; using proxy dispatcher"); + if (tlsStore) tlsStore.used = false; + } + } + // #9100: proxy path — attempt 0 uses the pooled keep-alive dispatcher // (pipelining 4, ONE reused TCP connection per proxy host). A transient // socket error on a stale pooled socket is retried ONCE on a fresh @@ -872,6 +1087,7 @@ async function patchedFetch( attempt === 0 ? createProxyDispatcher(proxyUrl) : getProxyRetryDispatcher(proxyUrl), }); } catch (error) { + if (isCallerAbort(error, getEffectiveSignal(input, options))) throw error; const msg = error instanceof Error ? error.message : String(error); const errCode = (error as { code?: unknown })?.code; const isTransportFailure = @@ -889,13 +1105,19 @@ async function patchedFetch( await new Promise((r) => setTimeout(r, RETRY_BACKOFF_MS)); continue; } - // A caller abort/timeout must propagate unchanged and without a noisy - // "Proxy request failed" log — it's not a proxy transport failure. - if (!isCallerAbort(error, options?.signal)) { - const message = error instanceof Error ? error.message : String(error); - console.error(`[ProxyFetch] Proxy request failed (${source}, fail-closed): ${message}`); - } - throw error; + tagProxyUnreachable(error); + const originalMsg = error instanceof Error ? error.message : String(error); + const sanitized = sanitizeTransportError( + error, + originalMsg + ? `Proxy request failed: ${originalMsg}` + : "Proxy request failed", + "PROXY_REQUEST_FAILED" + ); + console.error( + `[ProxyFetch] Proxy request failed (${source}, fail-closed; code=${sanitized.code})` + ); + throw sanitized; } } throw lastProxyError; @@ -919,19 +1141,64 @@ if (!isCloud && !patchState.isPatched) { patchState.isPatched = true; } +export type TlsTrackingIdentity = { + provider?: string | null; + sessionScope?: string; +}; + /** - * Run a function with TLS fingerprint tracking context. - * After fn completes, returns { result, tlsFingerprintUsed }. + * Run a function with account-scoped TLS fingerprint tracking. + * Both historical forms remain valid: runWithTlsTracking(fn) and + * runWithTlsTracking(provider, fn). */ -export async function runWithTlsTracking(fn) { - const store = { used: false }; - const result = await tlsFingerprintContext.run(store, fn); +export async function runWithTlsTracking( + fn: () => T +): Promise<{ result: Awaited; tlsFingerprintUsed: boolean }>; +export async function runWithTlsTracking( + provider: string | null | undefined, + fn: () => T +): Promise<{ result: Awaited; tlsFingerprintUsed: boolean }>; +export async function runWithTlsTracking( + identity: TlsTrackingIdentity, + fn: () => T +): Promise<{ result: Awaited; tlsFingerprintUsed: boolean }>; +export async function runWithTlsTracking( + providerOrIdentityOrFn: string | null | undefined | TlsTrackingIdentity | (() => T), + maybeFn?: () => T +): Promise<{ result: Awaited; tlsFingerprintUsed: boolean }> { + const legacyFn = + typeof providerOrIdentityOrFn === "function" ? providerOrIdentityOrFn : maybeFn; + if (typeof legacyFn !== "function") { + throw new TypeError("runWithTlsTracking requires a callback function"); + } + const identity: TlsTrackingIdentity = + providerOrIdentityOrFn && + typeof providerOrIdentityOrFn === "object" && + typeof providerOrIdentityOrFn !== "function" + ? providerOrIdentityOrFn + : { + provider: + typeof providerOrIdentityOrFn === "string" ? providerOrIdentityOrFn : undefined, + }; + const store: TlsFingerprintStore = { + used: false, + provider: identity.provider, + sessionScope: identity.sessionScope, + }; + const result = await tlsFingerprintContext.run(store, legacyFn); return { result, tlsFingerprintUsed: store.used }; } -/** Check if TLS fingerprint is enabled and available */ -export function isTlsFingerprintActive() { - return isTlsFingerprintEnabled() && tlsClient.available; +/** Check whether TLS fingerprint transport is enabled for this route identity. */ +export function isTlsFingerprintActive( + provider?: string | null, + proxied = false +): boolean { + return ( + isTlsFingerprintEnabled() && + activeTlsClient.available && + tlsFingerprintProviderAllowed(provider, proxied) + ); } /** diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index 688cd759ac..a5288ff738 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -177,6 +177,9 @@ const EMBEDDED_DEFAULTS = { 13, 92, 15, 89, 66, 91, 76, 70, 72, 29, 71, 70, 3, 65, 93, 84, 72, 23, 28, 87, 92, 88, 15, 95, 91, 22, 71, 87, 20, 66, 67, 86, 13, 81, 81, 21, ], + // Openference OAuth — public PKCE client id. The plaintext equals the first + // nine bytes of MASK, so its XOR-masked representation is nine zero bytes. + openference_id: [0, 0, 0, 0, 0, 0, 0, 0, 0], // Trae Cloud IDE — public oauth client id trae_id: [10, 3, 95, 6, 10, 22, 66, 3, 11, 90, 72, 31, 91, 2], // Microsoft Designer web app — public ClientId header sent by the diff --git a/open-sse/utils/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts index 72c864dfa7..375057cf77 100644 --- a/open-sse/utils/reasoningContentInjector.ts +++ b/open-sse/utils/reasoningContentInjector.ts @@ -13,6 +13,8 @@ * that proxy to thinking-mode models. */ +import { requiresReasoningReplay } from "../services/reasoningCache.ts"; + const PLACEHOLDER = " "; type JsonRecord = Record; @@ -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 e0d045e10b..21fc22cab1 100644 --- a/open-sse/utils/reasoningFields.ts +++ b/open-sse/utils/reasoningFields.ts @@ -1,3 +1,5 @@ +import { stripInternalReasoningPlaceholder } from "./reasoningPlaceholder.ts"; + type JsonRecord = Record; export function asReasoningRecord(value: unknown): JsonRecord { @@ -60,13 +62,70 @@ 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); if (mirrored) target.reasoning_content = mirrored; } + // 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, 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 (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..ea2cc6ff0c 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; @@ -71,7 +72,16 @@ function maskSensitiveHeaders(headers: HeaderInput): Record { : { ...(headers as Record) }; const masked = { ...headerEntries }; - const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token"]; + const sensitiveKeys = [ + "authorization", + "x-api-key", + "cookie", + "token", + "runtimekey", + "storage-state", + "storagestate", + "capability", + ]; for (const key of Object.keys(masked)) { const lowerKey = key.toLowerCase(); @@ -148,7 +158,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/setupPolyfill.ts b/open-sse/utils/setupPolyfill.ts index 6eed9c1ce0..3e016b0483 100644 --- a/open-sse/utils/setupPolyfill.ts +++ b/open-sse/utils/setupPolyfill.ts @@ -1,7 +1,19 @@ // Polyfill worker_threads.markAsUncloneable for Node.js < 21 compatibility (specifically Node 20.20.2) import worker_threads from "node:worker_threads"; +import { AsyncLocalStorage } from "node:async_hooks"; import { WebSocket } from "ws"; +// Next 16 reads AsyncLocalStorage from globalThis in its server runtime. Node +// provides that global, while Bun exposes the implementation through +// node:async_hooks only. +if (typeof globalThis.AsyncLocalStorage === "undefined") { + Object.defineProperty(globalThis, "AsyncLocalStorage", { + configurable: true, + value: AsyncLocalStorage, + writable: true, + }); +} + if (worker_threads && !worker_threads.markAsUncloneable) { (worker_threads as any).markAsUncloneable = function (obj: any) { if (worker_threads.markAsUntransferable) { diff --git a/open-sse/utils/sseHeartbeat.ts b/open-sse/utils/sseHeartbeat.ts index eb58cd4fa4..5fb415ea8b 100644 --- a/open-sse/utils/sseHeartbeat.ts +++ b/open-sse/utils/sseHeartbeat.ts @@ -79,7 +79,8 @@ export function sseCommentsEnabled(): boolean { if (typeof process === "undefined") return true; const v = process.env.OMNIROUTE_SSE_COMMENTS; if (v === undefined || v === "") return true; - return v.trim().toLowerCase() !== "off"; + const normalized = v.trim().toLowerCase(); + return normalized !== "off" && normalized !== "false" && normalized !== "0" && normalized !== "no"; } export function createSseHeartbeatTransform({ diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 7f0991a0b9..121b3950e6 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -8,6 +8,8 @@ import { logUsage, addBufferToUsage, filterUsageForFormat, + normalizeUsage as normalizeTokenUsage, + sanitizeUsagePayloadForRequest, } from "./usageTracking.ts"; import { parseSSELine, @@ -23,8 +25,10 @@ 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"; import { createStructuredSSECollector, buildStreamSummaryFromEvents, @@ -75,6 +79,7 @@ import { restoreOpenAIToolNames, } from "../translator/helpers/toolCallHelper.ts"; import { normalizeFinalOpenAIStreamChunk } from "./openAIStreamChunk.ts"; +import { collectClaudeDelta } from "./streamClaudeDelta.ts"; /** * Race a response body read against a timeout. @@ -724,6 +729,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 @@ -994,6 +1002,7 @@ export function createSSEStream(options: StreamOptions = {}) { const output = formatSSE(itemSanitized, sourceFormat); clientPayloadCollector.push(itemSanitized); reqLogger?.appendConvertedChunk?.(output); + forwardedValuableChunk = true; controller.enqueue(encoder.encode(output)); }; @@ -1001,7 +1010,14 @@ export function createSSEStream(options: StreamOptions = {}) { controller: TransformStreamDefaultController, finalUsage: UsageTokenRecord | Record | null | undefined ) => { - const costUsd = finalUsage ? await calculateCost(provider, model, finalUsage) : 0; + // Skip SSE metadata comment lines when OMNIROUTE_SSE_COMMENTS is disabled + // (e.g., "off", "false", "0", "no"). Strict OpenAI-compatible clients that + // JSON.parse every SSE line will crash on `: x-omniroute-*` comment lines. + if (!sseCommentsEnabled()) return; + + const costUsd = finalUsage + ? await calculateCost(provider, model, normalizeTokenUsage(finalUsage)) + : 0; const comment = buildOmniRouteSseMetadataComment({ provider, model, @@ -1302,7 +1318,10 @@ export function createSSEStream(options: StreamOptions = {}) { parsed.type.startsWith("content_block") || parsed.type === "ping" || parsed.type === "error"); - + if (sanitizeUsagePayloadForRequest(parsed, body, clientResponseFormat)) { + output = `data: ${JSON.stringify(parsed)}\n\n`; + injectedUsage = true; + } if (isResponsesSSE) { // #6199/#6561 — statefully drop internal commentary-phase output (see // ./responsesCommentaryDrop.ts) and clear the buffered `event:` line @@ -1366,7 +1385,9 @@ export function createSSEStream(options: StreamOptions = {}) { const responseToolCallEvents = buildResponsesFunctionCallEvents(collectedToolCall); output = formatSSEDataEvents(responseToolCallEvents); - clientPayloadCollector.push(...responseToolCallEvents); + for (const event of responseToolCallEvents) { + clientPayloadCollector.push(event); + } reqLogger?.appendConvertedChunk?.(output); controller.enqueue(encoder.encode(output)); injectedUsage = true; @@ -1620,7 +1641,13 @@ export function createSSEStream(options: StreamOptions = {}) { // retry." with finish_reason: "stop" — clients (Goose/opencode) feed that // text back as a turn and spin in a retry loop. This restores the #3400 // behavior that #3422 inadvertently reverted (regression #3388/#3502). - if (Array.isArray(parsed.choices) && parsed.choices.length === 0) { + if (Array.isArray(parsed.choices) && (parsed.choices.length === 0 || + (parsed.choices.length === 1 && + parsed.choices[0]?.delta && + typeof parsed.choices[0].delta === "object" && + Object.keys(parsed.choices[0].delta).length === 0 && + !parsed.choices[0]?.finish_reason)) + ) { const emptyChoicesUsage = extractUsage(parsed) ?? parsed.usage; if (hasValidUsage(emptyChoicesUsage)) { // Some upstreams (e.g. Ollama Cloud) emit prompt_tokens: 0 @@ -1829,6 +1856,7 @@ export function createSSEStream(options: StreamOptions = {}) { passthroughSawFinishReason = true; } + if (isFinishChunk && passthroughHasToolCalls) { toolFinishTime = now; try { @@ -1961,13 +1989,11 @@ export function createSSEStream(options: StreamOptions = {}) { } if (shouldDropResponsesCommentary && dropCommentary(parsed as JsonRecord)) continue; - providerPayloadCollector.push(parsed); - if (parsed && parsed.done) { continue; } - + sanitizeUsagePayloadForRequest(parsed, body, targetFormat); if (parsed.choices?.[0]?.delta?.tool_calls) { lastToolCallChunkTime = now; } @@ -1982,18 +2008,8 @@ export function createSSEStream(options: StreamOptions = {}) { // Do this before translation so we capture content regardless of translator output shape // Claude format - if (parsed.delta?.text) { - const t = parsed.delta.text; - totalContentLength += t.length; - if (state?.accumulatedContent !== undefined && typeof t === "string") - state.accumulatedContent = appendBoundedText(state.accumulatedContent, t); - } - if (parsed.delta?.thinking) { - const t = parsed.delta.thinking; - totalContentLength += t.length; - if (state?.accumulatedReasoning !== undefined && typeof t === "string") - state.accumulatedReasoning = appendBoundedText(state.accumulatedReasoning, t); - } + const claudeDelta = collectClaudeDelta(parsed.delta, state); + totalContentLength += claudeDelta.contentLength; // OpenAI format if (parsed.choices?.[0]?.delta?.content) { @@ -2075,7 +2091,7 @@ export function createSSEStream(options: StreamOptions = {}) { } const translateHasContent = - typeof parsed.delta?.text === "string" || + claudeDelta.hasText || typeof parsed.choices?.[0]?.delta?.content === "string" || Boolean(getAnyReasoningValue(parsed.choices?.[0]?.delta)); if (translateHasContent && !contentAfterToolSeen) { @@ -2179,6 +2195,7 @@ export function createSSEStream(options: StreamOptions = {}) { }, pushProviderPayload: (payload: unknown) => providerPayloadCollector.push(payload), pushClientPayload: (payload: unknown) => clientPayloadCollector.push(payload), + sanitizeUsagePayload: (payload: unknown) => sanitizeUsagePayloadForRequest(payload, body, clientResponseFormat), setPassthroughResponsesId: (value: string) => { passthroughResponsesId = value; }, @@ -2227,7 +2244,6 @@ export function createSSEStream(options: StreamOptions = {}) { return; } } - const bufferedLine = buffer.trim(); if (skipPassthroughEvent || /^event:\s*keepalive\b/i.test(bufferedLine)) { skipPassthroughEvent = false; @@ -2240,6 +2256,7 @@ export function createSSEStream(options: StreamOptions = {}) { const bufferedPayload = parseSSELine(bufferedLine); if (bufferedPayload) { providerPayloadCollector.push(bufferedPayload); + if (sanitizeUsagePayloadForRequest(bufferedPayload, body, clientResponseFormat)) output = `data: ${JSON.stringify(bufferedPayload)}\n\n`; if ( shouldInjectClaudeEmptyResponseBeforeCurrentEvent( claudeEmptyResponseLifecycle, @@ -2253,7 +2270,7 @@ export function createSSEStream(options: StreamOptions = {}) { updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, bufferedPayload); } clientPayloadCollector.push(bufferedPayload); - + // Normalize numeric IDs for final buffered data: chunk (same as transform path) if (typeof bufferedPayload === "object" && !Array.isArray(bufferedPayload)) { const flushedParsed = bufferedPayload as JsonRecord; const flushedType = @@ -2463,12 +2480,18 @@ export function createSSEStream(options: StreamOptions = {}) { status: 200, usage, responseBody, + // #9315 switched the summary to the accumulated responseBody to avoid + // stale/truncated event data — but responseBody here is synthesized in + // chat-completion shape, which loses the Responses API `response` object. + // Keep the events-derived summary for OPENAI_RESPONSES only. providerPayload: providerPayloadCollector.build( - buildStreamSummaryFromEvents( - providerPayloadCollector.getEvents(), - sourceFormat, - model - ), + sourceFormat === FORMATS.OPENAI_RESPONSES + ? buildStreamSummaryFromEvents( + providerPayloadCollector.getEvents(), + sourceFormat, + model + ) + : responseBody, { includeEvents: false } ), clientPayload: clientPayloadCollector.build(responseBody, { @@ -2607,6 +2630,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); @@ -2738,12 +2782,16 @@ export function createSSEStream(options: StreamOptions = {}) { status: 200, usage: state?.usage, responseBody, + // Same OPENAI_RESPONSES carve-out as the passthrough branch above — + // the synthesized chat-shaped responseBody drops the `response` object. providerPayload: providerPayloadCollector.build( - buildStreamSummaryFromEvents( - providerPayloadCollector.getEvents(), - targetFormat, - model - ), + targetFormat === FORMATS.OPENAI_RESPONSES + ? buildStreamSummaryFromEvents( + providerPayloadCollector.getEvents(), + targetFormat, + model + ) + : responseBody, { includeEvents: false } ), clientPayload: clientPayloadCollector.build(responseBody, { @@ -2786,7 +2834,7 @@ export function createSSETransformStreamWithLogger( body: unknown = null, onComplete: ((payload: StreamCompletePayload) => void) | null = null, apiKeyInfo: unknown = null, - onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null, + onFailure: ((payload: StreamFailurePayload) => boolean | void | Promise) | null = null, copilotCompatibleReasoning = false, suppressThinkClose = false, customToolNames: ReadonlySet = new Set(), @@ -2821,7 +2869,7 @@ export function createPassthroughStreamWithLogger( body: unknown = null, onComplete: ((payload: StreamCompletePayload) => void) | null = null, apiKeyInfo: unknown = null, - onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null, + onFailure: ((payload: StreamFailurePayload) => boolean | void | Promise) | null = null, clientResponseFormat: string | null = null, requestToolIdentityMap: Map | null = null ) { diff --git a/open-sse/utils/streamClaudeDelta.ts b/open-sse/utils/streamClaudeDelta.ts new file mode 100644 index 0000000000..62eb918e47 --- /dev/null +++ b/open-sse/utils/streamClaudeDelta.ts @@ -0,0 +1,29 @@ +import { appendBoundedText } from "./streamHelpers.ts"; + +type ClaudeDeltaState = { + accumulatedContent?: string; + accumulatedReasoning?: string; +}; + +export function collectClaudeDelta(delta: unknown, state?: ClaudeDeltaState) { + const record = + delta && typeof delta === "object" && !Array.isArray(delta) + ? (delta as Record) + : {}; + const text = record.text; + const thinking = record.thinking; + let contentLength = 0; + + if (typeof text === "string" && text) { + contentLength += text.length; + if (state?.accumulatedContent !== undefined) + state.accumulatedContent = appendBoundedText(state.accumulatedContent, text); + } + if (typeof thinking === "string" && thinking) { + contentLength += thinking.length; + if (state?.accumulatedReasoning !== undefined) + state.accumulatedReasoning = appendBoundedText(state.accumulatedReasoning, thinking); + } + + return { contentLength, hasText: typeof text === "string" }; +} 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/open-sse/utils/thinkingBudget.ts b/open-sse/utils/thinkingBudget.ts new file mode 100644 index 0000000000..b97078f782 --- /dev/null +++ b/open-sse/utils/thinkingBudget.ts @@ -0,0 +1,72 @@ +/** + * Thinking-budget helpers extracted from base.ts. + * + * Pure utilities for reading / clamping the thinking budget fields that + * different providers nest inside the request body. + */ + +export function hasActiveClaudeThinking(body: Record): boolean { + const thinking = body.thinking as Record | undefined; + return thinking?.type === "enabled" || thinking?.type === "adaptive"; +} + +/** + * Collect every `thinkingConfig` object in a transformed request body that holds + * a thinking budget, wherever the provider's envelope nests it: + * - body.generationConfig.thinkingConfig (native Gemini / openai→gemini) + * - body.request.generationConfig.thinkingConfig (Antigravity Cloud Code envelope) + * Returns only objects that actually carry a `thinkingBudget`/`thinking_budget` + * field — a request without thinking config is never mutated. + */ +export function collectThinkingConfigs(body: unknown): Array> { + if (!body || typeof body !== "object") return []; + const root = body as Record; + const configs: Array> = []; + const envelopes: unknown[] = [ + root.generationConfig, + (root.request as Record | undefined)?.generationConfig, + ]; + for (const env of envelopes) { + if (!env || typeof env !== "object") continue; + const tc = (env as Record).thinkingConfig; + if (tc && typeof tc === "object") { + const tcr = tc as Record; + if ("thinkingBudget" in tcr || "thinking_budget" in tcr) configs.push(tcr); + } + } + return configs; +} + +/** + * Read the first thinking budget found in the body (any supported nest / naming). + * Returns null when the body carries no readable numeric budget. + */ +export function readNestedThinkingBudget(body: unknown): number | null { + for (const tc of collectThinkingConfigs(body)) { + const raw = tc.thinkingBudget ?? tc.thinking_budget; + const n = Number(raw); + if (Number.isFinite(n)) return n; + } + return null; +} + +/** + * Clamp every thinking budget in the body down to `max` (only lowers; never + * raises a budget already below max). Mutates in place. Returns true when at + * least one budget was actually lowered (i.e. a retry would send a different + * body) — false means the 400 was not caused by an over-max budget we hold, so + * retrying would resend an identical body and loop. + */ +export function clampNestedThinkingBudget(body: unknown, max: number): boolean { + let changed = false; + for (const tc of collectThinkingConfigs(body)) { + for (const key of ["thinkingBudget", "thinking_budget"] as const) { + const n = Number(tc[key]); + if (Number.isFinite(n) && n > max) { + tc[key] = max; + changed = true; + } + } + } + return changed; +} diff --git a/open-sse/utils/tlsClient.ts b/open-sse/utils/tlsClient.ts index c2b41521d0..2411a89eb6 100644 --- a/open-sse/utils/tlsClient.ts +++ b/open-sse/utils/tlsClient.ts @@ -1,20 +1,40 @@ import { createRequire } from "module"; +import { createHash } from "node:crypto"; import { getTlsClientTimeoutConfig } from "@/shared/utils/runtimeTimeouts"; -const require = createRequire(import.meta.url); +const runtimeRequire = createRequire(import.meta.url); -type WreqSession = { - fetch: (url: string, options?: Record) => Promise; - close: () => Promise | void; +function loadRuntimeModule(moduleName: string): unknown { + // Keep the specifier dynamic. Turbopack rewrites a literal createRequire call + // to a hashed external name that is absent from the standalone Docker runtime. + return Reflect.apply(runtimeRequire, undefined, [moduleName]); +} + +export type WreqResponse = { + status: number; + statusText: string; + headers: Iterable<[string, string]>; + body: ReadableStream | null; + url?: string; + redirected?: boolean; }; -type CreateSessionFn = (options: Record) => Promise; +export type WreqSession = { + fetch: (url: string, options?: Record) => Promise; + close: () => Promise | void; + getCookies?: (url: string | URL) => Record; +}; + +export type CreateSessionFn = (options: Record) => Promise; let createSession: CreateSessionFn | null; try { - const loaded = require("wreq-js") as { createSession?: CreateSessionFn }; + const loaded = loadRuntimeModule("wreq-js") as { createSession?: CreateSessionFn }; createSession = typeof loaded.createSession === "function" ? loaded.createSession : null; } catch { + if (process.env.ENABLE_TLS_FINGERPRINT === "true") { + console.warn("[TlsClient] wreq-js unavailable; TLS fingerprint transport disabled"); + } createSession = null; } @@ -34,12 +54,26 @@ function getProxyFromEnv(): string | undefined { ); } -interface FetchOptions { +export type WreqBodyInit = + | string + | ArrayBuffer + | ArrayBufferView + | URLSearchParams + | Buffer + | Blob + | FormData + | null; + +export interface TlsFetchOptions { method?: string; headers?: HeadersInit; - body?: unknown; - redirect?: string; - signal?: AbortSignal; + body?: WreqBodyInit; + redirect?: RequestRedirect; + signal?: AbortSignal | null; + /** Exact resolved proxy. Undefined preserves legacy environment lookup; null means direct. */ + proxy?: string | null; + /** Stable account/connection identity used to isolate cookies and circuit state. */ + sessionScope?: string; } function normalizeHeaders(headers: HeadersInit | undefined): Record | undefined { @@ -62,182 +96,591 @@ function normalizeHeaders(headers: HeadersInit | undefined): Record= this.circuitOpenUntil; + if ( + "errorCode" in error && + typeof error.errorCode === "string" && + /^[a-zA-Z0-9_:-]{1,64}$/.test(error.errorCode) + ) { + sanitized.errorCode = error.errorCode; } + if ( + "statusCode" in error && + typeof error.statusCode === "number" && + Number.isFinite(error.statusCode) + ) { + sanitized.statusCode = error.statusCode; + } + return sanitized; +} - private recordFailure(): void { - this.failureCount++; - if (this.failureCount >= this.maxFailures) { - this.circuitOpenUntil = Date.now() + this.cooldownMs; - this.circuitTripped = true; - // Close the stale session so the next half-open retry creates a - // fresh one instead of reusing a broken connection. - if (this.session) { - Promise.resolve(this.session.close()).catch(() => {}); - this.session = null; - } - console.warn( - `[TlsClient] Circuit opened after ${this.failureCount} consecutive failures, cooling down for ${this.cooldownMs}ms` +function toNativeResponse( + response: WreqResponse, + onFinalize: () => void, + onBodyError: () => void, + signal?: AbortSignal | null +): Response { + let finalized = false; + let bodyFailureReported = false; + let consumerCancelled = false; + let consumerCancelReason: unknown; + const finalize = () => { + if (finalized) return; + finalized = true; + onFinalize(); + }; + const safeBodyError = (error: unknown): unknown => { + if (signal?.aborted) { + return signal.reason ?? new DOMException("The operation was aborted", "AbortError"); + } + if (consumerCancelled) { + return ( + consumerCancelReason ?? new DOMException("The response body was cancelled", "AbortError") ); - // Double cooldown for the next trip: 30s → 60s → 120s → ... → 10 min max - this.escalateCooldown(); } - } - - private recordSuccess(): void { - this.failureCount = 0; - if (this.circuitTripped) { - this.cooldownMultiplier = 1; - this.cooldownMs = this.baseCooldownMs; - console.log("[TlsClient] Circuit closed (success after cooldown)"); - this.circuitTripped = false; + if (!bodyFailureReported) { + bodyFailureReported = true; + onBodyError(); } + return sanitizeWreqError(error, "wreq-js response body failed"); + }; + if (response instanceof Response) { + finalize(); + return response; } - private escalateCooldown(): void { - this.cooldownMultiplier = Math.min(this.cooldownMultiplier * 2, 20); - this.cooldownMs = Math.min(this.baseCooldownMs * this.cooldownMultiplier, this.MAX_COOLDOWN_MS); + try { + const headers = new Headers(); + for (const [name, value] of response.headers) headers.append(name, value); + let body: ReadableStream | null = null; + if (response.body) { + const reader = response.body.getReader(); + body = new ReadableStream({ + async pull(controller) { + try { + const chunk = await reader.read(); + if (chunk.done) { + finalize(); + controller.close(); + } else { + controller.enqueue(chunk.value); + } + } catch (error) { + controller.error(safeBodyError(error)); + finalize(); + } + }, + async cancel(reason) { + consumerCancelled = true; + consumerCancelReason = reason; + try { + await reader.cancel(reason); + } catch (error) { + throw safeBodyError(error); + } finally { + finalize(); + } + }, + }); + } else { + finalize(); + } + const adapted = new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }); + if (response.url) { + Object.defineProperty(adapted, "url", { value: response.url, configurable: true }); + } + if (response.redirected !== undefined) { + Object.defineProperty(adapted, "redirected", { + value: response.redirected, + configurable: true, + }); + } + return adapted; + } catch (error) { + finalize(); + throw error; + } +} + +/** + * TLS Client — Chrome 124 TLS fingerprint spoofing via wreq-js. + * Sessions, cookie jars, and circuit state are isolated by account scope and exact proxy. + */ +export class TlsClient { + private readonly createSessionFn: CreateSessionFn | null; + private readonly sessions = new Map(); + private readonly pendingSessions = new Map>(); + private readonly pendingCloses = new Set>(); + private readonly sessionEpochs = new Map(); + private readonly sessionUseCounts = new Map(); + private readonly sessionLastUsed = new Map(); + private readonly pendingEvictions = new Set(); + private accessSequence = 0; + private readonly circuits = new Map< + string, + { + failureCount: number; + cooldownMs: number; + cooldownMultiplier: number; + circuitOpenUntil: number; + circuitTripped: boolean; + halfOpenInFlight: boolean; + sessionHadCookies: boolean; + } + >(); + private globalSessionEpoch = 0; + private readonly maxFailures = 3; + private readonly baseCooldownMs = 30_000; + private readonly maxCooldownMs = 600_000; + private readonly legacySessionScope = "legacy"; + private readonly _libraryAvailable: boolean; + private readonly maxSessions: number; + + constructor( + createSessionFn: CreateSessionFn | null = createSession, + maxSessions = 128 + ) { + this.createSessionFn = createSessionFn; + this._libraryAvailable = !!createSessionFn; + this.maxSessions = + Number.isInteger(maxSessions) && maxSessions > 0 ? maxSessions : 128; } - private checkCircuit(): boolean { - if (!this.circuitTripped) return true; + /** Library availability only. Per-session circuit state is enforced inside fetch(). */ + get available(): boolean { + return this._libraryAvailable; + } - if (Date.now() >= this.circuitOpenUntil) { - console.log("[TlsClient] Half-open: retrying after cooldown"); - // Don't call recordSuccess() here — that would reset failureCount. - // Instead, let the fetch() call succeed or fail naturally. - // If it succeeds, recordSuccess() in fetch() handles cleanup. - // If it fails, recordFailure() finds failureCount still >= maxFailures - // and re-opens with escalated cooldown. + private resolveProxy(proxy?: string | null): string | null { + return proxy === undefined ? (getProxyFromEnv() ?? null) : proxy; + } + + private getSessionKey(resolvedProxy: string | null, sessionScope?: string): string { + const scope = sessionScope?.trim() || this.legacySessionScope; + return createHash("sha256") + .update(scope) + .update("\0") + .update(resolvedProxy ?? "") + .digest("base64url"); + } + + private getDefaultSessionKey(): string { + return this.getSessionKey(this.resolveProxy(undefined), this.legacySessionScope); + } + + private getSessionEpoch(key: string): number { + return this.sessionEpochs.get(key) ?? 0; + } + + private hasSessionCookies(session: WreqSession | null, url: string): boolean { + if (!session) return false; + if (!session.getCookies) return true; + try { + return Object.keys(session.getCookies(url)).length > 0; + } catch { + // If cookie state cannot be inspected, fail closed and forbid replay. return true; } - - return false; } - async getSession() { - if (!this.checkCircuit()) return null; - if (!this.available) return null; - if (this.session) return this.session; - const createSessionFn = createSession; - if (!createSessionFn) return null; + private closeSession(session: WreqSession): Promise { + let closing: Promise; + closing = Promise.resolve() + .then(() => session.close()) + .catch(() => {}) + .finally(() => { + this.pendingCloses.delete(closing); + }); + this.pendingCloses.add(closing); + return closing; + } + + private findOldestIdleSession(protectedKey?: string): string | undefined { + let candidate: string | undefined; + let candidateSequence = Number.POSITIVE_INFINITY; + for (const key of this.sessions.keys()) { + if (key === protectedKey || (this.sessionUseCounts.get(key) ?? 0) > 0) continue; + const sequence = this.sessionLastUsed.get(key) ?? 0; + if (sequence < candidateSequence) { + candidate = key; + candidateSequence = sequence; + } + } + return candidate; + } + + private reserveSessionCapacity(protectedKey: string): void { + if ( + this.pendingSessions.size >= this.maxSessions || + this.pendingCloses.size >= this.maxSessions + ) { + const error = new Error("wreq-js session capacity exhausted") as Error & { + code?: string; + }; + error.code = "TLS_SESSION_CAPACITY"; + throw error; + } + while (this.sessions.size >= this.maxSessions) { + const candidate = this.findOldestIdleSession(protectedKey); + if (!candidate) { + const error = new Error("wreq-js session capacity exhausted") as Error & { + code?: string; + }; + error.code = "TLS_SESSION_CAPACITY"; + throw error; + } + void this.invalidateSession(candidate); + } + } + + private retainSession(key: string): void { + this.pendingEvictions.delete(key); + this.sessionUseCounts.set(key, (this.sessionUseCounts.get(key) ?? 0) + 1); + this.sessionLastUsed.set(key, ++this.accessSequence); + } + + private releaseSession(key: string): void { + const remaining = (this.sessionUseCounts.get(key) ?? 1) - 1; + if (remaining > 0) { + this.sessionUseCounts.set(key, remaining); + return; + } + this.sessionUseCounts.delete(key); + if (this.pendingEvictions.delete(key)) { + void this.invalidateSession(key); + return; + } + this.evictSessionsIfNeeded(); + } + + private evictSessionsIfNeeded(protectedKey?: string): void { + while (this.sessions.size > this.maxSessions) { + const candidate = this.findOldestIdleSession(protectedKey); + if (candidate) { + void this.invalidateSession(candidate); + continue; + } + + let activeCandidate: string | undefined; + let candidateSequence = Number.POSITIVE_INFINITY; + for (const key of this.sessions.keys()) { + if (key === protectedKey || this.pendingEvictions.has(key)) continue; + const sequence = this.sessionLastUsed.get(key) ?? 0; + if (sequence < candidateSequence) { + activeCandidate = key; + candidateSequence = sequence; + } + } + if (activeCandidate) this.pendingEvictions.add(activeCandidate); + return; + } + } + + private invalidateSession(key: string): Promise { + const pending = this.pendingSessions.get(key); + const invalidatedEpoch = this.getSessionEpoch(key) + 1; + this.sessionEpochs.set(key, invalidatedEpoch); + this.pendingSessions.delete(key); + this.sessionUseCounts.delete(key); + this.sessionLastUsed.delete(key); + this.pendingEvictions.delete(key); + const session = this.sessions.get(key); + this.sessions.delete(key); + if (pending) { + void pending + .finally(() => { + if ( + this.getSessionEpoch(key) === invalidatedEpoch && + !this.pendingSessions.has(key) && + !this.sessions.has(key) + ) { + this.sessionEpochs.delete(key); + } + }) + .catch(() => {}); + } else { + this.sessionEpochs.delete(key); + } + return session ? this.closeSession(session) : Promise.resolve(); + } + + private async closeSessions(): Promise { + const pending = [...this.pendingSessions.values()]; + this.globalSessionEpoch++; + this.pendingSessions.clear(); + this.sessionEpochs.clear(); + const sessions = [...this.sessions.values()]; + this.sessions.clear(); + this.sessionUseCounts.clear(); + this.sessionLastUsed.clear(); + this.pendingEvictions.clear(); + this.circuits.clear(); + const closes = sessions.map((session) => this.closeSession(session)); + await Promise.allSettled([...closes, ...pending]); + await Promise.allSettled([...this.pendingCloses]); + } + + private checkCircuit(key = this.getDefaultSessionKey()): boolean { + const state = this.circuits.get(key); + if (!state || !state.circuitTripped) return true; + if (Date.now() < state.circuitOpenUntil) return false; + if (state.halfOpenInFlight) return false; + state.halfOpenInFlight = true; + console.log("[TlsClient] Half-open: retrying after cooldown"); + return true; + } + + private recordFailure( + key = this.getDefaultSessionKey(), + sessionHadCookies = false + ): void { + const state = this.circuits.get(key) ?? { + failureCount: 0, + cooldownMs: this.baseCooldownMs, + cooldownMultiplier: 1, + circuitOpenUntil: 0, + circuitTripped: false, + halfOpenInFlight: false, + sessionHadCookies: false, + }; + state.sessionHadCookies ||= sessionHadCookies; + state.failureCount++; + state.halfOpenInFlight = false; + if (state.failureCount >= this.maxFailures) { + state.circuitOpenUntil = Date.now() + state.cooldownMs; + state.circuitTripped = true; + if ((this.sessionUseCounts.get(key) ?? 0) > 0) { + this.pendingEvictions.add(key); + } else { + void this.invalidateSession(key); + } + console.warn( + `[TlsClient] Circuit opened after ${state.failureCount} consecutive failures, cooling down for ${state.cooldownMs}ms` + ); + state.cooldownMultiplier = Math.min(state.cooldownMultiplier * 2, 20); + state.cooldownMs = Math.min( + this.baseCooldownMs * state.cooldownMultiplier, + this.maxCooldownMs + ); + } + this.circuits.delete(key); + this.circuits.set(key, state); + const maxCircuitEntries = this.maxSessions * 2; + while (this.circuits.size > maxCircuitEntries) { + const oldestKey = this.circuits.keys().next().value; + if (typeof oldestKey !== "string") break; + this.circuits.delete(oldestKey); + } + } + + private recordSuccess(key = this.getDefaultSessionKey()): void { + const state = this.circuits.get(key); + if (state?.circuitTripped) { + console.log("[TlsClient] Circuit closed (success after cooldown)"); + } + this.circuits.delete(key); + } + + private releaseHalfOpen(key: string): void { + const state = this.circuits.get(key); + if (state) state.halfOpenInFlight = false; + } + + private async getSession( + resolvedProxy: string | null, + key: string + ): Promise { + const cached = this.sessions.get(key); + if (cached) { + this.pendingEvictions.delete(key); + this.sessionLastUsed.set(key, ++this.accessSequence); + return cached; + } + const pending = this.pendingSessions.get(key); + if (pending) return pending; + if (!this.createSessionFn) return null; + this.reserveSessionCapacity(key); - const proxy = getProxyFromEnv(); const sessionOpts: Record = { browser: "chrome_124", os: "macos", }; - if (proxy) { - sessionOpts.proxy = proxy; - console.log(`[TlsClient] Using proxy: ${proxy}`); - } + if (resolvedProxy) sessionOpts.proxy = resolvedProxy; + const globalEpoch = this.globalSessionEpoch; + const sessionEpoch = this.getSessionEpoch(key); - this.session = await createSessionFn(sessionOpts); - console.log("[TlsClient] Session created (Chrome 124 TLS fingerprint)"); - return this.session; + const creating = Reflect.apply(this.createSessionFn, undefined, [sessionOpts]) + .then(async (session) => { + if ( + globalEpoch !== this.globalSessionEpoch || + sessionEpoch !== this.getSessionEpoch(key) + ) { + await this.closeSession(session); + throw new Error("wreq-js session invalidated"); + } + if (this.sessions.size >= this.maxSessions) { + const candidate = this.findOldestIdleSession(key); + if (!candidate) { + await this.closeSession(session); + const error = new Error("wreq-js session capacity exhausted") as Error & { + code?: string; + }; + error.code = "TLS_SESSION_CAPACITY"; + throw error; + } + void this.invalidateSession(candidate); + } + this.sessions.set(key, session); + this.sessionLastUsed.set(key, ++this.accessSequence); + this.evictSessionsIfNeeded(key); + console.log("[TlsClient] Session created (Chrome 124 TLS fingerprint)"); + return session; + }) + .finally(() => { + if (this.pendingSessions.get(key) === creating) { + this.pendingSessions.delete(key); + this.sessionEpochs.delete(key); + } + }); + this.pendingSessions.set(key, creating); + return creating; } - /** - * Fetch with Chrome 124 TLS fingerprint. - * wreq-js Response is already fetch-compatible (headers, text(), json(), clone(), body). - */ - async fetch(url: string, options: FetchOptions = {}) { - if (!this.checkCircuit()) { - throw new Error("wreq-js circuit open — skipping TLS request"); + /** Fetch with Chrome 124 TLS fingerprint and an account-scoped persistent cookie jar. */ + async fetch(url: string, options: TlsFetchOptions = {}): Promise { + const resolvedProxy = this.resolveProxy(options.proxy); + const key = this.getSessionKey(resolvedProxy, options.sessionScope); + if (!this.checkCircuit(key)) { + const state = this.circuits.get(key); + const error = new Error("wreq-js circuit open — skipping TLS request") as Error & { + code?: string; + }; + error.code = "TLS_CIRCUIT_OPEN"; + if (state?.sessionHadCookies) { + Object.defineProperty(error, "sessionHadCookies", { + value: true, + configurable: true, + }); + } + throw error; } + let session: WreqSession | null = null; + let sessionUseRetained = false; + const releaseSession = () => { + if (!sessionUseRetained) return; + sessionUseRetained = false; + this.releaseSession(key); + }; try { - const session = await this.getSession(); + session = await this.getSession(resolvedProxy, key); if (!session) throw new Error("wreq-js not available"); + this.retainSession(key); + sessionUseRetained = true; const { timeoutMs } = getTlsClientTimeoutConfig(process.env, (message) => { console.warn(`[TlsClient] ${message}`); }); - const method = (options.method || "GET").toUpperCase(); - const wreqOptions: Record = { - method, + method: (options.method || "GET").toUpperCase(), headers: normalizeHeaders(options.headers), body: options.body, - redirect: options.redirect === "manual" ? "manual" : "follow", + redirect: options.redirect ?? "follow", timeout: timeoutMs, }; + if (options.signal) wreqOptions.signal = options.signal; - if (options.signal) { - wreqOptions.signal = options.signal; - } - - const response = await session.fetch(url, wreqOptions); - this.recordSuccess(); + const response = toNativeResponse( + await session.fetch(url, wreqOptions), + releaseSession, + () => this.recordFailure(key, this.hasSessionCookies(session, url)), + options.signal + ); + this.recordSuccess(key); return response; } catch (err) { - const isAbort = - err instanceof Error && (err.name === "AbortError" || err.message.includes("aborted")); - if (!isAbort) { - this.recordFailure(); + const isCallerAbort = options.signal?.aborted === true; + const sessionHadCookies = + !isCallerAbort && this.hasSessionCookies(session, url); + releaseSession(); + if (isCallerAbort) { + this.releaseHalfOpen(key); + } else { + this.recordFailure(key, sessionHadCookies); } - throw err; + if (isCallerAbort) throw err; + const transportError = sanitizeWreqError(err, "wreq-js transport failed"); + if (sessionHadCookies) { + Object.defineProperty(transportError, "sessionHadCookies", { + value: true, + configurable: true, + }); + } + throw transportError; } } - async exit() { - if (this.session) { - await this.session.close(); - this.session = null; + async exit(): Promise { + await this.closeSessions(); + } + + resetCircuit(proxy?: string | null, sessionScope?: string): void { + if (arguments.length === 0) { + this.circuits.clear(); + return; } + const resolvedProxy = this.resolveProxy(proxy); + this.circuits.delete(this.getSessionKey(resolvedProxy, sessionScope)); } - resetCircuit(): void { - this.failureCount = 0; - this.circuitTripped = false; - this.circuitOpenUntil = 0; - } - - getCircuitState(): { + getCircuitState( + proxy?: string | null, + sessionScope?: string + ): { available: boolean; circuitTripped: boolean; failureCount: number; circuitOpenUntil: number; coolDownRemainingMs: number; } { + const resolvedProxy = this.resolveProxy(proxy); + const key = this.getSessionKey(resolvedProxy, sessionScope); + const state = this.circuits.get(key); + const circuitOpenUntil = state?.circuitOpenUntil ?? 0; + const circuitTripped = state?.circuitTripped ?? false; return { - available: this.available, - circuitTripped: this.circuitTripped, - failureCount: this.failureCount, - circuitOpenUntil: this.circuitOpenUntil, + available: + this._libraryAvailable && + (!circuitTripped || Date.now() >= circuitOpenUntil), + circuitTripped, + failureCount: state?.failureCount ?? 0, + circuitOpenUntil, coolDownRemainingMs: - this.circuitOpenUntil > 0 ? Math.max(0, this.circuitOpenUntil - Date.now()) : 0, + circuitOpenUntil > 0 ? Math.max(0, circuitOpenUntil - Date.now()) : 0, }; } } -const tlsClient = new TlsClient(); +const TLS_CLIENT_KEY = Symbol.for("omniroute.tlsClient.instance"); +const scopedGlobal = globalThis as typeof globalThis & { + [TLS_CLIENT_KEY]?: TlsClient; +}; +const tlsClient = scopedGlobal[TLS_CLIENT_KEY] ?? new TlsClient(); +scopedGlobal[TLS_CLIENT_KEY] = tlsClient; export default tlsClient; diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 93d41c83cc..e2abc2a67d 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -6,6 +6,7 @@ import { appendRequestLog } from "@/lib/usageDb"; import { getLoggedInputTokens, getLoggedOutputTokens, + getNoCacheTokens, getPromptCacheCreationTokens, getPromptCacheReadTokens, } from "@/lib/usage/tokenAccounting"; @@ -151,11 +152,11 @@ export function addBufferToUsage(usage) { result.context_budget_prompt_tokens = result.prompt_tokens + buffer; } - // Calculate or update the context-budget total + // Keep real total_tokens intact and calculate separate context-budget headroom. if (result.total_tokens !== undefined) { result.context_budget_total_tokens = result.total_tokens + buffer; } else if (result.prompt_tokens !== undefined && result.completion_tokens !== undefined) { - // Calculate total_tokens if not exists (real value — not buffered) + // Calculate a real total if the provider omitted it. result.total_tokens = result.prompt_tokens + result.completion_tokens; result.context_budget_total_tokens = result.total_tokens + buffer; } @@ -199,6 +200,14 @@ export function filterUsageForFormat(usage, targetFormat) { ) { convertedUsage.total_tokens = convertedUsage.prompt_tokens + convertedUsage.completion_tokens; } + // Rebuild prompt_tokens_details.cached_tokens from flat cached_tokens / cache_read_input_tokens (#8171) + const flatCached = convertedUsage.cached_tokens ?? convertedUsage.cache_read_input_tokens; + if (flatCached !== undefined && !convertedUsage.prompt_tokens_details?.cached_tokens) { + convertedUsage.prompt_tokens_details = { + ...convertedUsage.prompt_tokens_details, + cached_tokens: flatCached, + }; + } } // Helper to pick only defined fields from usage @@ -250,6 +259,10 @@ export function filterUsageForFormat(usage, targetFormat) { "reasoning_tokens", "prompt_tokens_details", "completion_tokens_details", + "prompt_cache_hit_tokens", + "prompt_cache_miss_tokens", + "cache_read_input_tokens", + "cache_creation_input_tokens", "estimated", ], }; @@ -269,6 +282,232 @@ export function filterUsageForFormat(usage, targetFormat) { return pickFields(fields); } +// Provider usage is normally authoritative, but compatibility gateways can return +// stale/cumulative cache counters. A token cannot encode less than one UTF-8 byte, +// so a stateless request's input count must remain related to the complete wire +// body. The 2x multiplier plus fixed allowance deliberately tolerates provider +// templates, tokenization differences, and format translation while still catching +// catastrophic values such as 336k tokens for a 115 KB request. +const INPUT_USAGE_BYTE_MULTIPLIER = 2; +const INPUT_USAGE_FIXED_ALLOWANCE = 8192; + +const REMOTE_CONTEXT_REFERENCE_KEYS = new Set([ + "previous_response_id", + "previousResponseId", + "conversation_id", + "conversationId", + "thread_id", + "threadId", + "parent_message_id", + "parentMessageId", + "cached_content", + "cachedContent", + "file_id", + "fileId", + "image_url", + "imageUrl", + "audio_url", + "audioUrl", + "video_url", + "videoUrl", +]); + +function hasValue(value): boolean { + if (value === null || value === undefined || value === false) return false; + if (typeof value === "string") return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; +} + +function hasRemoteContextReference(value, depth = 0): boolean { + if (!value || typeof value !== "object" || depth > 8) return false; + + if (Array.isArray(value)) { + return value.some((item) => hasRemoteContextReference(item, depth + 1)); + } + + for (const [key, nested] of Object.entries(value)) { + if (REMOTE_CONTEXT_REFERENCE_KEYS.has(key) && hasValue(nested)) { + return true; + } + if (hasRemoteContextReference(nested, depth + 1)) { + return true; + } + } + return false; +} + +function getSerializedBodyBytes(body): number | null { + if (!body || typeof body !== "object" || hasRemoteContextReference(body)) return null; + try { + const serialized = JSON.stringify(body); + if (!serialized) return null; + return Buffer.byteLength(serialized, "utf8"); + } catch { + return null; + } +} + +function tokenNumber(value): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +/** + * Return true when a provider-reported input count is plausible for this request. + * `null`/unserializable bodies and server-side context references fail open. + */ +export function isInputTokenCountPlausible(inputTokens, body): boolean { + if (typeof inputTokens !== "number" || !Number.isFinite(inputTokens) || inputTokens < 0) { + return false; + } + + const bodyBytes = getSerializedBodyBytes(body); + if (bodyBytes === null) return true; + const maximum = bodyBytes * INPUT_USAGE_BYTE_MULTIPLIER + INPUT_USAGE_FIXED_ALLOWANCE; + return inputTokens <= maximum; +} + +function resolveUsageFormat(usage, targetFormat) { + if (targetFormat === FORMATS.CLAUDE) return FORMATS.CLAUDE; + if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY) { + return FORMATS.GEMINI; + } + if (targetFormat === FORMATS.OPENAI_RESPONSES || targetFormat === FORMATS.OPENAI_RESPONSE) { + return FORMATS.OPENAI_RESPONSES; + } + if (targetFormat === FORMATS.OPENAI) return FORMATS.OPENAI; + + if (usage?.promptTokenCount !== undefined || usage?.candidatesTokenCount !== undefined) { + return FORMATS.GEMINI; + } + if ( + usage?.cache_read_input_tokens !== undefined || + usage?.cache_creation_input_tokens !== undefined + ) { + return FORMATS.CLAUDE; + } + if (usage?.input_tokens_details !== undefined) return FORMATS.OPENAI_RESPONSES; + return FORMATS.OPENAI; +} + +function getReportedInputTokens(usage, format): number { + if (format === FORMATS.CLAUDE) { + return ( + tokenNumber(usage.input_tokens) + + tokenNumber(usage.cache_read_input_tokens) + + tokenNumber(usage.cache_creation_input_tokens) + ); + } + if (format === FORMATS.GEMINI) { + return tokenNumber(usage.promptTokenCount); + } + if (format === FORMATS.OPENAI_RESPONSES) { + return tokenNumber(usage.input_tokens ?? usage.prompt_tokens); + } + return tokenNumber(usage.prompt_tokens ?? usage.input_tokens); +} + +function clearCachedTokenDetail(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const result = { ...value }; + if (result.cached_tokens !== undefined) result.cached_tokens = 0; + return result; +} + +/** + * Replace only physically implausible provider input/cache usage with the local + * request estimate. Valid usage is returned by reference and remains untouched. + */ +export function sanitizeProviderUsageForRequest(usage, body, targetFormat = null) { + if (!usage || typeof usage !== "object" || Array.isArray(usage)) return usage; + + const format = resolveUsageFormat(usage, targetFormat); + const reportedInput = getReportedInputTokens(usage, format); + if (reportedInput <= 0 || isInputTokenCountPlausible(reportedInput, body)) { + return usage; + } + + const estimatedInput = Math.max(1, estimateInputTokens(body)); + const result = { ...usage }; + + if (format === FORMATS.CLAUDE) { + result.input_tokens = estimatedInput; + result.cache_read_input_tokens = 0; + result.cache_creation_input_tokens = 0; + return result; + } + + if (format === FORMATS.GEMINI) { + const output = + tokenNumber(result.candidatesTokenCount) + tokenNumber(result.thoughtsTokenCount); + result.promptTokenCount = estimatedInput; + result.cachedContentTokenCount = 0; + if (result.totalTokenCount !== undefined) { + result.totalTokenCount = estimatedInput + output; + } + return result; + } + + if (format === FORMATS.OPENAI_RESPONSES) { + result.input_tokens = estimatedInput; + result.input_tokens_details = clearCachedTokenDetail(result.input_tokens_details); + result.cache_read_input_tokens = 0; + result.cache_creation_input_tokens = 0; + if (result.total_tokens !== undefined) { + result.total_tokens = estimatedInput + tokenNumber(result.output_tokens); + } + return result; + } + + result.prompt_tokens = estimatedInput; + result.cached_tokens = 0; + result.cache_read_input_tokens = 0; + result.cache_creation_input_tokens = 0; + result.prompt_tokens_details = clearCachedTokenDetail(result.prompt_tokens_details); + if (result.total_tokens !== undefined) { + result.total_tokens = estimatedInput + tokenNumber(result.completion_tokens); + } + return result; +} + +/** + * Sanitize the usage container used by native provider responses/SSE events. + * Returns true only when the payload was changed and must be re-serialized. + */ +export function sanitizeUsagePayloadForRequest(payload, body, targetFormat = null): boolean { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + + const replaceUsage = (owner, key, format) => { + if (!owner || typeof owner !== "object" || !owner[key]) return false; + const sanitized = sanitizeProviderUsageForRequest(owner[key], body, format); + if (sanitized === owner[key]) return false; + owner[key] = sanitized; + return true; + }; + + if (payload.type === "message_start" && payload.message?.usage) { + return replaceUsage(payload.message, "usage", FORMATS.CLAUDE); + } + if (payload.type === "message_delta" && payload.usage) { + return replaceUsage(payload, "usage", FORMATS.CLAUDE); + } + if (payload.response?.usage) { + return replaceUsage(payload.response, "usage", FORMATS.OPENAI_RESPONSES); + } + if (payload.response?.usageMetadata) { + return replaceUsage(payload.response, "usageMetadata", FORMATS.GEMINI); + } + if (payload.usageMetadata) { + return replaceUsage(payload, "usageMetadata", FORMATS.GEMINI); + } + if (payload.usage) { + const format = payload.type === "message" ? FORMATS.CLAUDE : targetFormat; + return replaceUsage(payload, "usage", format); + } + return false; +} + /** * Normalize usage object - ensure all values are valid numbers */ @@ -290,6 +529,7 @@ export function normalizeUsage(usage) { assignNumber("cache_read_input_tokens", usage?.cache_read_input_tokens); assignNumber("cache_creation_input_tokens", usage?.cache_creation_input_tokens); assignNumber("cached_tokens", usage?.cached_tokens); + assignNumber("no_cache_tokens", usage?.no_cache_tokens); assignNumber("reasoning_tokens", usage?.reasoning_tokens); // xAI's exact provider-reported cost (port of decolua/9router#2453, capability A — // @ryanngit). Ticks → USD conversion happens in costCalculator.ts, not here. @@ -416,12 +656,17 @@ export function extractUsage(chunk) { chunk.usage.input_tokens_details?.cached_tokens ?? chunk.usage.prompt_cache_hit_tokens ?? chunk.usage.cached_tokens, + cache_read_input_tokens: chunk.usage.cache_read_input_tokens, + cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens, + no_cache_tokens: chunk.usage.no_cache_tokens, reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens ?? chunk.usage.output_tokens_details?.reasoning_tokens ?? chunk.usage.reasoning_tokens, // xAI's exact provider-reported cost (port of decolua/9router#2453, capability A). cost_in_usd_ticks: chunk.usage.cost_in_usd_ticks, + cache_read_input_tokens: chunk.usage.cache_read_input_tokens, + cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens, }); } @@ -609,6 +854,11 @@ export function logUsage( const cacheCreation = getPromptCacheCreationTokens(usage); if (cacheCreation) msg += ` | cache_create=${cacheCreation}`; + // Non-cached (fresh) input tokens — informational only, already included in + // prompt_tokens (Command Code reports inputTokenDetails.noCacheTokens). + const noCache = getNoCacheTokens(usage); + if (noCache) msg += ` | no_cache=${noCache}`; + const reasoning = usage.reasoning_tokens; if (reasoning) msg += ` | reasoning=${reasoning}`; diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/base.ts b/open-sse/vendor/codex-chatgpt-web/adapters/base.ts new file mode 100644 index 0000000000..fabec0c03f --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/base.ts @@ -0,0 +1,17 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import type { AdapterEvent, CodexParsedRequest } from "../types"; + +/** Metadata about the caller's incoming request, for auth-forwarding adapters. */ +export interface IncomingMeta { + headers: Headers; + abortSignal?: AbortSignal; +} + +export interface ProviderAdapter { + name: string; + runTurn( + parsed: CodexParsedRequest, + incoming: IncomingMeta, + emit: (event: AdapterEvent) => void + ): Promise; +} diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts new file mode 100644 index 0000000000..69758e86a3 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts @@ -0,0 +1,969 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import { existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { type Browser, type BrowserContext, type Locator, type Page } from "playwright-core"; +import { atomicWriteFile, expandUserPath, getConfigDir } from "../../config"; +import type { CodexProviderConfig } from "../../types"; +import { parseDataUrl } from "../image"; +import { ChatGptMarkdownStream } from "./markdown"; +import { + resolveChatGptWebModelMode, + type ChatGptWebCapabilities, + type ChatGptWebModelMode, +} from "./model"; +import { + CHATGPT_INTERNAL_COMPACTION_MARKER, + containsChatGptCompactionMarker, + stripChatGptTransportMarkers, + type CompiledChatGptWebPrompt, + type ChatGptWebPromptImage, +} from "./prompt"; +import { estimateCompiledChatGptWebInputTokens } from "./usage"; +import { + assertAuthenticatedChatGptPage, + assertTemporaryChatPage, + CHATGPT_TEMPORARY_CHAT_URL, +} from "../../chatgpt-session"; +import { + browserLoginStateExists, + loginVerificationMarkerPath, + writeVerificationMarker, +} from "../../browser-login"; + +const workers = new Map(); + +export const DEFAULT_CHATGPT_TURN_TIMEOUT_MS = 40 * 60_000; +export const CHATGPT_RESPONSE_DOM_GRACE_MS = 30_000; +export const CHATGPT_EMPTY_RESPONSE_GRACE_MS = 10_000; + +const browserStageTimeouts = { + browserPage: 60_000, + navigation: 70_000, + composerReady: 40_000, + sessionVerification: 40_000, + effortSelection: 120_000, + promptAttachment: 60_000, + fileAttachment: 120_000, + send: 20_000, +} as const; + +export interface BrowserTurn { + traceId: string; + modelId: string; + reasoning?: string; + capabilities: ChatGptWebCapabilities; + prepare: () => Promise void }>; + abortSignal?: AbortSignal; + onHeartbeat?: () => void; + /** Visible ChatGPT reasoning-summary step titles only; never hidden chain-of-thought. */ + onReasoningSummary?: (text: string) => void; + /** Stable visible ChatGPT prose between status/tool rows. */ + onCommentary?: (text: string, continuation?: boolean) => void; + /** Append-only, structurally stable Markdown chunks. */ + onTextDelta: (delta: string) => void; +} + +interface ResolvedBrowserConfig { + appName: string; + storageStatePath: string; + chromeExecutablePath?: string; + cdpEndpoint?: string; + turnTimeoutMs: number; + headed: boolean; + autoApproveToolCalls: boolean; +} + +export function chatGptTurnIsComplete(state: { + responsePresent: boolean; + running: boolean; + currentText: string; + completionActionVisible: boolean; +}): boolean { + return ( + state.responsePresent && + !state.running && + state.currentText.length > 0 && + state.completionActionVisible + ); +} + +export class ChatGptCompletionTracker { + private candidate?: { signature: string; since: number }; + + constructor(private readonly stableMs = 750) {} + + update(state: Parameters[0], now = Date.now()): boolean { + if (!chatGptTurnIsComplete(state)) { + this.candidate = undefined; + return false; + } + const signature = state.currentText; + if (this.candidate?.signature !== signature) { + this.candidate = { signature, since: now }; + return false; + } + return now - this.candidate.since >= this.stableMs; + } +} + +export class ChatGptTurnDomHealthTracker { + private sawResponse = false; + private missingResponseSince?: number; + private emptyCompletionSince?: number; + + constructor( + private readonly missingResponseMs = CHATGPT_RESPONSE_DOM_GRACE_MS, + private readonly emptyCompletionMs = CHATGPT_EMPTY_RESPONSE_GRACE_MS + ) {} + + update( + state: { + responsePresent: boolean; + running: boolean; + currentText: string; + completionActionVisible: boolean; + }, + now = Date.now() + ): string | undefined { + if (state.responsePresent) { + this.sawResponse = true; + this.missingResponseSince = undefined; + } else { + this.missingResponseSince ??= now; + if (now - this.missingResponseSince >= this.missingResponseMs) { + return this.sawResponse + ? "ChatGPT response DOM disappeared while the browser turn was active" + : "ChatGPT did not create a response DOM after the message was sent"; + } + } + + const emptyCompletion = + state.responsePresent && + !state.running && + state.currentText.length === 0 && + state.completionActionVisible; + if (!emptyCompletion) { + this.emptyCompletionSince = undefined; + } else { + this.emptyCompletionSince ??= now; + if (now - this.emptyCompletionSince >= this.emptyCompletionMs) { + return "ChatGPT browser turn completed without a final answer"; + } + } + return undefined; + } +} + +export interface ChatGptVisibleTraceBlock { + kind: "markdown" | "status"; + text: string; +} + +export interface ChatGptVisibleTraceEvent { + kind: "reasoning" | "commentary"; + text: string; + continuation?: boolean; +} + +interface ChatGptResponseDomSnapshot { + responsePresent: boolean; + visibleText: string; + fullHtml: string; + stableHtml: string; + completionActionVisible: boolean; + traceBlocks: ChatGptVisibleTraceBlock[]; +} + +const absentResponseDomSnapshot = (): ChatGptResponseDomSnapshot => ({ + responsePresent: false, + visibleText: "", + fullHtml: "", + stableHtml: "", + completionActionVisible: false, + traceBlocks: [], +}); + +/** Convert the public ChatGPT turn DOM into append-only Codex reasoning summaries. */ +export class ChatGptVisibleTraceTracker { + private readonly seen = new Set(); + private readonly emittedCommentary = new Map(); + private readonly commentaryChangedAt = new Map(); + + constructor(private readonly commentaryStabilityMs = 1_000) {} + + observe( + blocks: ChatGptVisibleTraceBlock[], + completionActionVisible: boolean, + now = Date.now() + ): ChatGptVisibleTraceEvent[] { + let lastMarkdown = -1; + for (let index = 0; index < blocks.length; index++) { + if (blocks[index]!.kind === "markdown") lastMarkdown = index; + } + const output: ChatGptVisibleTraceEvent[] = []; + for (let index = 0; index < blocks.length; index++) { + const block = blocks[index]!; + if ( + containsChatGptCompactionMarker(block.text) && + !this.seen.has(CHATGPT_INTERNAL_COMPACTION_MARKER) + ) { + this.seen.add(CHATGPT_INTERNAL_COMPACTION_MARKER); + output.push({ kind: "reasoning", text: "Context automatically compacted" }); + } + const text = stripChatGptTransportMarkers(block.text) + .replace(/\r\n/g, "\n") + .split("\n") + .map((line) => line.replace(/[\t ]+/g, " ").trim()) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (!text) continue; + // The trailing Markdown root is ambiguous while running and becomes the final answer once + // complete. It stays owned by ChatGptMarkdownStream; earlier roots are stable commentary. + if ( + block.kind === "markdown" && + (completionActionVisible ? index === lastMarkdown : index === blocks.length - 1) + ) { + continue; + } + if (block.kind === "markdown") { + const previous = this.emittedCommentary.get(index); + if (previous === text) { + const changedAt = this.commentaryChangedAt.get(index) ?? now; + if (now - changedAt < this.commentaryStabilityMs) break; + continue; + } + this.commentaryChangedAt.set(index, now); + if (previous && text.startsWith(previous)) { + this.emittedCommentary.set(index, text); + output.push({ + kind: "commentary", + text: text.slice(previous.length), + continuation: true, + }); + break; + } + this.emittedCommentary.set(index, text); + } + const key = `${block.kind}\0${text}`; + if (this.seen.has(key)) continue; + this.seen.add(key); + output.push({ kind: block.kind === "markdown" ? "commentary" : "reasoning", text }); + if (block.kind === "markdown") break; + } + return output; + } +} + +export function chatGptEffortLabelsMatch(current: string, desired: string): boolean { + const normalize = (value: string) => { + const label = value.replace(/\s+/g, " ").trim(); + return /^(?:Instant|Instant 5\.5)$/.test(label) ? "Instant 5.5" : label; + }; + return normalize(current) === normalize(desired); +} + +export function isChatGptTraceControl(block: ChatGptVisibleTraceBlock): boolean { + return block.kind === "status" && block.text.replace(/\s+/g, " ").trim() === "Answer now"; +} + +export function redactChatGptUiDiagnostic(value: string): string { + return value + .replace( + /[\s\S]*?<\/codex_context_json>/gi, + "[redacted]" + ) + .replace(/\b(turn|binding|call)_[A-Za-z0-9_-]{12,}\b/g, "$1_[redacted]"); +} + +function resolveBrowserConfig(provider: CodexProviderConfig): ResolvedBrowserConfig { + const configured = provider.chatgptWeb ?? {}; + return { + appName: configured.appName?.trim() || "Codex Native", + storageStatePath: resolve( + expandUserPath( + configured.storageStatePath?.trim() || join(getConfigDir(), "browser", "storage-state.json") + ) + ), + ...(configured.chromeExecutablePath?.trim() + ? { chromeExecutablePath: resolve(expandUserPath(configured.chromeExecutablePath.trim())) } + : {}), + ...(configured.cdpEndpoint?.trim() ? { cdpEndpoint: configured.cdpEndpoint.trim() } : {}), + turnTimeoutMs: configured.turnTimeoutMs ?? DEFAULT_CHATGPT_TURN_TIMEOUT_MS, + headed: configured.headed !== false, + autoApproveToolCalls: configured.autoApproveToolCalls === true, + }; +} + +const imageExtensions = new Map([ + ["image/png", "png"], + ["image/jpeg", "jpg"], + ["image/gif", "gif"], + ["image/webp", "webp"], +]); + +export function chatGptImageFilePayloads( + images: ChatGptWebPromptImage[] +): Array<{ name: string; mimeType: string; buffer: Buffer }> { + if (images.length > 10) + throw new Error("ChatGPT web accepts at most 10 input images per Codex turn"); + let totalBytes = 0; + return images.map((image) => { + const parsed = parseDataUrl(image.imageUrl); + if (!parsed) + throw new Error(`ChatGPT web input image ${image.ref} must be an inline base64 data URL`); + const extension = imageExtensions.get(parsed.mediaType.toLowerCase()); + if (!extension) + throw new Error( + `ChatGPT web input image ${image.ref} has unsupported media type: ${parsed.mediaType}` + ); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(parsed.base64) || parsed.base64.length % 4 !== 0) { + throw new Error(`ChatGPT web input image ${image.ref} contains invalid base64 data`); + } + const buffer = Buffer.from(parsed.base64, "base64"); + if (buffer.length === 0) throw new Error(`ChatGPT web input image ${image.ref} is empty`); + if (buffer.length > 20_000_000) + throw new Error(`ChatGPT web input image ${image.ref} exceeds 20 MB`); + totalBytes += buffer.length; + if (totalBytes > 50_000_000) + throw new Error("ChatGPT web input images exceed the 50 MB per-turn limit"); + return { name: `${image.ref}.${extension}`, mimeType: parsed.mediaType.toLowerCase(), buffer }; + }); +} + +export function chatGptPromptFilePayloads( + prompt: CompiledChatGptWebPrompt +): Array<{ name: string; mimeType: string; buffer: Buffer }> { + const images = chatGptImageFilePayloads(prompt.images); + const contexts = prompt.contextAttachments ?? []; + const contextBytes = contexts.reduce((total, attachment) => total + attachment.buffer.length, 0); + if (contexts.length > 1) throw new Error("ChatGPT web accepts one Codex context attachment"); + if (contextBytes > 50_000_000) { + throw new Error("ChatGPT web Codex context attachment exceeds 50 MB"); + } + return [...images, ...contexts]; +} + +export class ChatGptBrowserWorker { + static forProvider(provider: CodexProviderConfig): ChatGptBrowserWorker { + const config = resolveBrowserConfig(provider); + const key = JSON.stringify(config); + let worker = workers.get(key); + if (!worker) { + worker = new ChatGptBrowserWorker(config); + workers.set(key, worker); + } + return worker; + } + + private browser?: Browser; + private context?: BrowserContext; + private page?: Page; + private tail: Promise = Promise.resolve(); + + private constructor(private readonly config: ResolvedBrowserConfig) {} + + run(turn: BrowserTurn): Promise { + const run = this.tail.then(() => this.runExclusive(turn)); + this.tail = run.then( + () => undefined, + () => undefined + ); + return run; + } + + async close(): Promise { + await this.tail; + const browser = this.browser; + this.browser = undefined; + this.context = undefined; + this.page = undefined; + if (browser) await browser.close(); + } + + private discardBrowser(): void { + const browser = this.browser; + this.browser = undefined; + this.context = undefined; + this.page = undefined; + if (browser) void browser.close().catch(() => {}); + } + + private async runStage( + traceId: string, + stage: string, + timeoutMs: number, + action: () => Promise + ): Promise { + const startedAt = performance.now(); + console.info(`[chatgpt-web] browser turn ${traceId} stage=${stage} started`); + let timer: ReturnType | undefined; + let timedOut = false; + try { + const timeout = new Promise((_, rejectTimeout) => { + timer = setTimeout(() => { + timedOut = true; + rejectTimeout(new Error(`ChatGPT browser stage timed out: ${stage}`)); + }, timeoutMs); + }); + const value = await Promise.race([action(), timeout]); + console.info( + `[chatgpt-web] browser turn ${traceId} stage=${stage} completed durationMs=${Math.round(performance.now() - startedAt)}` + ); + return value; + } catch (error) { + console.error( + `[chatgpt-web] browser turn ${traceId} stage=${stage} failed durationMs=${Math.round(performance.now() - startedAt)}: ${error instanceof Error ? error.message : String(error)}` + ); + if (timedOut) this.discardBrowser(); + throw error; + } finally { + if (timer) clearTimeout(timer); + } + } + + private async ensurePage(): Promise { + if (this.page && !this.page.isClosed()) return this.page; + if ( + !browserLoginStateExists({ + storageStatePath: this.config.storageStatePath, + chromeExecutablePath: this.config.chromeExecutablePath, + }) + ) { + throw new Error(`ChatGPT web login state is missing: ${this.config.storageStatePath}`); + } + if (!this.config.cdpEndpoint && !this.config.chromeExecutablePath) { + throw new Error("ChatGPT web browser runtime is not configured"); + } + if ( + !this.config.cdpEndpoint && + this.config.chromeExecutablePath && + !existsSync(this.config.chromeExecutablePath) + ) { + throw new Error( + `Configured Chrome executable does not exist: ${this.config.chromeExecutablePath}` + ); + } + const { chromium } = await import("playwright-core"); + if (this.config.cdpEndpoint) { + this.browser = await chromium.connectOverCDP(this.config.cdpEndpoint); + this.context = await this.browser.newContext({ storageState: this.config.storageStatePath }); + } else { + this.browser = await chromium.launch({ + executablePath: this.config.chromeExecutablePath, + headless: !this.config.headed, + }); + this.context = await this.browser.newContext({ storageState: this.config.storageStatePath }); + } + this.page = await this.context.newPage(); + return this.page; + } + + /** + * A Codex turn owns one isolated Temporary Chat document. Reusing the same + * ChatGPT SPA page can retain the previous transcript and autocomplete DOM, + * so an @app lookup may select stale UI from the preceding turn. + */ + private async pageForNewTurn(): Promise { + const previous = await this.ensurePage(); + if (previous.url() === "about:blank") return previous; + const context = this.context; + if (!context) throw new Error("ChatGPT web browser context is unavailable"); + const page = await context.newPage(); + this.page = page; + await previous.close().catch(() => {}); + return page; + } + + private async selectModelAndEffort( + page: Page, + modelId: string, + reasoning: string | undefined, + capabilities: ChatGptWebCapabilities + ): Promise { + const mode = resolveChatGptWebModelMode(modelId, reasoning, capabilities); + const currentEffort = page + .getByRole("button", { + name: /^(?:Instant(?:\s+5\.5)?|Medium|High|Extra High|Pro)$/, + }) + .last(); + try { + await currentEffort.waitFor({ state: "visible", timeout: 70_000 }); + } catch { + throw new Error( + "ChatGPT rendered the composer but its model/effort control did not become ready" + ); + } + if (chatGptEffortLabelsMatch(await currentEffort.innerText(), mode.uiEffortLabel)) return mode; + await currentEffort.click(); + const effortChoice = page + .getByRole("menuitem", { name: mode.uiEffortLabel, exact: true }) + .or(page.getByRole("menuitemradio", { name: mode.uiEffortLabel, exact: true })) + .last(); + try { + await effortChoice.waitFor({ state: "visible", timeout: 20_000 }); + } catch { + const choices = ( + await page + .locator('[role="menuitem"], [role="menuitemradio"]') + .allInnerTexts() + .catch(() => []) + ) + .map((value) => value.replace(/\s+/g, " ").trim()) + .filter((value) => /^(?:Instant(?: 5\.5)?|Medium|High|Extra High|Pro)$/.test(value)); + throw new Error( + `ChatGPT effort ${JSON.stringify(mode.uiEffortLabel)} is unavailable in the authenticated account UI` + + (choices.length > 0 ? `; available: ${choices.join(", ")}` : "") + ); + } + await effortChoice.click(); + try { + const deadline = Date.now() + 40_000; + while (Date.now() < deadline) { + const visibleLabel = await currentEffort.innerText().catch(() => ""); + if (chatGptEffortLabelsMatch(visibleLabel, mode.uiEffortLabel)) return mode; + await new Promise((resolveSleep) => setTimeout(resolveSleep, 100)); + } + throw new Error("effort control did not render the selected label"); + } catch { + const visible = await page + .getByRole("button", { + name: /^(?:Instant(?:\s+5\.5)?|Medium|High|Extra High|Pro)$/, + }) + .allInnerTexts() + .catch(() => []); + throw new Error( + `ChatGPT did not confirm effort ${JSON.stringify(mode.uiEffortLabel)}` + + (visible.length > 0 + ? `; visible effort control: ${visible.at(-1)!.replace(/\s+/g, " ").trim()}` + : "") + ); + } + } + + private async attachedPromptText(page: Page): Promise { + const composer = page.getByRole("textbox", { name: "Chat with ChatGPT" }); + return composer.evaluate( + (element) => { + const clone = element.cloneNode(true) as HTMLElement; + clone + .querySelectorAll( + "[data-inline-selection-pill], [data-inline-selection-pill-cursor-target]" + ) + .forEach((part) => part.remove()); + return [...clone.children] + .map((child) => child.textContent ?? "") + .join("\n") + .trimStart(); + }, + undefined, + { timeout: 20_000 } + ); + } + + private async assertPromptAttached(page: Page, prompt: string): Promise { + const deadline = Date.now() + 10_000; + let observed = ""; + while (Date.now() < deadline) { + observed = await this.attachedPromptText(page); + if (observed === prompt) return; + await new Promise((resolveSleep) => setTimeout(resolveSleep, 50)); + } + let commonPrefix = 0; + while (commonPrefix < prompt.length && prompt[commonPrefix] === observed[commonPrefix]) + commonPrefix += 1; + throw new Error( + `ChatGPT composer did not preserve the complete prompt (expectedChars=${prompt.length}, actualChars=${observed.length}, commonPrefixChars=${commonPrefix})` + ); + } + + private async attachPrompt(page: Page, prompt: string, localTools: boolean): Promise { + const composer = page.getByRole("textbox", { name: "Chat with ChatGPT" }); + if (!localTools) { + await composer.fill(prompt); + await this.assertPromptAttached(page, prompt); + return; + } + await composer.fill(`@${this.config.appName}`); + const appResult = page.getByRole("group").filter({ hasText: this.config.appName }).last(); + await appResult.waitFor({ state: "visible", timeout: 20_000 }); + await appResult.click(); + const selectedPlugin = composer.getByRole("link", { name: this.config.appName, exact: true }); + await selectedPlugin.waitFor({ state: "visible", timeout: 10_000 }); + await composer.focus(); + await page.keyboard.press("End"); + await page.keyboard.insertText(` ${prompt}`); + await this.assertPromptAttached(page, prompt); + } + + private async attachFiles(page: Page, prompt: CompiledChatGptWebPrompt): Promise { + const files = chatGptPromptFilePayloads(prompt); + if (files.length === 0) return; + const removeButtons = page.locator('button[aria-label^="Remove file "]'); + const existing = await removeButtons.count(); + const input = page + .locator('input[type="file"][data-testid="upload-photos-input"]') + .or(page.locator('input[type="file"]').last()); + await input.waitFor({ state: "attached", timeout: 20_000 }); + await input.setInputFiles(files); + try { + await removeButtons + .nth(existing + files.length - 1) + .waitFor({ state: "visible", timeout: 60_000 }); + } catch { + const alerts = ( + await page + .locator('[role="alert"]') + .allInnerTexts() + .catch(() => []) + ) + .map((text) => text.replace(/\s+/g, " ").trim()) + .filter(Boolean); + throw new Error( + `ChatGPT did not accept all prompt attachments` + + (alerts.length > 0 ? `: ${alerts.join(" | ")}` : "") + ); + } + const send = page.getByTestId("send-button"); + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (await send.isEnabled().catch(() => false)) return; + await new Promise((resolveSleep) => setTimeout(resolveSleep, 100)); + } + throw new Error( + "ChatGPT accepted the prompt attachments but did not make the message ready to send" + ); + } + + private async handleToolConfirmation(page: Page): Promise { + const heading = page + .getByText(`Allow ChatGPT to use ${this.config.appName}?`, { exact: true }) + .last(); + if (!(await heading.isVisible().catch(() => false))) return false; + if (!this.config.autoApproveToolCalls) { + throw new Error( + `ChatGPT is waiting for confirmation to use ${this.config.appName}; set chatgptWeb.autoApproveToolCalls=true to authorize per-call "Allow once" clicks` + ); + } + const allowOnce = page.getByRole("button", { name: "Allow once", exact: true }).last(); + await allowOnce.waitFor({ state: "visible", timeout: 10_000 }); + await allowOnce.click(); + return true; + } + + private async responseDomSnapshot(responseTurn: Locator): Promise { + const snapshot = await responseTurn + .evaluate( + (element) => { + const root = element as HTMLElement; + const visible = (candidate: HTMLElement): boolean => { + const style = getComputedStyle(candidate); + const rect = candidate.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + style.opacity !== "0" && + rect.width > 0 && + rect.height > 0 + ); + }; + + const rendered = [...root.querySelectorAll(".markdown")].at(-1); + const renderedChildren = rendered ? [...rendered.children] : []; + const completionAction = [ + ...root.querySelectorAll('button[aria-label="Copy response"]'), + ].find(visible); + const candidates = new Map(); + root + .querySelectorAll(".markdown") + .forEach((candidate) => candidates.set(candidate, "markdown")); + root + .querySelectorAll( + 'button, [role="status"], [aria-busy="true"], [data-testid*="cot"], [data-testid*="reason"], [data-testid*="thought"]' + ) + .forEach((candidate) => { + if (candidate.closest('[aria-label="Response actions"]')) return; + const semantic = candidate.closest("button") ?? candidate; + if (!candidates.has(semantic)) candidates.set(semantic, "status"); + }); + root + .querySelectorAll("[data-streaming-response-status]") + .forEach((container) => { + if (![...candidates.keys()].some((candidate) => container.contains(candidate))) { + candidates.set(container, "status"); + } + }); + const traceBlocks = [...candidates] + .filter(([candidate]) => visible(candidate)) + .sort(([left], [right]) => + left === right + ? 0 + : left.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING + ? -1 + : 1 + ) + .map(([candidate, kind]) => ({ kind, text: candidate.innerText.trim() })) + .filter((block) => block.text.length > 0) + .filter( + (block, index, blocks) => + blocks.findIndex( + (other) => other.kind === block.kind && other.text === block.text + ) === index + ); + return { + responsePresent: true, + visibleText: rendered?.innerText.trim() ?? "", + fullHtml: rendered?.innerHTML ?? "", + stableHtml: renderedChildren + .slice(0, -1) + .map((child) => child.outerHTML) + .join(""), + completionActionVisible: completionAction !== undefined, + traceBlocks, + }; + }, + undefined, + { timeout: 2_000 } + ) + .catch(() => absentResponseDomSnapshot()); + snapshot.traceBlocks = snapshot.traceBlocks.filter((block) => !isChatGptTraceControl(block)); + return snapshot; + } + + private async stalledTurnDiagnostic(page: Page, responseTurn: Locator): Promise { + const responseState = (await responseTurn.count()) + ? await responseTurn.evaluate((element) => { + const root = element as HTMLElement; + const descriptors = [ + ...root.querySelectorAll("[role], [data-testid], button, [aria-label]"), + ] + .filter((candidate) => { + const style = getComputedStyle(candidate); + return style.visibility !== "hidden" && style.display !== "none"; + }) + .slice(-80) + .map((candidate) => ({ + tag: candidate.tagName.toLowerCase(), + role: candidate.getAttribute("role"), + testId: candidate.getAttribute("data-testid"), + ariaLabel: candidate.getAttribute("aria-label"), + title: candidate.getAttribute("title"), + text: candidate.innerText.trim().slice(0, 500), + })); + return { + text: root.innerText.trim().slice(0, 2_000), + descriptors, + }; + }) + : { text: "", descriptors: [] }; + const overlays = await page + .locator('[role="dialog"], [role="alert"], [role="status"]') + .evaluateAll((elements) => + elements + .filter((element) => { + const candidate = element as HTMLElement; + const style = getComputedStyle(candidate); + return style.visibility !== "hidden" && style.display !== "none"; + }) + .slice(-30) + .map((element) => { + const candidate = element as HTMLElement; + return { + role: candidate.getAttribute("role"), + testId: candidate.getAttribute("data-testid"), + ariaLabel: candidate.getAttribute("aria-label"), + text: candidate.innerText.trim().slice(0, 1_000), + }; + }) + ) + .catch(() => [] as Array>); + return redactChatGptUiDiagnostic(JSON.stringify({ response: responseState, overlays })); + } + + private async runExclusive(turn: BrowserTurn): Promise { + if (turn.abortSignal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError"); + const prepared = await turn.prepare(); + try { + if (turn.abortSignal?.aborted) + throw new DOMException("ChatGPT web turn aborted", "AbortError"); + const estimatedInputTokens = estimateCompiledChatGptWebInputTokens(prepared, turn.modelId); + const deadline = Date.now() + this.config.turnTimeoutMs; + const page = await this.runStage( + turn.traceId, + "browser_page", + browserStageTimeouts.browserPage, + () => this.pageForNewTurn() + ); + console.info( + `[chatgpt-web] browser turn ${turn.traceId} opened (transport=${prepared.contextAttachments.length > 0 ? "jsonl" : "inline"}, promptChars=${prepared.text.length}, estimatedInputTokens=${estimatedInputTokens}, images=${prepared.images.length}, contextAttachments=${prepared.contextAttachments.length})` + ); + await this.runStage( + turn.traceId, + "temporary_chat_navigation", + browserStageTimeouts.navigation, + () => + page + .goto(CHATGPT_TEMPORARY_CHAT_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }) + .then(() => undefined) + ); + const composer = page.getByRole("textbox", { name: "Chat with ChatGPT" }); + try { + await this.runStage( + turn.traceId, + "composer_ready", + browserStageTimeouts.composerReady, + () => composer.waitFor({ state: "visible", timeout: 30_000 }) + ); + } catch { + throw new Error( + "ChatGPT web login is expired or the Temporary Chat surface is unavailable" + ); + } + await this.runStage( + turn.traceId, + "session_verification", + browserStageTimeouts.sessionVerification, + async () => { + await assertAuthenticatedChatGptPage(page); + await assertTemporaryChatPage(page); + } + ); + const mode = await this.runStage( + turn.traceId, + "effort_selection", + browserStageTimeouts.effortSelection, + () => this.selectModelAndEffort(page, turn.modelId, turn.reasoning, turn.capabilities) + ); + await this.runStage( + turn.traceId, + "prompt_attachment", + browserStageTimeouts.promptAttachment, + () => this.attachPrompt(page, prepared.text, mode.localTools) + ); + await this.runStage( + turn.traceId, + "file_attachment", + browserStageTimeouts.fileAttachment, + () => this.attachFiles(page, prepared) + ); + const responseTurns = page.locator( + 'section[data-testid^="conversation-turn-"][data-turn="assistant"]' + ); + const initialResponseTurnCount = await responseTurns.count(); + const responseTurn = responseTurns.nth(initialResponseTurnCount); + await this.runStage(turn.traceId, "send", browserStageTimeouts.send, () => + page.getByTestId("send-button").click() + ); + + let lastHeartbeat = 0; + let finalText = ""; + let sawRunning = false; + let loggedCompletionWait = false; + const sentAt = Date.now(); + const visibleTrace = new ChatGptVisibleTraceTracker(); + const markdownStream = new ChatGptMarkdownStream(stripChatGptTransportMarkers); + const completionTracker = new ChatGptCompletionTracker(); + const domHealthTracker = new ChatGptTurnDomHealthTracker(); + for (;;) { + if (turn.abortSignal?.aborted) { + const stop = page.getByRole("button", { name: "Stop answering" }); + if (await stop.isVisible().catch(() => false)) await stop.click().catch(() => {}); + throw new DOMException("ChatGPT web turn aborted", "AbortError"); + } + if (Date.now() >= deadline) throw new Error("ChatGPT web turn timed out"); + if (Date.now() - lastHeartbeat >= 10_000) { + turn.onHeartbeat?.(); + lastHeartbeat = Date.now(); + } + + if (mode.localTools && (await this.handleToolConfirmation(page))) { + await new Promise((resolveSleep) => setTimeout(resolveSleep, 250)); + continue; + } + + const snapshot = await this.responseDomSnapshot(responseTurn); + const stop = page.getByRole("button", { name: "Stop answering" }); + const running = await stop.isVisible().catch(() => false); + if (running) sawRunning = true; + if (snapshot.responsePresent) { + for (const trace of visibleTrace.observe( + snapshot.traceBlocks, + snapshot.completionActionVisible + )) { + if (trace.kind === "commentary") + turn.onCommentary?.(trace.text, trace.continuation === true); + else turn.onReasoningSummary?.(trace.text); + } + const domError = domHealthTracker.update({ + responsePresent: snapshot.responsePresent, + running, + currentText: snapshot.visibleText, + completionActionVisible: snapshot.completionActionVisible, + }); + if (domError) throw new Error(domError); + // ChatGPT can render visible commentary Markdown between tool-status rows. Only a + // Markdown root accompanied by the response action belongs to the final answer stream. + if (snapshot.completionActionVisible) { + const stableDelta = markdownStream.observeStableHtml(snapshot.stableHtml); + if (stableDelta) turn.onTextDelta(stableDelta); + } + if ( + completionTracker.update({ + responsePresent: snapshot.responsePresent, + running, + currentText: snapshot.visibleText, + completionActionVisible: snapshot.completionActionVisible, + }) + ) { + if (snapshot.visibleText === "api_tool unavailable") { + throw new Error( + "ChatGPT selected mode rejected the Codex Native MCP tool (api_tool unavailable)" + ); + } + const final = markdownStream.finish(snapshot.fullHtml); + if (!final.markdown && snapshot.visibleText) { + throw new Error( + "ChatGPT completed with visible text that could not be serialized as Markdown" + ); + } + if (final.delta) turn.onTextDelta(final.delta); + finalText = final.markdown; + break; + } + if (!loggedCompletionWait && Date.now() - sentAt >= 30_000) { + loggedCompletionWait = true; + const diagnostic = await this.stalledTurnDiagnostic(page, responseTurn).catch((error) => + JSON.stringify({ + diagnosticError: error instanceof Error ? error.message : String(error), + }) + ); + console.warn( + `[chatgpt-web] waiting for completed-turn evidence (running=${running}, sawRunning=${sawRunning}, textChars=${snapshot.visibleText.length}, completionActionVisible=${snapshot.completionActionVisible}, ui=${diagnostic})` + ); + } + } else { + const domError = domHealthTracker.update({ + responsePresent: false, + running, + currentText: "", + completionActionVisible: false, + }); + if (domError) throw new Error(domError); + } + await new Promise((resolveSleep) => setTimeout(resolveSleep, 250)); + } + + if (this.context) { + const state = await this.context.storageState(); + atomicWriteFile(this.config.storageStatePath, `${JSON.stringify(state)}\n`); + writeVerificationMarker(this.config.storageStatePath, capabilities.proAvailable); + } + console.info( + `[chatgpt-web] browser turn ${turn.traceId} completed (markdownChars=${finalText.length})` + ); + return finalText; + } finally { + prepared.release(); + } + } +} diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/environment.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/environment.ts new file mode 100644 index 0000000000..8d08b40472 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/environment.ts @@ -0,0 +1,323 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import { isAbsolute, relative, resolve } from "node:path"; +import type { CodexContentPart, CodexParsedRequest, CodexTool } from "../../types"; + +export type ChatGptSandboxPolicy = + | { type: "dangerFullAccess" } + | { type: "readOnly"; networkAccess: boolean } + | { type: "workspaceWrite"; writableRoots: string[]; networkAccess: boolean }; + +export interface ChatGptTurnEnvironment { + cwd: string; + roots: string[]; + writableRoots: string[]; + sandboxPolicy: ChatGptSandboxPolicy; + tools: CodexTool[]; +} + +export interface ChatGptTurnIdentity { + threadId?: string; + turnId?: string; + promptCacheKey?: string; +} + +export class MissingTrustedCodexEnvironmentError extends Error { + constructor(field: string) { + super(`ChatGPT web turn is missing ${field} in trusted Codex environment context`); + this.name = "MissingTrustedCodexEnvironmentError"; + } +} + +function contentText(content: string | CodexContentPart[]): string { + if (typeof content === "string") return content; + return content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function clientTurnMetadata(parsed: CodexParsedRequest): Record | undefined { + const body = record(parsed._rawBody); + const metadata = record(body?.client_metadata); + const raw = metadata?.["x-codex-turn-metadata"]; + if (typeof raw === "string") { + try { + return record(JSON.parse(raw)); + } catch { + return undefined; + } + } + return record(raw); +} + +function itemTurnId(value: unknown): string | undefined { + const turnId = record(record(value)?.internal_chat_message_metadata_passthrough)?.turn_id; + return typeof turnId === "string" ? turnId : undefined; +} + +function environmentBeforeUser( + input: unknown[], + userIndex: number, + expectedTurnId?: string +): string | undefined { + if (userIndex <= 0) return undefined; + const user = record(input[userIndex]); + const candidate = record(input[userIndex - 1]); + if (user?.type !== "message" || user.role !== "user") return undefined; + if (candidate?.type !== "message" || candidate.role !== "user") return undefined; + + const userTurnId = itemTurnId(user); + const candidateTurnId = itemTurnId(candidate); + if (!userTurnId || candidateTurnId !== userTurnId) return undefined; + if (expectedTurnId && userTurnId !== expectedTurnId) return undefined; + + const content = Array.isArray(candidate.content) ? candidate.content : []; + for (const part of content) { + const text = record(part)?.text; + if (typeof text !== "string") continue; + const trimmed = text.trim(); + if (/^[\s\S]*<\/environment_context>$/.test(trimmed)) return trimmed; + } + return undefined; +} + +function sandboxTypeFromEnvironment(text: string): ChatGptSandboxPolicy["type"] | undefined { + const unrestricted = + /]*>[\s\S]*?]*\/?\s*>/i.test( + text + ) || /danger-full-access<\/sandbox_mode>/i.test(text); + const workspaceWrite = /workspace-write<\/sandbox_mode>/i.test(text); + const readOnly = /read-only<\/sandbox_mode>/i.test(text); + if (Number(unrestricted) + Number(workspaceWrite) + Number(readOnly) !== 1) return undefined; + return unrestricted ? "dangerFullAccess" : workspaceWrite ? "workspaceWrite" : "readOnly"; +} + +function sandboxTypeFromMetadata(value: unknown): ChatGptSandboxPolicy["type"] | undefined { + if (typeof value !== "string") return undefined; + switch (value.trim().toLowerCase().replaceAll("_", "-")) { + case "none": + case "unrestricted": + case "danger-full-access": + return "dangerFullAccess"; + case "workspace-write": + return "workspaceWrite"; + case "read-only": + return "readOnly"; + default: + return undefined; + } +} + +function workspaceMetadataEnvironmentBeforeUser( + input: unknown[], + userIndex: number, + metadata: Record | undefined +): string | undefined { + if (userIndex <= 0 || !metadata) return undefined; + const workspaces = record(metadata.workspaces); + const metadataSandbox = sandboxTypeFromMetadata(metadata.sandbox); + if (!workspaces || !metadataSandbox) return undefined; + const metadataRoots = Object.keys(workspaces); + if (metadataRoots.length === 0 || metadataRoots.some((path) => !isAbsolute(path))) + return undefined; + const normalizedMetadataRoots = [...new Set(metadataRoots.map((path) => resolve(path)))]; + + const user = record(input[userIndex]); + const candidate = record(input[userIndex - 1]); + if (user?.type !== "message" || user.role !== "user" || typeof user.id !== "string") + return undefined; + if ( + candidate?.type !== "message" || + candidate.role !== "user" || + typeof candidate.id !== "string" + ) + return undefined; + + const content = Array.isArray(candidate.content) ? candidate.content : []; + for (const part of content) { + const text = record(part)?.text; + if (typeof text !== "string") continue; + const trimmed = text.trim(); + if (!/^[\s\S]*<\/environment_context>$/.test(trimmed)) continue; + + const cwdMatches = [...trimmed.matchAll(/([^<]+)<\/cwd>/g)].map((match) => + decodeXmlText(match[1]!.trim()) + ); + if (cwdMatches.length !== 1 || !isAbsolute(cwdMatches[0]!)) continue; + const rootMatches = [ + ...trimmed.matchAll(/[\s\S]*?<\/workspace_roots>/g), + ].flatMap((section) => + [...section[0].matchAll(/([^<]+)<\/root>/g)].map((match) => + decodeXmlText(match[1]!.trim()) + ) + ); + const declaredRoots = [ + ...new Set((rootMatches.length > 0 ? rootMatches : cwdMatches).map((path) => resolve(path))), + ]; + if (declaredRoots.some((path) => !normalizedMetadataRoots.includes(path))) continue; + if (!normalizedMetadataRoots.some((root) => matchesPath(root, resolve(cwdMatches[0]!)))) + continue; + if (sandboxTypeFromEnvironment(trimmed) !== metadataSandbox) continue; + return trimmed; + } + return undefined; +} + +function hasAssistantOutputBetween( + input: unknown[], + startIndex: number, + endIndex: number +): boolean { + for (let index = startIndex; index < endIndex; index += 1) { + const item = record(input[index]); + if (!item) continue; + if (item.type === "message" && item.role === "assistant") return true; + if (item.type === "function_call" || item.type === "reasoning") return true; + } + return false; +} + +function rawEnvironmentText(parsed: CodexParsedRequest): string | undefined { + const body = record(parsed._rawBody); + const input = Array.isArray(body?.input) ? body.input : []; + let activeUserIndex = -1; + for (let index = input.length - 1; index >= 0; index -= 1) { + if (record(input[index])?.role === "user") { + activeUserIndex = index; + break; + } + } + const turnId = clientTurnMetadata(parsed)?.turn_id; + const currentByTurn = environmentBeforeUser( + input, + activeUserIndex, + typeof turnId === "string" ? turnId : undefined + ); + if (currentByTurn) return currentByTurn; + + const current = workspaceMetadataEnvironmentBeforeUser( + input, + activeUserIndex, + clientTurnMetadata(parsed) + ); + if (current) return current; + + const replayPrefixLen = Math.min(parsed._replayPrefixLen ?? 0, input.length); + for (let index = replayPrefixLen - 1; index > 0; index -= 1) { + const replayed = environmentBeforeUser(input, index); + if (replayed) return replayed; + } + + // Codex can resume a local task by explicitly replaying its native transcript instead of + // sending previous_response_id. In that shape, accept a historical environment/user pair only + // when both items carry the same native turn_id and completed assistant output separates that + // historical turn from the active user. A user-authored inside one chat + // message cannot satisfy this provenance structure. + const currentTurnId = typeof turnId === "string" ? turnId : undefined; + for (let index = activeUserIndex - 1; index > 0; index -= 1) { + const historicalTurnId = itemTurnId(input[index]); + if (!historicalTurnId || historicalTurnId === currentTurnId) continue; + const historical = environmentBeforeUser(input, index); + if (!historical) continue; + if (hasAssistantOutputBetween(input, index + 1, activeUserIndex)) return historical; + } + return undefined; +} + +function trustedEnvironmentText(parsed: CodexParsedRequest): string { + const raw = rawEnvironmentText(parsed); + if (raw) return raw; + throw new MissingTrustedCodexEnvironmentError("native turn-bound environment metadata"); +} + +function decodeXmlText(value: string): string { + return value + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&") + .replaceAll(""", '"') + .replaceAll("'", "'"); +} + +function uniqueAbsolutePaths(values: string[], field: string): string[] { + const decoded = values.map((value) => decodeXmlText(value.trim())); + if (decoded.length === 0) throw new MissingTrustedCodexEnvironmentError(field); + if (decoded.some((path) => !isAbsolute(path))) + throw new Error(`ChatGPT web ${field} must contain absolute paths`); + return [...new Set(decoded.map((path) => resolve(path)))]; +} + +function matchesPath(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +export function extractChatGptTurnEnvironment(parsed: CodexParsedRequest): ChatGptTurnEnvironment { + const text = trustedEnvironmentText(parsed); + const cwdMatches = [...text.matchAll(/([^<]+)<\/cwd>/g)].map((match) => match[1] ?? ""); + const cwdCandidates = uniqueAbsolutePaths(cwdMatches, "cwd"); + if (cwdCandidates.length !== 1) + throw new Error("ChatGPT web turn has conflicting trusted Codex cwd values"); + const cwd = cwdCandidates[0]!; + + const rootMatches = [...text.matchAll(/[\s\S]*?<\/workspace_roots>/g)].flatMap( + (section) => [...section[0].matchAll(/([^<]+)<\/root>/g)].map((match) => match[1] ?? "") + ); + const roots = + rootMatches.length > 0 ? uniqueAbsolutePaths(rootMatches, "workspace_roots") : [cwd]; + if (!roots.some((root) => matchesPath(root, cwd))) { + throw new Error("ChatGPT web cwd is outside the trusted Codex workspace roots"); + } + + const sandboxType = sandboxTypeFromEnvironment(text); + const networkAccess = + /enabled<\/network_access>/i.test(text) || + /network access is enabled/i.test(text); + + if (!sandboxType) { + throw new Error("ChatGPT web turn requires one explicit trusted Codex sandbox mode"); + } + if (sandboxType === "dangerFullAccess") { + return { + cwd, + roots, + writableRoots: roots, + sandboxPolicy: { type: "dangerFullAccess" }, + tools: parsed.context.tools ?? [], + }; + } + if (sandboxType === "workspaceWrite") { + return { + cwd, + roots, + writableRoots: roots, + sandboxPolicy: { type: "workspaceWrite", writableRoots: roots, networkAccess }, + tools: parsed.context.tools ?? [], + }; + } + return { + cwd, + roots, + writableRoots: [], + sandboxPolicy: { type: "readOnly", networkAccess }, + tools: parsed.context.tools ?? [], + }; +} + +export function extractChatGptTurnIdentity(parsed: CodexParsedRequest): ChatGptTurnIdentity { + const body = record(parsed._rawBody); + const metadata = clientTurnMetadata(parsed); + return { + ...(typeof metadata?.thread_id === "string" ? { threadId: metadata.thread_id } : {}), + ...(typeof metadata?.turn_id === "string" ? { turnId: metadata.turn_id } : {}), + ...(typeof body?.prompt_cache_key === "string" + ? { promptCacheKey: body.prompt_cache_key } + : {}), + }; +} diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/index.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/index.ts new file mode 100644 index 0000000000..5594bc0eee --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/index.ts @@ -0,0 +1,517 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import { expandUserPath, getConfigDir } from "../../config"; +import { + namespacedToolName, + type AdapterEvent, + type CodexContentPart, + type CodexParsedRequest, + type CodexProviderConfig, + type CodexToolResultMessage, + type CodexUsage, +} from "../../types"; +import type { ProviderAdapter } from "../base"; +import { parseDataUrl } from "../image"; +import { ChatGptBrowserWorker, DEFAULT_CHATGPT_TURN_TIMEOUT_MS } from "./browser-worker"; +import { extractChatGptTurnEnvironment, extractChatGptTurnIdentity } from "./environment"; +import { resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model"; +import { chatGptReadOnlyContextWarning, compileChatGptWebPrompt } from "./prompt"; +import { TurnBroker, type BrokerToolRequest, type BrokerToolResult } from "./turn-broker"; +import { + ChatGptTextFeed, + ChatGptTraceFeed, + chatGptTurnExecutionKey, + chatGptTurnSessions, + type ChatGptBrowserOutcome, + type ChatGptTraceEvent, + type ChatGptTurnRuntime, + type ChatGptTurnSession, +} from "./turn-execution"; +import { estimateChatGptWebUsage } from "./usage"; +import { ChatGptThreadEnvironmentStore } from "./thread-environment"; + +function brokerSocketPath(provider: CodexProviderConfig): string { + const configured = provider.chatgptWeb?.brokerSocketPath?.trim(); + return resolve(expandUserPath(configured || `${getConfigDir()}/runtime/turn-broker.sock`)); +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +} { + let resolvePromise!: (value: T) => void; + let rejectPromise!: (error: Error) => void; + const promise = new Promise((resolveDeferred, rejectDeferred) => { + resolvePromise = resolveDeferred; + rejectPromise = rejectDeferred; + }); + return { promise, resolve: resolvePromise, reject: rejectPromise }; +} + +function abortError(): DOMException { + return new DOMException("ChatGPT web turn aborted", "AbortError"); +} + +function withAbort(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(abortError()); + return new Promise((resolveWait, rejectWait) => { + const onAbort = () => rejectWait(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolveWait(value); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + rejectWait(error); + } + ); + }); +} + +function structuredContent(text: string): unknown | undefined { + try { + const parsed: unknown = JSON.parse(text); + return parsed !== null && typeof parsed === "object" ? parsed : undefined; + } catch { + return undefined; + } +} + +function brokerContent(content: string | CodexContentPart[]): unknown[] { + if (typeof content === "string") return [{ type: "text", text: content }]; + return content.map((part) => { + if (part.type === "text") return { type: "text", text: part.text }; + const parsed = parseDataUrl(part.imageUrl); + if (parsed) return { type: "image", data: parsed.base64, mimeType: parsed.mediaType }; + return { + type: "resource_link", + uri: part.imageUrl, + name: "Codex tool image", + mimeType: "image/*", + }; + }); +} + +function brokerResult(message: CodexToolResultMessage): BrokerToolResult { + const content = brokerContent(message.content); + const text = + typeof message.content === "string" + ? message.content + : message.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); + const structured = structuredContent(text); + return { + content, + ...(structured !== undefined ? { structuredContent: structured } : {}), + ...(message.isError ? { isError: true } : {}), + }; +} + +function emitToolBatch( + requests: BrokerToolRequest[], + usage: CodexUsage, + emit: (event: AdapterEvent) => void +): void { + for (const request of requests) { + emit({ type: "tool_call_start", id: request.callId, name: request.wireName }); + emit({ + type: "tool_call_delta", + arguments: request.freeform + ? JSON.stringify({ input: request.input ?? "" }) + : JSON.stringify(request.arguments ?? {}), + }); + emit({ type: "tool_call_end" }); + } + emit({ type: "done", stopReason: "tool_use", endTurn: false, usage }); +} + +function emitBrowserCompletion( + outcome: ChatGptBrowserOutcome, + usage: CodexUsage, + emit: (event: AdapterEvent) => void +): void { + if (outcome.type === "error") throw outcome.error; + emit({ type: "done", stopReason: "stop", endTurn: true, usage }); +} + +function emitTraceEvents(trace: ChatGptTraceEvent[], emit: (event: AdapterEvent) => void): void { + for (const event of trace) { + if (!event.continuation) emit({ type: "assistant_boundary" }); + if (event.kind === "commentary") { + emit({ type: "text_delta", text: event.text, phase: "commentary" }); + } else { + emit({ type: "thinking_delta", thinking: `${event.text}\n` }); + } + } +} + +function emitTextDeltas(deltas: string[], emit: (event: AdapterEvent) => void): void { + for (const text of deltas) emit({ type: "text_delta", text, phase: "final_answer" }); +} + +function emitProContextWarning( + parsed: CodexParsedRequest, + capabilities: ChatGptWebCapabilities, + emit: (event: AdapterEvent) => void +): void { + const warning = chatGptReadOnlyContextWarning(parsed, capabilities); + if (!warning) return; + emit({ type: "assistant_boundary" }); + emit({ type: "text_delta", text: warning, phase: "commentary" }); + emit({ type: "assistant_boundary" }); +} + +function replayEvents(events: AdapterEvent[], emit: (event: AdapterEvent) => void): void { + for (const event of events) emit(event); +} + +function currentToolResults( + parsed: CodexParsedRequest, + session: ChatGptTurnSession +): CodexToolResultMessage[] { + const byId = new Map(); + for (const message of parsed.context.messages) { + if (message.role !== "toolResult" || !session.hasOutstanding(message.toolCallId)) continue; + if (byId.has(message.toolCallId)) + throw new Error(`Codex returned duplicate results for tool call ${message.toolCallId}`); + byId.set(message.toolCallId, message); + } + return [...byId.values()]; +} + +function validateBatchTools(parsed: CodexParsedRequest, requests: BrokerToolRequest[]): void { + const available = new Set( + (parsed.context.tools ?? []).map((tool) => namespacedToolName(tool.namespace, tool.name)) + ); + for (const request of requests) { + if (!available.has(request.wireName)) { + throw new Error( + `ChatGPT requested a tool that the active Codex round did not advertise: ${request.wireName}` + ); + } + } +} + +export function createChatGptWebAdapter(provider: CodexProviderConfig): ProviderAdapter { + const worker = ChatGptBrowserWorker.forProvider(provider); + const broker = TurnBroker.forSocket(brokerSocketPath(provider)); + const timeoutMs = provider.chatgptWeb?.turnTimeoutMs ?? DEFAULT_CHATGPT_TURN_TIMEOUT_MS; + const capabilities: ChatGptWebCapabilities = { + localToolsEnabled: provider.chatgptWeb?.localToolsEnabled === true, + proAvailable: provider.chatgptWeb?.proAvailable === true, + }; + const executionNamespace = createHash("sha256") + .update( + JSON.stringify({ + baseUrl: provider.baseUrl, + chatgptWeb: provider.chatgptWeb ?? {}, + }) + ) + .digest("hex"); + const environmentStore = new ChatGptThreadEnvironmentStore( + provider.chatgptWeb?.threadEnvironmentStatePath + ? resolve(expandUserPath(provider.chatgptWeb.threadEnvironmentStatePath)) + : undefined + ); + + const startRuntime = ( + parsed: CodexParsedRequest, + environment: ReturnType | undefined, + traceId: string + ): ChatGptTurnRuntime => { + const mode = resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, capabilities); + const browserAbort = new AbortController(); + const trace = new ChatGptTraceFeed(); + const text = new ChatGptTextFeed(); + if (!mode.localTools) { + const browser = worker.run({ + traceId, + modelId: parsed.modelId, + reasoning: parsed.options.reasoning, + capabilities, + prepare: async () => ({ + ...compileChatGptWebPrompt(parsed, capabilities), + release: () => {}, + }), + abortSignal: browserAbort.signal, + onReasoningSummary: (text) => trace.push({ kind: "reasoning", text }), + onCommentary: (text, continuation) => + trace.push({ kind: "commentary", text, ...(continuation ? { continuation: true } : {}) }), + onTextDelta: (delta) => text.push(delta), + }); + return { + mode: "read-only", + browser, + trace, + text, + cancel: () => browserAbort.abort(), + }; + } + if (!environment) + throw new Error("Tool-capable ChatGPT web mode requires a trusted Codex environment"); + const token = deferred(); + let tokenSettled = false; + let activeToken: string | undefined; + const browser = worker.run({ + traceId, + modelId: parsed.modelId, + reasoning: parsed.options.reasoning, + capabilities, + prepare: async () => { + const turnToken = await broker.register(environment, timeoutMs + 60_000, traceId); + activeToken = turnToken; + tokenSettled = true; + token.resolve(turnToken); + try { + const compiled = compileChatGptWebPrompt(parsed, capabilities, turnToken); + return { ...compiled, release: () => {} }; + } catch (error) { + broker.revoke(turnToken); + throw error; + } + }, + abortSignal: browserAbort.signal, + onReasoningSummary: (text) => trace.push({ kind: "reasoning", text }), + onCommentary: (text, continuation) => + trace.push({ kind: "commentary", text, ...(continuation ? { continuation: true } : {}) }), + onTextDelta: (delta) => text.push(delta), + }); + void browser.catch((error) => { + if (!tokenSettled) { + tokenSettled = true; + token.reject(error instanceof Error ? error : new Error(String(error))); + } + }); + return { + mode: "tools", + token: token.promise, + browser, + trace, + text, + cancel: () => { + browserAbort.abort(); + if (activeToken) broker.revoke(activeToken); + }, + }; + }; + + return { + name: "chatgpt-web", + async runTurn(parsed, incoming, emit) { + const mode = resolveChatGptWebModelMode( + parsed.modelId, + parsed.options.reasoning, + capabilities + ); + let environment: ReturnType | undefined; + if (mode.localTools) { + try { + environment = environmentStore.resolve(parsed); + } catch (error) { + const identity = extractChatGptTurnIdentity(parsed); + console.warn( + `[chatgpt-web] trusted environment unavailable (thread_id=${identity.threadId ? "present" : "missing"}, turn_id=${identity.turnId ? "present" : "missing"}, previous_response_id=${parsed.previousResponseId ?? "none"}, replay_prefix_items=${parsed._replayPrefixLen ?? 0}, context_messages=${parsed.context.messages.length})` + ); + throw error; + } + } + const executionKey = `${executionNamespace}:${chatGptTurnExecutionKey(parsed)}`; + const traceId = createHash("sha256").update(executionKey).digest("hex").slice(0, 12); + const session = chatGptTurnSessions.getOrCreate(executionKey, () => + startRuntime(parsed, environment, traceId) + ); + const heartbeat = setInterval(() => emit({ type: "heartbeat" }), 10_000); + try { + emit({ type: "heartbeat" }); + await session.runExclusive(async () => { + const settled = session.settledOutcome(); + if (settled) { + if (settled.type === "error") throw settled.error; + let reasoning = session.reasoningForFinalReplay(); + const replay = session.eventsForFinalReplay(); + if (replay.length > 0) { + replayEvents(replay, emit); + } else { + const events: AdapterEvent[] = []; + const emitCaptured = (event: AdapterEvent) => { + events.push(event); + emit(event); + }; + emitProContextWarning(parsed, capabilities, emitCaptured); + const trace = session.runtime.trace.drain(); + reasoning = trace.map((event) => event.text); + emitTraceEvents(trace, emitCaptured); + emitTextDeltas(session.runtime.text.drain(), emitCaptured); + if (session.runtime.text.value() !== settled.answer) { + throw new Error( + "ChatGPT browser Markdown stream did not reproduce the completed answer" + ); + } + session.setFinalReasoning(reasoning); + session.setFinalEvents(events); + } + emitBrowserCompletion( + settled, + estimateChatGptWebUsage(parsed, { answer: settled.answer, reasoning }, capabilities), + emit + ); + return; + } + + let turnToken: string | undefined; + if (session.runtime.mode === "tools") { + turnToken = await withAbort(session.runtime.token, incoming.abortSignal); + if (!environment) + throw new Error("Tool-capable ChatGPT web runtime lost its trusted environment"); + broker.updateEnvironment(turnToken, environment); + + const outstanding = session.outstanding(); + if (outstanding.length > 0) { + const results = currentToolResults(parsed, session); + if (results.length === 0) { + const reasoning = session.reasoningForOutstandingReplay(); + replayEvents(session.eventsForOutstandingReplay(), emit); + emitToolBatch( + outstanding, + estimateChatGptWebUsage( + parsed, + { reasoning, toolRequests: outstanding }, + capabilities + ), + emit + ); + return; + } + if (results.length !== outstanding.length) { + throw new Error( + `Codex returned ${results.length} of ${outstanding.length} results for a parallel ChatGPT tool batch` + ); + } + for (const message of results) { + broker.completeTool(turnToken, message.toolCallId, brokerResult(message)); + session.markResultDelivered(message.toolCallId); + } + } + } else if (session.outstanding().length > 0) { + throw new Error("Read-only ChatGPT Web runtime cannot own local tool calls"); + } + + const toolWaitAbort = new AbortController(); + try { + const roundReasoning: string[] = []; + const roundEvents: AdapterEvent[] = []; + const emitRound = (event: AdapterEvent) => { + roundEvents.push(event); + emit(event); + }; + const emitNewTrace = (trace: ChatGptTraceEvent[]) => { + roundReasoning.push(...trace.map((event) => event.text)); + emitTraceEvents(trace, emitRound); + }; + const emitNewText = (deltas: string[]) => emitTextDeltas(deltas, emitRound); + emitProContextWarning(parsed, capabilities, emitRound); + emitNewTrace(session.runtime.trace.drain()); + emitNewText(session.runtime.text.drain()); + const nextTools = turnToken + ? broker + .nextToolBatch(turnToken, toolWaitAbort.signal) + .then((requests) => ({ type: "tools" as const, requests })) + : undefined; + const browserOutcome = session.browserOutcome.then((outcome) => ({ + type: "browser" as const, + outcome, + })); + let nextTrace = session.runtime.trace + .next(toolWaitAbort.signal) + .then((event) => ({ type: "trace" as const, event })); + let nextText = session.runtime.text + .wait(toolWaitAbort.signal) + .then(() => ({ type: "text" as const })); + for (;;) { + const next = await withAbort( + Promise.race([ + ...(nextTools ? [nextTools] : []), + browserOutcome, + nextTrace, + nextText, + ]), + incoming.abortSignal + ); + if (next.type === "trace") { + emitNewTrace([next.event]); + nextTrace = session.runtime.trace + .next(toolWaitAbort.signal) + .then((event) => ({ type: "trace" as const, event })); + continue; + } + if (next.type === "text") { + emitNewText(session.runtime.text.drain()); + nextText = session.runtime.text + .wait(toolWaitAbort.signal) + .then(() => ({ type: "text" as const })); + continue; + } + emitNewTrace(session.runtime.trace.drain()); + emitNewText(session.runtime.text.drain()); + if (next.type === "browser") { + session.setFinalReasoning(roundReasoning); + session.setFinalEvents(roundEvents); + if (turnToken) broker.revoke(turnToken); + if (next.outcome.type === "error") throw next.outcome.error; + if (session.runtime.text.value() !== next.outcome.answer) { + throw new Error( + "ChatGPT browser Markdown stream did not reproduce the completed answer" + ); + } + emitBrowserCompletion( + next.outcome, + estimateChatGptWebUsage( + parsed, + { answer: next.outcome.answer, reasoning: roundReasoning }, + capabilities + ), + emit + ); + return; + } + if (!turnToken || session.runtime.mode !== "tools") { + throw new Error("Read-only ChatGPT Web runtime received a broker tool batch"); + } + if (next.requests.length === 0) + throw new Error("ChatGPT tool bridge returned an empty batch"); + validateBatchTools(parsed, next.requests); + session.setOutstanding(next.requests, roundReasoning, roundEvents); + emitToolBatch( + next.requests, + estimateChatGptWebUsage( + parsed, + { reasoning: roundReasoning, toolRequests: next.requests }, + capabilities + ), + emit + ); + return; + } + } finally { + toolWaitAbort.abort(); + } + }); + } catch (error) { + session.cancel(); + if (session.runtime.mode === "tools") { + void session.runtime.token.then((turnToken) => broker.revoke(turnToken)).catch(() => {}); + } + throw error; + } finally { + clearInterval(heartbeat); + } + }, + }; +} diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/markdown.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/markdown.ts new file mode 100644 index 0000000000..853386dbce --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/markdown.ts @@ -0,0 +1,76 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import TurndownService from "turndown"; +import { gfm } from "turndown-plugin-gfm"; + +const turndown = new TurndownService({ + headingStyle: "atx", + bulletListMarker: "-", + codeBlockStyle: "fenced", + fence: "```", + emDelimiter: "*", + strongDelimiter: "**", + linkStyle: "inlined", +}); +turndown.use(gfm); +turndown.remove(["button", "script", "style"]); +turndown.addRule("removeSvg", { + filter: (node) => node.nodeName === "SVG", + replacement: () => "", +}); +turndown.addRule("compactListItem", { + filter: "li", + replacement: (content, node, options) => { + const parent = node.parentNode as HTMLElement | null; + let prefix = `${options.bulletListMarker} `; + if (parent?.nodeName === "OL") { + const start = Number(parent.getAttribute("start") ?? "1"); + const index = Array.prototype.indexOf.call(parent.children, node) as number; + prefix = `${start + index}. `; + } + const normalized = content + .replace(/^\n+|\n+$/g, "") + .replace(/\n/g, `\n${" ".repeat(prefix.length)}`); + return `${prefix}${normalized}${node.nextSibling ? "\n" : ""}`; + }, +}); + +export function chatGptHtmlToMarkdown(html: string): string { + return html.trim() ? turndown.turndown(html).trim() : ""; +} + +/** + * Converts append-only rendered ChatGPT blocks into Responses text deltas. + * A stable prefix must be observed twice before it is committed. The final unstable block is + * emitted only by `finish`, so already-streamed Markdown never needs a retraction. + */ +export class ChatGptMarkdownStream { + private candidate = ""; + private committed = ""; + + constructor(private readonly transform: (markdown: string) => string = (markdown) => markdown) {} + + observeStableHtml(html: string): string { + const next = this.transform(chatGptHtmlToMarkdown(html)); + if (!next.startsWith(this.committed)) { + throw new Error("ChatGPT changed Markdown that was already streamed to Codex"); + } + if (next !== this.candidate) { + this.candidate = next; + return ""; + } + const delta = next.slice(this.committed.length); + this.committed = next; + return delta; + } + + finish(html: string): { markdown: string; delta: string } { + const markdown = this.transform(chatGptHtmlToMarkdown(html)); + if (!markdown.startsWith(this.committed)) { + throw new Error("ChatGPT final Markdown does not extend the streamed stable prefix"); + } + const delta = markdown.slice(this.committed.length); + this.committed = markdown; + this.candidate = markdown; + return { markdown, delta }; + } +} diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.ts new file mode 100644 index 0000000000..90772b23d4 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.ts @@ -0,0 +1,468 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import { createHash } from "node:crypto"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import * as z from "zod/v4"; +import { namespacedToolName, type CodexTool } from "../../types"; +import type { ChatGptTurnEnvironment } from "./environment"; +import { callTurnBroker, type BrokerToolResult } from "./turn-broker"; + +interface ClaimedTurn { + bindingId: string; + environment: ChatGptTurnEnvironment & { expiresAt: number }; +} + +interface ResolvedTurn { + environment: ChatGptTurnEnvironment & { expiresAt: number }; +} + +const bindingSchema = z + .string() + .min(20) + .max(256) + .describe("Opaque binding_id returned by codex_bind_turn."); +const jsonArgumentsSchema = z.record(z.string(), z.unknown()).default({}); + +function scopeHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +function requestScopeSummary(extra: { + sessionId?: string; + requestId: string | number; + _meta?: unknown; + requestInfo?: unknown; +}): string { + const meta = + extra._meta && typeof extra._meta === "object" && !Array.isArray(extra._meta) + ? Object.entries(extra._meta as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => ({ + key, + type: value === null ? "null" : Array.isArray(value) ? "array" : typeof value, + ...(typeof value === "string" ? { chars: value.length, hash: scopeHash(value) } : {}), + })) + : []; + const requestInfoKeys = + extra.requestInfo && typeof extra.requestInfo === "object" + ? Object.keys(extra.requestInfo as Record).sort() + : []; + return JSON.stringify({ + requestId: String(extra.requestId), + session: extra.sessionId + ? { chars: extra.sessionId.length, hash: scopeHash(extra.sessionId) } + : null, + meta, + requestInfoKeys, + }); +} + +function result(value: Record, isError = false) { + return { + content: [{ type: "text" as const, text: JSON.stringify(value) }], + structuredContent: value, + ...(isError ? { isError: true } : {}), + }; +} + +function wireName(tool: CodexTool): string { + return namespacedToolName(tool.namespace, tool.name); +} + +function exactTool(environment: ChatGptTurnEnvironment, name: string): CodexTool | undefined { + return environment.tools.find((tool) => !tool.namespace && tool.name === name); +} + +function namedTool(environment: ChatGptTurnEnvironment, requestedWireName: string): CodexTool { + const tool = environment.tools.find((candidate) => wireName(candidate) === requestedWireName); + if (!tool) throw new Error(`Codex tool is not available in this turn: ${requestedWireName}`); + return tool; +} + +function invocationTimeout(environment: ChatGptTurnEnvironment & { expiresAt: number }): number { + return Math.max(1, environment.expiresAt - Date.now()); +} + +function asMcpResult(value: BrokerToolResult) { + return { + content: value.content as never, + ...(value.structuredContent !== undefined && + value.structuredContent !== null && + typeof value.structuredContent === "object" + ? { structuredContent: value.structuredContent as Record } + : {}), + ...(value.isError ? { isError: true } : {}), + ...(value._meta !== undefined && value._meta !== null && typeof value._meta === "object" + ? { _meta: value._meta as Record } + : {}), + }; +} + +function execGateway(environment: ChatGptTurnEnvironment): CodexTool | undefined { + const tool = exactTool(environment, "exec"); + return tool?.freeform ? tool : undefined; +} + +function gatewayNestedToolName(toolName: string): string { + return toolName.replace(/[^A-Za-z0-9_$]/g, "_"); +} + +function execGatewayProgram( + nestedToolName: string, + freeform: boolean, + payload: { arguments?: Record; input?: string } +): string { + const nestedInput = freeform ? (payload.input ?? "") : (payload.arguments ?? {}); + return [ + `const result = await tools[${JSON.stringify(gatewayNestedToolName(nestedToolName))}](${JSON.stringify(nestedInput)});`, + "const emit = value => {", + " if (Array.isArray(value)) { for (const item of value) emit(item); return; }", + ' if (value && typeof value === "object") {', + ' if (value.type === "image") { image(value); return; }', + ' if (value.type === "audio") { audio(value); return; }', + ' if (value.type === "text" && typeof value.text === "string") { text(value.text); return; }', + ' if (typeof value.image_url === "string" && typeof value.output_hint === "string") { generatedImage(value); return; }', + ' if (typeof value.image_url === "string") { image(value.image_url, value.detail ?? "auto"); return; }', + ' if (typeof value.audio_url === "string") { audio(value.audio_url); return; }', + " if (Array.isArray(value.content)) { for (const item of value.content) emit(item); return; }", + " }", + " text(value);", + "};", + "emit(result);", + ].join("\n"); +} + +export async function runChatGptMcpServer(options: { brokerSocketPath: string }): Promise { + const server = new McpServer({ name: "codex-native", version: "3.0.0" }); + + const environment = async ( + bindingId: string + ): Promise => { + const resolved = await callTurnBroker(options.brokerSocketPath, { + method: "resolve", + bindingId, + }); + if (resolved.environment.expiresAt <= Date.now()) throw new Error("Codex turn binding expired"); + return resolved.environment; + }; + + const invoke = async ( + bindingId: string, + bound: ChatGptTurnEnvironment & { expiresAt: number }, + tool: CodexTool, + payload: { arguments?: Record; input?: string } + ) => { + const response = await callTurnBroker( + options.brokerSocketPath, + { + method: "invoke", + bindingId, + wireName: wireName(tool), + freeform: tool.freeform === true, + ...(tool.freeform + ? { input: payload.input ?? "" } + : { arguments: payload.arguments ?? {} }), + }, + invocationTimeout(bound) + ); + return asMcpResult(response); + }; + + const invokeNative = ( + bindingId: string, + bound: ChatGptTurnEnvironment & { expiresAt: number }, + tool: CodexTool, + payload: { arguments?: Record; input?: string } + ) => { + const gateway = execGateway(bound); + return gateway && gateway !== tool + ? invoke(bindingId, bound, gateway, { + input: execGatewayProgram(wireName(tool), tool.freeform === true, payload), + }) + : invoke(bindingId, bound, tool, payload); + }; + + const invokeNestedNative = ( + bindingId: string, + bound: ChatGptTurnEnvironment & { expiresAt: number }, + nestedToolName: string, + freeform: boolean, + payload: { arguments?: Record; input?: string } + ) => { + const gateway = execGateway(bound); + if (!gateway) { + throw new Error( + `This Codex turn did not advertise ${nestedToolName} or the native exec gateway` + ); + } + return invoke(bindingId, bound, gateway, { + input: execGatewayProgram(nestedToolName, freeform, payload), + }); + }; + + server.registerTool( + "codex_bind_turn", + { + title: "Bind this response to its Codex turn", + description: + "Idempotently claim the capability for the current outer Codex turn before calling its native tools.", + inputSchema: { turn_token: z.string().min(20).max(256) }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ turn_token }, extra) => { + console.error(`[chatgpt-web-mcp] codex_bind_turn scope=${requestScopeSummary(extra)}`); + const claimed = await callTurnBroker(options.brokerSocketPath, { + method: "claim", + token: turn_token, + }); + const commandTool = + exactTool(claimed.environment, "exec_command") ?? + exactTool(claimed.environment, "shell_command"); + const gateway = execGateway(claimed.environment); + return result({ + binding_id: claimed.bindingId, + harness_version: 3, + execution: "outer_codex_native", + cwd: claimed.environment.cwd, + roots: claimed.environment.roots, + writable_roots: claimed.environment.writableRoots, + sandbox: claimed.environment.sandboxPolicy.type, + expires_at: new Date(claimed.environment.expiresAt).toISOString(), + tool_count: claimed.environment.tools.length, + command_tool: commandTool ? wireName(commandTool) : gateway ? "exec_command" : null, + outer_tool_gateway: gateway ? wireName(gateway) : null, + capabilities: [ + "native_tool_loop", + "session_history", + "exec", + "apply_patch", + "images", + "tool_registry", + ], + }); + } + ); + + server.registerTool( + "codex_exec", + { + title: "Run a native Codex command", + description: + "Invoke the command tool advertised by the current outer Codex harness. A long-running command returns its native session_id.", + inputSchema: { + binding_id: bindingSchema, + cmd: z.string().min(1).max(100_000), + workdir: z.string().max(16_384).optional(), + yield_time_ms: z.number().int().min(250).max(30_000).optional(), + max_output_tokens: z.number().int().min(1).max(1_000_000).optional(), + tty: z.boolean().optional(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ binding_id, cmd, workdir, yield_time_ms, max_output_tokens, tty }, extra) => { + console.error(`[chatgpt-web-mcp] codex_exec scope=${requestScopeSummary(extra)}`); + const bound = await environment(binding_id); + const tool = exactTool(bound, "exec_command") ?? exactTool(bound, "shell_command"); + const commandName = tool?.name ?? "exec_command"; + const args = + commandName === "exec_command" + ? { + cmd, + ...(workdir ? { workdir } : {}), + ...(yield_time_ms !== undefined ? { yield_time_ms } : {}), + ...(max_output_tokens !== undefined ? { max_output_tokens } : {}), + ...(tty !== undefined ? { tty } : {}), + } + : { + command: cmd, + ...(workdir ? { workdir } : {}), + ...(yield_time_ms !== undefined ? { timeout_ms: yield_time_ms } : {}), + }; + return tool + ? invokeNative(binding_id, bound, tool, { arguments: args }) + : invokeNestedNative(binding_id, bound, commandName, false, { arguments: args }); + } + ); + + server.registerTool( + "codex_write_stdin", + { + title: "Continue a native Codex command session", + description: "Write characters to, or poll, a session_id returned by codex_exec.", + inputSchema: { + binding_id: bindingSchema, + session_id: z.number().int().nonnegative(), + chars: z.string().max(1_000_000).optional(), + yield_time_ms: z.number().int().min(250).max(300_000).optional(), + max_output_tokens: z.number().int().min(1).max(1_000_000).optional(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ binding_id, session_id, chars, yield_time_ms, max_output_tokens }) => { + const bound = await environment(binding_id); + const tool = exactTool(bound, "write_stdin"); + const payload = { + arguments: { + session_id, + ...(chars !== undefined ? { chars } : {}), + ...(yield_time_ms !== undefined ? { yield_time_ms } : {}), + ...(max_output_tokens !== undefined ? { max_output_tokens } : {}), + }, + }; + return tool + ? invokeNative(binding_id, bound, tool, payload) + : invokeNestedNative(binding_id, bound, "write_stdin", false, payload); + } + ); + + server.registerTool( + "codex_apply_patch", + { + title: "Apply a native Codex patch", + description: + "Invoke the outer Codex apply_patch tool, producing a native file-change item in the Codex task.", + inputSchema: { binding_id: bindingSchema, patch: z.string().min(1).max(5_000_000) }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ binding_id, patch }) => { + const bound = await environment(binding_id); + const tool = exactTool(bound, "apply_patch"); + if (!tool) + return invokeNestedNative(binding_id, bound, "apply_patch", true, { input: patch }); + return tool.freeform + ? invokeNative(binding_id, bound, tool, { input: patch }) + : invokeNative(binding_id, bound, tool, { arguments: { input: patch } }); + } + ); + + server.registerTool( + "codex_view_image", + { + title: "View an image through native Codex", + description: + "Invoke the outer Codex view_image tool and return its multimodal result to this same ChatGPT response.", + inputSchema: { + binding_id: bindingSchema, + path: z.string().min(1).max(16_384), + detail: z.enum(["high", "original"]).optional(), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ binding_id, path, detail }) => { + const bound = await environment(binding_id); + const tool = exactTool(bound, "view_image"); + const payload = { arguments: { path, ...(detail ? { detail } : {}) } }; + return tool + ? invokeNative(binding_id, bound, tool, payload) + : invokeNestedNative(binding_id, bound, "view_image", false, payload); + } + ); + + server.registerTool( + "codex_tool_inventory", + { + title: "Discover tools from the current Codex harness", + description: + "Search the exact tool registry supplied to the current outer Codex turn, including configured MCP/app tools.", + inputSchema: { + binding_id: bindingSchema, + query: z.string().max(500).optional(), + offset: z.number().int().min(0).max(100_000).default(0), + limit: z.number().int().min(1).max(50).default(20), + include_schema: z.boolean().default(true), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ binding_id, query, offset, limit, include_schema }) => { + const bound = await environment(binding_id); + const needle = query?.trim().toLowerCase(); + const matches = bound.tools.filter( + (tool) => + !needle || + [wireName(tool), tool.name, tool.namespace ?? "", tool.description] + .join("\n") + .toLowerCase() + .includes(needle) + ); + const page = matches.slice(offset, offset + limit).map((tool) => ({ + wire_name: wireName(tool), + name: tool.name, + namespace: tool.namespace ?? null, + description: tool.description, + kind: tool.freeform ? "freeform" : tool.toolSearch ? "tool_search" : "function", + ...(include_schema ? { parameters: tool.parameters } : {}), + })); + return result({ + tools: page, + total: matches.length, + next_offset: offset + page.length < matches.length ? offset + page.length : null, + }); + } + ); + + server.registerTool( + "codex_tool_call", + { + title: "Call any tool from the current Codex harness", + description: + "Invoke an exact wire_name returned by codex_tool_inventory. The outer Codex runtime performs the call, approvals, and UI lifecycle.", + inputSchema: { + binding_id: bindingSchema, + wire_name: z.string().min(1).max(1_000), + arguments: jsonArgumentsSchema.optional(), + input: z.string().max(5_000_000).optional(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ binding_id, wire_name, arguments: args, input }) => { + const bound = await environment(binding_id); + const tool = namedTool(bound, wire_name); + if (tool.freeform) { + if (input === undefined) throw new Error(`Freeform Codex tool ${wire_name} requires input`); + if (args && Object.keys(args).length > 0) + throw new Error(`Freeform Codex tool ${wire_name} does not accept arguments`); + return invokeNative(binding_id, bound, tool, { input }); + } + if (input !== undefined) + throw new Error(`Function Codex tool ${wire_name} does not accept freeform input`); + return invokeNative(binding_id, bound, tool, { arguments: args ?? {} }); + } + ); + + await server.connect(new StdioServerTransport()); +} diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/model.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/model.ts new file mode 100644 index 0000000000..3f2b25b75b --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/model.ts @@ -0,0 +1,66 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +export const CHATGPT_WEB_MODEL_ID = "gpt-5.6-sol"; + +export interface ChatGptWebCapabilities { + localToolsEnabled: boolean; + proAvailable: boolean; +} + +export interface ChatGptWebModelMode { + modelId: string; + effort: "low" | "medium" | "high" | "xhigh" | "max"; + displayLabel: "Instant" | "Medium" | "High" | "Extra High" | "Pro"; + uiEffortLabel: "Instant 5.5" | "Medium" | "High" | "Extra High" | "Pro"; + localTools: boolean; +} + +export function resolveChatGptWebModelMode( + modelId: string, + reasoning: string | undefined, + capabilities: ChatGptWebCapabilities +): ChatGptWebModelMode { + if (modelId !== CHATGPT_WEB_MODEL_ID) { + throw new Error(`ChatGPT web model is not supported: ${modelId}`); + } + const effort = reasoning ?? "high"; + switch (effort) { + case "low": + return { + modelId, + effort, + displayLabel: "Instant", + uiEffortLabel: "Instant 5.5", + localTools: capabilities.localToolsEnabled, + }; + case "medium": + return { + modelId, + effort, + displayLabel: "Medium", + uiEffortLabel: "Medium", + localTools: capabilities.localToolsEnabled, + }; + case "high": + return { + modelId, + effort, + displayLabel: "High", + uiEffortLabel: "High", + localTools: capabilities.localToolsEnabled, + }; + case "xhigh": + return { + modelId, + effort, + displayLabel: "Extra High", + uiEffortLabel: "Extra High", + localTools: capabilities.localToolsEnabled, + }; + case "max": + if (!capabilities.proAvailable) + throw new Error("ChatGPT Pro effort is not available for this account"); + return { modelId, effort, displayLabel: "Pro", uiEffortLabel: "Pro", localTools: false }; + default: + throw new Error(`ChatGPT web effort is not supported: ${effort}`); + } +} diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/prompt.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/prompt.ts new file mode 100644 index 0000000000..f18bd566fb --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/prompt.ts @@ -0,0 +1,213 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import type { + CodexAssistantContentPart, + CodexContentPart, + CodexMessage, + CodexParsedRequest, +} from "../../types"; +import { isReadableCompactionSummaryText } from "../../responses/compaction"; +import { resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model"; + +export const CHATGPT_INTERNAL_COMPACTION_MARKER = "[[CODEX_INTERNAL_CONTEXT_COMPACTED]]"; +const CHATGPT_INTERNAL_COMPACTION_PREFIX = "[[CODEX_INTERNAL_CONTEXT_COMPACT"; + +export function containsChatGptCompactionMarker(text: string): boolean { + const trimmed = text.trim(); + return ( + text.includes(CHATGPT_INTERNAL_COMPACTION_PREFIX) || + (trimmed.startsWith("[[CODEX_") && CHATGPT_INTERNAL_COMPACTION_MARKER.startsWith(trimmed)) + ); +} + +export function stripChatGptTransportMarkers(text: string): string { + let stripped = text.replace(/\[\[CODEX_INTERNAL_CONTEXT_COMPACT(?:ED)?(?:\]\])?/g, ""); + const trimmed = stripped.trim(); + if (trimmed.startsWith("[[CODEX_") && CHATGPT_INTERNAL_COMPACTION_MARKER.startsWith(trimmed)) + stripped = ""; + return stripped.replace(/\n{3,}/g, "\n\n").trim(); +} + +export interface ChatGptWebPromptImage { + ref: string; + imageUrl: string; + detail?: string; +} + +export interface CompiledChatGptWebPrompt { + text: string; + images: ChatGptWebPromptImage[]; + contextAttachments: Array<{ + name: string; + mimeType: "application/x-ndjson"; + buffer: Buffer; + }>; +} + +export const CHATGPT_INLINE_CONTEXT_MAX_CHARS = 120_000; + +function inputContent( + content: string | CodexContentPart[], + images: ChatGptWebPromptImage[] +): unknown { + if (typeof content === "string") return content; + if (!content.some((part) => part.type === "image")) { + return content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); + } + return content.map((part) => { + if (part.type === "text") return { type: "text", text: part.text }; + const ref = `codex-input-image-${images.length + 1}`; + images.push({ ref, imageUrl: part.imageUrl, ...(part.detail ? { detail: part.detail } : {}) }); + return { + type: "image_attachment", + attachment_ref: ref, + ...(part.detail ? { detail: part.detail } : {}), + }; + }); +} + +function assistantContent(content: CodexAssistantContentPart[]): unknown[] { + return content.map((part) => { + if (part.type === "text") return { type: "text", text: part.text }; + if (part.type === "thinking") return { type: "thinking_summary", text: part.thinking }; + return { type: "tool_call", id: part.id, name: part.name, arguments: part.arguments }; + }); +} + +function messageEnvelope( + message: CodexMessage, + images: ChatGptWebPromptImage[] +): Record { + if (message.role === "toolResult") { + return { + role: "tool_result", + tool_call_id: message.toolCallId, + tool_name: message.toolName, + is_error: message.isError, + content: inputContent(message.content, images), + }; + } + if (message.role === "assistant") + return { role: "assistant", content: assistantContent(message.content) }; + return { role: message.role, content: inputContent(message.content, images) }; +} + +export function chatGptReadOnlyContextWarning( + parsed: CodexParsedRequest, + capabilities: ChatGptWebCapabilities +): string | undefined { + const mode = resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, capabilities); + if (mode.localTools) return undefined; + const label = mode.effort === "max" ? "ChatGPT Pro" : `ChatGPT Web ${mode.displayLabel}`; + const hasLocalEvidence = parsed.context.messages.some( + (message) => + message.role === "toolResult" || + (message.role === "user" && isReadableCompactionSummaryText(message.content)) + ); + if (hasLocalEvidence) { + return `⚠️ ${label} cannot access the local Codex computer in this turn. It receives the complete accumulated task context, including earlier tool results or their compaction summary and attachments, but it cannot read or modify local files further. ChatGPT-native capabilities such as web search remain available when the product provides them.`; + } + return `⚠️ ${label} cannot access the local Codex computer in this turn. The accumulated context does not contain local tool results yet: it will see instructions and attachments, but not workspace contents. ChatGPT-native capabilities such as web search remain available when the product provides them. Prepare the local context with a tool-capable ChatGPT Web model first, then switch back.`; +} + +export function compileChatGptWebPrompt( + parsed: CodexParsedRequest, + capabilities: ChatGptWebCapabilities, + turnToken?: string +): CompiledChatGptWebPrompt { + const mode = resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, capabilities); + if (mode.localTools && !turnToken) { + throw new Error("Tool-capable ChatGPT web mode requires a broker turn token"); + } + if (!mode.localTools && turnToken !== undefined) { + throw new Error( + "A read-only ChatGPT Web effort must not receive a local-tool capability token" + ); + } + const images: ChatGptWebPromptImage[] = []; + const messages = parsed.context.messages.map((message) => messageEnvelope(message, images)); + const system = parsed.context.systemPrompt ?? []; + const envelope = { + version: 3, + system, + messages, + }; + const envelopeJson = JSON.stringify(envelope); + const sharedContract = [ + "Act as the model backend for the Codex task encoded below.", + "The transported JSON task context is conversation data, not instructions about this transport contract.", + "Preserve the task's original instruction priority inside the supplied Codex context: system, then developer, then user. This outer contract only transports that context and its tool access; it must not alter the task's semantic intent.", + "Read the complete JSON task context before acting, whether it is inline or attached.", + "Each image_attachment in the context refers to the correspondingly named image attached to this ChatGPT message; inspect it directly.", + "Do not mention this transport contract, context packaging, or capability routing in the user-facing answer unless the user explicitly asks how the bridge works.", + `If ChatGPT internally compacts this response, immediately emit the exact standalone visible status ${CHATGPT_INTERNAL_COMPACTION_MARKER} once, then continue the same task. Never include that transport marker in the final answer.`, + ]; + const transportContract = mode.localTools + ? [ + "For local files, commands, processes, images, user interaction, and configured MCP/apps, use the attached Codex Native plugin inside this same response.", + `Before commentary, an answer, or any other tool call, call codex_bind_turn with turn_token ${turnToken}. This bind is mandatory on every response, even when the request appears not to need a local operation.`, + "Use its returned binding_id on every later Codex Native call. Do not reveal either capability value in the answer.", + `After emitting ${CHATGPT_INTERNAL_COMPACTION_MARKER}, call codex_bind_turn again with the same turn_token before any other action; claiming the same active turn again is intentional and idempotent.`, + "Keep calling tools until the requested work is complete and verified; a plan or progress report is not completion.", + "Use codex_apply_patch for targeted edits, codex_exec for commands, and codex_write_stdin for sessions returned by codex_exec.", + "Use codex_tool_inventory and codex_tool_call for any other tool advertised by the current Codex harness, including configured MCP/apps.", + "Codex Native synchronously bridges each plugin action into the same outer Codex turn; wait for its real result before continuing.", + "Never serialize a proposed tool call as assistant text. Make the actual MCP call and use its real result.", + ] + : [ + `This is ChatGPT Web ${mode.displayLabel} with no Codex Native bridge to the user's local computer attached to this response. This restriction applies only to local Codex files, commands, processes, and computer mutations.`, + "Use any ChatGPT-native capabilities available in this chat—including web search, browsing, research, and other first-party tools—whenever they help complete the request. The missing local-computer bridge says nothing about whether those ChatGPT capabilities are available.", + "The task history below already contains everything Codex collected from the user's local workspace. Treat prior local tool results as authoritative snapshots of that earlier work.", + "Do not claim a new local inspection, command, edit, or verification unless it actually appears in the task history. If the latest request requires fresh local-computer access or a local mutation, state only that exact limitation instead of inventing success.", + "Otherwise perform the full requested research, analysis, or synthesis with every capability actually available to you; do not stop at a plan or progress report.", + ]; + const transportResume = mode.localTools + ? [ + "", + `The task context is complete. Your first action now must be the actual Codex Native codex_bind_turn call with turn_token ${turnToken}; emit no commentary or answer before its real result.`, + "After binding, execute the latest active user request under the preserved task instructions and keep using the returned binding_id for Codex Native calls.", + "", + ] + : [ + "", + "The task context is complete. Execute the latest active user request now under the capability contract above.", + "", + ]; + const contextAttachments: CompiledChatGptWebPrompt["contextAttachments"] = []; + let contextTransport: string[]; + if (envelopeJson.length <= CHATGPT_INLINE_CONTEXT_MAX_CHARS) { + contextTransport = ["", envelopeJson, ""]; + } else { + const records = [ + { + type: "manifest", + version: 1, + format: "omniroute-codex-context-jsonl", + system_count: system.length, + message_count: messages.length, + }, + ...system.map((text, index) => ({ type: "system", index, text })), + ...messages.map((message, index) => ({ type: "message", index, message })), + ]; + contextAttachments.push({ + name: "omniroute-codex-context.jsonl", + mimeType: "application/x-ndjson", + buffer: Buffer.from(`${records.map((record) => JSON.stringify(record)).join("\n")}\n`), + }); + contextTransport = [ + "", + "Read the complete attached omniroute-codex-context.jsonl file in JSONL order. The first record is its manifest; subsequent records contain the authoritative system and message context.", + "", + ]; + } + const text = [ + ...sharedContract, + ...transportContract, + "Return only the answer that the outer Codex task should receive.", + ...contextTransport, + ...transportResume, + ].join("\n"); + return { text, images, contextAttachments }; +} diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/thread-environment.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/thread-environment.ts new file mode 100644 index 0000000000..3271b01a49 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/thread-environment.ts @@ -0,0 +1,212 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; +import { atomicWriteFile } from "../../config"; +import type { CodexParsedRequest } from "../../types"; +import { + extractChatGptTurnEnvironment, + extractChatGptTurnIdentity, + MissingTrustedCodexEnvironmentError, + type ChatGptSandboxPolicy, + type ChatGptTurnEnvironment, +} from "./environment"; + +interface StoredThreadEnvironment { + cwd: string; + roots: string[]; + writableRoots: string[]; + sandboxPolicy: ChatGptSandboxPolicy; + updatedAt: number; +} + +interface StoredThreadEnvironmentFile { + version: 1; + threads: Record; +} + +const MAX_THREAD_ENVIRONMENTS = 256; +const THREAD_ENVIRONMENT_TTL_MS = 30 * 24 * 60 * 60_000; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function contains(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function absolutePaths(value: unknown, field: string): string[] { + if ( + !Array.isArray(value) || + value.length === 0 || + value.some((path) => typeof path !== "string" || !isAbsolute(path)) + ) { + throw new Error(`Invalid persisted ChatGPT thread ${field}`); + } + return [...new Set(value.map((path) => resolve(path as string)))]; +} + +function sandboxPolicy( + value: unknown, + roots: string[], + writableRoots: string[] +): ChatGptSandboxPolicy { + const parsed = record(value); + if (parsed?.type === "dangerFullAccess") { + if ( + writableRoots.length !== roots.length || + writableRoots.some((path) => !roots.includes(path)) + ) { + throw new Error("Invalid persisted ChatGPT danger-full-access roots"); + } + return { type: "dangerFullAccess" }; + } + if (parsed?.type === "workspaceWrite") { + if ( + typeof parsed.networkAccess !== "boolean" || + writableRoots.some((path) => !roots.some((root) => contains(root, path))) + ) { + throw new Error("Invalid persisted ChatGPT workspace-write policy"); + } + return { type: "workspaceWrite", writableRoots, networkAccess: parsed.networkAccess }; + } + if (parsed?.type === "readOnly") { + if (typeof parsed.networkAccess !== "boolean" || writableRoots.length !== 0) { + throw new Error("Invalid persisted ChatGPT read-only policy"); + } + return { type: "readOnly", networkAccess: parsed.networkAccess }; + } + throw new Error("Invalid persisted ChatGPT sandbox policy"); +} + +function validateStoredEnvironment(value: unknown): StoredThreadEnvironment { + const parsed = record(value); + if ( + !parsed || + typeof parsed.cwd !== "string" || + !isAbsolute(parsed.cwd) || + typeof parsed.updatedAt !== "number" + ) { + throw new Error("Invalid persisted ChatGPT thread environment"); + } + const cwd = resolve(parsed.cwd); + const roots = absolutePaths(parsed.roots, "roots"); + const writableRoots = + Array.isArray(parsed.writableRoots) && parsed.writableRoots.length === 0 + ? [] + : absolutePaths(parsed.writableRoots, "writable roots"); + if (!roots.some((root) => contains(root, cwd))) + throw new Error("Persisted ChatGPT cwd is outside its roots"); + return { + cwd, + roots, + writableRoots, + sandboxPolicy: sandboxPolicy(parsed.sandboxPolicy, roots, writableRoots), + updatedAt: parsed.updatedAt, + }; +} + +function authority( + environment: ChatGptTurnEnvironment, + updatedAt: number +): StoredThreadEnvironment { + return { + cwd: environment.cwd, + roots: environment.roots, + writableRoots: environment.writableRoots, + sandboxPolicy: environment.sandboxPolicy, + updatedAt, + }; +} + +/** + * Codex emits its trusted environment envelope when a task starts or its environment changes, + * not on every follow-up. This store carries only that trusted authority across turns. Tool + * declarations are always taken from the current request and are never persisted. + */ +export class ChatGptThreadEnvironmentStore { + private loaded = false; + private readonly threads = new Map(); + + constructor( + private readonly path?: string, + private readonly now: () => number = Date.now + ) {} + + resolve(parsed: CodexParsedRequest): ChatGptTurnEnvironment { + const identity = extractChatGptTurnIdentity(parsed); + try { + const environment = extractChatGptTurnEnvironment(parsed); + if (identity.threadId) this.set(identity.threadId, environment); + return environment; + } catch (error) { + if (!(error instanceof MissingTrustedCodexEnvironmentError) || !identity.threadId) + throw error; + const stored = this.get(identity.threadId); + if (!stored) throw error; + return { + cwd: stored.cwd, + roots: stored.roots, + writableRoots: stored.writableRoots, + sandboxPolicy: stored.sandboxPolicy, + tools: parsed.context.tools ?? [], + }; + } + } + + private get(threadId: string): StoredThreadEnvironment | undefined { + this.load(); + const stored = this.threads.get(threadId); + if (!stored) return undefined; + if (this.now() - stored.updatedAt > THREAD_ENVIRONMENT_TTL_MS) { + this.threads.delete(threadId); + this.persist(); + return undefined; + } + return stored; + } + + private set(threadId: string, environment: ChatGptTurnEnvironment): void { + this.load(); + this.threads.delete(threadId); + this.threads.set(threadId, authority(environment, this.now())); + while (this.threads.size > MAX_THREAD_ENVIRONMENTS) { + const oldest = this.threads.keys().next().value as string | undefined; + if (!oldest) break; + this.threads.delete(oldest); + } + this.persist(); + } + + private load(): void { + if (this.loaded) return; + this.loaded = true; + if (!this.path || !existsSync(this.path)) return; + const parsed = JSON.parse( + readFileSync(this.path, "utf8") + ) as Partial; + const rawThreads = record(parsed.threads); + if (parsed.version !== 1 || !rawThreads) { + throw new Error(`Invalid ChatGPT thread environment store: ${this.path}`); + } + const cutoff = this.now() - THREAD_ENVIRONMENT_TTL_MS; + const entries = Object.entries(rawThreads) + .map(([threadId, value]) => [threadId, validateStoredEnvironment(value)] as const) + .filter(([, environment]) => environment.updatedAt >= cutoff) + .sort((left, right) => left[1].updatedAt - right[1].updatedAt) + .slice(-MAX_THREAD_ENVIRONMENTS); + for (const [threadId, environment] of entries) this.threads.set(threadId, environment); + } + + private persist(): void { + if (!this.path) return; + const payload: StoredThreadEnvironmentFile = { + version: 1, + threads: Object.fromEntries(this.threads), + }; + atomicWriteFile(this.path, `${JSON.stringify(payload, null, 2)}\n`); + } +} diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/turn-broker.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/turn-broker.ts new file mode 100644 index 0000000000..2f0d07e6ef --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/turn-broker.ts @@ -0,0 +1,494 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import { randomBytes } from "node:crypto"; +import { chmodSync, existsSync, lstatSync, mkdirSync, unlinkSync } from "node:fs"; +import { createConnection, createServer, type Server, type Socket } from "node:net"; +import { dirname } from "node:path"; +import type { ChatGptTurnEnvironment } from "./environment"; + +interface PendingTurn extends ChatGptTurnEnvironment { + expiresAt: number; +} + +export interface BrokerToolRequest { + callId: string; + wireName: string; + freeform: boolean; + arguments?: Record; + input?: string; +} + +export interface BrokerToolResult { + content: unknown[]; + structuredContent?: unknown; + isError?: boolean; + _meta?: unknown; +} + +interface PendingInvocation { + request: BrokerToolRequest; + resolve: (result: BrokerToolResult) => void; + reject: (error: Error) => void; +} + +interface ToolWaiter { + resolve: (requests: BrokerToolRequest[]) => void; + reject: (error: Error) => void; + signal?: AbortSignal; + onAbort?: () => void; +} + +interface TurnChannel { + traceId: string; + environment: PendingTurn; + bindingId?: string; + queuedCallIds: string[]; + invocations: Map; + waiters: Set; + batchTimer?: ReturnType; +} + +interface BrokerRequest { + id: string; + method: "claim" | "resolve" | "release" | "invoke"; + token?: string; + bindingId?: string; + wireName?: string; + freeform?: boolean; + arguments?: Record; + input?: string; +} + +interface BrokerResponse { + id: string; + result?: unknown; + error?: string; +} + +const brokers = new Map(); +const MAX_BROKER_LINE_CHARS = 67_108_864; + +function opaqueId(prefix: string): string { + return `${prefix}_${randomBytes(24).toString("base64url")}`; +} + +function errorOf(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +function environmentIdentity(environment: ChatGptTurnEnvironment): string { + return JSON.stringify({ + cwd: environment.cwd, + roots: environment.roots, + writableRoots: environment.writableRoots, + sandboxPolicy: environment.sandboxPolicy, + }); +} + +export class TurnBroker { + static forSocket(path: string): TurnBroker { + let broker = brokers.get(path); + if (!broker) { + broker = new TurnBroker(path); + brokers.set(path, broker); + } + return broker; + } + + private readonly channels = new Map(); + private readonly pending = new Map(); + private readonly bindings = new Map(); + private server?: Server; + private startPromise?: Promise; + + private constructor(readonly socketPath: string) {} + + async register( + environment: ChatGptTurnEnvironment, + ttlMs: number, + traceId = "unknown" + ): Promise { + await this.start(); + this.prune(); + const token = opaqueId("turn"); + const channel: TurnChannel = { + traceId, + environment: { ...environment, expiresAt: Date.now() + ttlMs }, + queuedCallIds: [], + invocations: new Map(), + waiters: new Set(), + }; + this.channels.set(token, channel); + this.pending.set(token, channel); + return token; + } + + updateEnvironment(token: string, environment: ChatGptTurnEnvironment): void { + this.prune(); + const channel = this.channels.get(token); + if (!channel) throw new Error("turn token is invalid or expired"); + if (environmentIdentity(channel.environment) !== environmentIdentity(environment)) { + throw new Error("Codex turn environment changed during an active ChatGPT tool loop"); + } + channel.environment = { ...environment, expiresAt: channel.environment.expiresAt }; + } + + async nextToolBatch(token: string, signal?: AbortSignal): Promise { + this.prune(); + const channel = this.channels.get(token); + if (!channel) throw new Error("turn token is invalid or expired"); + const ready = this.takeQueued(channel); + if (ready.length > 0) return ready; + if (signal?.aborted) throw new DOMException("tool wait aborted", "AbortError"); + return new Promise((resolveWait, rejectWait) => { + const waiter: ToolWaiter = { + resolve: resolveWait, + reject: rejectWait, + ...(signal ? { signal } : {}), + }; + if (signal) { + waiter.onAbort = () => { + channel.waiters.delete(waiter); + rejectWait(new DOMException("tool wait aborted", "AbortError")); + }; + signal.addEventListener("abort", waiter.onAbort, { once: true }); + } + channel.waiters.add(waiter); + }); + } + + completeTool(token: string, callId: string, result: BrokerToolResult): void { + this.prune(); + const channel = this.channels.get(token); + if (!channel) throw new Error("turn token is invalid or expired"); + const invocation = channel.invocations.get(callId); + if (!invocation) throw new Error(`tool call is not pending: ${callId}`); + if (channel.queuedCallIds.includes(callId)) + throw new Error(`tool call was completed before it was delivered: ${callId}`); + channel.invocations.delete(callId); + console.info( + `[chatgpt-web] broker trace=${channel.traceId} completed call=${callId.slice(0, 17)} pending=${channel.invocations.size}` + ); + invocation.resolve(result); + } + + revoke(token: string): void { + const channel = this.channels.get(token); + if (!channel) return; + this.channels.delete(token); + this.pending.delete(token); + if (channel.bindingId) this.bindings.delete(channel.bindingId); + this.rejectChannel(channel, new Error("Codex turn binding was revoked")); + } + + async close(): Promise { + for (const token of [...this.channels.keys()]) this.revoke(token); + const server = this.server; + this.server = undefined; + this.startPromise = undefined; + brokers.delete(this.socketPath); + if (server?.listening) { + await new Promise((resolveClose, rejectClose) => + server.close((error) => { + if (!error || (error as NodeJS.ErrnoException).code === "ERR_SERVER_NOT_RUNNING") + resolveClose(); + else rejectClose(error); + }) + ); + } + if (existsSync(this.socketPath) && lstatSync(this.socketPath).isSocket()) + unlinkSync(this.socketPath); + } + + private start(): Promise { + if (this.startPromise) return this.startPromise; + this.startPromise = new Promise((resolveStart, rejectStart) => { + mkdirSync(dirname(this.socketPath), { recursive: true, mode: 0o700 }); + const listen = () => { + const server = createServer((socket) => this.handleSocket(socket)); + this.server = server; + server.once("error", rejectStart); + server.listen(this.socketPath, () => { + server.off("error", rejectStart); + chmodSync(this.socketPath, 0o600); + resolveStart(); + }); + }; + + if (!existsSync(this.socketPath)) { + listen(); + return; + } + if (!lstatSync(this.socketPath).isSocket()) { + rejectStart( + new Error(`ChatGPT web broker path exists and is not a socket: ${this.socketPath}`) + ); + return; + } + const probe = createConnection(this.socketPath); + probe.once("connect", () => { + probe.destroy(); + rejectStart( + new Error( + `ChatGPT web broker socket is already owned by another process: ${this.socketPath}` + ) + ); + }); + probe.once("error", () => { + unlinkSync(this.socketPath); + listen(); + }); + }); + return this.startPromise; + } + + private handleSocket(socket: Socket): void { + let buffered = ""; + let handled = false; + socket.setEncoding("utf8"); + socket.on("error", () => {}); + socket.on("data", (chunk) => { + if (handled) return; + buffered += chunk; + if ( + buffered.length > MAX_BROKER_LINE_CHARS && + !buffered.slice(0, MAX_BROKER_LINE_CHARS + 1).includes("\n") + ) { + handled = true; + this.writeSocketResponse(socket, { + id: "unknown", + error: "turn broker request exceeds size limit", + }); + return; + } + const newline = buffered.indexOf("\n"); + if (newline < 0) return; + handled = true; + const line = buffered.slice(0, newline); + let request: BrokerRequest | undefined; + try { + if (line.length > MAX_BROKER_LINE_CHARS) + throw new Error("turn broker request exceeds size limit"); + request = JSON.parse(line) as BrokerRequest; + this.validateRequest(request); + } catch (error) { + this.writeSocketResponse(socket, { + id: request?.id ?? "unknown", + error: errorOf(error).message, + }); + return; + } + void Promise.resolve() + .then(() => this.dispatch(request!)) + .then( + (result) => this.writeSocketResponse(socket, { id: request!.id, result }), + (error) => + this.writeSocketResponse(socket, { id: request!.id, error: errorOf(error).message }) + ); + }); + } + + private writeSocketResponse(socket: Socket, response: BrokerResponse): void { + const line = `${JSON.stringify(response)}\n`; + if (line.length > MAX_BROKER_LINE_CHARS) { + socket.end( + `${JSON.stringify({ id: response.id, error: "turn broker response exceeds size limit" } satisfies BrokerResponse)}\n` + ); + return; + } + socket.end(line); + } + + private validateRequest(request: BrokerRequest): void { + if ( + !request || + typeof request !== "object" || + typeof request.id !== "string" || + request.id.length === 0 || + request.id.length > 256 + ) { + throw new Error("turn broker request id is invalid"); + } + if ( + request.method !== "claim" && + request.method !== "resolve" && + request.method !== "release" && + request.method !== "invoke" + ) { + throw new Error("turn broker method is invalid"); + } + } + + private dispatch(request: BrokerRequest): unknown | Promise { + this.prune(); + if (request.method === "claim") { + const token = request.token?.trim(); + if (!token) throw new Error("turn token is required"); + const channel = this.channels.get(token); + console.error( + `[chatgpt-web] broker claim received (tokenChars=${token.length}, valid=${Boolean(channel)})` + ); + if (!channel) throw new Error("turn token is invalid, expired, or revoked"); + if (channel.bindingId) { + const existing = this.bindings.get(channel.bindingId); + if (!existing || existing.token !== token || existing.channel !== channel) { + throw new Error("turn token binding state is inconsistent"); + } + return { bindingId: channel.bindingId, environment: channel.environment }; + } + this.pending.delete(token); + const bindingId = opaqueId("binding"); + channel.bindingId = bindingId; + this.bindings.set(bindingId, { token, channel }); + return { bindingId, environment: channel.environment }; + } + + const bindingId = request.bindingId?.trim(); + if (!bindingId) throw new Error("binding id is required"); + const binding = this.bindings.get(bindingId); + if (!binding) throw new Error("binding id is invalid or expired"); + if (request.method === "release") { + this.revoke(binding.token); + return { released: true }; + } + if (request.method === "resolve") return { environment: binding.channel.environment }; + + const wireName = request.wireName?.trim(); + if (!wireName) throw new Error("wire tool name is required"); + const callId = opaqueId("call"); + const toolRequest: BrokerToolRequest = { + callId, + wireName, + freeform: request.freeform === true, + ...(request.freeform === true + ? { input: request.input ?? "" } + : { arguments: request.arguments ?? {} }), + }; + return new Promise((resolveInvoke, rejectInvoke) => { + binding.channel.invocations.set(callId, { + request: toolRequest, + resolve: resolveInvoke, + reject: rejectInvoke, + }); + binding.channel.queuedCallIds.push(callId); + console.info( + `[chatgpt-web] broker trace=${binding.channel.traceId} queued call=${callId.slice(0, 17)} tool=${wireName} waiters=${binding.channel.waiters.size}` + ); + this.scheduleToolWaiters(binding.channel); + }); + } + + private takeQueued(channel: TurnChannel): BrokerToolRequest[] { + const ids = channel.queuedCallIds.splice(0); + return ids + .map((id) => channel.invocations.get(id)?.request) + .filter((request): request is BrokerToolRequest => Boolean(request)); + } + + private scheduleToolWaiters(channel: TurnChannel): void { + if (channel.queuedCallIds.length === 0 || channel.waiters.size === 0) return; + if (channel.batchTimer) return; + channel.batchTimer = setTimeout(() => { + channel.batchTimer = undefined; + this.wakeToolWaiters(channel); + }, 15); + } + + private wakeToolWaiters(channel: TurnChannel): void { + if (channel.queuedCallIds.length === 0 || channel.waiters.size === 0) return; + const batch = this.takeQueued(channel); + console.info( + `[chatgpt-web] broker trace=${channel.traceId} delivered calls=${batch.length} tools=${batch.map((request) => request.wireName).join(",")}` + ); + const waiters = [...channel.waiters]; + channel.waiters.clear(); + const first = waiters.shift(); + if (first) { + if (first.signal && first.onAbort) first.signal.removeEventListener("abort", first.onAbort); + first.resolve(batch); + } + for (const waiter of waiters) { + if (waiter.signal && waiter.onAbort) + waiter.signal.removeEventListener("abort", waiter.onAbort); + waiter.reject(new Error("another adapter waiter already claimed the queued tool batch")); + } + } + + private rejectChannel(channel: TurnChannel, error: Error): void { + if (channel.batchTimer) clearTimeout(channel.batchTimer); + channel.batchTimer = undefined; + for (const waiter of channel.waiters) { + if (waiter.signal && waiter.onAbort) + waiter.signal.removeEventListener("abort", waiter.onAbort); + waiter.reject(error); + } + channel.waiters.clear(); + for (const invocation of channel.invocations.values()) invocation.reject(error); + channel.invocations.clear(); + channel.queuedCallIds = []; + } + + private prune(): void { + const now = Date.now(); + for (const [token, channel] of this.channels) { + if (channel.environment.expiresAt > now) continue; + this.revoke(token); + } + } +} + +export async function callTurnBroker( + socketPath: string, + request: Omit, + timeoutMs = 5_000 +): Promise { + const id = opaqueId("request"); + return new Promise((resolveCall, rejectCall) => { + const socket = createConnection(socketPath); + let buffered = ""; + let settled = false; + const finishError = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + rejectCall(error); + }; + const timer = setTimeout( + () => finishError(new Error("ChatGPT web turn broker timed out")), + timeoutMs + ); + socket.setEncoding("utf8"); + socket.once("error", (error) => + finishError(new Error(`ChatGPT web turn broker unavailable: ${error.message}`)) + ); + socket.once("connect", () => socket.write(`${JSON.stringify({ id, ...request })}\n`)); + socket.on("data", (chunk) => { + if (settled) return; + buffered += chunk; + if (buffered.length > MAX_BROKER_LINE_CHARS) { + finishError(new Error("ChatGPT web turn broker response exceeds size limit")); + return; + } + const newline = buffered.indexOf("\n"); + if (newline < 0) return; + let response: BrokerResponse; + try { + response = JSON.parse(buffered.slice(0, newline)) as BrokerResponse; + } catch (error) { + finishError( + new Error(`ChatGPT web turn broker returned invalid JSON: ${errorOf(error).message}`) + ); + return; + } + if (response.id !== id) { + finishError(new Error("ChatGPT web turn broker response id mismatch")); + return; + } + settled = true; + clearTimeout(timer); + socket.end(); + if (response.error) rejectCall(new Error(response.error)); + else resolveCall(response.result as T); + }); + }); +} diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/turn-execution.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/turn-execution.ts new file mode 100644 index 0000000000..3307733963 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/turn-execution.ts @@ -0,0 +1,313 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import { createHash } from "node:crypto"; +import type { AdapterEvent, CodexParsedRequest } from "../../types"; +import type { BrokerToolRequest } from "./turn-broker"; +import { extractChatGptTurnIdentity } from "./environment"; + +export type ChatGptBrowserOutcome = + { type: "final"; answer: string } | { type: "error"; error: Error }; + +export interface ChatGptTraceEvent { + kind: "reasoning" | "commentary"; + text: string; + continuation?: boolean; +} + +interface TraceWaiter { + resolve: (event: ChatGptTraceEvent) => void; + reject: (error: Error) => void; + signal?: AbortSignal; + onAbort?: () => void; +} + +export class ChatGptTraceFeed { + private readonly queued: ChatGptTraceEvent[] = []; + private readonly waiters = new Set(); + + push(event: ChatGptTraceEvent): void { + const normalized = event.continuation ? event.text : event.text.trim(); + if (!normalized) return; + const normalizedEvent = { ...event, text: normalized }; + const waiter = this.waiters.values().next().value as TraceWaiter | undefined; + if (!waiter) { + this.queued.push(normalizedEvent); + return; + } + this.waiters.delete(waiter); + if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort); + waiter.resolve(normalizedEvent); + } + + drain(): ChatGptTraceEvent[] { + return this.queued.splice(0); + } + + next(signal?: AbortSignal): Promise { + const queued = this.queued.shift(); + if (queued !== undefined) return Promise.resolve(queued); + if (signal?.aborted) + return Promise.reject(new DOMException("trace wait aborted", "AbortError")); + return new Promise((resolveWait, rejectWait) => { + const waiter: TraceWaiter = { + resolve: resolveWait, + reject: rejectWait, + ...(signal ? { signal } : {}), + }; + if (signal) { + waiter.onAbort = () => { + this.waiters.delete(waiter); + rejectWait(new DOMException("trace wait aborted", "AbortError")); + }; + signal.addEventListener("abort", waiter.onAbort, { once: true }); + } + this.waiters.add(waiter); + }); + } +} + +interface TextWaiter { + resolve: () => void; + reject: (error: Error) => void; + signal?: AbortSignal; + onAbort?: () => void; +} + +/** Append-only browser Markdown feed. Waiters are notifications; `drain` owns consumption. */ +export class ChatGptTextFeed { + private readonly queued: string[] = []; + private readonly waiters = new Set(); + private text = ""; + + push(delta: string): void { + if (!delta) return; + this.text += delta; + this.queued.push(delta); + const waiter = this.waiters.values().next().value as TextWaiter | undefined; + if (!waiter) return; + this.waiters.delete(waiter); + if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort); + waiter.resolve(); + } + + drain(): string[] { + return this.queued.splice(0); + } + + value(): string { + return this.text; + } + + wait(signal?: AbortSignal): Promise { + if (this.queued.length > 0) return Promise.resolve(); + if (signal?.aborted) return Promise.reject(new DOMException("text wait aborted", "AbortError")); + return new Promise((resolveWait, rejectWait) => { + const waiter: TextWaiter = { + resolve: resolveWait, + reject: rejectWait, + ...(signal ? { signal } : {}), + }; + if (signal) { + waiter.onAbort = () => { + this.waiters.delete(waiter); + rejectWait(new DOMException("text wait aborted", "AbortError")); + }; + signal.addEventListener("abort", waiter.onAbort, { once: true }); + } + this.waiters.add(waiter); + }); + } +} + +interface ChatGptTurnRuntimeBase { + browser: Promise; + trace: ChatGptTraceFeed; + text: ChatGptTextFeed; + cancel: () => void; +} + +export type ChatGptTurnRuntime = + | (ChatGptTurnRuntimeBase & { mode: "tools"; token: Promise }) + | (ChatGptTurnRuntimeBase & { mode: "read-only" }); + +export function chatGptTurnExecutionKey(parsed: CodexParsedRequest): string { + const identity = extractChatGptTurnIdentity(parsed); + if (!identity.turnId) + throw new Error( + "ChatGPT web requires native Codex turn_id metadata for browser-session replay" + ); + const payload = { threadId: identity.threadId, turnId: identity.turnId }; + return createHash("sha256") + .update( + JSON.stringify({ + modelId: parsed.modelId, + reasoning: parsed.options.reasoning, + payload, + }) + ) + .digest("hex"); +} + +export class ChatGptTurnSession { + readonly createdAt = Date.now(); + readonly browserOutcome: Promise; + private readonly outstandingById = new Map(); + private readonly deliveredResultIds = new Set(); + private outstandingReasoning: string[] = []; + private finalReasoning: string[] = []; + private outstandingPrelude: AdapterEvent[] = []; + private finalPrelude: AdapterEvent[] = []; + private settledBrowserOutcome?: ChatGptBrowserOutcome; + private tail: Promise = Promise.resolve(); + + constructor(readonly runtime: ChatGptTurnRuntime) { + this.browserOutcome = runtime.browser + .then((answer) => ({ type: "final", answer }) as ChatGptBrowserOutcome) + .catch( + (error) => + ({ + type: "error", + error: error instanceof Error ? error : new Error(String(error)), + }) as ChatGptBrowserOutcome + ) + .then((outcome) => { + this.settledBrowserOutcome = outcome; + return outcome; + }); + } + + runExclusive(task: () => Promise): Promise { + const run = this.tail.then(task); + this.tail = run.then( + () => undefined, + () => undefined + ); + return run; + } + + outstanding(): BrokerToolRequest[] { + return [...this.outstandingById.values()]; + } + + settledOutcome(): ChatGptBrowserOutcome | undefined { + return this.settledBrowserOutcome; + } + + isActive(): boolean { + return this.settledBrowserOutcome === undefined; + } + + setOutstanding( + requests: BrokerToolRequest[], + reasoning: string[] = [], + prelude: AdapterEvent[] = [] + ): void { + if (this.outstandingById.size > 0) + throw new Error( + "cannot emit a new ChatGPT tool batch while the previous batch is unresolved" + ); + for (const request of requests) { + if (this.deliveredResultIds.has(request.callId) || this.outstandingById.has(request.callId)) { + throw new Error(`duplicate ChatGPT bridge tool call id: ${request.callId}`); + } + this.outstandingById.set(request.callId, request); + } + this.outstandingReasoning = [...reasoning]; + this.outstandingPrelude = [...prelude]; + } + + hasOutstanding(callId: string): boolean { + return this.outstandingById.has(callId); + } + + markResultDelivered(callId: string): void { + if (!this.outstandingById.delete(callId)) + throw new Error(`ChatGPT bridge tool result does not match an outstanding call: ${callId}`); + this.deliveredResultIds.add(callId); + if (this.outstandingById.size === 0) { + this.outstandingReasoning = []; + this.outstandingPrelude = []; + } + } + + reasoningForOutstandingReplay(): string[] { + return [...this.outstandingReasoning]; + } + + eventsForOutstandingReplay(): AdapterEvent[] { + return [...this.outstandingPrelude]; + } + + setFinalReasoning(reasoning: string[]): void { + this.finalReasoning = [...reasoning]; + } + + reasoningForFinalReplay(): string[] { + return [...this.finalReasoning]; + } + + setFinalEvents(events: AdapterEvent[]): void { + this.finalPrelude = [...events]; + } + + eventsForFinalReplay(): AdapterEvent[] { + return [...this.finalPrelude]; + } + + cancel(): void { + this.runtime.cancel(); + } +} + +export class ChatGptTurnSessions { + private readonly entries = new Map(); + + constructor( + private readonly ttlMs = 30 * 60_000, + private readonly maxEntries = 256 + ) {} + + getOrCreate(key: string, start: () => ChatGptTurnRuntime): ChatGptTurnSession { + this.prune(); + const existing = this.entries.get(key); + if (existing) return existing; + if (this.entries.size >= this.maxEntries) + throw new Error(`ChatGPT web session registry is full (${this.maxEntries} entries)`); + const session = new ChatGptTurnSession(start()); + this.entries.set(key, session); + return session; + } + + clear(): number { + const cancelled = this.entries.size; + for (const session of this.entries.values()) session.cancel(); + this.entries.clear(); + return cancelled; + } + + activeCount(): number { + this.prune(); + let active = 0; + for (const session of this.entries.values()) if (session.isActive()) active += 1; + return active; + } + + waitingCount(): number { + this.prune(); + let waiting = 0; + for (const session of this.entries.values()) { + if (session.outstanding().length > 0) waiting += 1; + } + return waiting; + } + + private prune(): void { + const cutoff = Date.now() - this.ttlMs; + for (const [key, session] of this.entries) { + if (session.createdAt >= cutoff) continue; + session.cancel(); + this.entries.delete(key); + } + } +} + +export const chatGptTurnSessions = new ChatGptTurnSessions(); diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/usage.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/usage.ts new file mode 100644 index 0000000000..da0cb7b638 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/usage.ts @@ -0,0 +1,103 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import { estimateTokens } from "../../lib/token-estimate"; +import type { CodexParsedRequest, CodexUsage } from "../../types"; +import type { CompiledChatGptWebPrompt } from "./prompt"; +import { compileChatGptWebPrompt } from "./prompt"; +import { resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model"; +import type { BrokerToolRequest } from "./turn-broker"; + +// The real capability has the same length. Keeping it out of usage accounting would make +// estimates differ slightly between the prepared browser prompt and later Codex tool rounds. +const ESTIMATE_TURN_TOKEN = "turn_00000000000000000000000000000000"; + +// ChatGPT's product system prompt and the fixed Codex Native MCP schemas are not present in the +// visible composer text. Reserve them explicitly; over-counting fails safe by compacting earlier. +const CHATGPT_PLATFORM_RESERVE_TOKENS = 8_192; +const CHATGPT_IMAGE_RESERVE_TOKENS = 4_096; +const CHATGPT_ORIGINAL_IMAGE_RESERVE_TOKENS = 8_192; +const CHATGPT_WEB_CHARS_PER_TOKEN = 3; + +export interface ChatGptWebRoundEvidence { + answer?: string; + reasoning?: string[]; + toolRequests?: BrokerToolRequest[]; +} + +function conservativeTextTokens(text: string, modelId: string): number { + return Math.max( + estimateTokens(text, modelId), + text.length === 0 ? 0 : Math.ceil(text.length / CHATGPT_WEB_CHARS_PER_TOKEN) + ); +} + +export function estimateCompiledChatGptWebInputTokens( + compiled: CompiledChatGptWebPrompt, + modelId: string +): number { + const imageTokens = compiled.images.reduce( + (total, image) => + total + + (image.detail === "original" + ? CHATGPT_ORIGINAL_IMAGE_RESERVE_TOKENS + : CHATGPT_IMAGE_RESERVE_TOKENS), + 0 + ); + return ( + CHATGPT_PLATFORM_RESERVE_TOKENS + + conservativeTextTokens(compiled.text, modelId) + + compiled.contextAttachments.reduce( + (total, attachment) => + total + conservativeTextTokens(attachment.buffer.toString("utf8"), modelId), + 0 + ) + + imageTokens + ); +} + +export function estimateChatGptWebInputTokens( + parsed: CodexParsedRequest, + capabilities: ChatGptWebCapabilities +): number { + const mode = resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, capabilities); + return estimateCompiledChatGptWebInputTokens( + compileChatGptWebPrompt( + parsed, + capabilities, + mode.localTools ? ESTIMATE_TURN_TOKEN : undefined + ), + parsed.modelId + ); +} + +function roundEvidenceText(evidence: ChatGptWebRoundEvidence): string { + return JSON.stringify({ + reasoning: evidence.reasoning ?? [], + ...(evidence.answer !== undefined ? { answer: evidence.answer } : {}), + ...(evidence.toolRequests + ? { + tool_calls: evidence.toolRequests.map((request) => ({ + call_id: request.callId, + name: request.wireName, + ...(request.freeform + ? { input: request.input ?? "" } + : { arguments: request.arguments ?? {} }), + })), + } + : {}), + }); +} + +export function estimateChatGptWebUsage( + parsed: CodexParsedRequest, + evidence: ChatGptWebRoundEvidence, + capabilities: ChatGptWebCapabilities +): CodexUsage { + const inputTokens = estimateChatGptWebInputTokens(parsed, capabilities); + const outputTokens = conservativeTextTokens(roundEvidenceText(evidence), parsed.modelId); + return { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + estimated: true, + }; +} diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/image.ts b/open-sse/vendor/codex-chatgpt-web/adapters/image.ts new file mode 100644 index 0000000000..564a0b8cbb --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/adapters/image.ts @@ -0,0 +1,10 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +/** + * Parse a `data:;base64,` URL into the file payload Playwright attaches to the + * ChatGPT composer. Returns null for remote URLs; the browser bridge refuses those explicitly. + */ +export function parseDataUrl(url: string): { mediaType: string; base64: string } | null { + const m = url.match(/^data:([^;,]+);base64,(.*)$/s); + if (!m) return null; + return { mediaType: m[1], base64: m[2] }; +} diff --git a/open-sse/vendor/codex-chatgpt-web/bridge.ts b/open-sse/vendor/codex-chatgpt-web/bridge.ts new file mode 100644 index 0000000000..31b876c03a --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/bridge.ts @@ -0,0 +1,1386 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import type { + AdapterEvent, + CodexMessagePhase, + CodexProviderContinuationState, + CodexUsage, +} from "./types"; +import { adapterFailureFromMessage, classifyError, type CodexErrorPayload } from "./lib/errors"; +import { encodeCompactionSummary } from "./responses/compaction"; +import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope"; +import { resolveStallTimeoutSec } from "./stall-timeout"; +import { usageDisplayTotalTokens } from "./usage/totals"; + +function uuid(): string { + return crypto.randomUUID().replace(/-/g, ""); +} + +function sseEvent(name: string, data: Record): string { + return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`; +} + +function responsesUsage(usage: CodexUsage | undefined): Record { + if (!usage) return { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; + // inputTokens is already inclusive of cache read/write (types.ts convention). + const inputTokens = usage.inputTokens; + const out: Record = { + input_tokens: inputTokens, + output_tokens: usage.outputTokens, + total_tokens: usageDisplayTotalTokens(usage) ?? inputTokens + usage.outputTokens, + }; + const inputDetails: Record = {}; + if (usage.cachedInputTokens !== undefined) { + // cached_tokens carries cache READS only, matching OpenAI semantics. + inputDetails.cached_tokens = usage.cachedInputTokens; + } + if (usage.cacheCreationInputTokens !== undefined) { + inputDetails.cache_write_tokens = usage.cacheCreationInputTokens; + } + if (Object.keys(inputDetails).length > 0) { + out.input_tokens_details = inputDetails; + } + if (usage.reasoningOutputTokens !== undefined) { + out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens }; + } + return out; +} + +function responseError(status: number, type: string, message: string): CodexErrorPayload { + return classifyError(status, type, message); +} + +function adapterFailureFromEvent(event: Extract): { + httpStatus: number; + error: CodexErrorPayload; +} { + if (event.status === undefined && event.errorType === undefined && event.code === undefined) { + return adapterFailureFromMessage(event.message); + } + const fallback = adapterFailureFromMessage(event.message); + const httpStatus = event.status ?? fallback.httpStatus; + const error = classifyError(httpStatus, event.errorType ?? fallback.error.type, event.message); + if (event.errorType !== undefined) error.type = event.errorType; + if (event.code !== undefined) error.code = event.code; + return { httpStatus, error }; +} + +export { adapterFailureFromMessage } from "./lib/errors"; + +/** + * Build the native `WebSearchAction::Search` payload from the queries that ran. codex-rs prefers a + * non-empty `query` over `queries` for the cell label, and only renders " ..." when `query` + * is absent and `queries.len() > 1`. So a single query → `{ query }`; multiple → `{ queries }` with + * no singular `query`, so Codex shows the native plural ellipsis. Empty → `{ query: "" }`. + */ +function webSearchAction(queries: string[]): Record { + if (queries.length <= 1) return { type: "search", query: queries[0] ?? "" }; + return { type: "search", queries }; +} + +interface OutputItem { + type: string; + id: string; + [key: string]: unknown; +} + +export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete"; + +export function bridgeToResponsesSSE( + events: AsyncIterable, + modelId: string, + toolNsMap?: Map, + freeformToolNames?: Set, + toolSearchToolNames?: Set, + onCancel?: () => void, + heartbeatMs = 2_000, + options?: { + responseId?: string; + stallTimeoutSec?: number; + hideThinkingSummary?: boolean; + /** + * Remote compaction v2 turn: accumulate all assistant text and, on done, emit ONE synthetic + * `{type:"compaction", encrypted_content:"ocx1:"+base64(text)}` output item before + * response.completed — codex-rs collect_compaction_output requires exactly one. + */ + compaction?: boolean; + /** One-shot: first non-empty text/thinking/raw-reasoning delta observed (WP4 TTFT). */ + onFirstOutput?: () => void; + onTerminal?: (status: ResponsesTerminalStatus) => void; + onCompletedResponse?: ( + response: Record, + providerState?: CodexProviderContinuationState + ) => void; + } +): ReadableStream { + // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a + // function with `{input:string}`, so unwrap it here when relaying back as a custom_tool_call. + const freeformInput = (args: string): string => { + try { + const o = JSON.parse(args); + if (o && typeof o.input === "string") return o.input; + } catch { + /* raw */ + } + return args; + }; + // Best-effort unwrap of a PARTIAL freeform arg buffer for live input streaming + // (`response.custom_tool_call_input.delta` — codex-rs uses it for UI preview only; + // the completed custom_tool_call item stays authoritative). Compact `{"input":"...` + // buffers get their string value progressively unescaped; anything else streams raw. + const FREEFORM_WRAP_PREFIX = '{"input":"'; + const freeformPartialInput = (args: string): string => { + if (!args.startsWith(FREEFORM_WRAP_PREFIX)) return args; + const body = args.slice(FREEFORM_WRAP_PREFIX.length); + let out = ""; + for (let i = 0; i < body.length; i++) { + const c = body[i]; + if (c === '"') break; // unescaped closing quote: value complete + if (c === "\\") { + const n = body[i + 1]; + if (n === undefined) break; // escape split across chunks: wait for more + i++; + if (n === "n") out += "\n"; + else if (n === "t") out += "\t"; + else if (n === "r") out += "\r"; + else if (n === "u") { + const hex = body.slice(i + 1, i + 5); + if (hex.length === 4 && /^[0-9a-fA-F]{4}$/.test(hex)) { + out += String.fromCharCode(parseInt(hex, 16)); + i += 4; + } else break; // incomplete \uXXXX: wait for more + } else out += n; // \" \\ \/ etc. + } else out += c; + } + return out; + }; + // tool_search_call carries arguments as a JSON object ({query, limit}); parse the model's arg string. + const parseArgsObj = (args: string): Record => { + try { + const o = JSON.parse(args); + return o && typeof o === "object" ? o : {}; + } catch { + return {}; + } + }; + const encoder = new TextEncoder(); + const responseId = options?.responseId ?? `resp_${uuid()}`; + let seq = 0; + // Set once the client is gone (cancel) or an enqueue throws on a torn-down controller, so we + // never enqueue again and never throw a second time inside start() — the RC2 double-throw that + // otherwise surfaced as proxy-side stream noise on every client disconnect. + let closed = false; + let clientCancelled = false; + let terminalReported = false; + const reportTerminal = (status: ResponsesTerminalStatus) => { + if (terminalReported || clientCancelled || closed) return; + terminalReported = true; + options?.onTerminal?.(status); + }; + // RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an + // eventsource_stream; ANY received event re-arms it, while an unknown type is ignored + // (responses.rs `_ => Ok(None)`). We emit a real, parser-ignored `response.heartbeat` only during + // upstream silence so a stalled routed provider never trips "idle timeout waiting for SSE". + let activity = false; + let beat: ReturnType | undefined; + let controller: ReadableStreamDefaultController; + let emittedFrames = 0; + let gated = false; + let stepping = false; + const emit = (name: string, data: Record) => { + if (closed) return; + activity = true; + try { + controller.enqueue( + encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })) + ); + emittedFrames++; + } catch { + closed = true; + } + }; + const emitDone = () => { + if (closed) return; + try { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + emittedFrames++; + } catch { + closed = true; + } + }; + + const createdAt = Math.floor(Date.now() / 1000); + let outputIndex = 0; + const finishedItems: OutputItem[] = []; + + const responseSnapshot = (status: string, output: OutputItem[], endTurn?: boolean) => ({ + id: responseId, + object: "response", + created_at: createdAt, + status, + model: modelId, + output, + usage: null, + ...(endTurn !== undefined ? { end_turn: endTurn } : {}), + }); + + const heartbeatFrame = encoder.encode( + 'event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n' + ); + let stallTicks = 0; + const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec); + const maxStallTicks = Math.ceil((stallSec * 1000) / heartbeatMs); + + let currentMsg: { + itemId: string; + outputIndex: number; + text: string; + phase?: CodexMessagePhase; + } | null = null; + let currentReasoning: { itemId: string; outputIndex: number; text: string } | null = null; + let currentRawReasoning: { itemId: string; outputIndex: number; text: string } | null = null; + // Opaque signed-reasoning round-trip state: the signature signs the CURRENT thinking + // block; redacted blocks are opaque payloads replayed verbatim. Attached to the reasoning + // item as an ocxr1 encrypted_content envelope on close. hiddenThinkingText collects the + // suppressed text under hideThinkingSummary so the signed text still round-trips. + let pendingSignature: string | undefined; + let pendingRedacted: string[] = []; + let hiddenThinkingText = ""; + const takeReasoningEnvelope = (hiddenText?: string): string | undefined => { + if (!pendingSignature && pendingRedacted.length === 0) return undefined; + const envelope: ReasoningEnvelope = {}; + if (pendingSignature) envelope.sig = pendingSignature; + if (pendingRedacted.length > 0) envelope.red = pendingRedacted; + if (hiddenText) envelope.txt = hiddenText; + pendingSignature = undefined; + pendingRedacted = []; + return encodeReasoningEnvelope(envelope); + }; + // hideThinkingSummary path: no visible reasoning item exists, but a signed thinking block + // must still round-trip — emit an envelope-only reasoning item (empty summary, no text leak). + const flushHiddenReasoningEnvelope = () => { + const encrypted = takeReasoningEnvelope(hiddenThinkingText || undefined); + hiddenThinkingText = ""; + if (!encrypted) return; + const itemId = `rs_${uuid()}`; + const item = { + type: "reasoning", + id: itemId, + summary: [] as never[], + encrypted_content: encrypted, + }; + emit("response.output_item.added", { output_index: outputIndex, item }); + emit("response.output_item.done", { output_index: outputIndex, item }); + finishedItems.push(item as OutputItem); + outputIndex++; + }; + // hideThinkingSummary for raw reasoning: no + // visible reasoning item is emitted — the app renders nothing, so tool cells keep grouping + // like native models — but the text still round-trips in a txt-only ocxr1 envelope so + // preserveReasoningContentModels replay (GLM interleaved thinking) keeps working. Direct + // encodeReasoningEnvelope: takeReasoningEnvelope's sig/red guard would drop txt-only. + let hiddenRawReasoningText = ""; + const flushHiddenRawReasoning = () => { + if (!hiddenRawReasoningText) return; + const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText }); + hiddenRawReasoningText = ""; + const itemId = `rs_${uuid()}`; + const item = { + type: "reasoning", + id: itemId, + summary: [] as never[], + encrypted_content: encrypted, + }; + emit("response.output_item.added", { output_index: outputIndex, item }); + emit("response.output_item.done", { output_index: outputIndex, item }); + finishedItems.push(item as OutputItem); + outputIndex++; + }; + // Full assistant text of a compaction turn (across message boundaries) — becomes the + // synthetic compaction item's payload on done. + let compactionText = ""; + let currentToolCall: { + itemId: string; + outputIndex: number; + callId: string; + name: string; + args: string; + namespace?: string; + freeform?: boolean; + toolSearch?: boolean; + inputEmitted?: string; + } | null = null; + // Open native web-search cell (between begin and end). Holds the output index allocated on + // begin so the matching done reuses it; closed as `failed` if the stream terminates early. + let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null; + // Sources from completed web searches, awaiting the next assistant message. Attached as + // url_citation annotations on that message (the desktop app's Sources chip), then cleared so + // they bind to exactly one message. Deduped by URL across multiple searches in the turn. + let pendingWebSources: { url: string; title?: string }[] = []; + const takeWebAnnotations = (): { + type: string; + url: string; + title?: string; + start_index: number; + end_index: number; + }[] => { + if (pendingWebSources.length === 0) return []; + const anns = pendingWebSources.map((s) => ({ + type: "url_citation", + url: s.url, + ...(s.title ? { title: s.title } : {}), + start_index: 0, + end_index: 0, + })); + pendingWebSources = []; + return anns; + }; + + const closeCurrentMessage = () => { + if (!currentMsg) return; + // Bind any pending web-search citations to this assistant message (then they clear). + const annotations = takeWebAnnotations(); + // Finalize the text part (Responses protocol). Without these .done events Codex never + // commits the content part and renders the message as truncated / cut off. + emit("response.output_text.done", { + item_id: currentMsg.itemId, + output_index: currentMsg.outputIndex, + content_index: 0, + text: currentMsg.text, + }); + emit("response.content_part.done", { + item_id: currentMsg.itemId, + output_index: currentMsg.outputIndex, + content_index: 0, + part: { type: "output_text", text: currentMsg.text, annotations }, + }); + const item = { + type: "message", + id: currentMsg.itemId, + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: currentMsg.text, annotations }], + ...(currentMsg.phase ? { phase: currentMsg.phase } : {}), + }; + emit("response.output_item.done", { output_index: currentMsg.outputIndex, item }); + finishedItems.push(item as OutputItem); + outputIndex++; + currentMsg = null; + }; + + const closeCurrentReasoning = () => { + if (!currentReasoning) return; + emit("response.reasoning_summary_text.done", { + item_id: currentReasoning.itemId, + output_index: currentReasoning.outputIndex, + summary_index: 0, + text: currentReasoning.text, + }); + emit("response.reasoning_summary_part.done", { + item_id: currentReasoning.itemId, + output_index: currentReasoning.outputIndex, + summary_index: 0, + part: { type: "summary_text", text: currentReasoning.text }, + }); + const encrypted = takeReasoningEnvelope(); + const item = { + type: "reasoning", + id: currentReasoning.itemId, + summary: [{ type: "summary_text", text: currentReasoning.text }], + ...(encrypted ? { encrypted_content: encrypted } : {}), + }; + emit("response.output_item.done", { output_index: currentReasoning.outputIndex, item }); + finishedItems.push(item as OutputItem); + outputIndex++; + currentReasoning = null; + }; + + const closeCurrentRawReasoning = () => { + if (!currentRawReasoning) return; + const item = { + type: "reasoning", + id: currentRawReasoning.itemId, + summary: [], + content: [{ type: "reasoning_text", text: currentRawReasoning.text }], + }; + emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item }); + finishedItems.push(item as OutputItem); + outputIndex++; + currentRawReasoning = null; + }; + + const closeCurrentToolCall = () => { + if (!currentToolCall) return; + // Empty input (no-arg tools like computer_use get_app_state / list_apps) must serialize as + // "{}", never "" — Codex echoes the call back as a function_call next turn, and JSON.parse("") + // would 400 the whole session ("invalid JSON arguments"), poisoning all later turns. + const argsStr = currentToolCall.args || "{}"; + // Finalize streamed function-call arguments so Codex commits the call (incl. MCP / computer_use). + if (!currentToolCall.freeform && !currentToolCall.toolSearch) { + emit("response.function_call_arguments.done", { + item_id: currentToolCall.itemId, + output_index: currentToolCall.outputIndex, + arguments: argsStr, + }); + } + if (currentToolCall.freeform) { + emit("response.custom_tool_call_input.done", { + item_id: currentToolCall.itemId, + output_index: currentToolCall.outputIndex, + input: freeformInput(currentToolCall.args), + }); + } + const item = currentToolCall.toolSearch + ? { + type: "tool_search_call", + id: currentToolCall.itemId, + call_id: currentToolCall.callId, + execution: "client", + arguments: parseArgsObj(currentToolCall.args), + status: "completed", + } + : currentToolCall.freeform + ? { + type: "custom_tool_call", + id: currentToolCall.itemId, + call_id: currentToolCall.callId, + name: currentToolCall.name, + input: freeformInput(currentToolCall.args), + status: "completed", + } + : { + type: "function_call", + id: currentToolCall.itemId, + call_id: currentToolCall.callId, + name: currentToolCall.name, + arguments: argsStr, + status: "completed", + ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}), + }; + emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item }); + finishedItems.push(item as OutputItem); + outputIndex++; + currentToolCall = null; + }; + + // Finalize an open web-search cell. `status` is "completed" on a normal end, or "failed" when + // the stream terminates (error/incomplete) while a search was still in flight, so Codex never + // leaves a "Searching the web" spinner spinning forever. + // `sources` rides on the done item (additive field; codex-rs serde ignores unknown fields) so + // downstream Responses consumers can fill web_search_tool_result content. + const closeCurrentWebSearch = ( + status: "completed" | "failed", + queries: string[], + sources?: { url: string; title?: string }[] + ) => { + if (!currentWebSearch) return; + const item = { + type: "web_search_call", + id: currentWebSearch.itemId, + status, + action: webSearchAction(queries), + ...(sources && sources.length > 0 ? { sources } : {}), + }; + emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item }); + finishedItems.push(item as OutputItem); + outputIndex++; + currentWebSearch = null; + }; + + // RC1: guarantee the Responses stream always ends with exactly one terminal event. Set true + // when a done/error/catch terminal is emitted; if the adapter generator returns without one + // we synthesize response.completed below, so Codex never hits the parser's + // "stream closed before response.completed" (responses.rs) -> ApiError::Stream. + let terminated = false; + let firstOutputReported = false; + const reportFirstOutput = (event: AdapterEvent): void => { + if (firstOutputReported) return; + const nonEmpty = + event.type === "text_delta" + ? event.text.length > 0 + : event.type === "thinking_delta" + ? event.thinking.length > 0 + : event.type === "reasoning_raw_delta" + ? event.text.length > 0 + : false; + if (!nonEmpty) return; + firstOutputReported = true; + try { + options?.onFirstOutput?.(); + } catch { + /* metrics must not break the stream */ + } + }; + const it = events[Symbol.asyncIterator](); + let iteratorStarted = false; + let iteratorReturned = false; + let upstreamDone = false; + const returnIterator = () => { + if (iteratorReturned) return; + iteratorReturned = true; + const finishReturn = () => { + try { + void it.return?.()?.catch(() => {}); + } catch { + /* synchronous iterator cleanup failure is also best-effort */ + } + }; + // Async-generator return() before the first next() does not enter the generator, so its + // finally blocks cannot cancel prepared upstream bodies. The cancel hook has already + // aborted the turn; bootstrap one cleanup step, then close the iterator without awaiting it. + if (!iteratorStarted) { + iteratorStarted = true; + try { + void it + .next() + .then(finishReturn, () => {}) + .catch(() => {}); + } catch { + /* synchronous iterator start failure is also best-effort */ + } + return; + } + finishReturn(); + }; + const step = async () => { + if (stepping || closed) return; + stepping = true; + gated = false; + const emittedAtStart = emittedFrames; + try { + while (!terminated && !closed && emittedFrames === emittedAtStart) { + iteratorStarted = true; + const next = await it.next(); + if (next.done) { + upstreamDone = true; + break; + } + const event = next.value; + let terminalEvent = false; + activity = true; + stallTicks = 0; + reportFirstOutput(event); + // Compaction turns emit ONLY the synthetic compaction item + response.completed. The + // summary text is accumulated silently: emitting it as a normal assistant message would + // duplicate the summary if this response is ever replayed via previous_response_id + // expansion (rememberResponseState stores input + output). Codex ignores extra items but + // its compaction UI renders nothing mid-turn, so nothing is lost visually. + if (options?.compaction) { + if (event.type === "text_delta") { + compactionText += event.text; + continue; + } + if (event.type !== "done" && event.type !== "incomplete" && event.type !== "error") + continue; + } + switch (event.type) { + case "assistant_boundary": { + // A guarded continuation starts a fresh assistant output item while keeping the + // intermediate, suspicious text in the same Responses turn. + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + flushHiddenReasoningEnvelope(); + break; + } + case "text_delta": { + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (currentMsg && currentMsg.phase !== event.phase) closeCurrentMessage(); + if (!currentMsg) { + const itemId = `msg_${uuid()}`; + const item = { + type: "message", + id: itemId, + status: "in_progress", + role: "assistant", + content: [] as { type: string; text: string; annotations: never[] }[], + ...(event.phase ? { phase: event.phase } : {}), + }; + emit("response.output_item.added", { output_index: outputIndex, item }); + emit("response.content_part.added", { + item_id: itemId, + output_index: outputIndex, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }); + currentMsg = { + itemId, + outputIndex, + text: "", + ...(event.phase ? { phase: event.phase } : {}), + }; + } + currentMsg.text += event.text; + emit("response.output_text.delta", { + item_id: currentMsg.itemId, + output_index: currentMsg.outputIndex, + content_index: 0, + delta: event.text, + }); + break; + } + case "thinking_delta": { + if (options?.hideThinkingSummary) { + hiddenThinkingText += event.thinking; + break; + } + if (currentMsg) closeCurrentMessage(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (!currentReasoning) { + const itemId = `rs_${uuid()}`; + const item = { + type: "reasoning", + id: itemId, + summary: [] as { type: string; text: string }[], + }; + emit("response.output_item.added", { output_index: outputIndex, item }); + emit("response.reasoning_summary_part.added", { + item_id: itemId, + output_index: outputIndex, + summary_index: 0, + part: { type: "summary_text", text: "" }, + }); + currentReasoning = { itemId, outputIndex, text: "" }; + } + currentReasoning.text += event.thinking; + emit("response.reasoning_summary_text.delta", { + item_id: currentReasoning.itemId, + output_index: currentReasoning.outputIndex, + summary_index: 0, + delta: event.thinking, + }); + break; + } + case "thinking_signature": { + pendingSignature = event.signature; + // Signature arrives at the end of the thinking block. With a visible reasoning item + // open, closeCurrentReasoning attaches the envelope; hidden/suppressed blocks flush + // an envelope-only reasoning item now. + if (!currentReasoning) flushHiddenReasoningEnvelope(); + break; + } + case "redacted_thinking": { + pendingRedacted.push(event.data); + break; + } + case "reasoning_raw_delta": { + if (options?.hideThinkingSummary) { + hiddenRawReasoningText += event.text; + break; + } + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (!currentRawReasoning) { + const itemId = `rs_${uuid()}`; + const item = { + type: "reasoning", + id: itemId, + summary: [] as never[], + content: [] as { type: string; text: string }[], + }; + emit("response.output_item.added", { output_index: outputIndex, item }); + currentRawReasoning = { itemId, outputIndex, text: "" }; + } + currentRawReasoning.text += event.text; + emit("response.reasoning_text.delta", { + item_id: currentRawReasoning.itemId, + output_index: currentRawReasoning.outputIndex, + content_index: 0, + delta: event.text, + }); + break; + } + case "tool_call_start": { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + const mapped = toolNsMap?.get(event.name); + const realName = mapped?.name ?? event.name; + const ns = mapped?.namespace; + const toolSearch = toolSearchToolNames?.has(realName) ?? false; + const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false); + const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`; + const item = toolSearch + ? { + type: "tool_search_call", + id: itemId, + call_id: event.id, + execution: "client", + arguments: {}, + status: "in_progress", + } + : freeform + ? { + type: "custom_tool_call", + id: itemId, + call_id: event.id, + name: realName, + input: "", + status: "in_progress", + } + : { + type: "function_call", + id: itemId, + call_id: event.id, + name: realName, + arguments: "", + status: "in_progress", + ...(ns ? { namespace: ns } : {}), + }; + emit("response.output_item.added", { output_index: outputIndex, item }); + currentToolCall = { + itemId, + outputIndex, + callId: event.id, + name: realName, + args: "", + namespace: ns, + freeform, + toolSearch, + }; + break; + } + case "tool_call_delta": { + if (currentToolCall) { + currentToolCall.args += event.arguments; + if (!currentToolCall.freeform && !currentToolCall.toolSearch) { + emit("response.function_call_arguments.delta", { + item_id: currentToolCall.itemId, + output_index: currentToolCall.outputIndex, + delta: event.arguments, + }); + } + if (currentToolCall.freeform) { + // Hold while the buffer is still an ambiguous prefix of the JSON wrapper, + // then stream only the unwrapped input suffix (never rewind on mode flips). + if (!FREEFORM_WRAP_PREFIX.startsWith(currentToolCall.args)) { + const full = freeformPartialInput(currentToolCall.args); + const emitted = currentToolCall.inputEmitted ?? ""; + if (full.startsWith(emitted) && full.length > emitted.length) { + emit("response.custom_tool_call_input.delta", { + item_id: currentToolCall.itemId, + output_index: currentToolCall.outputIndex, + delta: full.slice(emitted.length), + }); + currentToolCall.inputEmitted = full; + } + } + } + } + break; + } + case "tool_call_end": { + closeCurrentToolCall(); + break; + } + case "web_search_call_begin": { + // Open the native search cell so Codex shows the "Searching the web" spinner WHILE the + // sidecar runs. Close any other open item first, allocate this item's output index, and + // hold it open until the matching `web_search_call_end` (or a terminal close). + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("completed", []); + const wsItemId = `ws_${uuid()}`; + emit("response.output_item.added", { + output_index: outputIndex, + item: { type: "web_search_call", id: wsItemId, status: "in_progress" }, + }); + currentWebSearch = { itemId: wsItemId, eventId: event.id, outputIndex }; + break; + } + case "web_search_call_end": { + // The sidecar resolved — finalize the cell as "Searched ". If no begin opened + // (defensive), synthesize the added frame first so the done has a matching item. + if (!currentWebSearch || currentWebSearch.eventId !== event.id) { + if (currentWebSearch) closeCurrentWebSearch("completed", []); + const wsItemId2 = `ws_${uuid()}`; + emit("response.output_item.added", { + output_index: outputIndex, + item: { type: "web_search_call", id: wsItemId2, status: "in_progress" }, + }); + currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex }; + } + closeCurrentWebSearch(event.status ?? "completed", event.queries, event.sources); + // Queue this search's sources for the next assistant message (dedup by URL). + if (event.sources) { + const seen = new Set(pendingWebSources.map((s) => s.url)); + for (const s of event.sources) { + if (!seen.has(s.url)) { + seen.add(s.url); + pendingWebSources.push(s); + } + } + } + break; + } + case "done": { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("completed", []); + // Redacted-only turns (or hidden thinking without a trailing signature event) still + // need their envelope-only reasoning item so the blocks replay next turn. + flushHiddenReasoningEnvelope(); + if (options?.compaction) { + // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0. + const item = { + type: "compaction", + id: `cmp_${uuid()}`, + encrypted_content: encodeCompactionSummary(compactionText), + }; + emit("response.output_item.done", { output_index: outputIndex, item }); + finishedItems.push(item as OutputItem); + outputIndex++; + } + if (event.stopReason === "max_tokens" || event.stopReason === "content_filter") { + // Upstream stopped before a normal completion. Surface as incomplete so the + // client can distinguish a truncated/filtered turn from a finished one. + const response = { + ...responseSnapshot("incomplete", finishedItems, event.endTurn), + usage: responsesUsage(event.usage), + incomplete_details: { + reason: + event.stopReason === "max_tokens" ? "max_output_tokens" : "content_filter", + }, + }; + // Cache max-output partials so previous_response_id replay can continue them; + // rememberResponseState rejects content-filtered incomplete responses. + options?.onCompletedResponse?.(response, event.providerState); + emit("response.incomplete", { response }); + reportTerminal("incomplete"); + } else { + const response = { + ...responseSnapshot("completed", finishedItems, event.endTurn), + usage: responsesUsage(event.usage), + }; + options?.onCompletedResponse?.(response, event.providerState); + emit("response.completed", { + response, + }); + reportTerminal("completed"); + } + terminalEvent = true; + break; + } + case "incomplete": { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("failed", []); + flushHiddenReasoningEnvelope(); + emit("response.incomplete", { + response: { + ...responseSnapshot("incomplete", finishedItems, event.endTurn), + usage: responsesUsage(event.usage), + incomplete_details: { + reason: event.reason, + ...(event.message ? { message: event.message } : {}), + ...(event.retryable !== undefined ? { retryable: event.retryable } : {}), + }, + }, + }); + reportTerminal("incomplete"); + terminalEvent = true; + break; + } + case "error": { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("failed", []); + const failure = adapterFailureFromEvent(event); + emit("response.failed", { + response: { + ...responseSnapshot("failed", finishedItems), + // Partial consumption from a mid-stream upstream failure: surfaced so the request + // log can record real tokens instead of usageStatus "unreported" with 0. + ...(event.usage ? { usage: responsesUsage(event.usage) } : {}), + error: failure.error, + last_error: failure.error, + ...(event.retryable !== undefined ? { retryable: event.retryable } : {}), + }, + }); + reportTerminal("failed"); + terminalEvent = true; + break; + } + } + if (terminalEvent) { + onCancel?.(); + terminated = true; + returnIterator(); + break; + } + } + } catch (err) { + if (!terminated) { + flushHiddenRawReasoning(); + if (currentWebSearch) closeCurrentWebSearch("failed", []); + emit("response.failed", { + response: { + ...responseSnapshot("failed", finishedItems), + error: responseError( + 500, + "proxy_error", + err instanceof Error ? err.message : String(err) + ), + last_error: responseError( + 500, + "proxy_error", + err instanceof Error ? err.message : String(err) + ), + }, + }); + reportTerminal("failed"); + onCancel?.(); + terminated = true; + returnIterator(); + } + } + + if (!terminated && !upstreamDone) { + gated = true; + stepping = false; + return; + } + if (beat) { + clearInterval(beat); + beat = undefined; + } + + if (!terminated) { + // The adapter generator ended without an explicit done/error event. Mark as incomplete + // rather than completed so Codex can distinguish a clean finish from a truncated stream. + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("failed", []); + emit("response.incomplete", { + response: { + ...responseSnapshot("incomplete", finishedItems), + usage: responsesUsage(undefined), + incomplete_details: { reason: "adapter_eof" }, + }, + }); + reportTerminal("incomplete"); + terminated = true; + } + + emitDone(); + try { + controller.close(); + } catch { + /* already closed (e.g. client cancelled) */ + } + closed = true; + gated = true; + stepping = false; + }; + + const startStream = () => { + emit("response.created", { response: responseSnapshot("in_progress", []) }); + // The default ReadableStream strategy has HWM=1. Once one event's frames fill that + // queue, pull stepping pauses; no custom FIFO or queuing strategy is layered on top. + gated = true; + beat = setInterval(() => { + if (closed || gated) return; + if (activity) { + activity = false; + stallTicks = 0; + return; + } + if (++stallTicks >= maxStallTicks) { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("failed", []); + emit("response.incomplete", { + response: { + ...responseSnapshot("incomplete", finishedItems), + incomplete_details: { reason: "upstream_stall_timeout" }, + }, + }); + reportTerminal("incomplete"); + onCancel?.(); + terminated = true; + returnIterator(); + emitDone(); + if (beat) clearInterval(beat); + beat = undefined; + try { + controller.close(); + } catch { + /* already closed */ + } + closed = true; + return; + } + try { + controller.enqueue(heartbeatFrame); + emittedFrames++; + } catch { + closed = true; + } + }, heartbeatMs); + }; + + return new ReadableStream({ + start(streamController) { + controller = streamController; + startStream(); + }, + pull() { + return step(); + }, + cancel() { + // Client (Codex) disconnected. Stop emitting and let the caller abort the upstream fetch so a + // cancelled turn does not leak the upstream stream or keep draining tokens (RC2). + clientCancelled = true; + closed = true; + if (beat) clearInterval(beat); + onCancel?.(); + returnIterator(); + }, + }); +} + +export function buildResponseJSON( + events: AdapterEvent[], + modelId: string, + options?: { + hideThinkingSummary?: boolean; + toolNsMap?: Map; + freeformToolNames?: Set; + toolSearchToolNames?: Set; + /** Remote compaction v2 turn — append one synthetic compaction output item (see bridgeToResponsesSSE). */ + compaction?: boolean; + onProviderState?: (state: CodexProviderContinuationState) => void; + } +): Record { + const responseId = `resp_${uuid()}`; + const output: OutputItem[] = []; + let usage: CodexUsage | undefined; + let errorEvent: Extract | undefined; + let incompleteEvent: Extract | undefined; + let endTurn: boolean | undefined; + let stopReason: string | undefined; + let compactionText = ""; + + let currentText = ""; + let currentTextPhase: CodexMessagePhase | undefined; + let currentSummaryReasoning = ""; + let currentRawReasoning = ""; + // Opaque signed-reasoning round-trip (batch): see bridgeToResponsesSSE counterpart. + let batchSignature: string | undefined; + let batchRedacted: string[] = []; + let currentToolCallId = ""; + let currentToolCallName = ""; + let currentToolCallArgs = ""; + // Web-search citations awaiting the next assistant message (attached as url_citation annotations). + let pendingWebSources: { url: string; title?: string }[] = []; + + const freeformInput = (args: string): string => { + try { + const o = JSON.parse(args); + if (o && typeof o.input === "string") return o.input; + } catch { + /* raw */ + } + return args; + }; + const parseArgsObj = (args: string): Record => { + try { + const o = JSON.parse(args); + return o && typeof o === "object" ? o : {}; + } catch { + return {}; + } + }; + + const flushText = () => { + if (!currentText) return; + const annotations = pendingWebSources.map((s) => ({ + type: "url_citation", + url: s.url, + ...(s.title ? { title: s.title } : {}), + start_index: 0, + end_index: 0, + })); + pendingWebSources = []; + output.push({ + type: "message", + id: `msg_${uuid()}`, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: currentText, annotations }], + ...(currentTextPhase ? { phase: currentTextPhase } : {}), + }); + currentText = ""; + currentTextPhase = undefined; + }; + const flushSummaryReasoning = () => { + if (!currentSummaryReasoning && !batchSignature && batchRedacted.length === 0) return; + const envelope: ReasoningEnvelope = {}; + if (batchSignature) envelope.sig = batchSignature; + if (batchRedacted.length > 0) envelope.red = batchRedacted; + const hidden = options?.hideThinkingSummary === true; + if (hidden && currentSummaryReasoning && (envelope.sig || envelope.red)) + envelope.txt = currentSummaryReasoning; + const encrypted = + envelope.sig || envelope.red || envelope.txt ? encodeReasoningEnvelope(envelope) : undefined; + batchSignature = undefined; + batchRedacted = []; + if (hidden && !encrypted) { + currentSummaryReasoning = ""; + return; + } + output.push({ + type: "reasoning", + id: `rs_${uuid()}`, + summary: + !hidden && currentSummaryReasoning + ? [{ type: "summary_text", text: currentSummaryReasoning }] + : [], + ...(encrypted ? { encrypted_content: encrypted } : {}), + }); + currentSummaryReasoning = ""; + }; + const flushRawReasoning = () => { + if (!currentRawReasoning) return; + if (options?.hideThinkingSummary === true) { + // Same contract as the streaming path: no visible reasoning, txt-only envelope round-trip. + output.push({ + type: "reasoning", + id: `rs_${uuid()}`, + summary: [], + encrypted_content: encodeReasoningEnvelope({ txt: currentRawReasoning }), + }); + currentRawReasoning = ""; + return; + } + output.push({ + type: "reasoning", + id: `rs_${uuid()}`, + summary: [], + content: [{ type: "reasoning_text", text: currentRawReasoning }], + }); + currentRawReasoning = ""; + }; + const flushToolCall = () => { + if (!currentToolCallId) return; + const mapped = options?.toolNsMap?.get(currentToolCallName); + const realName = mapped?.name ?? currentToolCallName; + const ns = mapped?.namespace; + const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false; + const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false); + if (toolSearch) { + output.push({ + type: "tool_search_call", + id: `tsc_${uuid()}`, + call_id: currentToolCallId, + execution: "client", + arguments: parseArgsObj(currentToolCallArgs), + status: "completed", + }); + } else if (freeform) { + output.push({ + type: "custom_tool_call", + id: `ctc_${uuid()}`, + call_id: currentToolCallId, + name: realName, + input: freeformInput(currentToolCallArgs), + status: "completed", + }); + } else { + output.push({ + type: "function_call", + id: `fc_${uuid()}`, + call_id: currentToolCallId, + name: realName, + arguments: currentToolCallArgs || "{}", + status: "completed", + ...(ns ? { namespace: ns } : {}), + }); + } + currentToolCallId = ""; + currentToolCallName = ""; + currentToolCallArgs = ""; + }; + + for (const e of events) { + switch (e.type) { + case "assistant_boundary": + flushText(); + flushSummaryReasoning(); + flushRawReasoning(); + flushToolCall(); + break; + case "text_delta": + if (currentText && currentTextPhase !== e.phase) flushText(); + if (currentSummaryReasoning) flushSummaryReasoning(); + if (currentRawReasoning) flushRawReasoning(); + if (currentToolCallId) flushToolCall(); + // Compaction turns keep the summary out of normal message output (replay dedup — see + // bridgeToResponsesSSE); it ships only inside the synthetic compaction item below. + if (options?.compaction) compactionText += e.text; + else { + currentTextPhase = e.phase; + currentText += e.text; + } + break; + case "thinking_delta": + if (currentText) flushText(); + if (currentRawReasoning) flushRawReasoning(); + if (currentToolCallId) flushToolCall(); + currentSummaryReasoning += e.thinking; + break; + case "thinking_signature": + // End of the current thinking block — flush it WITH the signature envelope so the + // block/signature pairing survives multi-block turns. + batchSignature = e.signature; + flushSummaryReasoning(); + break; + case "redacted_thinking": + batchRedacted.push(e.data); + break; + case "reasoning_raw_delta": + if (currentText) flushText(); + if (currentSummaryReasoning) flushSummaryReasoning(); + if (currentToolCallId) flushToolCall(); + currentRawReasoning += e.text; + break; + case "tool_call_start": + if (currentText) flushText(); + if (currentSummaryReasoning) flushSummaryReasoning(); + if (currentRawReasoning) flushRawReasoning(); + flushToolCall(); + currentToolCallId = e.id; + currentToolCallName = e.name; + currentToolCallArgs = ""; + break; + case "tool_call_delta": + currentToolCallArgs += e.arguments; + break; + case "tool_call_end": + flushToolCall(); + break; + case "web_search_call_begin": + // Batch/non-streaming output has no in_progress phase to animate — the search cell is a + // single finalized item, emitted on `end`. Begin is a no-op here. + break; + case "web_search_call_end": + if (currentText) flushText(); + if (currentSummaryReasoning) flushSummaryReasoning(); + if (currentRawReasoning) flushRawReasoning(); + flushToolCall(); + output.push({ + type: "web_search_call", + id: `ws_${uuid()}`, + status: e.status ?? "completed", + action: webSearchAction(e.queries), + ...(e.sources && e.sources.length > 0 ? { sources: e.sources } : {}), + }); + if (e.sources) { + const seen = new Set(pendingWebSources.map((s) => s.url)); + for (const s of e.sources) { + if (!seen.has(s.url)) { + seen.add(s.url); + pendingWebSources.push(s); + } + } + } + break; + case "error": + errorEvent = e; + usage = e.usage ?? usage; + break; + case "incomplete": + incompleteEvent = e; + endTurn = e.endTurn; + if (e.providerState) options?.onProviderState?.(e.providerState); + break; + case "done": + usage = e.usage; + endTurn = e.endTurn; + if (e.providerState) options?.onProviderState?.(e.providerState); + if (e.stopReason === "max_tokens") stopReason = "max_tokens"; + break; + } + } + flushText(); + flushSummaryReasoning(); + flushRawReasoning(); + flushToolCall(); + // A truncated turn must never be installed as replacement history: emit the + // compaction item only when the turn actually completed (#422). + if (options?.compaction && !errorEvent && !incompleteEvent && stopReason !== "max_tokens") { + output.push({ + type: "compaction", + id: `cmp_${uuid()}`, + encrypted_content: encodeCompactionSummary(compactionText), + }); + } + + const failure = errorEvent ? adapterFailureFromEvent(errorEvent) : undefined; + const status = errorEvent + ? "failed" + : incompleteEvent || stopReason === "max_tokens" + ? "incomplete" + : "completed"; + return { + id: responseId, + object: "response", + created_at: Math.floor(Date.now() / 1000), + status, + model: modelId, + output, + ...(endTurn !== undefined ? { end_turn: endTurn } : {}), + ...(failure ? { error: failure.error, last_error: failure.error } : {}), + ...(errorEvent?.retryable !== undefined ? { retryable: errorEvent.retryable } : {}), + ...(incompleteEvent + ? { + incomplete_details: { + reason: incompleteEvent.reason, + ...(incompleteEvent.message ? { message: incompleteEvent.message } : {}), + ...(incompleteEvent.retryable !== undefined + ? { retryable: incompleteEvent.retryable } + : {}), + }, + } + : stopReason === "max_tokens" + ? { + incomplete_details: { reason: "max_output_tokens" }, + } + : {}), + usage: responsesUsage(incompleteEvent?.usage ?? usage), + }; +} + +export function formatErrorResponse(status: number, type: string, message: string): Response { + return new Response(JSON.stringify({ error: classifyError(status, type, message) }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/open-sse/vendor/codex-chatgpt-web/browser-login.ts b/open-sse/vendor/codex-chatgpt-web/browser-login.ts new file mode 100644 index 0000000000..212fa09d8f --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/browser-login.ts @@ -0,0 +1,250 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { BrowserContextOptions } from "playwright-core"; +import type { AppConfig } from "./config"; +import { atomicWriteFile } from "./config"; +import { + assertAuthenticatedChatGptPage, + assertTemporaryChatPage, + CHATGPT_TEMPORARY_CHAT_URL, + detectChatGptProCapability, +} from "./chatgpt-session"; + +export interface BrowserLoginResult { + storageStatePath: string; + accountSurfaceUrl: string; + proAvailable: boolean; +} + +interface LoginVerificationMarker { + version: 1; + authenticated: true; + verifiedAt: string; + proAvailable?: boolean; + cookieFingerprint?: string; + storageStateFingerprint?: string; + pendingBrowserVerification?: boolean; +} + +export function loginVerificationMarkerPath(storageStatePath: string): string { + return `${storageStatePath}.verified.json`; +} + +export function writeVerificationMarker(storageStatePath: string, proAvailable: boolean): void { + let previous: Partial = {}; + try { + previous = JSON.parse( + readFileSync(loginVerificationMarkerPath(storageStatePath), "utf8") + ) as Partial; + } catch { + // No prior cookie-injection marker. + } + let storageStateFingerprint = previous.storageStateFingerprint; + try { + const state = JSON.parse(readFileSync(storageStatePath, "utf8")) as Record; + storageStateFingerprint = createHash("sha256").update(JSON.stringify(state)).digest("hex"); + } catch { + // The caller that owns storage-state validation reports malformed state. + } + const marker: LoginVerificationMarker = { + version: 1, + authenticated: true, + verifiedAt: new Date().toISOString(), + proAvailable, + ...(previous.cookieFingerprint ? { cookieFingerprint: previous.cookieFingerprint } : {}), + ...(storageStateFingerprint ? { storageStateFingerprint } : {}), + pendingBrowserVerification: false, + }; + atomicWriteFile(loginVerificationMarkerPath(storageStatePath), `${JSON.stringify(marker)}\n`); +} + +async function inspectStoredState( + config: AppConfig, + storageState: NonNullable +): Promise<{ proAvailable: boolean; url: string }> { + const { chromium } = await import("playwright-core"); + if (!config.cdpEndpoint && !config.chromeExecutablePath) { + throw new Error("ChatGPT browser runtime is not configured"); + } + const verifierBrowser = config.cdpEndpoint + ? await chromium.connectOverCDP(config.cdpEndpoint) + : await chromium.launch({ + executablePath: config.chromeExecutablePath, + headless: !config.headed, + ignoreDefaultArgs: ["--password-store=basic", "--use-mock-keychain"], + args: ["--no-first-run", "--no-default-browser-check"], + }); + try { + const verifierContext = await verifierBrowser.newContext({ storageState }); + try { + const verifierPage = await verifierContext.newPage(); + await verifierPage.goto(CHATGPT_TEMPORARY_CHAT_URL, { + waitUntil: "domcontentloaded", + timeout: 60_000, + }); + await verifierPage + .getByRole("textbox", { name: "Chat with ChatGPT" }) + .waitFor({ state: "visible", timeout: 60_000 }); + await assertAuthenticatedChatGptPage(verifierPage); + await assertTemporaryChatPage(verifierPage); + return { + proAvailable: await detectChatGptProCapability(verifierPage), + url: verifierPage.url(), + }; + } finally { + await verifierContext.close(); + } + } finally { + await verifierBrowser.close(); + } +} + +export async function inspectBrowserLoginCapabilities( + config: AppConfig +): Promise<{ proAvailable: boolean }> { + if ( + !existsSync(config.storageStatePath) || + !existsSync(loginVerificationMarkerPath(config.storageStatePath)) + ) { + throw new Error("ChatGPT login state is missing"); + } + const inspected = await inspectStoredState(config, config.storageStatePath); + writeVerificationMarker(config.storageStatePath, inspected.proAvailable); + return { proAvailable: inspected.proAvailable }; +} + +export function storedBrowserLoginCapabilities(config: AppConfig): { proAvailable?: boolean } { + if (!browserLoginStateExists(config)) return {}; + try { + const marker = JSON.parse( + readFileSync(loginVerificationMarkerPath(config.storageStatePath), "utf8") + ) as Partial; + return typeof marker.proAvailable === "boolean" ? { proAvailable: marker.proAvailable } : {}; + } catch { + return {}; + } +} + +export async function loginToChatGpt( + config: AppConfig, + options: { timeoutMs?: number } = {} +): Promise { + const { chromium } = await import("playwright-core"); + if (!config.chromeExecutablePath || !existsSync(config.chromeExecutablePath)) { + throw new Error( + `Google Chrome was not found at ${config.chromeExecutablePath}. Pass --chrome with its executable path.` + ); + } + const profileDir = join(dirname(config.storageStatePath), "login-profile"); + mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + process.stdout.write( + "A normal Chrome window is open. Sign in to ChatGPT, confirm that the composer is visible, then quit this dedicated Chrome instance completely.\n" + ); + const loginBrowser = spawn( + config.chromeExecutablePath, + [ + `--user-data-dir=${profileDir}`, + "--new-window", + "--disable-background-mode", + "--no-first-run", + "--no-default-browser-check", + CHATGPT_TEMPORARY_CHAT_URL, + ], + { env: process.env, stdio: "ignore" } + ); + const loginExit = await new Promise((resolveExit, rejectExit) => { + loginBrowser.once("error", rejectExit); + loginBrowser.once("exit", (code, signal) => { + if (signal) rejectExit(new Error(`Normal Chrome login window exited from signal ${signal}`)); + else resolveExit(code ?? 1); + }); + }); + if (loginExit !== 0) + throw new Error(`Normal Chrome login window exited with status ${loginExit}`); + + const context = await chromium.launchPersistentContext(profileDir, { + executablePath: config.chromeExecutablePath, + headless: false, + ignoreDefaultArgs: ["--password-store=basic", "--use-mock-keychain"], + args: ["--no-first-run", "--no-default-browser-check"], + }); + try { + const page = context.pages()[0] ?? (await context.newPage()); + await page.goto(CHATGPT_TEMPORARY_CHAT_URL, { + waitUntil: "domcontentloaded", + timeout: 60_000, + }); + const composer = page + .getByRole("textbox", { name: "Chat with ChatGPT" }) + .or( + page.locator( + '[data-testid="prompt-textarea"], [contenteditable="true"][data-lexical-editor="true"]' + ) + ) + .first(); + try { + await composer.waitFor({ state: "visible", timeout: options.timeoutMs ?? 60_000 }); + } catch { + throw new Error("The authenticated ChatGPT page did not produce a visible composer"); + } + await assertAuthenticatedChatGptPage(page); + await assertTemporaryChatPage(page); + const state = await context.storageState(); + + const inspected = await inspectStoredState(config, state); + atomicWriteFile(config.storageStatePath, `${JSON.stringify(state)}\n`); + writeVerificationMarker(config.storageStatePath, inspected.proAvailable); + return { + storageStatePath: config.storageStatePath, + accountSurfaceUrl: page.url(), + proAvailable: inspected.proAvailable, + }; + } finally { + await context.close(); + if (browserLoginStateExists(config)) rmSync(profileDir, { recursive: true, force: true }); + } +} + +export function browserLoginStateExists(config: AppConfig): boolean { + if (!existsSync(config.storageStatePath)) return false; + const markerPath = loginVerificationMarkerPath(config.storageStatePath); + if (!existsSync(markerPath)) return false; + try { + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as Partial; + return ( + marker.version === 1 && + marker.authenticated === true && + marker.pendingBrowserVerification !== true && + typeof marker.verifiedAt === "string" + ); + } catch { + return false; + } +} + +export async function checkBrowserEngine(config: AppConfig): Promise { + const { chromium } = await import("playwright-core"); + if (config.cdpEndpoint) { + const browser = await chromium.connectOverCDP(config.cdpEndpoint); + await browser.close(); + return; + } + if (!config.chromeExecutablePath || !existsSync(config.chromeExecutablePath)) + throw new Error(`Google Chrome was not found at ${config.chromeExecutablePath}`); + const browser = await chromium.launch({ + executablePath: config.chromeExecutablePath, + headless: true, + args: ["--no-first-run", "--no-default-browser-check"], + }); + try { + const page = await browser.newPage(); + await page.goto("about:blank"); + if ((await page.evaluate(() => document.readyState)) !== "complete") + throw new Error("Browser page did not reach complete state"); + } finally { + await browser.close(); + } +} diff --git a/open-sse/vendor/codex-chatgpt-web/chatgpt-session.ts b/open-sse/vendor/codex-chatgpt-web/chatgpt-session.ts new file mode 100644 index 0000000000..9ea443dcf3 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/chatgpt-session.ts @@ -0,0 +1,67 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import type { Locator, Page } from "playwright-core"; + +export const CHATGPT_TEMPORARY_CHAT_URL = "https://chatgpt.com/?temporary-chat=true"; + +async function anyVisible(locator: Locator): Promise { + const count = await locator.count(); + for (let index = 0; index < count; index += 1) { + if ( + await locator + .nth(index) + .isVisible() + .catch(() => false) + ) + return true; + } + return false; +} + +export async function assertAuthenticatedChatGptPage(page: Page): Promise { + const loginButtons = page.getByRole("button", { name: "Log in", exact: true }); + if (await anyVisible(loginButtons)) { + throw new Error("ChatGPT is signed out: a visible Log in button is present"); + } + const accountControl = page + .getByRole("button", { name: /(?:profile|account) menu/i }) + .or(page.locator('[data-testid="profile-button"], button[aria-label*="account" i]')); + if (!(await anyVisible(accountControl))) { + throw new Error( + "ChatGPT authentication could not be verified: no visible account control is present" + ); + } +} + +export async function assertTemporaryChatPage(page: Page): Promise { + const url = new URL(page.url()); + const expected = new URL(CHATGPT_TEMPORARY_CHAT_URL); + if ( + url.origin !== expected.origin || + url.pathname !== expected.pathname || + url.searchParams.get("temporary-chat") !== "true" + ) { + throw new Error(`ChatGPT left the isolated Temporary Chat surface (${page.url()})`); + } + await page + .getByRole("heading", { name: "Temporary Chat", exact: true }) + .waitFor({ state: "visible", timeout: 20_000 }); +} + +export async function detectChatGptProCapability(page: Page): Promise { + const effortButton = page + .getByRole("button", { + name: /^(?:Instant(?:\s+5\.5)?|Medium|High|Extra High|Pro)$/, + }) + .last(); + await effortButton.waitFor({ state: "visible", timeout: 30_000 }); + await effortButton.click(); + try { + const pro = page + .getByRole("menuitem", { name: "Pro", exact: true }) + .or(page.getByRole("menuitemradio", { name: "Pro", exact: true })) + .last(); + return await pro.isVisible().catch(() => false); + } finally { + await page.keyboard.press("Escape").catch(() => {}); + } +} diff --git a/open-sse/vendor/codex-chatgpt-web/config.ts b/open-sse/vendor/codex-chatgpt-web/config.ts new file mode 100644 index 0000000000..ee391579ed --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/config.ts @@ -0,0 +1,68 @@ +/* + * OmniRoute integration layer for code adapted from miuuyy/codex-chatgpt-web + * commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). + */ +import { + chmodSync, + closeSync, + mkdirSync, + openSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export type RuntimeMode = "browser-only" | "full"; + +export interface AppConfig { + mode: RuntimeMode; + appName: string; + chromeExecutablePath?: string; + cdpEndpoint?: string; + storageStatePath: string; + brokerSocketPath: string; + headed: boolean; + proAvailable: boolean; + autoApproveToolCalls: boolean; +} + +export function expandUserPath(value: string): string { + if (value === "~") return homedir(); + if (value.startsWith("~/")) return join(homedir(), value.slice(2)); + return value; +} + +export function getConfigDir(): string { + const configured = process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR; + return resolve(configured?.trim() || join(homedir(), ".omniroute"), "chatgpt-web-codex"); +} + +export function atomicWriteFile(path: string, data: string | Uint8Array): void { + const directory = dirname(path); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + try { + chmodSync(directory, 0o700); + } catch { + // Windows ACLs are managed by the host. + } + const temp = `${path}.tmp-${process.pid}-${crypto.randomUUID()}`; + const fd = openSync(temp, "wx", 0o600); + try { + writeFileSync(fd, data); + closeSync(fd); + renameSync(temp, path); + } catch (error) { + try { + closeSync(fd); + } catch {} + rmSync(temp, { force: true }); + throw error; + } + try { + chmodSync(path, 0o600); + } catch { + // Windows ACLs are managed by the host. + } +} diff --git a/open-sse/vendor/codex-chatgpt-web/event-queue.ts b/open-sse/vendor/codex-chatgpt-web/event-queue.ts new file mode 100644 index 0000000000..f40ea28183 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/event-queue.ts @@ -0,0 +1,46 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +export class AsyncEventQueue implements AsyncIterable { + private readonly buffered: T[] = []; + private readonly waiters: Array<(result: IteratorResult) => void> = []; + private closed = false; + + constructor(private readonly maxBuffered = 10_000) {} + + push(value: T): void { + if (this.closed) return; + const waiter = this.waiters.shift(); + if (waiter) { + waiter({ value, done: false }); + return; + } + if (this.buffered.length >= this.maxBuffered) throw new Error("Adapter event backlog exceeded"); + this.buffered.push(value); + } + + close(): void { + if (this.closed) return; + this.closed = true; + while (this.waiters.length > 0) this.waiters.shift()!({ value: undefined, done: true }); + } + + async collect(): Promise { + const values: T[] = []; + for await (const value of this) values.push(value); + return values; + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + const value = this.buffered.shift(); + if (value !== undefined) return Promise.resolve({ value, done: false }); + if (this.closed) return Promise.resolve({ value: undefined, done: true }); + return new Promise((resolve) => this.waiters.push(resolve)); + }, + return: () => { + this.close(); + return Promise.resolve({ value: undefined, done: true }); + }, + }; + } +} diff --git a/open-sse/vendor/codex-chatgpt-web/lib/errors.ts b/open-sse/vendor/codex-chatgpt-web/lib/errors.ts new file mode 100644 index 0000000000..f745802c32 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/lib/errors.ts @@ -0,0 +1,279 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +export interface CodexErrorPayload { + message: string; + type: string; + code: string | null; +} + +function isSubscriptionGateMessage(text: string): boolean { + return ( + text.includes("requires a subscription") || + text.includes("requires subscription") || + text.includes("subscription required") || + text.includes("upgrade for access") || + text.includes("upgrade to pro") || + text.includes("pro subscription") || + (text.includes("upgrade") && text.includes("subscription")) + ); +} + +function isAuthenticationMessage(text: string): boolean { + const accessDeniedWithCredentialCue = + (text.includes("access denied") || text.includes("accessdeniedexception")) && + (text.includes("authentication") || + text.includes("credential") || + text.includes("api key") || + text.includes("token") || + text.includes("signature")); + return ( + text.includes("authentication failed") || + text.includes("authentication") || + text.includes("invalid_api_key") || + text.includes("invalid api key") || + text.includes("invalid token") || + text.includes("unauthorizedexception") || + text.includes("unrecognizedclientexception") || + text.includes("unrecognizedclient") || + text.includes("expired token") || + text.includes("expiredtoken") || + text.includes("unauthenticated") || + text.includes("unauthorized") || + accessDeniedWithCredentialCue + ); +} + +function isPermissionMessage(text: string): boolean { + return ( + text.includes("permission_denied") || + text.includes("permission denied") || + text.includes("forbidden") || + text.includes("access denied") || + text.includes("accessdeniedexception") || + text.includes("not allowed to use") || + text.includes("model access") + ); +} + +/** + * Client cancelled / closed the turn. Matches ONLY abort phrases this codebase + * produces — "client closed request during web-search" (src/web-search/loop.ts), + * "Client cancelled request" (src/server/responses.ts) — plus the explicit + * "request cancel(l)ed by client" forms. Deliberately narrow: bare "client closed" + * would also swallow legitimate upstream failures like "upstream HTTP client + * closed idle connection" and turn a real 502 into a 499. + */ +export function isClientClosedMessage(text: string): boolean { + const lower = text.toLowerCase(); + return ( + lower.includes("client closed request") || + lower.includes("client cancelled request") || + lower.includes("client canceled request") || + lower.includes("request canceled by client") || + lower.includes("request cancelled by client") + ); +} + +export function classifyError(status: number, type: string, message: string): CodexErrorPayload { + const text = message.toLowerCase(); + // Preserve explicit cancel types used by compact/combo JSON errors; unify message-inferred + // client closes (web-search abort text) onto client_closed_request for /api/logs. + if (type === "client_cancelled") { + return { message, type: "client_cancelled", code: "client_cancelled" }; + } + if (status === 499 || type === "client_closed_request" || isClientClosedMessage(text)) { + return { message, type: "invalid_request_error", code: "client_closed_request" }; + } + if ( + text.includes("context_length_exceeded") || + text.includes("context window") || + text.includes("context length") || + text.includes("maximum context") || + text.includes("too many tokens") + ) { + return { message, type: "invalid_request_error", code: "context_length_exceeded" }; + } + if ( + text.includes("insufficient_quota") || + text.includes("exceeded your current quota") || + text.includes("quota exhausted") || + text.includes("account quota exceeded") || + text.includes("monthly quota exceeded") || + text.includes("daily quota exceeded") + ) { + return { message, type: "insufficient_quota", code: "insufficient_quota" }; + } + if ( + status === 429 || + text.includes("rate limit") || + text.includes("rate limited") || + text.includes("too many requests") || + text.includes("resource_exhausted") || + text.includes("resource exhausted") || + text.includes("throttlingexception") || + text.includes("throttling") + ) { + return { message, type: "rate_limit_error", code: "rate_limit_exceeded" }; + } + if (type === "origin_rejected") { + return { message, type: "invalid_request_error", code: "origin_rejected" }; + } + // HTTP 401 and explicit auth failures are authoritative even when provider text + // also advertises an upgrade or subscription. + if (status === 401 || type === "authentication_error" || isAuthenticationMessage(text)) { + return { message, type: "authentication_error", code: "invalid_api_key" }; + } + // Subscription labels are valid only in a known permission context. + if ((status === 403 || type === "permission_error") && isSubscriptionGateMessage(text)) { + return { message, type: "permission_error", code: "subscription_required" }; + } + if (status === 403 || type === "permission_error" || isPermissionMessage(text)) { + return { message, type: "permission_error", code: "permission_denied" }; + } + if ( + status === 503 || + text.includes("overloaded") || + text.includes("server is busy") || + text.includes("temporarily unavailable") + ) { + // Codex recognizes "server_is_overloaded" and applies retry-after backoff + // (responses.rs is_server_overloaded_error); generic "upstream_server_error" is not recognized. + return { message, type: "server_error", code: "server_is_overloaded" }; + } + if ( + text.includes("validationexception") || + text.includes("invalid request") || + text.includes("model unavailable") || + text.includes("model not found") || + text.includes("unsupported model") + ) { + return { message, type: "invalid_request_error", code: "invalid_request_error" }; + } + if (status >= 500) { + return { message, type: "server_error", code: "upstream_server_error" }; + } + if (status === 400 || type === "invalid_request_error") { + return { message, type: "invalid_request_error", code: "invalid_request_error" }; + } + return { message, type, code: type || null }; +} + +/** Best-effort parse of a retry delay embedded in an upstream error message. */ +export function parseRetryAfterFromMessage(message: string): number | undefined { + const patterns = [ + /try again in (\d+(?:\.\d+)?)\s*s(?:ec(?:ond)?s?)?/i, + /retry after (\d+(?:\.\d+)?)\s*s(?:ec(?:ond)?s?)?/i, + /retry[- ]after[:\s]+(\d+)/i, + ]; + for (const pattern of patterns) { + const match = message.match(pattern); + if (!match?.[1]) continue; + const seconds = Number.parseFloat(match[1]); + if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds); + } + return undefined; +} + +/** Infer HTTP status from adapter terminal error text (provider-agnostic keyword matching). */ +export function inferHttpStatusFromAdapterMessage(message: string): number { + const lower = message.toLowerCase(); + // Client aborts (e.g. mid web-search loop) must not look like upstream 502s in /api/logs. + if (isClientClosedMessage(lower)) return 499; + if ( + lower.includes("resource_exhausted") || + lower.includes("resource exhausted") || + lower.includes("rate limit") || + lower.includes("too many requests") || + lower.includes("throttling") + ) + return 429; + // Strong authentication signals win when a message contains mixed auth and + // subscription/permission wording. + if (isAuthenticationMessage(lower)) return 401; + if (isSubscriptionGateMessage(lower) || isPermissionMessage(lower)) return 403; + if ( + lower.includes("unavailable") || + lower.includes("overloaded") || + lower.includes("temporarily") || + lower.includes("server is busy") + ) + return 503; + if ( + lower.includes("invalid") || + lower.includes("not found") || + lower.includes("unsupported") || + lower.includes("malformed") || + lower.includes("unimplemented") + ) + return 400; + if ( + lower.includes("timed out") || + lower.includes("timeout") || + lower.includes("etimedout") || + lower.includes("deadline") + ) + return 504; + return 502; +} + +/** Map an adapter terminal error message to HTTP status + classified Codex error payload. */ +export function adapterFailureFromMessage(message: string): { + httpStatus: number; + error: CodexErrorPayload; +} { + const httpStatus = inferHttpStatusFromAdapterMessage(message); + let finalMessage = message; + const retryAfterSeconds = parseRetryAfterFromMessage(message); + if (retryAfterSeconds && !/please try again in /i.test(message)) { + finalMessage = `${message} Please try again in ${retryAfterSeconds}s.`; + } + const errorType = + httpStatus === 499 + ? "client_closed_request" + : httpStatus === 429 + ? "rate_limit_error" + : httpStatus === 401 + ? "authentication_error" + : httpStatus === 403 + ? "permission_error" + : httpStatus === 503 || httpStatus === 504 + ? "server_error" + : httpStatus === 400 + ? "invalid_request_error" + : "upstream_error"; + return { + httpStatus, + error: classifyError(httpStatus, errorType, finalMessage), + }; +} + +/** Map a terminal Responses error object to the HTTP status we record in /api/logs. */ +export function httpStatusFromTerminalError( + error: + | { + type?: string; + code?: string | null; + message?: string; + } + | undefined +): number { + if (!error) return 502; + if (error.code === "client_closed_request" || error.code === "client_cancelled") return 499; + if (error.type === "rate_limit_error" || error.code === "rate_limit_exceeded") return 429; + if (error.type === "authentication_error" || error.code === "invalid_api_key") return 401; + if ( + error.type === "permission_error" || + error.code === "permission_denied" || + error.code === "subscription_required" + ) + return 403; + if (error.type === "insufficient_quota" || error.code === "insufficient_quota") return 429; + if (error.type === "server_error" && error.code === "server_is_overloaded") return 503; + // Client-closed messages often arrive as invalid_request_error after classifyError; check message + // before treating every invalid_request_error as HTTP 400. + const message = error.message ?? ""; + if (message && isClientClosedMessage(message)) return 499; + if (error.type === "invalid_request_error") return 400; + if (error.type === "proxy_error") return 500; + if (message) return inferHttpStatusFromAdapterMessage(message); + return 502; +} diff --git a/open-sse/vendor/codex-chatgpt-web/lib/token-estimate.ts b/open-sse/vendor/codex-chatgpt-web/lib/token-estimate.ts new file mode 100644 index 0000000000..3d0cccffaf --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/lib/token-estimate.ts @@ -0,0 +1,56 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +/** + * Heuristic token-estimation sidecar. + * + * ChatGPT's rendered web response exposes no Responses API usage object, so Codex's usage display + * and auto-compact need a conservative local estimate. + * + * Code, JSON, and tool arguments pack more tokens per character than English prose, so the ratio + * intentionally over-counts a little and compacts early. + * Over-counting fails safe (auto-compact fires earlier); under-counting risks context overflow. + */ + +const DEFAULT_CHARS_PER_TOKEN = 3.5; + +/** Model-aware chars-per-token ratio. Unknown models fall back to the generic English ratio. */ +export function charsPerToken(modelId?: string): number { + void modelId; + return DEFAULT_CHARS_PER_TOKEN; +} + +/** + * CJK-aware ratio (devlog 260712 B3, audit R2#7): Korean/Chinese/Japanese text packs + * roughly one token per 1.5-3 chars, so a CJK-heavy blob estimated at English ratios + * badly undercounts. When >30% of chars are CJK, clamp DOWN to 2.5 chars/token — + * `min(model ratio, 2.5)` keeps non-Latin context conservative. + */ +const CJK_CHARS_PER_TOKEN = 2.5; +const CJK_RATIO_THRESHOLD = 0.3; +// Hangul syllables/jamo, CJK unified ideographs (+ext A), hiragana/katakana. +const CJK_RE = /[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u30FF]/; + +function cjkRatio(text: string): number { + if (text.length === 0) return 0; + // Sample long blobs for O(1) cost: every char up to 2k, then a stride. + const stride = text.length > 2048 ? Math.ceil(text.length / 2048) : 1; + let cjk = 0; + let sampled = 0; + for (let i = 0; i < text.length; i += stride) { + sampled++; + if (CJK_RE.test(text[i]!)) cjk++; + } + return sampled === 0 ? 0 : cjk / sampled; +} + +/** + * Estimate the token count of a text blob. Pure and deterministic. + * Returns 0 for empty/whitespace-free-empty input; otherwise ceil(length / ratio), min 1. + */ +export function estimateTokens(text: string, modelId?: string): number { + if (!text) return 0; + const len = text.length; + if (len === 0) return 0; + let ratio = charsPerToken(modelId); + if (cjkRatio(text) > CJK_RATIO_THRESHOLD) ratio = Math.min(ratio, CJK_CHARS_PER_TOKEN); + return Math.max(1, Math.ceil(len / ratio)); +} diff --git a/open-sse/vendor/codex-chatgpt-web/responses/compaction.ts b/open-sse/vendor/codex-chatgpt-web/responses/compaction.ts new file mode 100644 index 0000000000..042a0ffb39 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/responses/compaction.ts @@ -0,0 +1,135 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +/** + * Remote compaction v2 support for ROUTED providers. + * + * Codex decides "this provider supports remote compaction" by provider name (built-in `OpenAI`), + * and Design B points that provider at this proxy — so Codex sends remote compaction v2 requests + * for EVERY routed model. The request is a normal /responses call whose input ends with + * `{"type":"compaction_trigger"}`; codex-rs `collect_compaction_output` then requires the stream + * to carry EXACTLY ONE `{"type":"compaction","encrypted_content":...}` output item + * (compact_remote_v2.rs) or it fatals with "expected exactly one compaction output item". + * + * Routed models cannot produce OpenAI's encrypted blob, so the proxy runs the model as a plain + * summarizer and wraps the summary text in a transparent envelope: `ocx1:` + base64(utf8 summary). + * Codex stores the item and replays it in later input; the parser decodes our envelope back into + * plain text for routed models. Real OpenAI-encrypted blobs (no `ocx1:` prefix) are opaque — + * routed models get a short "history was compacted" note instead. + */ + +export const BRIDGE_COMPACTION_PREFIX = "ocx1:"; + +/** Mirrors codex-rs core/templates/compact/prompt.md (the local-compaction instruction). */ +export const COMPACT_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. + +Include: +- Current progress and key decisions made +- Important context, constraints, or user preferences +- What remains to be done (clear next steps) +- Any critical data, examples, or references needed to continue + +Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`; + +/** Mirrors codex-rs core/templates/compact/summary_prefix.md (framing for a replayed summary). */ +export const SUMMARY_PREFIX = + "Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:"; + +export const OPAQUE_COMPACTION_NOTE = + "[earlier conversation was compacted; the summary is stored in a format this model cannot read]"; + +/** Exact framing emitted by this proxy for a readable replayed Codex compaction summary. */ +export function isReadableCompactionSummaryText(value: unknown): value is string { + return typeof value === "string" && value.startsWith(`${SUMMARY_PREFIX}\n\n`); +} + +export function encodeCompactionSummary(summary: string): string { + return BRIDGE_COMPACTION_PREFIX + Buffer.from(summary, "utf-8").toString("base64"); +} + +/** Decode an `ocx1:` envelope; returns null for real (OpenAI-encrypted) blobs or garbage. */ +export function decodeCompactionSummary(encryptedContent: string): string | null { + if (!encryptedContent.startsWith(BRIDGE_COMPACTION_PREFIX)) return null; + try { + return Buffer.from(encryptedContent.slice(BRIDGE_COMPACTION_PREFIX.length), "base64").toString( + "utf-8" + ); + } catch { + return null; + } +} + +/** Render a replayed compaction item as plain user-visible text for a routed model. */ +export function compactionItemToText(encryptedContent: string | undefined): string { + const decoded = + typeof encryptedContent === "string" ? decodeCompactionSummary(encryptedContent) : null; + return decoded ? `${SUMMARY_PREFIX}\n\n${decoded}` : OPAQUE_COMPACTION_NOTE; +} + +/** + * Remote compaction v1 (`POST /responses/compact`, unary) — codex-rs installs the returned + * `{"output":[ResponseItem...]}` as the REPLACEMENT history (compact_remote.rs + * process_compacted_history). Mirror codex-rs local `build_compacted_history`: recent real user + * messages within a token budget, then one user message `SUMMARY_PREFIX\n`. Plain user + * message items parse as real user messages on the codex side (event_mapping parse_user_message); + * contextual wrappers are filtered there, and v2-style `compaction` items are NOT expected here. + */ + +/** codex-rs compact.rs COMPACT_USER_MESSAGE_MAX_TOKENS = 20k tokens (~4 chars/token). */ +const COMPACT_V1_RETAINED_CHAR_BUDGET = 20_000 * 4; + +/** Extract plain-text user messages from a Responses `input` array (for v1 compact retention). */ +export function extractCompactUserMessages(input: unknown): string[] { + if (!Array.isArray(input)) return []; + const out: string[] = []; + for (const item of input) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const rec = item as { type?: string; role?: string; content?: unknown }; + if (rec.type !== undefined && rec.type !== "message") continue; + if (rec.role !== "user") continue; + let text = ""; + if (typeof rec.content === "string") text = rec.content; + else if (Array.isArray(rec.content)) { + text = rec.content + .map((b) => { + if (!b || typeof b !== "object") return ""; + const block = b as { type?: string; text?: string }; + return (block.type === "input_text" || block.type === "text") && + typeof block.text === "string" + ? block.text + : ""; + }) + .join(""); + } + if (text.trim().length > 0) out.push(text); + } + return out; +} + +function compactUserMessageItem(text: string): Record { + return { type: "message", role: "user", content: [{ type: "input_text", text }] }; +} + +/** Build the v1 compact `output` array: retained recent user messages + the summary message. */ +export function buildCompactV1Output( + userMessages: string[], + summary: string +): Record[] { + const selected: string[] = []; + let remaining = COMPACT_V1_RETAINED_CHAR_BUDGET; + for (let i = userMessages.length - 1; i >= 0 && remaining > 0; i--) { + const msg = userMessages[i]; + if (msg.length <= remaining) { + selected.push(msg); + remaining -= msg.length; + } else { + // Budget partially covers this older message: keep its tail (most recent context) and stop. + selected.push(msg.slice(msg.length - remaining)); + break; + } + } + selected.reverse(); + // codex-rs compact.rs uses "{SUMMARY_PREFIX}\n{summary}" (single newline) and detects stored + // summaries by that exact prefix — keep the same shape. + const summaryText = + summary.trim().length > 0 ? `${SUMMARY_PREFIX}\n${summary}` : "(no summary available)"; + return [...selected.map(compactUserMessageItem), compactUserMessageItem(summaryText)]; +} diff --git a/open-sse/vendor/codex-chatgpt-web/responses/parser.ts b/open-sse/vendor/codex-chatgpt-web/responses/parser.ts new file mode 100644 index 0000000000..744e43bb22 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/responses/parser.ts @@ -0,0 +1,717 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import type { + CodexAssistantMessage, + CodexContentPart, + CodexContext, + CodexMessage, + CodexParsedRequest, + CodexRequestOptions, + CodexTextContent, + CodexThinkingContent, + CodexTool, + CodexToolCall, +} from "../types"; +import { namespacedToolName } from "../types"; +import { responsesRequestSchema } from "./schema"; +import { compactionItemToText } from "./compaction"; +import { previousResponseReplayPrefixLength } from "./state"; +import { decodeReasoningEnvelope } from "./reasoning-envelope"; +import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool"; + +function isObj(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +type InputBlock = + | { type: "input_text"; text: string } + | { type: "text"; text: string } + | { type: "input_image"; image_url?: string; file_id?: string; detail?: string } + | { type: "input_file"; file_id?: string; filename?: string }; + +function inputContentParts(blocks: unknown[] | string | undefined): string | CodexContentPart[] { + if (typeof blocks === "string") return blocks; + if (!blocks) return []; + const parts: CodexContentPart[] = []; + for (const raw of blocks) { + const block = raw as InputBlock; + if (block.type === "input_text" || block.type === "text") { + parts.push({ type: "text", text: (block as { text: string }).text }); + } else if (block.type === "input_image") { + const b = block as { image_url?: string; file_id?: string; detail?: string }; + if (b.image_url) { + // Preserve the image as a structured part — adapters send it as a native image block. + // NEVER inline the (often base64 data-URL) image_url as text: that explodes the token count. + parts.push({ + type: "image", + imageUrl: b.image_url, + ...(b.detail ? { detail: normalizeImageDetail(b.detail) } : {}), + }); + } else { + parts.push({ type: "text", text: `[image: ${b.file_id ?? "?"}]` }); // file_id ref → no inline data + } + } else if (block.type === "input_file") { + const ref = + (block as { file_id?: string; filename?: string }).file_id ?? + (block as { filename?: string }).filename ?? + "?"; + parts.push({ type: "text", text: `[file: ${ref}]` }); + } + } + // Collapse to a plain string only for a single TEXT part; images must stay structured. + if (parts.length === 1 && parts[0].type === "text") return parts[0].text; + return parts; +} + +type OutputBlock = + | { type: "output_text"; text: string } + | { type: "text"; text: string } + | { type: "refusal"; refusal: string }; + +function outputTextOf(blocks: unknown[] | string | undefined): CodexTextContent[] { + if (typeof blocks === "string") return blocks.length > 0 ? [{ type: "text", text: blocks }] : []; + if (!blocks) return []; + const out: CodexTextContent[] = []; + for (const raw of blocks) { + const b = raw as OutputBlock; + if (b.type === "output_text" || b.type === "text") + out.push({ type: "text", text: (b as { text: string }).text }); + else if (b.type === "refusal") + out.push({ type: "text", text: `[refusal: ${(b as { refusal: string }).refusal}]` }); + } + return out; +} + +function mapToolChoice(value: unknown): CodexRequestOptions["toolChoice"] { + if (value === undefined || value === null) return undefined; + if (value === "auto" || value === "none" || value === "required") return value; + if (isObj(value) && "type" in value) { + const t = (value as { type: string }).type; + if ((t === "function" || t === "custom") && "name" in value) { + return { name: (value as { name: string }).name }; + } + if (t === "allowed_tools" && Array.isArray(value.tools)) { + const names = value.tools + .map(allowedToolName) + .filter((name): name is string => Boolean(name)); + return names.length > 0 + ? { + allowedTools: [...new Set(names)], + mode: value.mode === "required" ? "required" : "auto", + } + : "none"; + } + return "auto"; + } + return undefined; +} + +function allowedToolName(tool: unknown): string | undefined { + if (!isObj(tool)) return undefined; + if (typeof tool.name === "string" && tool.name.length > 0) return tool.name; + if (tool.type === "web_search" || tool.type === "web_search_preview") return WEB_SEARCH_TOOL_NAME; + if (tool.type === "tool_search") return "tool_search"; + return undefined; +} + +function buildTools(tools: unknown[] | undefined): CodexTool[] | undefined { + if (!tools) return undefined; + const out: CodexTool[] = []; + const pushFn = (t: Record, namespace?: string) => { + const tool: CodexTool = { + name: t.name as string, + description: (t.description as string) ?? "", + parameters: (t.parameters ?? {}) as Record, + }; + if (t.strict !== undefined) tool.strict = t.strict as boolean; + if (namespace) tool.namespace = namespace; + out.push(tool); + }; + for (const t of tools) { + if (!isObj(t)) continue; + if (t.type === "function" && typeof t.name === "string") { + pushFn(t); + } else if (t.type === "namespace" && Array.isArray(t.tools)) { + // MCP tools arrive grouped under a namespace tool; flatten the inner function tools so + // chat-completions models receive them (round-trip restores the namespace in the bridge). + const ns = typeof t.name === "string" ? t.name : undefined; + for (const inner of t.tools as unknown[]) { + if (isObj(inner) && inner.type === "function" && typeof inner.name === "string") + pushFn(inner, ns); + } + } else if (t.type === "custom" && typeof t.name === "string") { + // Freeform custom tool (e.g. apply_patch). Chat models can't emit a lark grammar, so expose a + // function with a single string `input` carrying the raw tool body; the bridge relays the model's + // call back as a custom_tool_call (Codex's freeform handler rejects a function_call → fatal abort). + out.push({ + name: t.name, + description: (t.description as string) ?? "", + parameters: { + type: "object", + properties: { + input: { + type: "string", + description: + "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope.", + }, + }, + required: ["input"], + }, + freeform: true, + }); + } else if (t.type === "tool_search") { + // Client-executed tool discovery — the gateway to deferred tools (subagents, extra MCP tools). + // Expose as a function so chat models can call it; the bridge relays it as a tool_search_call. + out.push({ + name: "tool_search", + description: + (t.description as string) ?? "Search for additional tools to load for the next turn.", + parameters: (isObj(t.parameters) + ? t.parameters + : { + type: "object", + properties: { + query: { type: "string", description: "Search query for tools to load." }, + limit: { type: "number", description: "Maximum number of tools to return." }, + }, + required: ["query"], + }) as Record, + toolSearch: true, + }); + } else if ( + typeof t.name === "string" && + t.type !== "web_search" && + t.type !== "image_generation" + ) { + // Any other named tool (for example a native computer-use tool type this parser does not + // model) is client-executed — pass it through as a function so the routed model can read and + // call it naturally; the bridge relays its call as a function_call. Previously such tools were + // silently dropped, so the model never saw them. + pushFn(t); + } + // Only the OpenAI-hosted server-side tools (web_search, image_generation) are intentionally + // dropped — they're executed by OpenAI and can't be relayed to a routed chat model. + } + return out.length > 0 ? out : undefined; +} + +function ensureAssistantPlaceholder( + messages: CodexMessage[], + modelId: string, + now: number +): CodexAssistantMessage { + const last = messages[messages.length - 1]; + if (last && last.role === "assistant") return last; + const placeholder: CodexAssistantMessage = { + role: "assistant", + content: [], + model: modelId, + timestamp: now, + }; + messages.push(placeholder); + return placeholder; +} + +/** + * Tool-call output content. Preserves images (e.g. Codex `view_image` returns + * `input_image` items): returns content parts when any image is present, else a plain joined string. + * Never inlines an image_url as text (that would explode the token count). + */ +function outputToToolResultContent( + output: string | unknown[] | undefined +): string | CodexContentPart[] { + if (typeof output === "string") return output; + if (!Array.isArray(output)) return ""; + const parts: CodexContentPart[] = []; + let hasImage = false; + for (const raw of output) { + if (!isObj(raw)) continue; + if (raw.type === "output_text" || raw.type === "text" || raw.type === "input_text") { + if (typeof raw.text === "string") parts.push({ type: "text", text: raw.text }); + } else if (raw.type === "refusal" && typeof raw.refusal === "string") { + parts.push({ type: "text", text: `[refusal: ${raw.refusal}]` }); + } else if (raw.type === "input_image" && typeof raw.image_url === "string") { + parts.push({ + type: "image", + imageUrl: raw.image_url, + ...(typeof raw.detail === "string" ? { detail: normalizeImageDetail(raw.detail) } : {}), + }); + hasImage = true; + } else if (raw.type === "encrypted_content") { + // codex-rs FunctionCallOutputContentItem::EncryptedContent — opaque to routed models. + parts.push({ type: "text", text: "[encrypted content omitted]" }); + } + } + if (!hasImage) return parts.map((p) => (p.type === "text" ? p.text : "")).join(""); + return parts; +} + +function toolOutputContainsEncryptedContent(output: string | unknown[] | undefined): boolean { + return ( + Array.isArray(output) && output.some((raw) => isObj(raw) && raw.type === "encrypted_content") + ); +} + +/** + * codex-rs ImageDetail allows "original", but chat-completions providers only accept + * auto|low|high on image_url.detail — degrade "original" to "high" (the codex default). + */ +function normalizeImageDetail(detail: string): string { + return detail === "original" ? "high" : detail; +} + +function findToolById( + messages: CodexMessage[], + callId: string +): { name: string; namespace?: string } { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role !== "assistant") continue; + for (const part of m.content) { + if (part.type === "toolCall" && part.id === callId) + return { name: part.name, namespace: part.namespace }; + } + } + return { name: "" }; +} + +const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]); + +export function parseRequest(body: unknown): CodexParsedRequest { + const replayedInputPrefixLength = previousResponseReplayPrefixLength(body); + const parsed = responsesRequestSchema.safeParse(body); + if (!parsed.success) { + throw new Error(`responses parse error: ${parsed.error.message}`); + } + const data = parsed.data; + const now = Date.now(); + const messages: CodexMessage[] = []; + const systemPrompt: string[] = []; + // Responses reasoning siblings belong to the following assistant, including across call items. + // Keep them off the message list until that assistant arrives; turn boundaries clear the array. + const pendingReasoning: Array<{ part: CodexThinkingContent; envelopeSigned: boolean }> = []; + // Assistant placeholder that folds pending reasoning into the same turn before tool calls. + const assistantHolderWithReasoning = (): CodexAssistantMessage => { + const holder = ensureAssistantPlaceholder(messages, data.model, now); + if (pendingReasoning.length > 0) { + holder.content.push(...pendingReasoning.map((entry) => entry.part)); + pendingReasoning.length = 0; + } + return holder; + }; + // Tool specs surfaced by a prior tool_search (deferred tools, e.g. subagents). Codex does not + // re-list these in `tools`, but chat models can only call listed tools — so we re-inject them. + const loadedToolSpecs: unknown[] = []; + // Remote compaction v2: the input tail carries `{type:"compaction_trigger"}` and Codex expects a + // synthetic `{type:"compaction"}` output item (src/responses/compaction.ts). Flagged for the server. + let compactionRequest = false; + let contextCompactionBoundary = false; + + if (typeof data.instructions === "string" && data.instructions.length > 0) { + systemPrompt.push(data.instructions); + } + + if (typeof data.input === "string") { + messages.push({ role: "user", content: data.input, timestamp: now }); + } else if (data.input) { + for (let inputIndex = 0; inputIndex < data.input.length; inputIndex++) { + const item = data.input[inputIndex]; + const effectiveType = + (item as { type?: string }).type ?? ("role" in item ? "message" : undefined); + + if (effectiveType === "compaction_trigger") { + compactionRequest = true; + continue; + } + + if (effectiveType === "additional_tools") { + // Codex Desktop responses_lite WS path: tools ride INSIDE input as an + // `additional_tools` item ({type, role, tools:[...]}) instead of body.tools. + // Same spec wire shapes (function/namespace/custom/tool_search) — collect and + // merge through the exact buildTools path so surface detection (collabSurface) + // and chat-model tool listing see them. The item itself never becomes a message; + // the native passthrough keeps it verbatim in _rawBody. + const at = item as { tools?: unknown[] }; + if (Array.isArray(at.tools)) loadedToolSpecs.push(...at.tools); + continue; + } + + if ( + effectiveType === "compaction" || + effectiveType === "compaction_summary" || + effectiveType === "context_compaction" + ) { + // A stored summary from a previous compaction. Decode our ocx1 envelope into plain text so + // the routed model keeps the compacted context; real OpenAI-encrypted blobs degrade to a note. + // `context_compaction` (encrypted_content optional) is codex-rs's local-compaction marker; + // with no payload it is a pure marker (the summary follows as its own user message), so it + // is dropped silently. It must NOT flag _compactionRequest. Only a marker newly appended in + // this request starts a provider-private context epoch; markers inside the prefix restored by + // previous_response_id were already acknowledged on the turn that introduced them. + if (inputIndex >= replayedInputPrefixLength) contextCompactionBoundary = true; + const encrypted = (item as { encrypted_content?: unknown }).encrypted_content; + if (effectiveType === "context_compaction" && typeof encrypted !== "string") continue; + pendingReasoning.length = 0; + messages.push({ + role: "user", + content: compactionItemToText(typeof encrypted === "string" ? encrypted : undefined), + timestamp: now, + }); + continue; + } + + if (effectiveType === "agent_message") { + const agentMessage = item as { + author?: string; + recipient?: string; + content?: unknown; + }; + + const content = inputContentParts(agentMessage.content as unknown[] | string | undefined); + + const hasContent = + typeof content === "string" ? content.trim().length > 0 : content.length > 0; + + // An agent_message is external input delivered to the parent agent. + // Preserve it as a user-role turn so signed reasoning blocks + // on either side are never merged into one modified assistant response. + pendingReasoning.length = 0; + messages.push({ + role: "user", + content: hasContent ? content : "(sub-agent message received)", + timestamp: now, + }); + + continue; + } + + if (effectiveType === "message") { + const msg = item as { + role?: string; + content?: unknown; + phase?: "commentary" | "final_answer"; + }; + switch (msg.role) { + case "system": { + pendingReasoning.length = 0; + const text = inputContentParts(msg.content as unknown[] | string | undefined); + const flat = + typeof text === "string" + ? text + : text.map((p) => (p.type === "text" ? p.text : "")).join(""); + if (flat.length > 0) systemPrompt.push(flat); + break; + } + case "user": + case "developer": { + pendingReasoning.length = 0; + const content = inputContentParts(msg.content as unknown[] | string | undefined); + messages.push({ role: msg.role, content, timestamp: now }); + break; + } + case "assistant": { + const parts = outputTextOf(msg.content as unknown[] | string | undefined); + messages.push({ + role: "assistant", + content: + pendingReasoning.length > 0 + ? [...pendingReasoning.map((entry) => entry.part), ...parts] + : parts, + ...(msg.phase ? { phase: msg.phase } : {}), + model: data.model, + timestamp: now, + }); + pendingReasoning.length = 0; + break; + } + } + continue; + } + + if (effectiveType === "reasoning") { + const reasoning = item as { + id?: string; + summary?: { text: string }[]; + content?: { text: string }[]; + encrypted_content?: string; + }; + const fromSummary = (reasoning.summary ?? []).map((c) => c.text).join(""); + const text = fromSummary || (reasoning.content ?? []).map((c) => c.text).join(""); + const envelope = + typeof reasoning.encrypted_content === "string" + ? decodeReasoningEnvelope(reasoning.encrypted_content) + : null; + const thinkingText = envelope?.txt || text; + + // Native/non-ocxr1 encrypted-only reasoning is opaque here. Do not create a detached + // assistant turn or invent replayable plaintext/signatures from the encrypted payload. + if (thinkingText.length > 0) { + const part: CodexThinkingContent = { + type: "thinking", + thinking: thinkingText, + signature: envelope?.sig ?? JSON.stringify(reasoning), + ...(envelope?.red ? { redacted: envelope.red } : {}), + ...(reasoning.id ? { itemId: reasoning.id } : {}), + }; + const envelopeSigned = typeof envelope?.sig === "string"; + const previous = pendingReasoning[pendingReasoning.length - 1]; + + if (!envelopeSigned && previous && !previous.envelopeSigned) { + previous.part = { + ...part, + thinking: `${previous.part.thinking}\n${part.thinking}`, + }; + } else { + pendingReasoning.push({ part, envelopeSigned }); + } + } + continue; + } + + if (effectiveType === "function_call") { + const call = item as { + id?: string; + call_id: string; + name: string; + arguments?: string; + namespace?: string; + }; + // Tolerate empty/non-JSON arguments (e.g. a no-arg tool call serialized as "") instead of + // throwing — a single poisoned history item would otherwise 400 every subsequent turn. + let args: Record = {}; + const rawArgs = call.arguments?.trim(); + if (rawArgs) { + try { + const parsed: unknown = JSON.parse(rawArgs); + if (isObj(parsed)) args = parsed; + } catch { + console.warn( + `[parser] function_call ${call.call_id} has non-JSON arguments; defaulting to {}` + ); + } + } + // Do NOT map Responses item `id` (fc_/ctc_/…) onto `thoughtSignature`. That field is + // reserved for genuine opaque thought tokens. A Responses item id is not such a token; + // continuity comes from the in-process replay cache and any real stored signature. + const toolCall: CodexToolCall = { + type: "toolCall", + id: call.call_id, + name: call.name, + arguments: args, + ...(call.namespace ? { namespace: call.namespace } : {}), + }; + assistantHolderWithReasoning().content.push(toolCall); + continue; + } + + if (effectiveType === "custom_tool_call") { + const call = item as { id?: string; call_id: string; name: string; input: string }; + const toolCall: CodexToolCall = { + type: "toolCall", + id: call.call_id, + name: call.name, + arguments: { input: call.input ?? "" }, + customWireName: call.name, + }; + assistantHolderWithReasoning().content.push(toolCall); + continue; + } + + if (effectiveType === "local_shell_call") { + // codex-rs LocalShellCall replay: pair it as an assistant toolCall so the subsequent + // function_call_output (same call_id) doesn't become an orphaned tool result. + const call = item as { + id?: string; + call_id?: string; + action?: { type?: string; command?: string[] }; + }; + const callId = call.call_id ?? call.id; + if (callId) { + const command = Array.isArray(call.action?.command) ? call.action.command : []; + assistantHolderWithReasoning().content.push({ + type: "toolCall", + id: callId, + name: "shell", + arguments: command.length > 0 ? { command } : {}, + }); + } + continue; + } + + if (effectiveType === "web_search_call") { + // Replayed hosted web-search evidence has no paired result payload that routed providers can + // consume. Keep it out of assistant-visible text: the old marker was useful as an internal + // loop hint, but when no sidecar is available the model can echo it as a fake answer. + pendingReasoning.length = 0; + continue; + } + + if (effectiveType === "tool_search_call") { + // Preserve the model's prior tool_search call as an assistant tool call so multi-turn + // history stays complete (otherwise the model re-issues tool_search forever). + const call = item as { id?: string; call_id?: string; arguments?: unknown }; + const callId = call.call_id ?? call.id ?? ""; + assistantHolderWithReasoning().content.push({ + type: "toolCall", + id: callId, + name: "tool_search", + arguments: isObj(call.arguments) ? call.arguments : {}, + }); + continue; + } + + if (effectiveType === "tool_search_output") { + pendingReasoning.length = 0; + // Pair the tool_search call with its result so the model sees what was loaded. + const out = item as { call_id?: string; status?: string; tools?: unknown[] }; + const specs = Array.isArray(out.tools) ? (out.tools as Record[]) : []; + loadedToolSpecs.push(...specs); + // List the EXACT wire names the model must call (flattened for namespaced specs), matching + // how buildTools exposes them — otherwise the model guesses wrong names (e.g. the bare namespace). + const wireNames: string[] = []; + for (const spec of specs) { + if (spec.type === "namespace" && Array.isArray(spec.tools)) { + for (const inner of spec.tools as Record[]) { + if (typeof inner.name === "string") + wireNames.push(namespacedToolName(spec.name as string, inner.name)); + } + } else if (typeof spec.name === "string") { + wireNames.push(spec.name); + } + } + const failed = + typeof out.status === "string" && out.status !== "completed" && out.status !== "success"; + messages.push({ + role: "toolResult", + toolCallId: out.call_id ?? "", + toolName: "tool_search", + content: + failed && wireNames.length === 0 + ? `Tool search failed (status: ${out.status}).` + : wireNames.length + ? `Tool search loaded these tools — they are now in your available tools. Call one by its EXACT name: ${wireNames.join(", ")}.` + : "Tool search returned no tools.", + isError: failed && wireNames.length === 0, + timestamp: now, + }); + continue; + } + + if (effectiveType === "function_call_output") { + pendingReasoning.length = 0; + const output = item as { call_id: string; output?: string | unknown[] }; + const toolInfo = findToolById(messages, output.call_id); + messages.push({ + role: "toolResult", + toolCallId: output.call_id, + toolName: toolInfo.name, + toolNamespace: toolInfo.namespace, + content: outputToToolResultContent(output.output), + isError: false, + timestamp: now, + ...(toolOutputContainsEncryptedContent(output.output) + ? { containsEncryptedContent: true } + : {}), + }); + continue; + } + + if (effectiveType === "custom_tool_call_output") { + pendingReasoning.length = 0; + const output = item as { call_id: string; output: string | unknown[] }; + const toolInfo = findToolById(messages, output.call_id); + messages.push({ + role: "toolResult", + toolCallId: output.call_id, + toolName: toolInfo.name, + toolNamespace: toolInfo.namespace, + // Same payload shape as function_call_output (codex-rs FunctionCallOutputPayload): + // string or content items — normalize arrays instead of leaking raw wire blocks. + content: outputToToolResultContent(output.output), + isError: false, + timestamp: now, + ...(toolOutputContainsEncryptedContent(output.output) + ? { containsEncryptedContent: true } + : {}), + }); + } + } + } + + const declaredTools = buildTools(data.tools as unknown[] | undefined) ?? []; + const loadedTools = buildTools(loadedToolSpecs) ?? []; + const loadedToolNames = new Set(loadedTools.map((t) => namespacedToolName(t.namespace, t.name))); + const seenTools = new Set(); + const mergedTools = [...declaredTools, ...loadedTools] + .filter((t) => { + const k = namespacedToolName(t.namespace, t.name); + if (seenTools.has(k)) return false; + seenTools.add(k); + return true; + }) + .map((t) => + loadedToolNames.has(namespacedToolName(t.namespace, t.name)) + ? { ...t, loadedFromToolSearch: true } + : t + ); + const context: CodexContext = { + ...(systemPrompt.length > 0 ? { systemPrompt } : {}), + messages, + ...(mergedTools.length > 0 ? { tools: mergedTools } : {}), + }; + + const options: CodexRequestOptions = {}; + if (data.max_output_tokens !== undefined) options.maxOutputTokens = data.max_output_tokens; + if (data.temperature !== undefined) options.temperature = data.temperature; + if (data.top_p !== undefined) options.topP = data.top_p; + if (data.stop !== undefined && data.stop !== null) { + options.stopSequences = typeof data.stop === "string" ? [data.stop] : data.stop; + } + const tc = mapToolChoice(data.tool_choice); + if (tc !== undefined) options.toolChoice = tc; + if (data.parallel_tool_calls !== undefined) options.parallelToolCalls = data.parallel_tool_calls; + // Upstream codex-rs converts "ultra" to "max" at the inference boundary (core/src/client.rs + // `reasoning_effort_for_request`), so current clients never send it — but a catalog that + // advertises ultra plus an older/direct caller can. Degrade it to max like upstream instead of + // silently dropping reasoning altogether. + const requestedEffort = data.reasoning?.effort === "ultra" ? "max" : data.reasoning?.effort; + if (requestedEffort && REASONING_EFFORTS.has(requestedEffort)) { + options.reasoning = requestedEffort; + } + const summaryMode = data.reasoning?.summary; + if (!summaryMode || summaryMode === "none") options.hideThinkingSummary = true; + if (data.presence_penalty !== undefined) options.presencePenalty = data.presence_penalty; + if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty; + if (data.service_tier !== undefined) options.serviceTier = data.service_tier; + if (data.prompt_cache_key !== undefined) options.promptCacheKey = data.prompt_cache_key; + + // Stash the hosted web_search config (if Codex enabled it) so the proxy can run searches via the + // gpt-mini sidecar for routed providers. buildTools still drops the hosted tool; the sidecar path + // re-injects a synthetic function tool only when it will actually handle the call. + const webSearch = extractHostedWebSearch(data.tools as unknown[] | undefined); + // Detect structured-output mode (Responses `text.format`) so the web-search sidecar can render its + // tool_result as JSON rather than prose that could corrupt the model's schema-constrained answer. + const structuredOutput = detectStructuredOutput(data.text); + + return { + modelId: data.model, + ...(data.previous_response_id ? { previousResponseId: data.previous_response_id } : {}), + context, + stream: data.stream === true, + options, + _rawBody: body, + ...(replayedInputPrefixLength > 0 ? { _replayPrefixLen: replayedInputPrefixLength } : {}), + ...(webSearch ? { _webSearch: webSearch } : {}), + ...(structuredOutput ? { _structuredOutput: true } : {}), + ...(compactionRequest ? { _compactionRequest: true } : {}), + ...(contextCompactionBoundary ? { _contextCompactionBoundary: true } : {}), + }; +} + +/** True when the Responses `text.format` requests structured output (json_schema or json_object). */ +function detectStructuredOutput(text: unknown): boolean { + if (!isObj(text)) return false; + const format = (text as { format?: unknown }).format; + if (!isObj(format)) return false; + const t = (format as { type?: unknown }).type; + return t === "json_schema" || t === "json_object"; +} diff --git a/open-sse/vendor/codex-chatgpt-web/responses/reasoning-envelope.ts b/open-sse/vendor/codex-chatgpt-web/responses/reasoning-envelope.ts new file mode 100644 index 0000000000..7a49c6c5de --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/responses/reasoning-envelope.ts @@ -0,0 +1,56 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +/** + * Opaque signed-reasoning metadata round-trip through Codex's `encrypted_content` slot. + * + * Some Responses histories contain signed or redacted reasoning metadata that must be replayed + * verbatim. Codex round-trips `encrypted_content`, so the bridge preserves that metadata inside + * the inherited `ocxr1:` + base64(JSON) envelope format. + * + * Native OpenAI-encrypted blobs (no ocxr1 prefix) are left untouched by the decoder, and the + * passthrough scrub strips ocxr1 envelopes before native forwarding. + */ + +export const BRIDGE_REASONING_PREFIX = "ocxr1:"; + +export interface ReasoningEnvelope { + /** Opaque reasoning-block signature, if captured. */ + sig?: string; + /** Raw redacted_thinking block data payloads, order preserved. */ + red?: string[]; + /** + * Hidden thinking text (hideThinkingSummary providers): the signature signs this exact text, + * so replay needs it even though the visible summary was suppressed. + */ + txt?: string; +} + +export function encodeReasoningEnvelope(envelope: ReasoningEnvelope): string { + return ( + BRIDGE_REASONING_PREFIX + Buffer.from(JSON.stringify(envelope), "utf-8").toString("base64") + ); +} + +/** Decode an ocxr1 envelope; returns null for native (OpenAI-encrypted) blobs or garbage. */ +export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnvelope | null { + if (!encryptedContent.startsWith(BRIDGE_REASONING_PREFIX)) return null; + try { + const parsed: unknown = JSON.parse( + Buffer.from(encryptedContent.slice(BRIDGE_REASONING_PREFIX.length), "base64").toString( + "utf-8" + ) + ); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const obj = parsed as { sig?: unknown; red?: unknown }; + const envelope: ReasoningEnvelope = {}; + if (typeof obj.sig === "string") envelope.sig = obj.sig; + if (Array.isArray(obj.red)) { + const red = obj.red.filter((r): r is string => typeof r === "string"); + if (red.length > 0) envelope.red = red; + } + const txt = (parsed as { txt?: unknown }).txt; + if (typeof txt === "string" && txt.length > 0) envelope.txt = txt; + return envelope.sig || envelope.red || envelope.txt ? envelope : null; + } catch { + return null; + } +} diff --git a/open-sse/vendor/codex-chatgpt-web/responses/schema.ts b/open-sse/vendor/codex-chatgpt-web/responses/schema.ts new file mode 100644 index 0000000000..a4fe2518ef --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/responses/schema.ts @@ -0,0 +1,182 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import * as z from "zod/v4"; + +const inputTextSchema = z.object({ type: z.literal("input_text"), text: z.string() }); +const plainTextSchema = z.object({ type: z.literal("text"), text: z.string() }); +const inputImageBlockSchema = z + .object({ + type: z.literal("input_image"), + // codex-rs ImageDetail: auto|low|high|original (view_image --detail original). + detail: z.enum(["auto", "low", "high", "original"]).optional(), + image_url: z.string().optional(), + file_id: z.string().optional(), + }) + .refine((v) => typeof v.image_url === "string" || typeof v.file_id === "string", { + message: "input_image requires at least one of image_url or file_id", + }); +const inputFileBlockSchema = z.object({ + type: z.literal("input_file"), + file_id: z.string().optional(), + filename: z.string().optional(), + file_data: z.string().optional(), +}); +const outputTextSchema = z.object({ type: z.literal("output_text"), text: z.string() }); +const outputRefusalSchema = z.object({ type: z.literal("refusal"), refusal: z.string() }); +const summaryTextSchema = z.object({ type: z.literal("summary_text"), text: z.string() }); +const reasoningTextSchema = z.object({ type: z.literal("reasoning_text"), text: z.string() }); +// codex-rs FunctionCallOutputContentItem (protocol/src/models.rs): input_text | input_image | encrypted_content. +const encryptedContentBlockSchema = z.object({ + type: z.literal("encrypted_content"), + encrypted_content: z.string(), +}); + +const inputContentBlockSchema = z.union([ + inputTextSchema, + plainTextSchema, + inputImageBlockSchema, + inputFileBlockSchema, +]); +const outputContentBlockSchema = z.union([outputTextSchema, plainTextSchema, outputRefusalSchema]); +// Codex tool outputs can contain both input-shaped and output-shaped content blocks. +const toolOutputContentBlockSchema = z.union([ + outputTextSchema, + plainTextSchema, + outputRefusalSchema, + inputTextSchema, + inputImageBlockSchema, + encryptedContentBlockSchema, +]); +const toolOutputSchema = z.union([z.string(), z.array(toolOutputContentBlockSchema)]); + +const userMessageItemSchema = z.object({ + type: z.literal("message").optional(), + role: z.union([z.literal("user"), z.literal("developer")]), + content: z.union([z.string(), z.array(inputContentBlockSchema)]).optional(), +}); +const systemMessageItemSchema = z.object({ + type: z.literal("message").optional(), + role: z.literal("system"), + content: z.union([z.string(), z.array(inputContentBlockSchema)]).optional(), +}); +const assistantMessageItemSchema = z.object({ + type: z.literal("message").optional(), + role: z.literal("assistant"), + content: z.union([z.string(), z.array(outputContentBlockSchema)]).optional(), + phase: z.enum(["commentary", "final_answer"]).optional(), +}); +const reasoningItemSchema = z.object({ + type: z.literal("reasoning"), + id: z.string().optional(), + summary: z.array(summaryTextSchema).optional(), + content: z.array(reasoningTextSchema).optional(), + // Round-tripped opaque payload (native OpenAI encryption OR the proxy's ocxr1 envelope). + encrypted_content: z.string().optional(), +}); +const functionCallItemSchema = z.object({ + type: z.literal("function_call"), + id: z.string().optional(), + call_id: z.string().min(1), + name: z.string().min(1), + namespace: z.string().optional(), + arguments: z.string().optional(), +}); +const functionCallOutputItemSchema = z.object({ + type: z.literal("function_call_output"), + call_id: z.string().min(1), + output: toolOutputSchema.optional(), +}); +const customToolCallItemSchema = z.object({ + type: z.literal("custom_tool_call"), + id: z.string().optional(), + call_id: z.string().min(1), + name: z.string().min(1), + input: z.string(), +}); +const customToolCallOutputItemSchema = z.object({ + type: z.literal("custom_tool_call_output"), + call_id: z.string().min(1), + // codex-rs CustomToolCallOutput carries FunctionCallOutputPayload: string OR content items. + output: toolOutputSchema, +}); + +export const inputItemSchema = z.union([ + userMessageItemSchema, + systemMessageItemSchema, + assistantMessageItemSchema, + reasoningItemSchema, + functionCallItemSchema, + functionCallOutputItemSchema, + customToolCallItemSchema, + customToolCallOutputItemSchema, + z.object({ type: z.string() }).loose(), +]); + +export const toolSchema = z.object({ + type: z.literal("function"), + name: z.string().min(1), + description: z.string().optional(), + parameters: z.record(z.string(), z.unknown()).optional(), + strict: z.boolean().optional(), +}); + +const builtinToolSchema = z.object({ type: z.string() }).loose(); + +const hostedToolType = z.enum([ + "web_search_preview", + "file_search", + "computer_use_preview", + "code_interpreter", + "image_generation", + "mcp", +]); + +const allowedToolEntrySchema = z.object({ type: z.string(), name: z.string().optional() }); + +export const toolChoiceSchema = z.union([ + z.literal("auto"), + z.literal("none"), + z.literal("required"), + z.object({ type: z.literal("function"), name: z.string().min(1) }), + z.object({ type: z.literal("custom"), name: z.string().min(1) }), + z.object({ type: hostedToolType }), + z.object({ + type: z.literal("allowed_tools"), + mode: z.enum(["auto", "required"]), + tools: z.array(allowedToolEntrySchema), + }), +]); + +export const reasoningConfigSchema = z.object({ + effort: z.string().optional(), + summary: z.enum(["auto", "concise", "detailed", "none"]).optional(), +}); + +export const stopSchema = z.union([z.string(), z.array(z.string()), z.null()]); + +export const responsesRequestSchema = z.object({ + model: z.string().min(1), + input: z.union([z.string(), z.array(inputItemSchema)]).optional(), + instructions: z.union([z.string(), z.null()]).optional(), + tools: z.array(z.union([toolSchema, builtinToolSchema])).optional(), + tool_choice: toolChoiceSchema.optional(), + max_output_tokens: z.number().optional(), + temperature: z.number().optional(), + top_p: z.number().optional(), + stop: stopSchema.optional(), + stream: z.boolean().optional(), + reasoning: reasoningConfigSchema.nullable().optional(), + store: z.boolean().optional(), + previous_response_id: z.string().optional(), + parallel_tool_calls: z.boolean().optional(), + prompt_cache_key: z.string().optional(), + metadata: z.unknown().optional(), + user: z.string().optional(), + service_tier: z.string().optional(), + presence_penalty: z.number().optional(), + frequency_penalty: z.number().optional(), + background: z.unknown().optional(), + include: z.unknown().optional(), + prompt: z.unknown().optional(), + text: z.unknown().optional(), + truncation: z.unknown().optional(), +}); diff --git a/open-sse/vendor/codex-chatgpt-web/responses/state.ts b/open-sse/vendor/codex-chatgpt-web/responses/state.ts new file mode 100644 index 0000000000..6197d4ca51 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/responses/state.ts @@ -0,0 +1,277 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile, getConfigDir } from "../config"; + +const MAX_STORED_RESPONSES = 1_000; +const RESPONSE_TTL_MS = 60 * 60 * 1_000; +const SNAPSHOT_DEBOUNCE_MS = 2_000; +/** In-memory high-water byte cap across all entries. Forced store:false continuation chains + * store the full expanded input each turn — ~quadratic bytes per chain — + * so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */ +const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024; +/** Entries whose serialized size exceeds this are kept in memory but skipped on disk: inputs can + * carry base64 `input_image` data URLs, and one screenshot-heavy thread must not balloon the file. */ +const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024; +const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024; + +interface StoredResponseState { + createdAt: number; + items: unknown[]; + namespace?: string; + /** Approximate in-memory size, computed locally at insert time (never trusted from disk). */ + sizeBytes?: number; +} + +const states = new Map(); +let storedResponseBytes = 0; +let byteCapOverride: number | null = null; + +function byteCap(): number { + return byteCapOverride ?? MAX_STORED_RESPONSE_BYTES; +} + +/** Test-only: lower/restore the in-memory byte cap (null restores the default). */ +export function setResponseStateByteCapForTests(bytes: number | null): void { + byteCapOverride = bytes; +} + +/** Test-only: current in-memory byte accounting (proves evictions release their bytes). */ +export function getStoredResponseBytesForTests(): number { + return storedResponseBytes; +} + +/** The ONLY size computation: approximate entry weight from its items payload. */ +function measuredEntry(entry: Omit): StoredResponseState { + let sizeBytes = 0; + try { + sizeBytes = JSON.stringify(entry.items).length; + } catch { + /* unserializable items: weightless rather than fatal */ + } + return { ...entry, sizeBytes }; +} + +/** The ONLY insertion point: keeps the byte counter consistent on replacement. */ +function setEntry(id: string, entry: Omit): void { + deleteEntry(id); + const measured = measuredEntry(entry); + storedResponseBytes += measured.sizeBytes ?? 0; + states.set(id, measured); +} + +/** The ONLY deletion point: TTL, count, byte, and explicit deletes all route here. */ +function deleteEntry(id: string): void { + const existing = states.get(id); + if (!existing) return; + storedResponseBytes -= existing.sizeBytes ?? 0; + if (storedResponseBytes < 0) storedResponseBytes = 0; + states.delete(id); +} +// Expansion provenance must stay proxy-private: a WeakMap distinguishes replayed history from the +// newly appended input suffix without adding an unknown field that native passthrough could send +// upstream. The parser uses this boundary to acknowledge historical compaction markers exactly once. +const replayedInputPrefixLengths = new WeakMap(); +let loaded = false; +let persistTimer: ReturnType | null = null; +let pendingPersistPath: string | null = null; + +function now(): number { + return Date.now(); +} + +function snapshotPath(): string { + return join(getConfigDir(), "responses-state.json"); +} + +/** + * Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the + * dominant expansion-miss cause: an in-memory-only store dies with the process, and the next + * chained turn then reaches the upstream as a naked delta). Load is lazy on first store access; + * persistence is debounced + unref'd so the hot path never blocks and the process can exit. + * Every disk failure is swallowed — the snapshot is a cache, not a source of truth. + */ +function ensureLoaded(): void { + if (loaded) return; + loaded = true; + try { + const path = snapshotPath(); + if (!existsSync(path)) return; + const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown }; + if (raw.version !== 1 || !Array.isArray(raw.states)) return; + for (const entry of raw.states) { + if (!Array.isArray(entry) || entry.length !== 2) continue; + const [id, state] = entry as [unknown, unknown]; + if (typeof id !== "string" || !state || typeof state !== "object") continue; + const rec = state as StoredResponseState; + if (typeof rec.createdAt !== "number" || !Array.isArray(rec.items)) continue; + // Recompute sizes locally while loading; persisted sizeBytes is never trusted. + setEntry(id, { + createdAt: rec.createdAt, + items: rec.items, + }); + } + pruneResponses(); + } catch { + /* missing/corrupt snapshot: start empty */ + } +} + +function persistNow(path: string): void { + if (persistTimer) { + clearTimeout(persistTimer); + persistTimer = null; + } + pendingPersistPath = null; + try { + const entries: [string, StoredResponseState][] = []; + let total = 0; + // Newest-first so the most recent chains survive both caps. + for (const entry of [...states].reverse()) { + // sizeBytes is in-memory accounting only; keep it out of the disk snapshot. + const [id, state] = entry; + const { sizeBytes: _sizeBytes, ...persistable } = state; + const persistEntry: [string, StoredResponseState] = [id, persistable]; + const size = JSON.stringify(persistEntry).length; + if (size > SNAPSHOT_ENTRY_MAX_BYTES) continue; + if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break; + total += size; + entries.push(persistEntry); + } + entries.reverse(); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + // mkdirSync's mode only applies on creation — re-harden an existing config dir so the + // conversation-content snapshot never lands in a group/world-readable directory. + try { + chmodSync(dirname(path), 0o700); + } catch { + /* best-effort (e.g. Windows) */ + } + atomicWriteFile(path, JSON.stringify({ version: 1, states: entries })); + } catch { + /* best-effort: disk trouble must never affect request handling */ + } +} + +function schedulePersist(): void { + if (persistTimer) return; + // Resolve the target path now: tests may swap CODEX_CHATGPT_WEB_HOME before the + // debounce fires, and a late write must land in the home that owned the recorded state. + pendingPersistPath = snapshotPath(); + const path = pendingPersistPath; + persistTimer = setTimeout(() => persistNow(path), SNAPSHOT_DEBOUNCE_MS); + (persistTimer as { unref?: () => void }).unref?.(); +} + +/** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */ +export function flushResponseState(): void { + if (!persistTimer) return; + // Use the path captured when the write was scheduled; CODEX_CHATGPT_WEB_HOME may have moved. + persistNow(pendingPersistPath ?? snapshotPath()); +} + +function inputItems(input: unknown): unknown[] { + if (input === undefined) return []; + if (Array.isArray(input)) return input; + if (typeof input === "string") return [{ role: "user", content: input }]; + return [input]; +} + +function pruneResponses(at = now()): void { + for (const [id, state] of states) { + if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id); + } + while (states.size > MAX_STORED_RESPONSES) { + const oldest = states.keys().next().value; + if (!oldest) break; + deleteEntry(oldest); + } + // Byte high-water eviction, oldest-first (Map preserves insertion order). + while (storedResponseBytes > byteCap() && states.size > 1) { + const oldest = states.keys().next().value; + if (!oldest) break; + deleteEntry(oldest); + } +} + +export function expandPreviousResponseInput(body: unknown, namespace = "default"): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const request = body as Record; + const previousId = + typeof request.previous_response_id === "string" ? request.previous_response_id : undefined; + if (!previousId) return body; + ensureLoaded(); + pruneResponses(); + const previous = states.get(previousId); + if (!previous || (previous.namespace ?? "default") !== namespace) return body; + const expanded = { + ...request, + input: [...previous.items, ...inputItems(request.input)], + }; + replayedInputPrefixLengths.set(expanded, previous.items.length); + return expanded; +} + +/** Number of leading input items restored from previous_response_id state for this exact body. */ +export function previousResponseReplayPrefixLength(body: unknown): number { + if (!body || typeof body !== "object" || Array.isArray(body)) return 0; + return replayedInputPrefixLengths.get(body) ?? 0; +} + +/** + * Cache completed output and max_output_tokens partial output for previous_response_id replay. + * Content-filtered incomplete and failed output are not authoritative replay history. + */ +export function rememberResponseState( + requestBody: unknown, + response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown }, + opts?: { force?: boolean; namespace?: string } +): void { + if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return; + const request = requestBody as Record; + // `force` bypasses only the store:false skip: Codex sends `store:false` on every non-Azure + // HTTP request (and WS inherits it), yet its WS turns still chain with previous_response_id. + // The passthrough branch records with force so those chains can be expanded locally; the + // store stays in-memory with a 1h TTL, so this is a proxy-internal continuation cache, not + // real server-side response storage. + if (request.store === false && !opts?.force) return; + if (typeof response.id !== "string" || !Array.isArray(response.output)) return; + if (response.status === "incomplete") { + const details = response.incomplete_details; + if ( + !details || + typeof details !== "object" || + Array.isArray(details) || + (details as { reason?: unknown }).reason !== "max_output_tokens" + ) + return; + } else if (response.status !== undefined && response.status !== "completed") return; + ensureLoaded(); + setEntry(response.id, { + createdAt: now(), + items: [...inputItems(request.input), ...response.output], + namespace: opts?.namespace ?? "default", + }); + pruneResponses(); + schedulePersist(); +} + +/** Memory-only reset (simulates a process restart: the snapshot file survives). */ +export function clearResponseStateMemoryForTests(): void { + if (persistTimer) { + clearTimeout(persistTimer); + persistTimer = null; + } + states.clear(); + storedResponseBytes = 0; + loaded = false; +} + +export function clearResponseStateForTests(): void { + clearResponseStateMemoryForTests(); + try { + unlinkSync(snapshotPath()); + } catch { + /* no snapshot on disk */ + } +} diff --git a/open-sse/vendor/codex-chatgpt-web/stall-timeout.ts b/open-sse/vendor/codex-chatgpt-web/stall-timeout.ts new file mode 100644 index 0000000000..60096465d5 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/stall-timeout.ts @@ -0,0 +1,21 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +/** + * Bridge upstream stall budget: seconds of silence (no adapter events) before the + * Responses bridge emits `response.incomplete` / `upstream_stall_timeout`. + * + * Raised from 90s so long reasoning + large tool writes are not cut mid-turn. + * Hung streams still die; they just get a more realistic window. + */ +export const DEFAULT_STALL_TIMEOUT_SEC = 300; + +/** + * Resolve the effective bridge stall deadline for a turn. + * - unset / non-finite config → {@link DEFAULT_STALL_TIMEOUT_SEC} + * - finite config → ceil, minimum 1 + */ +export function resolveStallTimeoutSec(configuredSec: number | undefined): number { + if (typeof configuredSec === "number" && Number.isFinite(configuredSec)) { + return Math.max(1, Math.ceil(configuredSec)); + } + return DEFAULT_STALL_TIMEOUT_SEC; +} diff --git a/open-sse/vendor/codex-chatgpt-web/types.ts b/open-sse/vendor/codex-chatgpt-web/types.ts new file mode 100644 index 0000000000..21ae2bcfaf --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/types.ts @@ -0,0 +1,334 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +export interface CodexParsedRequest { + modelId: string; + previousResponseId?: string; + context: CodexContext; + stream: boolean; + options: CodexRequestOptions; + _rawBody?: unknown; + /** Number of leading raw input items restored from local previous_response_id state. */ + _replayPrefixLen?: number; + /** True when the proxy expanded a previous_response_id request into a full input replay. */ + _previousResponseInputExpanded?: boolean; + /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */ + _clientThreadId?: string; + /** + * The hosted `{type:"web_search", ...}` tool config, stashed when Codex enables web search. Routed + * (non-OpenAI) providers can't run it server-side, so the proxy re-exposes it as a function tool and + * executes searches via the gpt-5.4-mini sidecar (see src/web-search). Absent when not requested. + */ + _webSearch?: Record; + /** + * True when Codex requested structured output (`text.format` = json_schema/json_object). The + * web-search tool_result is then rendered as compact JSON instead of markdown prose, so its + * answer/"Sources:" text can't bleed into and corrupt the model's schema-constrained output. + */ + _structuredOutput?: boolean; + /** + * True when the input carried `{type:"compaction_trigger"}` — Codex remote compaction v2 asking + * this turn to produce a `{type:"compaction"}` output item. Routed adapters can't natively; + * the server runs the model as a summarizer and the bridge emits a synthetic compaction item + * (see src/responses/compaction.ts). + */ + _compactionRequest?: boolean; + /** + * True when the current request newly introduced a stored compaction summary/marker. Historical + * markers restored by previous_response_id expansion were already acknowledged and do not reset + * provider-private continuation caches again on every later turn. + */ + _contextCompactionBoundary?: boolean; +} + +export interface CodexContext { + systemPrompt?: string[]; + messages: CodexMessage[]; + tools?: CodexTool[]; +} + +export type CodexMessage = + CodexUserMessage | CodexAssistantMessage | CodexDeveloperMessage | CodexToolResultMessage; + +export interface CodexUserMessage { + role: "user"; + content: string | CodexContentPart[]; + timestamp: number; +} + +export interface CodexAssistantMessage { + role: "assistant"; + content: CodexAssistantContentPart[]; + /** Responses message phase, preserved when replaying translated provider output. */ + phase?: CodexMessagePhase; + model?: string; + timestamp: number; +} + +export interface CodexDeveloperMessage { + role: "developer"; + content: string | CodexContentPart[]; + timestamp: number; +} + +export interface CodexToolResultMessage { + role: "toolResult"; + toolCallId: string; + toolName: string; + /** MCP namespace from the originating tool call, if any. */ + toolNamespace?: string; + /** Text, or content parts when a tool (e.g. Codex view_image) returns an image in its output. */ + content: string | CodexContentPart[]; + /** True when the Responses result contained opaque encrypted output this browser bridge cannot translate. */ + containsEncryptedContent?: boolean; + isError: boolean; + timestamp: number; +} + +export interface CodexTextContent { + type: "text"; + text: string; +} + +export interface CodexImageContent { + type: "image"; + /** A `data:` URL (base64) or a remote https URL — passed through from Codex verbatim, NEVER inlined as text. */ + imageUrl: string; + /** Fidelity hint from Codex: "low" | "high" | "auto". */ + detail?: string; +} + +/** A user/developer message content part: text or an image (vision). */ +export type CodexContentPart = CodexTextContent | CodexImageContent; + +export interface CodexThinkingContent { + type: "thinking"; + thinking: string; + signature?: string; + itemId?: string; + /** Raw opaque reasoning blocks to replay verbatim (order preserved). */ + redacted?: string[]; +} + +export interface CodexToolCall { + type: "toolCall"; + id: string; + name: string; + arguments: Record; + customWireName?: string; + thoughtSignature?: string; + /** MCP namespace (e.g. "mcp__context7") when this call targets a namespaced tool. */ + namespace?: string; +} + +export type CodexAssistantContentPart = CodexTextContent | CodexThinkingContent | CodexToolCall; + +export interface CodexTool { + name: string; + description: string; + parameters: Record; + strict?: boolean; + /** MCP namespace (e.g. "mcp__context7") for tools flattened out of a Responses "namespace" tool. */ + namespace?: string; + /** Freeform/custom tool (e.g. apply_patch): the model's call must be relayed as a custom_tool_call. */ + freeform?: boolean; + /** Client-executed tool discovery (tool_search): the model's call must be relayed as a tool_search_call. */ + toolSearch?: boolean; + /** Tool definition restored from a prior tool_search output; transports may prioritize it when catalogs are bounded. */ + loadedFromToolSearch?: boolean; + /** Synthetic web_search tool: the model's call is executed by the gpt-5.4-mini sidecar, not relayed to Codex. */ + webSearch?: boolean; +} + +/** + * Wire name a chat model sees for a tool. Namespaced (MCP) tools are flattened to + * "__" so they survive the chat-completions function-tool format; + * the proxy maps this back to {namespace, name} on the return trip (Codex routes MCP + * calls by an explicit `namespace` field, not by parsing the name). + */ +export function namespacedToolName(namespace: string | undefined, name: string): string { + return namespace ? `${namespace}__${name}` : name; +} + +export function toolChoiceAliases(tool: Pick): string[] { + const wireName = namespacedToolName(tool.namespace, tool.name); + return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName]; +} + +export function toolAllowedByChoice( + tool: Pick, + allowedTools: ReadonlySet +): boolean { + return toolChoiceAliases(tool).some((name) => allowedTools.has(name)); +} + +export function resolveToolChoiceWireName( + tools: readonly Pick[] | undefined, + name: string +): string { + const match = tools?.find((tool) => toolChoiceAliases(tool).includes(name)); + return match ? namespacedToolName(match.namespace, match.name) : name; +} + +export type CodexToolChoice = + | "auto" + | "none" + | "required" + | { name: string } + | { allowedTools: string[]; mode: "auto" | "required" }; + +export function isAllowedToolChoice( + value: CodexToolChoice | undefined +): value is { allowedTools: string[]; mode: "auto" | "required" } { + return typeof value === "object" && value !== null && "allowedTools" in value; +} + +export interface CodexRequestOptions { + maxOutputTokens?: number; + temperature?: number; + topP?: number; + stopSequences?: string[]; + toolChoice?: CodexToolChoice; + parallelToolCalls?: boolean; + reasoning?: string; + hideThinkingSummary?: boolean; + serviceTier?: string; + presencePenalty?: number; + frequencyPenalty?: number; + /** Responses prompt-cache affinity key. Passthrough preserves it via _rawBody; routed adapters do not consume it unless their upstream wire supports it. */ + promptCacheKey?: string; +} + +export type CodexMessagePhase = "commentary" | "final_answer"; + +/** + * Provider-private state that must follow a locally expanded `previous_response_id` chain. + * Kept out of public Responses output and persisted only in the bounded local continuation cache. + */ +export interface CodexProviderContinuationState { + [provider: string]: Record | undefined; +} + +export type AdapterEvent = + | { type: "heartbeat" } + | { type: "text_delta"; text: string; phase?: CodexMessagePhase } + | { type: "thinking_delta"; thinking: string } + // Opaque signed-reasoning metadata preserved when it appears in a Codex history. + | { type: "thinking_signature"; signature: string } + | { type: "redacted_thinking"; data: string } + | { type: "reasoning_raw_delta"; text: string } + | { type: "tool_call_start"; id: string; name: string } + | { type: "tool_call_delta"; arguments: string } + | { type: "tool_call_end" } + /** Internal boundary between a guarded first pass and its one-shot continuation. */ + | { type: "assistant_boundary" } + // Native web-search activity surfaced by the web-search sidecar so Codex renders a "Searched the + // web" cell. Emitted as a lifecycle PAIR at real wall-clock moments by src/web-search/loop.ts + // (routed adapters never emit these): `begin` right before the sidecar runs so Codex shows the + // "Searching the web" spinner, then `end` once it resolves. The bridge maps begin → an + // output_item.added(in_progress) and end → the matching output_item.done(completed|failed) under + // the SAME output index, so the activity animates instead of flashing completed instantly. + | { type: "web_search_call_begin"; id: string } + | { + type: "web_search_call_end"; + id: string; + queries: string[]; + status?: "completed" | "failed"; + sources?: CodexUrlCitation[]; + } + | { + type: "done"; + usage?: CodexUsage; + stopReason?: string; + endTurn?: boolean; + providerState?: CodexProviderContinuationState; + } + | { + type: "incomplete"; + reason: string; + message?: string; + usage?: CodexUsage; + retryable?: boolean; + endTurn?: boolean; + providerState?: CodexProviderContinuationState; + } + // `usage` carries best-effort partial consumption when a turn dies before a clean done + // so failed requests can log best-effort token counts. + | { + type: "error"; + message: string; + usage?: CodexUsage; + /** Authoritative upstream/proxy status when known; avoids message-based classification. */ + status?: number; + /** Responses error type and code when the adapter has a structured provider failure. */ + errorType?: string; + code?: string; + retryable?: boolean; + }; + +/** + * A web source backing a search answer. Surfaced on the search-end event and rendered by the bridge + * as a `url_citation` annotation on the following assistant message (the desktop app's Sources chip + * reads these; the TUI ignores annotations, so this is additive). + */ +export interface CodexUrlCitation { + url: string; + title?: string; +} + +/** + * Canonical Responses usage convention: + * - `inputTokens` is the TOTAL prompt size, INCLUDING cache reads and cache writes + * (OpenAI Responses convention). + * - `cachedInputTokens` is cache READ tokens only (a subset of `inputTokens`). + * - `cacheReadInputTokens`/`cacheCreationInputTokens` carry the read/write split when + * the provider reports both; reads mirror `cachedInputTokens`. + * - `totalTokens` = inputTokens + outputTokens. Never re-add cache detail on top. + */ +export interface CodexUsage { + inputTokens: number; + outputTokens: number; + totalTokens?: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + reasoningOutputTokens?: number; + estimated?: boolean; +} + +/** The only provider configuration supported by this focused runtime. */ +export interface CodexProviderConfig { + adapter: "chatgpt-web"; + baseUrl: string; + defaultModel?: string; + models?: string[]; + liveModels?: boolean; + contextWindow?: number; + modelContextWindows?: Record; + modelInputModalities?: Record; + modelReasoningEfforts?: Record; + modelDefaultReasoningEfforts?: Record; + noReasoningModels?: string[]; + chatgptWeb?: { + /** ChatGPT custom connector attached to tool-capable temporary chats. */ + appName?: string; + /** Playwright storage-state file created by the explicit browser login. */ + storageStatePath?: string; + /** System Chrome executable. The runtime never downloads a browser. */ + chromeExecutablePath?: string; + /** Internal-only Chromium DevTools endpoint used by the Docker sidecar. */ + cdpEndpoint?: string; + /** Unix socket bridging the turn-bound MCP capability into outer Codex tools. */ + brokerSocketPath?: string; + /** Persisted, trusted Codex task authority used for follow-up turns that omit the envelope. */ + threadEnvironmentStatePath?: string; + /** Maximum duration of one complete browser response. */ + turnTimeoutMs?: number; + /** Keep the single controlled browser visible. */ + headed?: boolean; + /** Attach the turn-bound Codex MCP capability for non-Pro efforts. */ + localToolsEnabled?: boolean; + /** Account capability proven by the authenticated browser probe. */ + proAvailable?: boolean; + /** Authorize per-call "Allow once" confirmation clicks for this connector. */ + autoApproveToolCalls?: boolean; + }; +} diff --git a/open-sse/vendor/codex-chatgpt-web/usage/totals.ts b/open-sse/vendor/codex-chatgpt-web/usage/totals.ts new file mode 100644 index 0000000000..2e6baa2bde --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/usage/totals.ts @@ -0,0 +1,13 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import type { CodexUsage } from "../types"; + +/** + * `inputTokens` already includes cache detail, so cache tokens are never added twice. A provider's + * explicit total is accepted only when it is at least input+output. + */ +export function usageDisplayTotalTokens(usage: CodexUsage | undefined): number | undefined { + if (!usage) return undefined; + const baseTotal = usage.inputTokens + usage.outputTokens; + const explicitTotal = usage.totalTokens; + return typeof explicitTotal === "number" ? Math.max(explicitTotal, baseTotal) : baseTotal; +} diff --git a/open-sse/vendor/codex-chatgpt-web/web-search/synthetic-tool.ts b/open-sse/vendor/codex-chatgpt-web/web-search/synthetic-tool.ts new file mode 100644 index 0000000000..78b3d11f75 --- /dev/null +++ b/open-sse/vendor/codex-chatgpt-web/web-search/synthetic-tool.ts @@ -0,0 +1,54 @@ +/* Adapted from miuuyy/codex-chatgpt-web commit 55592fca0ba19a27f1b769cec8fff61ff340a785 (MIT). */ +import type { CodexTool } from "../types"; + +/** The function name the chat model sees + the name the loop intercepts. */ +export const WEB_SEARCH_TOOL_NAME = "web_search"; + +/** + * Find the hosted `{type:"web_search", ...}` entry in a Responses request's `tools[]` and return it + * verbatim (so its config — external_web_access/filters/user_location/search_context_size — can be + * replayed into the sidecar's REAL web_search tool). Returns undefined when web search isn't enabled. + */ +export function extractHostedWebSearch( + tools: unknown[] | undefined +): Record | undefined { + if (!Array.isArray(tools)) return undefined; + for (const t of tools) { + if (t && typeof t === "object" && (t as { type?: string }).type === "web_search") { + return t as Record; + } + } + return undefined; +} + +/** + * The synthetic function tool exposed to the browser-backed model in place of the dropped hosted + * web_search. The model calls it like any function; the proxy intercepts the call and runs the real + * search via the sidecar (the call is never relayed to Codex). `webSearch:true` flags it for the loop. + */ +export function buildWebSearchTool(): CodexTool { + return { + name: WEB_SEARCH_TOOL_NAME, + description: + "Search the web for current, real-world, or post-training-cutoff information. " + + "Returns a concise answer synthesized from live results, with sources. " + + "Use it whenever the user asks about recent events, versions, prices, docs, or anything you are unsure is current.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "A single search query — a focused natural-language question or keywords.", + }, + queries: { + type: "array", + items: { type: "string" }, + description: + "Optional: run several related queries together in one call. Use instead of `query` to batch independent searches.", + }, + }, + // Either `query` or `queries` is accepted; the proxy normalizes them. + }, + webSearch: true, + }; +} diff --git a/package-lock.json b/package-lock.json index 7c321b3421..35cbaa3f74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,15 @@ "hasInstallScript": true, "license": "MIT", "workspaces": [ - "open-sse" + "open-sse", + "packages/browser-pool" ], "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1073.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@huggingface/transformers": "^4.2.0", "@lobehub/icons": "^5.8.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", @@ -27,12 +29,12 @@ "@xyflow/react": "^12.11.1", "axios": "^1.16.1", "bcryptjs": "^3.0.3", - "better-sqlite3": "^13.0.2", "bottleneck": "^2.19.5", "clsx": "^2.1.1", "commander": "^15.0.0", + "cron-parser": "^5.6.2", "csv-stringify": "^6.7.0", - "dompurify": "^3.4.12", + "dompurify": "^3.4.13", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", @@ -59,6 +61,7 @@ "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "omniglyph": "^1.0.2", + "onnxruntime-node": "~1.24.3", "open": "^11.0.0", "ora": "^9.4.1", "parse5": "^8.0.1", @@ -74,13 +77,15 @@ "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", - "sqlite-vec": "^0.1.9", "tailwind-merge": "^3.6.0", "tsx": "^4.23.0", - "undici": "^8.3.0", + "turndown": "7.2.0", + "turndown-plugin-gfm": "1.0.2", + "undici": "^8.10.0", "update-notifier": "^7.3.1", "uuid": "^14.0.0", "ws": "^8.18.0", @@ -133,6 +138,7 @@ "lint-staged": "^17.0.8", "lockfile-lint": "^5.0.0", "node-loader": "^2.1.0", + "opencode-ai": "1.18.8", "playwright-ctrf-json-reporter": "^0.0.29", "prettier": "^3.8.3", "promptfoo": "^0.121.18", @@ -150,11 +156,11 @@ }, "optionalDependencies": { "@atjsh/llmlingua-2": "2.0.3", - "@huggingface/transformers": "3.5.2", "@tensorflow/tfjs": "4.22.0", "better-sqlite3": "^13.0.2", "js-tiktoken": "^1.0.20", "keytar": "^7.9.0", + "sqlite-vec": "^0.1.9", "tls-client-node": "^0.2.0", "wreq-js": "^2.3.1" } @@ -462,9 +468,9 @@ } }, "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -3075,9 +3081,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -3441,11 +3447,10 @@ } }, "node_modules/@huggingface/jinja": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.4.1.tgz", - "integrity": "sha512-3WXbMFaPkk03LRCM0z0sylmn8ddDm4ubjU7X+Hg4M2GOuMklwoGAFXp9V2keq7vltoB/c7McE5aHUVVddAewsw==", + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", + "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -3454,21 +3459,19 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", - "dev": true, - "license": "Apache-2.0", - "optional": true + "license": "Apache-2.0" }, "node_modules/@huggingface/transformers": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.5.2.tgz", - "integrity": "sha512-mfRXkmcL99+ibpjM++pvZmc2h3po8i1ZgSRI5Rtgh++P15GU0lY8UQteYt/w5V+GQw+Jpao93MoipcePzh3mKg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", + "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { - "@huggingface/jinja": "^0.4.1", - "onnxruntime-node": "1.21.0", - "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", - "sharp": "^0.34.1" + "@huggingface/jinja": "^0.5.6", + "@huggingface/tokenizers": "^0.1.3", + "onnxruntime-node": "1.24.3", + "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", + "sharp": "^0.34.5" } }, "node_modules/@humanfs/core": { @@ -3560,7 +3563,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" } @@ -4520,7 +4522,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -4984,6 +4986,12 @@ "@chevrotain/types": "~11.1.2" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -6225,6 +6233,10 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@omniroute/browser-pool": { + "resolved": "packages/browser-pool", + "link": true + }, "node_modules/@omniroute/open-sse": { "resolved": "open-sse", "link": true @@ -8271,35 +8283,30 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.1" @@ -8309,28 +8316,24 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@radix-ui/number": { @@ -11612,7 +11615,6 @@ "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -12639,9 +12641,9 @@ } }, "node_modules/@yarnpkg/parsers/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -12880,9 +12882,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=14.0" } @@ -13598,14 +13598,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 +13667,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": { @@ -13858,8 +13855,7 @@ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/bottleneck": { "version": "2.19.5", @@ -13954,16 +13950,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 +15788,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", @@ -16790,7 +16796,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "devOptional": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -16820,7 +16825,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "devOptional": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -16921,8 +16925,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/detect-node-es": { "version": "1.1.0", @@ -16979,9 +16982,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -17766,8 +17769,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/es6-promisify": { "version": "7.0.0", @@ -19259,8 +19261,7 @@ "version": "25.9.23", "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", - "license": "Apache-2.0", - "optional": true + "license": "Apache-2.0" }, "node_modules/flatted": { "version": "3.4.2", @@ -20079,7 +20080,6 @@ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", "license": "BSD-3-Clause", - "optional": true, "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", @@ -20093,11 +20093,10 @@ } }, "node_modules/global-agent/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "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" }, @@ -20146,7 +20145,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "devOptional": true, "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -20501,8 +20499,7 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/hachure-fill": { "version": "0.5.2", @@ -20536,7 +20533,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "devOptional": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -23641,8 +23637,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/json5": { "version": "2.2.3", @@ -24392,14 +24387,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", @@ -25196,9 +25183,9 @@ } }, "node_modules/lockfile-lint/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -25433,6 +25420,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", @@ -25581,7 +25577,6 @@ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", "license": "MIT", - "optional": true, "dependencies": { "escape-string-regexp": "^4.0.0" }, @@ -26005,9 +26000,9 @@ } }, "node_modules/mermaid": { - "version": "11.16.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", - "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "version": "11.16.1", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz", + "integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.2", @@ -26883,24 +26878,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", @@ -26914,7 +26891,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -27034,7 +27011,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -27528,9 +27505,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -28430,7 +28407,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -28635,41 +28611,38 @@ } }, "node_modules/onnxruntime-common": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", - "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", - "license": "MIT", - "optional": true + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "license": "MIT" }, "node_modules/onnxruntime-node": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", - "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", "hasInstallScript": true, "license": "MIT", - "optional": true, "os": [ "win32", "darwin", "linux" ], "dependencies": { + "adm-zip": "^0.5.16", "global-agent": "^3.0.0", - "onnxruntime-common": "1.21.0", - "tar": "^7.0.1" + "onnxruntime-common": "1.24.3" } }, "node_modules/onnxruntime-web": { - "version": "1.22.0-dev.20250409-89f8206ba4", - "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", - "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "version": "1.26.0-dev.20260416-b7804b056c", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", + "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", "license": "MIT", - "optional": true, "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", - "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } @@ -28678,15 +28651,13 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0", - "optional": true + "license": "Apache-2.0" }, "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { - "version": "1.22.0-dev.20250409-89f8206ba4", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", - "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", - "license": "MIT", - "optional": true + "version": "1.24.0-dev.20251116-b39e144322", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", + "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", + "license": "MIT" }, "node_modules/open": { "version": "11.0.0", @@ -28739,6 +28710,196 @@ } } }, + "node_modules/opencode-ai": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.8.tgz", + "integrity": "sha512-eZvYK0rIc/NUDQ+s3LsO9gyUU3MswsbNOLZz06iPwVhbg/2jF6bkTaroBgiIdFWKwUn5sj+kSMc4TBYxFkMrNQ==", + "cpu": [ + "arm64", + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "bin": { + "opencode": "bin/opencode.exe" + }, + "optionalDependencies": { + "opencode-darwin-arm64": "1.18.8", + "opencode-darwin-x64": "1.18.8", + "opencode-darwin-x64-baseline": "1.18.8", + "opencode-linux-arm64": "1.18.8", + "opencode-linux-arm64-musl": "1.18.8", + "opencode-linux-x64": "1.18.8", + "opencode-linux-x64-baseline": "1.18.8", + "opencode-linux-x64-baseline-musl": "1.18.8", + "opencode-linux-x64-musl": "1.18.8", + "opencode-windows-arm64": "1.18.8", + "opencode-windows-x64": "1.18.8", + "opencode-windows-x64-baseline": "1.18.8" + } + }, + "node_modules/opencode-darwin-arm64": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.8.tgz", + "integrity": "sha512-ZZCIEgTvHxOHk52Aeqhq59t/R0aqs29bPIgu45XE4rkgjmn/XCkTWalCPtyzJHipdcEbq/g0lqsE1OlJV0oNbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-darwin-x64": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.8.tgz", + "integrity": "sha512-2EXRMJbRKnFPWI9oDU9tb7jDGmKiPmfjCLtwJMe3EF57h5wfcdEH9sP25bR3Og5NbE2M+PtMcJm0jMeHn2XoLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-darwin-x64-baseline": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.8.tgz", + "integrity": "sha512-eLXa2tK9LRuZ5e20QG2k4dmWAA5xnLgJ1afRTSD0/ybE6CAeK02i8vFCnFFDaxuBo+gnq+yqO8AkqvN1m64V/Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-linux-arm64": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.8.tgz", + "integrity": "sha512-7kj3c9JEdryHgK+o8zE/N9KzTOdbiDn6KpY8dl+hM9n5Cnmxezx4IAlgJeC9QxpIx8Omop6CYuZ+17KfrKdKLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-arm64-musl": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.8.tgz", + "integrity": "sha512-tww5TF/LIOv/GoTNyzGYgqDRhbJrhoMu8R+p5yD/SpnXPg3rcfYREw2wRy9yikyPU9sAQksuIIteTsyGerPjlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.8.tgz", + "integrity": "sha512-Sm4fbQ9BdLI6hgN6FYYX8Nql+Sqe/2EKHJu3iWg0UYs93AXN4ROi0rvOmRbMk+ycYgOchb0hL6Ti2opxLx17sg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-baseline": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.8.tgz", + "integrity": "sha512-egeEF4tk1rK9flIQjjeSVB9cR/X3zUti0pNAHW6ROJkNkj72z2C2FmjK1hZbfjtteCueMXPLptS23JROHGWL1w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-baseline-musl": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.8.tgz", + "integrity": "sha512-S+438BXs48gLeXX/ya4TSNytDy9mliU3sOAf6j9rfFjzGiF/S08LedemSAnHkr0riBtamik1aRPSmTjhQ0dOBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-musl": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.8.tgz", + "integrity": "sha512-c+E4Zsp0DYVcuqcDtgxw/4YcFLrVYWdGBR8x4CzpW48ga3RshaH+BlmUiy+GY0yr1x6UR+e2V3w4uzvzm/L9UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-windows-arm64": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.8.tgz", + "integrity": "sha512-7NjdtEIiX28kmsKD9jHbFG4bbwBB5T4dAe2UwdnOqCBb2cl+ETV5eO6kbdC/xWxrgOghgZM1Wtw791T5pQPyag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/opencode-windows-x64": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.8.tgz", + "integrity": "sha512-G+NEgEMvu/dEYshH5IaqHVTmsHVuGdORBvVmgphFiknT7q/NXPuoZCMtMIdfNlEFbu54BlzRDdJCR3Mqe98gUw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/opencode-windows-x64-baseline": { + "version": "1.18.8", + "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.8.tgz", + "integrity": "sha512-IGbjFyWoSN9rdGUJX7TWkQ1Yl673Q3dDna54b5NtqeRcZ839p+Z47zzM5m883HKAxrdKCC6Z22HYuDcXLV0laA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -29728,8 +29889,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/playwright": { "version": "1.62.0", @@ -29753,7 +29913,6 @@ "version": "1.61.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", - "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -30296,32 +30455,6 @@ "sharp": "^0.35.3" } }, - "node_modules/promptfoo/node_modules/@huggingface/jinja": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", - "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/promptfoo/node_modules/@huggingface/transformers": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", - "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@huggingface/jinja": "^0.5.6", - "@huggingface/tokenizers": "^0.1.3", - "onnxruntime-node": "1.24.3", - "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", - "sharp": "^0.34.5" - } - }, "node_modules/promptfoo/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -30435,14 +30568,6 @@ "@keyv/serialize": "^1.1.1" } }, - "node_modules/promptfoo/node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "dev": true, - "license": "Apache-2.0", - "optional": true - }, "node_modules/promptfoo/node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -30486,57 +30611,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/promptfoo/node_modules/onnxruntime-common": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", - "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/promptfoo/node_modules/onnxruntime-node": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", - "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "win32", - "darwin", - "linux" - ], - "dependencies": { - "adm-zip": "^0.5.16", - "global-agent": "^3.0.0", - "onnxruntime-common": "1.24.3" - } - }, - "node_modules/promptfoo/node_modules/onnxruntime-web": { - "version": "1.26.0-dev.20260416-b7804b056c", - "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", - "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "flatbuffers": "^25.1.24", - "guid-typescript": "^1.0.9", - "long": "^5.2.3", - "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", - "platform": "^1.3.6", - "protobufjs": "^7.2.4" - } - }, - "node_modules/promptfoo/node_modules/onnxruntime-web/node_modules/onnxruntime-common": { - "version": "1.24.0-dev.20251116-b39e144322", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", - "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/promptfoo/node_modules/path-key": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", @@ -30625,7 +30699,6 @@ "version": "7.6.5", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", - "devOptional": true, "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -30649,7 +30722,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/proxy-addr": { @@ -31975,13 +32047,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", @@ -32059,7 +32124,6 @@ "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", "license": "BSD-3-Clause", - "optional": true, "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", @@ -32076,8 +32140,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/robot3": { "version": "0.4.1", @@ -32442,8 +32505,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/send": { "version": "1.2.1", @@ -32476,7 +32538,6 @@ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", "license": "MIT", - "optional": true, "dependencies": { "type-fest": "^0.13.1" }, @@ -32492,7 +32553,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", "license": "(MIT OR CC0-1.0)", - "optional": true, "engines": { "node": ">=10" }, @@ -32579,7 +32639,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", @@ -32629,7 +32688,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" }, @@ -33290,6 +33348,7 @@ "resolved": "https://registry.npmjs.org/sqlite-vec/-/sqlite-vec-0.1.9.tgz", "integrity": "sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==", "license": "MIT OR Apache", + "optional": true, "optionalDependencies": { "sqlite-vec-darwin-arm64": "0.1.9", "sqlite-vec-darwin-x64": "0.1.9", @@ -34062,7 +34121,7 @@ "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -34109,7 +34168,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -34119,7 +34178,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -34637,6 +34696,21 @@ "node": "*" } }, + "node_modules/turndown": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz", + "integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, + "node_modules/turndown-plugin-gfm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz", + "integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==", + "license": "MIT" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -34958,9 +35032,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", - "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -34970,7 +35044,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "devOptional": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -36409,9 +36482,9 @@ } }, "node_modules/xmlbuilder2/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -36751,12 +36824,52 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.50", + "version": "3.8.50" + }, + "packages/browser-pool": { + "name": "@omniroute/browser-pool", + "version": "0.1.0", "dependencies": { - "@toon-format/toon": "^4.1.0", - "safe-regex": "^2.1.1", - "smol-toml": "1.7.1" + "playwright": "1.61.1" + }, + "devDependencies": { + "@types/node": "^22" } + }, + "packages/browser-pool/node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/browser-pool/node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "packages/browser-pool/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 954ee5871c..7691c1a1c3 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", @@ -34,12 +34,16 @@ "scripts/dev/tls-options.mjs", "scripts/check/check-supported-node-runtime.ts", "scripts/dev/sync-env.mjs", + "scripts/build/assembleStandalone.mjs", + "scripts/build/backendOnlyPages.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", "!**/node_modules/**", + "THIRD_PARTY_NOTICES.md", "!**/__tests__/**", "!**/*.test.ts", "!**/*.test.tsx", @@ -49,7 +53,8 @@ "!**/*.spec.tsx" ], "workspaces": [ - "open-sse" + "open-sse", + "packages/browser-pool" ], "engines": { "node": ">=22.22.2 <23 || >=24.0.0 <27" @@ -144,6 +149,7 @@ "i18n:check-value-drift": "node scripts/i18n/check-ui-value-drift.mjs", "i18n:check-value-drift:warn": "node scripts/i18n/check-ui-value-drift.mjs --warn", "i18n:check-glossary": "node scripts/i18n/check-glossary-consistency.mjs", + "i18n:check-glossary:ko": "node scripts/i18n/check-glossary-consistency.mjs --locale=ko", "check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts", "check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts", "check:pack-boot": "node scripts/check/check-pack-boot.mjs", @@ -178,6 +184,7 @@ "check:known-symbols": "bun scripts/check/check-known-symbols.ts", "check:route-guard-membership": "node --import tsx scripts/check/check-route-guard-membership.ts", "check:test-discovery": "node scripts/check/check-test-discovery.mjs", + "check:forgotten-sibling-tests": "node scripts/check/check-forgotten-sibling-tests.mjs", "check:mutation-test-coverage": "node scripts/check/check-mutation-test-coverage.mjs --strict", "check:complexity": "node scripts/check/check-complexity.mjs", "check:dead-code": "node scripts/check/check-dead-code.mjs", @@ -207,6 +214,7 @@ "typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json", "typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json", "check:dashboard-typecheck": "node scripts/check/check-dashboard-typecheck.mjs", + "check:open-sse-typecheck": "node scripts/check/check-open-sse-typecheck.mjs", "backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts", "env:sync": "node scripts/dev/sync-env.mjs", "test:integration": "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/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", @@ -221,7 +229,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", @@ -240,10 +248,12 @@ "prepare": "husky", "system-info": "node scripts/dev/system-info.mjs", "build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs", + "postbuild": "node scripts/build/colocate-standalone.mjs", "release:contributors": "node scripts/release/gen-contributors.mjs", "release:uncovered": "node scripts/release/list-uncovered-commits.mjs", "test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", - "test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"" + "test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"", + "alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs" }, "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1073.0", @@ -263,8 +273,9 @@ "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.12", + "dompurify": "^3.4.13", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", @@ -306,30 +317,34 @@ "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", - "sqlite-vec": "^0.1.9", "tailwind-merge": "^3.6.0", "tsx": "^4.23.0", - "undici": "^8.3.0", + "turndown": "7.2.0", + "turndown-plugin-gfm": "1.0.2", + "undici": "^8.10.0", "update-notifier": "^7.3.1", "uuid": "^14.0.0", "ws": "^8.18.0", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", "zod": "^4.4.3", - "zustand": "^5.0.13" + "zustand": "^5.0.13", + "@huggingface/transformers": "^4.2.0", + "onnxruntime-node": "~1.24.3" }, "optionalDependencies": { "@atjsh/llmlingua-2": "2.0.3", - "@huggingface/transformers": "3.5.2", "@tensorflow/tfjs": "4.22.0", "better-sqlite3": "^13.0.2", "js-tiktoken": "^1.0.20", "keytar": "^7.9.0", "tls-client-node": "^0.2.0", - "wreq-js": "^2.3.1" + "wreq-js": "^2.3.1", + "sqlite-vec": "^0.1.9" }, "devDependencies": { "@axe-core/playwright": "^4.11.3", @@ -371,6 +386,7 @@ "lint-staged": "^17.0.8", "lockfile-lint": "^5.0.0", "node-loader": "^2.1.0", + "opencode-ai": "1.18.8", "playwright-ctrf-json-reporter": "^0.0.29", "prettier": "^3.8.3", "promptfoo": "^0.121.18", @@ -412,9 +428,8 @@ "unrs-resolver": true }, "overrides": { - "dompurify": "^3.4.12", "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", @@ -428,7 +443,7 @@ "fast-uri": "^3.1.5", "body-parser": "^2.3.0", "@yarnpkg/parsers": { - "js-yaml": "^4.2.0" + "js-yaml": "^4.3.1" }, "jsdom": { "undici": "^7.29.0" @@ -442,12 +457,39 @@ "adm-zip": "^0.6.0", "promptfoo": { "js-yaml": "^5.2.2", - "@apidevtools/json-schema-ref-parser": { - "js-yaml": "^4.2.0" - }, "undici": "^7.29.0" }, "socket.io-parser": "^4.2.7", - "tar": "^7.5.21" + "tar": "^7.5.21", + "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" + }, + "lockfile-lint": { + "js-yaml": "^4.3.1" + }, + "xmlbuilder2": { + "js-yaml": "^4.3.1" + }, + "nanoid": "^3.3.17", + "monaco-editor": { + "dompurify": "^3.4.13" + } } } diff --git a/packages/browser-pool/package.json b/packages/browser-pool/package.json new file mode 100644 index 0000000000..6e773eb7ab --- /dev/null +++ b/packages/browser-pool/package.json @@ -0,0 +1,15 @@ +{ + "name": "@omniroute/browser-pool", + "version": "0.1.0", + "private": true, + "description": "Optional browser pool service for OmniRoute — CloakBrowser and Playwright-backed chat", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "playwright": "1.61.1" + }, + "devDependencies": { + "@types/node": "^22" + } +} diff --git a/packages/browser-pool/src/index.ts b/packages/browser-pool/src/index.ts new file mode 100644 index 0000000000..923e8a03c9 --- /dev/null +++ b/packages/browser-pool/src/index.ts @@ -0,0 +1,37 @@ +/** + * @omniroute/browser-pool — Optional browser pool for Playwright-backed + * executor support (claude-web, duckduckgo-web, grok). + * + * Core stubs dynamically import this package at runtime. When the package + * is not installed, the stubs degrade gracefully (fallback or error). + */ + +// ── Re-exports from browserPool ────────────────────────────────────────── +export { + acquireBrowserContext, + releaseBrowserContext, + getBrowserPoolMetrics, + readPageResponseBody, + openPage, + shutdownPool, + setProxyResolver, + __resetBrowserPoolMetricsForTest, +} from "./services/browserPool.ts"; + +export type { BrowserPoolContextOptions, BrowserPoolMetrics, PooledContext } from "./interfaces.ts"; + +// ── Re-exports from browserBackedChat ──────────────────────────────────── +export { + browserBackedChat, + startBrowserWarmup, + getFreshCookiesWithWarmup, +} from "./services/browserBackedChat.ts"; + +// ── Re-exports from grokClearance ───────────────────────────────────────── +export { + getCachedCookies, + setCachedCookies, + clearCookieCache, +} from "./services/browserBackedChat.ts"; + +export { shouldUseGrokBrowserBacked, acquireFreshGrokClearance } from "./services/grokClearance.ts"; diff --git a/packages/browser-pool/src/interfaces.ts b/packages/browser-pool/src/interfaces.ts new file mode 100644 index 0000000000..d11f092bc1 --- /dev/null +++ b/packages/browser-pool/src/interfaces.ts @@ -0,0 +1,94 @@ +/** + * interfaces.ts — Shared type definitions for @omniroute/browser-pool. + * + * These types are used by both the package entry and the core stubs. + * The core stubs re-export them so existing import paths remain stable. + */ + +import type { BrowserContext, Page } from "playwright"; + +// ── Browser pool ─────────────────────────────────────── + +export interface BrowserPoolContextOptions { + cookieDomain: string; + cookieString?: string | null; + warmupUrl?: string | null; + userAgent?: string; + locale?: string; + timezone?: string; + preferCloakbrowser?: boolean; + /** Time (ms) to wait for the warmup page to be ready. */ + waitFor?: number; +} + +export interface PooledContext { + id: string; + context: BrowserContext; + warmupPage: Page | null; + lastUsed: number; + isStealth: boolean; +} + +export interface BrowserPoolMetrics { + browserLaunches: number; + browserLaunchFailures: number; + contextsCreated: number; + contextsReused: number; + contextsEvicted: number; + contextsReleased: number; + contextCreateFailures: number; + shutdowns: number; + lastShutdownReason: string | null; +} + +// ── Browser-backed chat ──────────────────────────────── + +export interface BrowserBackedChatRequest { + /** Pool key — typically a provider id like "duckduckgo-web" or + * "claude-web", optionally suffixed by user/account id. */ + poolKey: string; + /** Chat URL the page should submit to (captured via waitForResponse). */ + chatUrl: string; + /** Chat page URL to navigate to before typing. */ + chatPageUrl: string; + /** The text the user wants to send. */ + userMessage: string; + /** Cookie string (raw) to inject into the browser context. */ + cookieString?: string | null; + /** Cookie domain (used together with cookieString). */ + cookieDomain?: string; + /** Domain for the page's fetch to identify the chat endpoint. */ + chatUrlMatchDomain: string; + /** User-Agent string for the browser context. */ + userAgent?: string; + /** Locale (BCP 47). Defaults to en-US. */ + locale?: string; + /** IANA timezone. Defaults to America/New_York. */ + timezone?: string; + /** Selector for the chat input. */ + inputSelector: string; + /** Selector for the submit button (optional — falls back to Enter). */ + submitButtonSelector?: string; + /** Wait after submit for SSE/JSON to arrive. Default 15 seconds. */ + postSubmitWaitMs?: number; + /** Optional AbortSignal. Cancels navigation/submit. */ + signal?: AbortSignal | null; + /** Reuse the same context across requests. Default true. */ + reuseContext?: boolean; +} + +export interface BrowserBackedChatTiming { + acquireContextMs: number; + navigateMs: number; + submitMs: number; + captureResponseMs: number; + totalMs: number; +} + +export interface BrowserBackedChatResult { + status: number; + contentType: string | null; + body: Buffer; + isStealth: boolean; + timing: BrowserBackedChatTiming; +} diff --git a/packages/browser-pool/src/services/browserBackedChat.ts b/packages/browser-pool/src/services/browserBackedChat.ts new file mode 100644 index 0000000000..143f7a0833 --- /dev/null +++ b/packages/browser-pool/src/services/browserBackedChat.ts @@ -0,0 +1,461 @@ +/** + * browserBackedChat.ts — Full browser-backed chat interaction for @omniroute/browser-pool. + * + * Opens a page on a shared browser context, navigates to the provider's + * chat page, types the user's message, clicks Send, and returns the + * upstream SSE/JSON response body as a structured result. + * + * Providers using this path: duckduckgo-web, claude-web. + * + * The browser solves the provider's challenge natively (VQD, Cloudflare + * Turnstile, etc.) by computing real DOM measurement values. The + * Node-side challenge solver still runs as a first-line best-effort; + * this module is the fallback. + */ + +import { Buffer } from "node:buffer"; +import { + acquireBrowserContext, + openPage, + readPageResponseBody, + releaseBrowserContext, +} from "./browserPool.ts"; +import type { + PooledContext, + BrowserBackedChatRequest, + BrowserBackedChatResult, +} from "../interfaces.ts"; + +// Safety constants +const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB + +// Cookie cache constants +const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000; // Cache fresh cookies for 5 minutes +const COOKIE_POLL_INTERVAL_MS = 500; // Poll for cookies every 500ms +const COOKIE_POLL_TIMEOUT_MS = 5000; // Max poll time for cookies + +// Cookie cache — avoids repeated browser launches when cookies are still valid +interface CachedCookies { + cookieString: string; + expiresAt: number; + domain: string; +} +const cookieCache = new Map(); + +export function getCachedCookies(domain: string): string | null { + const cached = cookieCache.get(domain); + if (cached && Date.now() < cached.expiresAt) return cached.cookieString; + cookieCache.delete(domain); + return null; +} + +export function setCachedCookies(domain: string, cookieString: string, ttlMs?: number): void { + cookieCache.set(domain, { + cookieString, + expiresAt: Date.now() + (ttlMs ?? COOKIE_CACHE_TTL_MS), + domain, + }); +} + +export function clearCookieCache(): void { + cookieCache.clear(); +} + +// Dedup pending cookie refreshes per pool key +const pendingRefreshes = new Map>(); + +/** Sanitize an error message for safe JSON transport. */ +const MAX_ERROR_LEN = 512; +function sanitizeErrorMessage(message: unknown): string { + let str = typeof message === "string" ? message : String(message ?? ""); + if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN); + const nl = str.indexOf("\n"); + if (nl >= 0) str = str.slice(0, nl); + return str.replace(/[^ -~]/g, "").trim(); +} + +/** Wait N milliseconds, abortable via signal. */ +async function waitWithSignal(ms: number, signal?: AbortSignal | null): Promise { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * waitForCookiesWithPolling — Poll for cookies every 500ms up to 5s. + * Returns as soon as challenge cookies appear, instead of always + * waiting the full timeout. Saves 1-4s when anti-bot resolves quickly. + */ +async function waitForCookiesWithPolling( + context: import("playwright").BrowserContext, + cookieDomain: string, + signal: AbortSignal | null +): Promise { + const deadline = Date.now() + COOKIE_POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + const cookies = await context.cookies(cookieDomain); + const cookieString = cookies.map((c) => `${c.name}=${c.value}`).join("; "); + if (cookieString) return cookieString; + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await waitWithSignal(Math.min(COOKIE_POLL_INTERVAL_MS, remaining), signal); + } + return null; +} + +/** + * doCookieRefreshOnContext — Run cookie extraction on an already-acquired + * browser context. Opens a temporary page, navigates to the chat URL, + * polls for cookies, and returns the result. + * NOTE: Does NOT pass AbortSignal to Playwright methods — signals are + * handled via waitWithSignal wrapping instead. + */ +async function doCookieRefreshOnContext( + pooled: PooledContext, + chatPageUrl: string, + cookieDomain: string, + signal: AbortSignal | null +): Promise { + const page = await openPage(pooled); + try { + await page.goto(chatPageUrl, { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + return await waitForCookiesWithPolling(pooled.context, cookieDomain, signal); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") throw err; + return null; + } finally { + await page.close().catch(() => {}); + } +} + +/** + * Match a URL against a chat URL template, allowing a single dynamic + * id segment (PLACEHOLDER) in the template. + */ +function chatUrlMatcher(u: string, matchDomain: string, chatUrl: string): boolean { + if (u === chatUrl) return true; + let parsed: URL; + let chatParsed: URL; + try { + parsed = new URL(u); + chatParsed = new URL(chatUrl); + } catch { + return false; + } + if (!parsed.host.endsWith(matchDomain)) return false; + const chatSeg = chatParsed.pathname.split("/").filter(Boolean); + const reqSeg = parsed.pathname.split("/").filter(Boolean); + if (chatSeg.length < 2 || reqSeg.length !== chatSeg.length) return false; + let allowedDynamic = 1; + for (let i = 0; i < chatSeg.length; i++) { + if (chatSeg[i] === reqSeg[i]) continue; + if (chatSeg[i] === "PLACEHOLDER" && allowedDynamic > 0) { + allowedDynamic--; + continue; + } + return false; + } + return true; +} + +/** Resolve a unique pool key; when reuseContext is false, create a unique key. */ +async function settlePoolKey( + requestedKey: string, + reuseContext: boolean +): Promise<{ key: string; acquired: boolean }> { + if (reuseContext) return { key: requestedKey, acquired: true }; + return { + key: `${requestedKey}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + acquired: false, + }; +} + +// ── Cookie refresh helpers ────────────────────────── + +/** + * doRefresh — Acquire a fresh browser context, navigate to the + * chat page, and poll for cookies. Returns the cookie string + * or null on failure. + * NOTE: Does NOT pass AbortSignal to Playwright methods — signals + * are handled via waitWithSignal wrapping instead. + */ +async function doRefresh(options: { + chatPageUrl: string; + cookieDomain: string; + poolKey: string; + signal: AbortSignal | null; +}): Promise { + const pooled = await acquireBrowserContext(options.poolKey + "-refresh", { + cookieDomain: options.cookieDomain, + cookieString: null, + warmupUrl: options.chatPageUrl, + }); + const page = await openPage(pooled); + try { + await page.goto(options.chatPageUrl, { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + return await waitForCookiesWithPolling(pooled.context, options.cookieDomain, options.signal); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") throw err; + return null; + } finally { + await page.close().catch(() => {}); + // release context — we got the cookies + setTimeout(() => { + releaseBrowserContext(options.poolKey + "-refresh").catch(() => {}); + }, 1000); + } +} + +/** + * refreshCookiesViaBrowser — Refresh cookies using a browser context. + * Uses pendingRefreshes dedup so concurrent requests share one browser launch. + * NOTE: Override check (httpOverride) is handled in the core stub — this + * package version always attempts browser cookie refresh. + */ +async function refreshCookiesViaBrowser( + chatUrl: string, + chatPageUrl: string, + cookieDomain: string, + poolKey: string, + signal: AbortSignal | null +): Promise { + const pending = pendingRefreshes.get(poolKey); + if (pending) return pending; + const promise = doRefresh({ chatPageUrl, cookieDomain, poolKey, signal }); + pendingRefreshes.set(poolKey, promise); + try { + return await promise; + } finally { + pendingRefreshes.delete(poolKey); + } +} + +/** + * startBrowserWarmup — Pre-warm a browser context for the given pool key. + * This is a fire-and-forget operation: errors are caught and ignored. + * The warmup page serves as a readiness indicator — we open a page in the + * pooled context to force early navigation before the actual request. + */ +export async function startBrowserWarmup( + poolKey: string, + chatPageUrl: string, + cookieDomain: string, + signal: AbortSignal | null +): Promise { + if (process.env.OMNIROUTE_BROWSER_POOL === "off") return; + const pooled = await acquireBrowserContext(poolKey, { + cookieDomain, + cookieString: null, + warmupUrl: chatPageUrl, + waitFor: 2000, + }); + // Warmup: open a page in the pooled context — this can happen in parallel + openPage(pooled).catch(() => {}); +} + +/** + * getFreshCookiesWithWarmup — Try cached cookies first; if none, start + * a browser warmup in parallel with a cookie refresh. Returns cookie string + * or null. Caches successful results. + */ +export async function getFreshCookiesWithWarmup( + chatUrl: string, + chatPageUrl: string, + cookieDomain: string, + poolKey: string, + signal: AbortSignal | null +): Promise { + // Try cached cookies first + const cached = getCachedCookies(cookieDomain); + if (cached) return cached; + + // Start warmup in parallel with refresh + const warmup = startBrowserWarmup(poolKey, chatPageUrl, cookieDomain, signal); + const fresh = await refreshCookiesViaBrowser(chatUrl, chatPageUrl, cookieDomain, poolKey, signal); + // Await warmup (errors are non-fatal) + await warmup.catch(() => {}); + if (fresh) { + setCachedCookies(cookieDomain, fresh); + return fresh; + } + return null; +} + +// ── Main entry point ─────────────────────────────────── + +export async function browserBackedChat( + req: BrowserBackedChatRequest +): Promise { + const t0 = Date.now(); + const { + poolKey, + chatUrl, + chatPageUrl, + userMessage, + cookieString, + cookieDomain, + chatUrlMatchDomain, + userAgent, + locale, + timezone, + inputSelector, + submitButtonSelector, + postSubmitWaitMs = 15000, + signal, + reuseContext = true, + } = req; + + const { key, acquired: reuseAcquired } = await settlePoolKey(poolKey, reuseContext); + const tAcquireStart = Date.now(); + const pooled: PooledContext = await acquireBrowserContext(key, { + cookieDomain: cookieDomain || chatUrlMatchDomain, + cookieString: cookieString || null, + warmupUrl: chatPageUrl, + userAgent, + locale, + timezone, + }); + const acquireContextMs = Date.now() - tAcquireStart; + + const page = await openPage(pooled); + try { + const tNavStart = Date.now(); + await page.goto(chatPageUrl, { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + const navigateMs = Date.now() - tNavStart; + + const inputLocator = page.locator(inputSelector).first(); + await inputLocator.waitFor({ state: "visible", timeout: 10000 }); + await waitWithSignal(800, signal); + + const responsePromise = page.waitForResponse( + (r) => + r.request().method() === "POST" && chatUrlMatcher(r.url(), chatUrlMatchDomain, chatUrl), + { timeout: 30000 } + ); + + let abortListener: (() => void) | undefined; + const signalPromise = signal + ? new Promise((_, reject) => { + if (signal.aborted) return reject(new DOMException("Aborted", "AbortError")); + abortListener = () => reject(new DOMException("Aborted", "AbortError")); + signal.addEventListener("abort", abortListener, { once: true }); + }) + : null; + + if (submitButtonSelector) { + const btn = page.locator(submitButtonSelector).first(); + if ((await btn.count()) > 0) { + try { + await btn.click({ timeout: 2000 }); + } catch { + await page.keyboard.press("Enter"); + } + } else { + await page.keyboard.press("Enter"); + } + } else { + await page.keyboard.press("Enter"); + } + const tCaptureStart = Date.now(); + const response = signalPromise + ? await Promise.race([responsePromise, signalPromise]).catch(() => null) + : await responsePromise.catch(() => null); + if (signal && abortListener) { + signal.removeEventListener("abort", abortListener); + } + if (response) { + await waitWithSignal(Math.min(postSubmitWaitMs, 30000), signal); + } else { + await waitWithSignal(postSubmitWaitMs, signal); + } + const captureResponseMs = Date.now() - tCaptureStart; + const submitMs = captureResponseMs; + + let status = 0; + let contentType: string | null = null; + let body: Buffer = Buffer.alloc(0); + if (response) { + const captured = await readPageResponseBody(response); + if (captured.body.length > MAX_RESPONSE_BYTES) { + body = Buffer.from( + JSON.stringify({ + error: { + message: "Response too large", + type: "upstream_error", + }, + }) + ); + status = 502; + contentType = "application/json"; + } else { + body = captured.body as unknown as Buffer; + contentType = captured.headers["content-type"] || null; + } + } + + return { + status, + contentType, + body, + isStealth: pooled.isStealth, + timing: { + acquireContextMs, + navigateMs, + submitMs, + captureResponseMs, + totalMs: Date.now() - t0, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const body = Buffer.from( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(`browserBackedChat failed: ${msg}`), + type: "upstream_error", + }, + }) + ); + return { + status: 502, + contentType: "application/json", + body, + isStealth: pooled.isStealth, + timing: { + acquireContextMs, + navigateMs: 0, + submitMs: 0, + captureResponseMs: 0, + totalMs: Date.now() - t0, + }, + }; + } finally { + await page.close(); + if (!reuseAcquired) { + try { + await pooled.context.close(); + } catch { + /* ignore */ + } + } + } +} diff --git a/packages/browser-pool/src/services/browserPool.ts b/packages/browser-pool/src/services/browserPool.ts new file mode 100644 index 0000000000..f2566b1378 --- /dev/null +++ b/packages/browser-pool/src/services/browserPool.ts @@ -0,0 +1,440 @@ +/** + * browserPool.ts — Shared stealth browser pool for web-cookie providers. + * + * The DuckDuckGo VQD challenge and Claude web's Cloudflare Turnstile both + * validate values that only a real browser can produce (DOM layout + * measurements like offsetWidth/Height, getBoundingClientRect, + * getComputedStyle, iframe contentWindow probes). Plain Node fetch + a + * VM-stubs solver structurally runs the JS but cannot match those values, + * so the server rejects the request. + * + * This pool keeps one Chromium instance warm and serves "browser contexts" + * (one per provider) on demand. Each context owns one or more pages; the + * caller is expected to be polite (one page per request, close on done). + * + * The pool prefers `cloakbrowser` (npm) when available — its binary-level + * fingerprint patches (--fingerprint-timezone, --fingerprint-locale, and + * dozens more) are the only thing that gets past DuckDuckGo's anti-bot + * in this environment. Falls back to plain `playwright` if cloakbrowser + * is not installed; the fallback works for Claude web (which only needs + * valid cookies) but not for DDG's VQD challenge. + * + * Opt-in: pool only launches Chromium when an executor explicitly asks + * for a context, so users who never use the browser-backed path pay zero + * startup cost. Set OMNIROUTE_BROWSER_POOL=off to fully disable. + */ + +import { Buffer } from "node:buffer"; +import type { + BrowserPoolContextOptions, + BrowserPoolMetrics, + PooledContext, +} from "../interfaces.ts"; + +type Browser = import("playwright").Browser; +type BrowserContext = import("playwright").BrowserContext; +type Page = import("playwright").Page; + +/** Proxy resolver injected by the core stub after dynamic import. */ +type ProxyResolverFn = ( + providerKey: string +) => Promise; + +let injectedProxyResolver: ProxyResolverFn | null = null; + +export function setProxyResolver(fn: ProxyResolverFn): void { + injectedProxyResolver = fn; +} + +function createBrowserPoolMetrics(): BrowserPoolMetrics { + return { + browserLaunches: 0, + browserLaunchFailures: 0, + contextsCreated: 0, + contextsReused: 0, + contextsEvicted: 0, + contextsReleased: 0, + contextCreateFailures: 0, + shutdowns: 0, + lastShutdownReason: null, + }; +} + +interface PoolState { + browser: Browser | null; + contexts: Map; + pendingContexts: Map>; + launching: Promise | null; + lastActivity: number; + idleTimer: NodeJS.Timeout | null; + evictTimer: NodeJS.Timeout | null; + cloakLaunch: ((opts: unknown) => Promise) | null; + cloakLaunchResolved: boolean; + metrics: BrowserPoolMetrics; +} + +const POOL_IDLE_TIMEOUT_MS = 5 * 60 * 1000; +const CONTEXT_TTL_MS = 10 * 60 * 1000; // 10 min — evict stale contexts +const EVICT_INTERVAL_MS = 60 * 1000; // check every 60s +const DEFAULT_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; + +const state: PoolState = { + browser: null, + contexts: new Map(), + pendingContexts: new Map(), + launching: null, + lastActivity: 0, + idleTimer: null, + evictTimer: null, + cloakLaunch: null, + cloakLaunchResolved: false, + metrics: createBrowserPoolMetrics(), +}; + +function getCloakbrowserModuleId(): string { + // Keep this computed: cloakbrowser is an optional runtime enhancer, and a literal + // dynamic import with the package name makes Turbopack resolve it during route compilation. + return ["cloak", "browser"].join(""); +} + +async function resolveCloakLaunch(): Promise<((opts: unknown) => Promise) | null> { + if (state.cloakLaunchResolved) return state.cloakLaunch; + state.cloakLaunchResolved = true; + try { + const mod = (await import(getCloakbrowserModuleId())) as unknown as { + launch?: (opts: unknown) => Promise; + }; + state.cloakLaunch = mod.launch ?? null; + } catch { + state.cloakLaunch = null; + } + return state.cloakLaunch; +} + +function isPoolEnabled(): boolean { + const flag = process.env.OMNIROUTE_BROWSER_POOL; + if (flag === undefined) return true; + return flag !== "off" && flag !== "0" && flag !== "false"; +} + +function resetIdleTimer(): void { + if (state.idleTimer) clearTimeout(state.idleTimer); + state.idleTimer = setTimeout(() => { + void shutdownPool("idle-timeout"); + }, POOL_IDLE_TIMEOUT_MS); + state.idleTimer.unref?.(); +} + +function evictStaleContexts(): void { + const now = Date.now(); + for (const [key, pooled] of state.contexts) { + if (now - pooled.lastUsed > CONTEXT_TTL_MS) { + console.log( + "[BrowserPool] Evicted stale context:", + key, + "(idle", + ((now - pooled.lastUsed) / 1000).toFixed(0) + "s)" + ); + state.contexts.delete(key); + state.metrics.contextsEvicted++; + pooled.context.close().catch(() => {}); + } + } + if (state.contexts.size === 0 && !state.launching) { + void shutdownPool("all-contexts-evicted"); + } +} + +function startEvictTimer(): void { + if (state.evictTimer) clearInterval(state.evictTimer); + state.evictTimer = setInterval(() => evictStaleContexts(), EVICT_INTERVAL_MS); + state.evictTimer.unref?.(); +} + +async function launchBrowser(): Promise { + if (state.browser) return state.browser; + if (state.launching) return state.launching; + state.launching = (async () => { + const cloakLaunch = await resolveCloakLaunch(); + let browser: Browser; + if (cloakLaunch) { + browser = await cloakLaunch({ + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + } else { + // Fallback: plain Playwright. Works for Claude web (cookie-only + // auth) but DDG's VQD challenge will detect this Chromium build. + const { chromium } = await import("playwright"); + browser = await chromium.launch({ + headless: true, + args: [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-blink-features=AutomationControlled", + ], + }); + } + state.browser = browser; + state.launching = null; + state.metrics.browserLaunches++; + return browser; + })(); + try { + return await state.launching; + } catch (err) { + state.launching = null; + state.metrics.browserLaunchFailures++; + throw err; + } +} + +function parseCookieString( + raw: string, + domain: string +): Array<{ + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: "Lax" | "Strict" | "None"; +}> { + return raw + .split(";") + .map((p) => p.trim()) + .filter(Boolean) + .map((pair) => { + const eq = pair.indexOf("="); + if (eq < 0) return null; + const name = pair.slice(0, eq).trim(); + const value = pair.slice(eq + 1).trim(); + if (!name || !value) return null; + return { + name, + value, + domain: domain.startsWith(".") ? domain : `.${domain}`, + path: "/", + expires: -1, + httpOnly: false, + secure: true, + sameSite: "Lax" as const, + }; + }) + .filter(Boolean) as Array<{ + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: "Lax" | "Strict" | "None"; + }>; +} + +// Clear a key from the pending-creation map once its promise settles, counting +// failures. Kept as a leaf helper so acquireBrowserContext stays under the +// function-length ceiling (#3368 PR7 metrics). +function settlePendingContext(key: string, failed: boolean): void { + if (failed) state.metrics.contextCreateFailures++; + state.pendingContexts.delete(key); +} + +export async function acquireBrowserContext( + key: string, + options: BrowserPoolContextOptions +): Promise { + if (!isPoolEnabled()) { + throw new Error( + "browserPool: OMNIROUTE_BROWSER_POOL=off — context requested but pool is disabled" + ); + } + const existing = state.contexts.get(key); + if (existing) { + existing.lastUsed = Date.now(); + state.lastActivity = Date.now(); + state.metrics.contextsReused++; + resetIdleTimer(); + return existing; + } + + // Dedup concurrent creations for the same key + const pending = state.pendingContexts.get(key); + if (pending) return pending; + + const createPromise = (async (): Promise => { + const proxy = injectedProxyResolver ? await injectedProxyResolver(key) : undefined; + const [browser] = await Promise.all([launchBrowser()]); + const isStealth = state.cloakLaunch !== null; + const context = await browser.newContext({ + userAgent: options.userAgent || DEFAULT_USER_AGENT, + locale: options.locale || "en-US", + timezoneId: options.timezone || "America/New_York", + viewport: { width: 1280, height: 800 }, + ...(proxy ? { proxy } : {}), + }); + + if (options.cookieString) { + const cookies = parseCookieString(options.cookieString, options.cookieDomain); + if (cookies.length > 0) { + await context.addCookies(cookies); + } + } + + let warmupPage: Page | null = null; + if (options.warmupUrl) { + try { + warmupPage = await context.newPage(); + await warmupPage.goto(options.warmupUrl, { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + // Give the warmup a moment for the upstream's status/auth/country + // JSON endpoints to fire. Without this, the first chat request would + // pay the warmup cost on the hot path. + await new Promise((r) => setTimeout(r, 1500)); + } catch (err) { + try { + await warmupPage?.close(); + } catch { + /* ignore */ + } + warmupPage = null; + void err; + } + } + + // Guard: if shutdownPool() ran while we were creating this context, + // the browser we obtained is now closed. Close our temp context and + // throw so the caller knows to retry. + if (state.browser !== browser) { + await context.close().catch(() => {}); + if (warmupPage) { + await warmupPage.close().catch(() => {}); + } + throw new Error("Pool shut down during context creation"); + } + + const pooled: PooledContext = { + id: key, + context, + warmupPage, + lastUsed: Date.now(), + isStealth, + }; + state.contexts.set(key, pooled); + state.metrics.contextsCreated++; + state.lastActivity = Date.now(); + resetIdleTimer(); + startEvictTimer(); + return pooled; + })(); + + state.pendingContexts.set(key, createPromise); + createPromise + .then(() => settlePendingContext(key, false)) + .catch(() => settlePendingContext(key, true)); + + return createPromise; +} + +export async function openPage(pooled: PooledContext): Promise { + return pooled.context.newPage(); +} + +export async function releaseBrowserContext(key: string): Promise { + const pooled = state.contexts.get(key); + if (!pooled) return; + state.contexts.delete(key); + state.metrics.contextsReleased++; + try { + await pooled.context.close(); + } catch { + /* ignore */ + } + if (state.contexts.size === 0) { + await shutdownPool("last-context-closed"); + } +} + +export async function shutdownPool(reason: string): Promise { + state.metrics.shutdowns++; + state.metrics.lastShutdownReason = reason; + if (state.idleTimer) { + clearTimeout(state.idleTimer); + state.idleTimer = null; + } + if (state.evictTimer) { + clearInterval(state.evictTimer); + state.evictTimer = null; + } + state.pendingContexts.clear(); + for (const [key, pooled] of state.contexts) { + try { + await pooled.context.close(); + } catch { + /* ignore */ + } + state.contexts.delete(key); + } + if (state.browser) { + try { + await state.browser.close(); + } catch { + /* ignore */ + } + state.browser = null; + } + state.lastActivity = Date.now(); + // Avoid unused-parameter lint: log reason via debug if anyone hooks + // process.on('exit') and prints state. + void reason; +} + +function getBrowserPoolStatus(): { + enabled: boolean; + contexts: number; + browserRunning: boolean; + stealthAvailable: boolean; + lastActivityAgoMs: number; +} { + return { + enabled: isPoolEnabled(), + contexts: state.contexts.size, + browserRunning: state.browser !== null, + stealthAvailable: state.cloakLaunch !== null, + lastActivityAgoMs: state.lastActivity === 0 ? -1 : Date.now() - state.lastActivity, + }; +} + +/** + * #3368 PR7 — browser-pool observability. Returns live status plus cumulative + * lifecycle telemetry (launches, context create/reuse/evict/release counts, + * failures, shutdowns). Surfaced via the omniroute_browser_pool_status MCP tool. + */ +export function getBrowserPoolMetrics(): { + status: ReturnType; + metrics: BrowserPoolMetrics; +} { + return { status: getBrowserPoolStatus(), metrics: { ...state.metrics } }; +} + +/** Test-only: reset cumulative metrics so assertions start from a clean slate. */ +export function __resetBrowserPoolMetricsForTest(): void { + state.metrics = createBrowserPoolMetrics(); +} + +export async function readPageResponseBody( + response: import("playwright").Response +): Promise<{ status: number; headers: Record; body: Buffer }> { + const headers: Record = {}; + for (const [name, value] of Object.entries(response.headers())) { + headers[name] = value; + } + const body = await response.body(); + return { status: response.status(), headers, body: Buffer.from(body) }; +} diff --git a/packages/browser-pool/src/services/grokClearance.ts b/packages/browser-pool/src/services/grokClearance.ts new file mode 100644 index 0000000000..4b7838ea1b --- /dev/null +++ b/packages/browser-pool/src/services/grokClearance.ts @@ -0,0 +1,73 @@ +/** + * grokClearance.ts — gated browser-backed cf_clearance acquisition for + * grok-web (#8019). + * + * grok.com sits behind Cloudflare Enterprise, which pins `cf_clearance` to + * the client's IP+TLS+UA fingerprint. Pure cookie-replay from + * `grokTlsClient.ts` (TLS-impersonating fetch) cannot forge a fresh + * clearance from a datacenter egress that Cloudflare has already flagged — + * only a real browser solving the challenge natively can mint one bound to + * that egress's own fingerprint. + * + * This module reuses the EXISTING provider-agnostic browser pool + * (`browserPool.ts`, already live for claude-web + duckduckgo-web) rather + * than adding a new Turnstile solver — `claudeTurnstileSolver.ts` is + * claude.ai-specific and does not apply here. + * + * Opt-in only: gated behind `OMNIROUTE_BROWSER_POOL` / `WEB_COOKIE_USE_BROWSER` + * (the same env gate already used by claude-web.ts / duckduckgo-web.ts). + * With the gate off, `acquireFreshGrokClearance` is never called — the + * executor stays on the Step-1 `cloudflare_challenge` classification. + */ + +import { acquireBrowserContext } from "./browserPool.ts"; +import type { PooledContext } from "../interfaces.ts"; + +const GROK_WARMUP_URL = "https://grok.com/"; +const GROK_COOKIE_DOMAIN = ".grok.com"; +const GROK_POOL_KEY = "grok-web"; + +/** + * Reads the same opt-in gate as claude-web/duckduckgo-web + * (`WEB_COOKIE_USE_BROWSER` or `OMNIROUTE_BROWSER_POOL`). Off by default. + */ +export function shouldUseGrokBrowserBacked(): boolean { + const flag = process.env.WEB_COOKIE_USE_BROWSER; + if (flag === "1" || flag === "true" || flag === "on") return true; + const poolFlag = process.env.OMNIROUTE_BROWSER_POOL; + return poolFlag === "on" || poolFlag === "1" || poolFlag === "true"; +} + +async function readCfClearanceFromContext(pooled: PooledContext): Promise { + const cookies = await pooled.context.cookies(GROK_WARMUP_URL); + const match = cookies.find((c) => c.name === "cf_clearance"); + return match?.value || null; +} + +async function acquireViaPool(): Promise { + try { + const pooled = await acquireBrowserContext(GROK_POOL_KEY, { + cookieDomain: GROK_COOKIE_DOMAIN, + cookieString: null, + warmupUrl: GROK_WARMUP_URL, + }); + return await readCfClearanceFromContext(pooled); + } catch { + return null; + } +} + +/** + * Acquire a fresh `.grok.com` cf_clearance via the shared browser pool. + * Never throws — resolves to `null` on any failure so callers can fall + * through to the Cloudflare-challenge error rather than crash the request. + */ +export async function acquireFreshGrokClearance( + signal?: AbortSignal | null +): Promise { + try { + return await acquireViaPool(); + } catch { + return null; + } +} diff --git a/packages/browser-pool/tsconfig.json b/packages/browser-pool/tsconfig.json new file mode 100644 index 0000000000..02444e43a6 --- /dev/null +++ b/packages/browser-pool/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "esModuleInterop": true, + "strict": false, + "lib": ["esnext"], + "types": ["node"], + "allowJs": false + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 72766ea810..565bce2e98 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - "packages/*" - "open-sse" allowBuilds: "@parcel/watcher": true 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/public/providers/soniox.svg b/public/providers/soniox.svg new file mode 100644 index 0000000000..343c3d5f33 --- /dev/null +++ b/public/providers/soniox.svg @@ -0,0 +1 @@ +Soniox diff --git a/quality-ratchet/quality-ratchet.md b/quality-ratchet/quality-ratchet.md new file mode 100644 index 0000000000..1d59da5ada --- /dev/null +++ b/quality-ratchet/quality-ratchet.md @@ -0,0 +1,62 @@ +# Quality Ratchet + +| Métrica | Baseline | Atual | Status | +| ----------------------------------------------------------------- | -------- | ----- | --------------------- | +| eslintWarnings | 0 | 0 | ok | +| eslintErrors | 0 | 0 | ok | +| coverage.statements | 80.8 | — | SKIP (ausente) | +| coverage.lines | 80.8 | — | SKIP (ausente) | +| coverage.functions | 86.42 | — | SKIP (ausente) | +| coverage.branches | 78.1 | — | SKIP (ausente) | +| coverage.chatCore.lines | 72.45 | — | SKIP (ausente) | +| coverage.combo.lines | 85.42 | — | SKIP (ausente) | +| coverage.accountFallback.lines | 96.78 | — | SKIP (ausente) | +| coverage.auth.lines | 92.55 | — | SKIP (ausente) | +| coverage.routeGuard.lines | 98.73 | — | SKIP (ausente) | +| coverage.error.lines | 92.13 | — | SKIP (ausente) | +| coverage.publicCreds.lines | 99.07 | — | SKIP (ausente) | +| coverage.circuitBreaker.lines | 95.09 | — | SKIP (ausente) | +| openapiCoverage.pct | 38 | 38 | ok | +| i18nUiCoverage.pct | 99 | 99 | ok | +| deadExports | 227 | — | SKIP (dedicated gate) | +| cognitiveComplexity | 1223 | — | SKIP (dedicated gate) | +| typeCoveragePct | 92.17 | — | SKIP (dedicated gate) | +| codeqlAlerts | 0 | — | SKIP (dedicated gate) | +| secretFindings | 0 | — | SKIP (dedicated gate) | +| zizmorFindings | 190 | — | SKIP (dedicated gate) | +| vulnCount | 10 | — | SKIP (dedicated gate) | +| bundleSize | 7666 | — | SKIP (dedicated gate) | +| openapiBreaking | 0 | — | SKIP (dedicated gate) | +| mutationScore.src/sse/services/auth.ts | 52.57 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/services/accountFallback.ts | 68.38 | — | SKIP (dedicated gate) | +| mutationScore.src/server/authz/routeGuard.ts | 76.08 | — | SKIP (dedicated gate) | +| mutationScore.src/shared/utils/circuitBreaker.ts | 56.94 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/utils/error.ts | 43.83 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/utils/publicCreds.ts | 59.76 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/services/combo/autoStrategy.ts | 41.33 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/services/combo/comboStructure.ts | 57.82 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/services/combo/validateQuality.ts | 61.33 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/services/combo/comboPredicates.ts | 56.62 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/services/combo/rrState.ts | 70.88 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/services/combo/shadowRouting.ts | 48 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/services/combo/targetSorters.ts | 68.3 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/services/combo/comboData.ts | 76.94 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/services/combo/quotaScoring.ts | 39.73 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/services/combo/quotaStrategies.ts | 50.3 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/passthroughHelpers.ts | 80.89 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/sanitization.ts | 70.15 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/upstreamTimeouts.ts | 33 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/comboContextCache.ts | 13.62 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/idempotency.ts | 42.82 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/responseHeaders.ts | 62.7 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/executorHelpers.ts | 70.39 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/memoryExtraction.ts | 62.06 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/nonStreamingSse.ts | 72.82 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/passthroughToolNames.ts | 66.42 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/headers.ts | 94.29 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/logTruncation.ts | 77.64 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/memorySkillsInjection.ts | 13.49 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/semanticCache.ts | 60.16 | — | SKIP (dedicated gate) | +| mutationScore.open-sse/handlers/chatCore/telemetryHelpers.ts | 83.18 | — | SKIP (dedicated gate) | + +**Sem regressões — gate OK.** diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index 3b9842e45a..ccc87157d3 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -10,7 +10,7 @@ * .next/standalone -> outDir (cp) Y Y Y SHARED * .next/static -> outDir/.next/static (cp) Y Y Y SHARED * public/ -> outDir/public/ (cp) Y Y Y SHARED - * wreq-js/rust -> outDir/node_modules/wreq-js/rust Y - - SHARED (native asset) + * wreq-js -> outDir/node_modules/wreq-js Y Y Y SHARED (extra module) * better-sqlite3/build -> outDir/node_modules/better-sqlite3/ Y - - SHARED (native asset) * @swc/helpers -> outDir/node_modules/@swc/helpers Y Y Y SHARED (extra module) * pino-abstract-transport -> outDir/node_modules/... Y - - SHARED (extra module) @@ -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,17 +75,30 @@ 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 = [ - { - label: "wreq-js native runtime", - src: ["node_modules", "wreq-js", "rust"], - dest: ["node_modules", "wreq-js", "rust"], - }, +export const NATIVE_ASSET_ENTRIES = [ { label: "better-sqlite3 native binary", src: ["node_modules", "better-sqlite3", "build"], dest: ["node_modules", "better-sqlite3", "build"], }, + { + 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 @@ -102,6 +112,15 @@ const NATIVE_ASSET_ENTRIES = [ /** @type {{label:string, src:string[], dest:string[]}[]} */ const EXTRA_MODULE_ENTRIES = [ + { + // tlsClient.ts intentionally resolves wreq-js through a runtime-dynamic + // require so Turbopack cannot rewrite the package name to a hashed external. + // That also makes the package invisible to static tracing, so copy the whole + // module—not only rust/—into every standalone artifact. + label: "wreq-js TLS runtime", + src: ["node_modules", "wreq-js"], + dest: ["node_modules", "wreq-js"], + }, { label: "@swc/helpers", src: ["node_modules", "@swc", "helpers"], @@ -179,6 +198,11 @@ const EXTRA_MODULE_ENTRIES = [ src: ["scripts", "dev", "responses-ws-proxy.mjs"], dest: ["responses-ws-proxy.mjs"], }, + { + label: "ChatGPT Web Codex MCP tunnel entrypoint", + src: ["bin", "chatgpt-web-codex-mcp.mjs"], + dest: ["bin", "chatgpt-web-codex-mcp.mjs"], + }, { label: "webdav-handler (server-ws.mjs dependency)", src: ["scripts", "dev", "webdav-handler.mjs"], @@ -237,6 +261,16 @@ const EXTRA_MODULE_ENTRIES = [ src: ["node_modules", "undici"], dest: ["node_modules", "undici"], }, + { + // Turbopack's standalone tracer can emit a hollow node_modules/ws/ directory + // for the externalized `ws` package (no package.json / index.js), which then + // shadows the real install at runtime and crashes instrumentation with: + // "Cannot find package '/node_modules/ws/index.js'" (#OmniRoute v3.8.50 live bug). + // Overlay the full source package so the bundled server resolves the real entrypoint. + label: "ws (externalized runtime package shadow fix)", + src: ["node_modules", "ws"], + dest: ["node_modules", "ws"], + }, { label: "sql.js WASM fallback runtime", src: ["node_modules", "sql.js"], @@ -263,7 +297,7 @@ const EXTRA_MODULE_ENTRIES = [ ]; /** - * Copy native standalone assets (wreq-js rust/, better-sqlite3 build/). + * Copy native standalone assets (better-sqlite3 build/prebuilds and TPROXY). * * The destination is derived as //standalone/node_modules/... * for backward compatibility with existing callers and tests. @@ -313,6 +347,8 @@ async function syncNativeAssetsToDir(projectRoot, outDir, fsImpl, log) { if (!(await exists(sourcePath))) continue; const destinationPath = path.join(outDir, ...entry.dest); + if (path.resolve(sourcePath) === path.resolve(destinationPath)) continue; + const mkdir = typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs); await mkdir(path.dirname(destinationPath), { recursive: true }); @@ -349,6 +385,8 @@ async function syncExtraModulesToDir(projectRoot, outDir, fsImpl, log) { if (!(await exists(sourcePath))) continue; const destPath = path.join(outDir, ...entry.dest); + if (path.resolve(sourcePath) === path.resolve(destPath)) continue; + const mkdir = typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs); await mkdir(path.dirname(destPath), { recursive: true }); @@ -497,8 +535,8 @@ function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir } /** - * Copy native assets (wreq-js, better-sqlite3) and extra runtime modules/sidecars - * (pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …) + * Copy native assets (better-sqlite3 and TPROXY) and extra runtime modules/sidecars + * (wreq-js, pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …) * into the assembled bundle. Missing sources are skipped silently. * * @param {string} projectRoot @@ -509,6 +547,7 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) { const src = path.join(projectRoot, ...asset.src); if (!fsSync.existsSync(src)) continue; const dest = path.join(resolvedOutDir, ...asset.dest); + if (path.resolve(src) === path.resolve(dest)) continue; fsSync.mkdirSync(path.dirname(dest), { recursive: true }); fsSync.cpSync(src, dest, { recursive: true, force: true }); console.log(`[assembleStandalone] Copied native asset: ${asset.label}`); @@ -518,12 +557,75 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) { const src = path.join(projectRoot, ...mod.src); if (!fsSync.existsSync(src)) continue; const dest = path.join(resolvedOutDir, ...mod.dest); + if (path.resolve(src) === path.resolve(dest)) continue; fsSync.mkdirSync(path.dirname(dest), { recursive: true }); fsSync.cpSync(src, dest, { recursive: true, force: true }); console.log(`[assembleStandalone] Synced module: ${mod.label}`); } } +/** + * Next/Turbopack standalone output can leave behind hollow top-level package + * directories for externalized runtime deps (directory exists, but contains no + * files). Those empty placeholders shadow the real repo-level install and make + * runtime ESM externals fail with "Cannot find package '/node_modules//index.js'" + * even though the dependency is present in the source tree. + * + * Repair strategy: for each empty top-level package dir already present in the + * assembled bundle, if the same package exists in the project root node_modules, + * replace the hollow directory with a full recursive copy from the source install. + * This keeps the fix narrowly scoped to packages the standalone already expects. + * + * @param {string} projectRoot + * @param {string} resolvedOutDir + * @returns {{repaired: number, packages: string[]}} + */ +function repairEmptyExternalPackageDirs(projectRoot, resolvedOutDir) { + const summary = { repaired: 0, packages: [] }; + const bundleNodeModules = path.join(resolvedOutDir, "node_modules"); + const sourceNodeModules = path.join(projectRoot, "node_modules"); + if (!fsSync.existsSync(bundleNodeModules) || !fsSync.existsSync(sourceNodeModules)) { + return summary; + } + + for (const name of fsSync.readdirSync(bundleNodeModules)) { + if (name.startsWith(".") || name.startsWith("@")) continue; + + const bundlePkgDir = path.join(bundleNodeModules, name); + const sourcePkgDir = path.join(sourceNodeModules, name); + + let bundleStat; + try { + bundleStat = fsSync.statSync(bundlePkgDir); + } catch { + continue; + } + if (!bundleStat.isDirectory()) continue; + + let bundleEntries = []; + try { + bundleEntries = fsSync.readdirSync(bundlePkgDir); + } catch { + continue; + } + if (bundleEntries.length > 0 || !fsSync.existsSync(sourcePkgDir)) continue; + + let sourceStat; + try { + sourceStat = fsSync.statSync(sourcePkgDir); + } catch { + continue; + } + if (!sourceStat.isDirectory()) continue; + + fsSync.cpSync(sourcePkgDir, bundlePkgDir, { recursive: true, force: true }); + summary.repaired += 1; + summary.packages.push(name); + } + + return summary; +} + /** * Materialize Turbopack "hashed external module" symlinks inside a bundled * node_modules dir into real, self-contained directories. @@ -740,6 +842,13 @@ export function assembleStandalone({ // 6. Optionally copy native assets + extra modules (synchronous) if (copyNatives) { copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir); + const emptyPkgRepair = repairEmptyExternalPackageDirs(projectRoot, resolvedOutDir); + if (emptyPkgRepair.repaired > 0) { + console.log( + `[assembleStandalone] Repaired ${emptyPkgRepair.repaired} hollow external package dir(s): ` + + emptyPkgRepair.packages.join(", ") + ); + } // #9166: dynamically imported LLMLingua packages are not reliably traced // into the standalone bundle. Copy their complete dependency closure from @@ -750,8 +859,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/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index 8d2e0da139..37cd31a14a 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -96,7 +96,16 @@ function runNextBuild() { const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next"); const buildEnv = resolveNextBuildEnv(process.env); ensureWindowsBuildProfileDirs(buildEnv); - const child = spawn(process.execPath, [nextBin, "build", resolveNextBuildBundlerFlag()], { + const nextArgs = process.versions.bun + ? [ + "--preload", + path.join(projectRoot, "open-sse", "utils", "setupPolyfill.ts"), + nextBin, + "build", + resolveNextBuildBundlerFlag(), + ] + : [nextBin, "build", resolveNextBuildBundlerFlag()]; + const child = spawn(process.execPath, nextArgs, { cwd: projectRoot, stdio: "inherit", env: buildEnv, @@ -327,7 +336,12 @@ export async function main() { distDir, outDir: standaloneDir, projectRoot, + // Match the hardened packaging path used by Electron builds: + // Turbopack can emit hashed external-package references and + // standalone symlinks that break after the bundle is moved/copied. + patchTurbopackChunks: true, copyNatives: true, + materializeSymlinks: true, }); const { spawnSync } = await import("node:child_process"); const basePathWrite = spawnSync( diff --git a/scripts/build/colocate-standalone.mjs b/scripts/build/colocate-standalone.mjs new file mode 100644 index 0000000000..b1bf44f8c0 --- /dev/null +++ b/scripts/build/colocate-standalone.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * OmniRoute — Co-locate the LLMLingua-2 runtime into the raw Next standalone build. + * + * WHY: `npm run build` produces `.build/next/standalone/` and THIS machine's PM2 + * deployment runs `server.js` from that directory directly (not the assembled + * `dist/` bundle). The standalone trace: + * - does NOT bundle `open-sse/services/compression/engines/llmlingua/onnxWorker.js` + * (dynamically spawned via worker_threads — untraceable by webpack), and + * - does NOT include the optional SLM deps (`@atjsh/llmlingua-2`, + * `@tensorflow/tfjs`, `js-tiktoken`) — they are optionalDependencies and are + * only installed at the ROOT `node_modules`. + * + * Result: after every plain `npm run build`, the LLMLingua engine silently + * fail-opens (text returned unchanged, no error) because the worker's runtime + * anchors (`process.cwd()` = the standalone dir) find neither the worker file + * nor the deps. This script re-applies both, mirroring what prepublish.ts + + * colocateOptionals.mjs do for the `dist/` bundle. + * + * Idempotent + fail-soft: skips quietly when the optional deps are absent at the + * root (the common slim-install case) and never throws into the build. + * + * Run manually after a build, or automatically via the `postbuild` npm hook. + */ +import { cpSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { computeDependencyClosure } from "./colocateOptionals.mjs"; + +const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const STANDALONE = join(ROOT, ".build", "next", "standalone"); + +const WORKER_REL = join( + "open-sse", + "services", + "compression", + "engines", + "llmlingua", + "onnxWorker.js" +); +const GATE_PKG = join("node_modules", "@atjsh", "llmlingua-2", "package.json"); + +const hasOptionals = existsSync( + join(ROOT, "node_modules", "@atjsh", "llmlingua-2", "package.json") +); + +if (!existsSync(STANDALONE)) { + console.log("[colocate-standalone] .build/next/standalone not found — nothing to do."); + process.exit(0); +} +if (!hasOptionals) { + console.log( + "[colocate-standalone] optional SLM deps absent at root node_modules — LLMLingua stays fail-open (slim install)." + ); + process.exit(0); +} + +// 1) Bundle the worker the resolver expects: /open-sse/.../onnxWorker.js +const workerDest = join(STANDALONE, WORKER_REL); +if (!existsSync(workerDest)) { + mkdirSync(dirname(workerDest), { recursive: true }); + try { + execFileSync( + join(ROOT, "node_modules", ".bin", "esbuild"), + [ + join(ROOT, "open-sse", "services", "compression", "engines", "llmlingua", "onnxWorker.ts"), + "--bundle", + "--platform=node", + "--packages=external", + "--format=esm", + `--outfile=${workerDest}`, + ], + { stdio: "inherit" } + ); + console.log("[colocate-standalone] ✅ LLMLingua worker bundled into standalone tree"); + } catch (err) { + console.warn("[colocate-standalone] ⚠️ worker bundle error:", err.message); + } +} else { + console.log("[colocate-standalone] worker already present (skipping bundle)"); +} + +// 2) Co-locate the optional-dep closure (NO-CLOBBER, same semantics as colocateOptionals.mjs) +const srcNm = join(ROOT, "node_modules"); +const dstNm = join(STANDALONE, "node_modules"); +const closure = computeDependencyClosure(srcNm); +let copied = 0; +for (const pkg of closure) { + const src = join(srcNm, pkg); + const dst = join(dstNm, pkg); + if (!existsSync(src)) continue; + if (existsSync(dst)) continue; // no-clobber: keep traced instances (e.g. pinned @huggingface/transformers) + mkdirSync(dirname(dst), { recursive: true }); + cpSync(src, dst, { recursive: true }); + copied++; +} +console.log( + `[colocate-standalone] ✅ optional-dep closure: ${closure.length} packages (copied ${copied})` +); diff --git a/scripts/build/colocateOptionals.mjs b/scripts/build/colocateOptionals.mjs index 91548954b0..073a59fbed 100644 --- a/scripts/build/colocateOptionals.mjs +++ b/scripts/build/colocateOptionals.mjs @@ -47,7 +47,8 @@ */ import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; +import { dirname, join, sep } from "node:path"; /** * Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is @@ -96,6 +97,33 @@ export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES) return closure; } +/** + * A package in the target tree counts as PRESENT only when its entrypoint + * resolves from inside that tree — the same contract the Dockerfile's + * post-build guard enforces. Next's file tracing can materialize a package + * PARTIALLY (the package.json lands, the files its `main` points at do not), + * and a directory-level `existsSync` check then skips the package forever + * while the runtime dies with "Cannot find module /dist/index.js". + * + * @param {string} targetNodeModulesDir + * @param {string} name + * @returns {boolean} + */ +function isPackageIntact(targetNodeModulesDir, name) { + if (!existsSync(join(targetNodeModulesDir, name))) return false; + try { + const probe = createRequire( + join(targetNodeModulesDir, "__colocate_probe__.js") + ); + const resolved = probe.resolve(name); + // A resolution that walked past the target into an ancestor tree does not + // prove the target copy is usable. + return resolved.startsWith(targetNodeModulesDir + sep); + } catch { + return false; + } +} + /** * Co-locate the SLM optional dependency closure from `/node_modules` * into a standalone bundle's `node_modules`. @@ -129,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", }; } @@ -142,11 +168,12 @@ export function colocateLlmlinguaOptionals({ const closure = computeDependencyClosure(rootNm, seeds); - // Check the complete closure rather than only the entry package. A partially - // populated bundle must still receive any missing transitive dependencies. + // Check the complete closure rather than only the entry package, and judge + // presence by entrypoint integrity — a partially traced directory (see + // isPackageIntact) must still receive its missing files. if ( closure.length > 0 && - closure.every((name) => existsSync(join(targetNm, name))) + closure.every((name) => isPackageIntact(targetNm, name)) ) { return { skipped: true, reason: "already co-located" }; } @@ -155,16 +182,21 @@ export function colocateLlmlinguaOptionals({ for (const name of closure) { const dest = join(targetNm, name); - if (existsSync(dest)) continue; + if (isPackageIntact(targetNm, name)) continue; try { mkdirSync(dirname(dest), { recursive: true }); - cpSync(join(rootNm, name), dest, { recursive: true }); + // force:false merges into a partially traced directory: files the trace + // already materialized are kept, missing ones (the package payload) are + // filled in from the root tree. + cpSync(join(rootNm, name), dest, { + recursive: true, + force: false, + errorOnExist: false, + }); copied++; } catch (err) { - log( - ` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}` - ); + log(` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`); } } diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 49fcd94e95..c066dd084e 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -41,6 +41,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ "head-response-guard.cjs", "http-method-guard.cjs", "open-sse/mcp-server/server.js", + "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js", // LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads // (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server. "open-sse/services/compression/engines/llmlingua/onnxWorker.js", @@ -48,6 +49,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ "peer-stamp.mjs", "main-server-timeouts.mjs", "responses-ws-proxy.mjs", + "bin/chatgpt-web-codex-mcp.mjs", "scripts/dev/sync-env.mjs", "scripts/dev/tls-options.mjs", "server.js", @@ -86,13 +88,19 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [ ".env.example", "LICENSE", "README.md", + "THIRD_PARTY_NOTICES.md", "bin/aliasResolver.mjs", + "bin/chatgpt-web-codex-mcp.mjs", // #7808: ESM loader hook split out of bin/aliasResolver.mjs to silence CodeQL // js/incomplete-url-substring-sanitization (the old code built a // `data:text/javascript,...` URL dynamically). Loaded via pathToFileURL() at // runtime; shipped via package.json "files", so it must be allowed here. "bin/aliasResolverHook.mjs", "bin/mcp-server.mjs", + // #9281: stdout/stderr console guard preloaded via `node --import` by + // bin/mcp-server.mjs before the MCP entry's module graph evaluates — without it + // the published CLI's `omniroute --mcp` crashes on the pathToFileURL() import. + "bin/mcpStdioConsoleGuard.mjs", "bin/nodeRuntimeSupport.mjs", "bin/omniroute.mjs", "bin/reset-password.mjs", @@ -117,6 +125,9 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [ // shipped via package.json "files", so it must be allowed in the tarball. "open-sse/utils/setupPolyfill.ts", "package.json", + "scripts/build/assembleStandalone.mjs", + "scripts/build/backendOnlyPages.mjs", + "scripts/build/build-tproxy-native.mjs", "scripts/build/build-next-isolated.mjs", "scripts/check/check-supported-node-runtime.ts", "scripts/build/native-binary-compat.mjs", @@ -160,6 +171,7 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "dist/open-sse/services/compression/engines/rtk/filters/generic-output.json", + "dist/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js", "dist/open-sse/services/compression/rules/en/filler.json", "dist/server.js", "dist/server-ws.mjs", @@ -183,6 +195,10 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "bin/cli/utils/storageKeyProvision.mjs", "bin/cli/utils/versionFastPath.mjs", "bin/mcp-server.mjs", + // #9281: stdout/stderr console guard preloaded via `node --import` by + // bin/mcp-server.mjs before the MCP entry's module graph evaluates — without it + // the published CLI's `omniroute --mcp` crashes on the pathToFileURL() import. + "bin/mcpStdioConsoleGuard.mjs", "bin/nodeRuntimeSupport.mjs", "bin/omniroute.mjs", // #7808: aliasResolver + its hook file. bin/omniroute.mjs imports diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index 50cdaf8cbf..6de4af27e2 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -340,6 +340,42 @@ if (existsSync(mcpSrcFile)) { } } +const chatGptWebCodexMcpSrcFile = join( + ROOT, + "open-sse", + "vendor", + "codex-chatgpt-web", + "adapters", + "chatgpt-web", + "mcp-server.ts" +); +const chatGptWebCodexMcpDestFile = join( + DIST_DIR, + "open-sse", + "vendor", + "codex-chatgpt-web", + "adapters", + "chatgpt-web", + "mcp-server.js" +); +if (existsSync(chatGptWebCodexMcpSrcFile)) { + console.log(" 🔨 Bundling ChatGPT Web (Codex) MCP bridge..."); + mkdirSync(dirname(chatGptWebCodexMcpDestFile), { recursive: true }); + execFileSync( + NPX_BIN, + [ + "esbuild", + "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.ts", + "--bundle", + "--platform=node", + "--packages=external", + "--format=esm", + "--outfile=dist/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js", + ], + { cwd: ROOT, stdio: "inherit" } + ); +} + // ── Step 8.6: Bundle LLMLingua ONNX worker ──────────────────────────── // The worker is spawned via worker_threads at a path the Next.js bundler cannot // statically trace, so it must ship as a standalone .js (mirrors the MCP-server diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index 2c65c2f6dd..e9123b7d34 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 @@ -83,16 +84,19 @@ export const INTENTIONALLY_INTERNAL = new Set([ export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL; // (c) Leituras de SQL contra bancos EXTERNOS, permitidas por design (#3500). -// Estas rotas NÃO consultam o DB do OmniRoute (getDbInstance) — elas abrem o -// SQLite de OUTRO aplicativo (Cursor / Kiro) para auto-importar credenciais. -// Por isso NÃO podem viver em src/lib/db/ (que é o domínio do DB do OmniRoute): -// são leituras read-only de um arquivo externo, com caminho/escopo próprios. -// Continuam no allowlist como exceção DOCUMENTADA — o gate ainda bloqueia +// Esta rota NÃO consulta o DB do OmniRoute (getDbInstance) — ela abre o +// SQLite de OUTRO aplicativo (Kiro) para auto-importar credenciais. +// Por isso NÃO pode viver em src/lib/db/ (que é o domínio do DB do OmniRoute): +// é uma leitura read-only de um arquivo externo, com caminho/escopo próprio. +// Continua no allowlist como exceção DOCUMENTADA — o gate ainda bloqueia // QUALQUER novo SQL cru contra o DB do OmniRoute em rotas/handlers. // Toda a dívida real da Hard Rule #5 (15 rotas internas) foi migrada para // módulos src/lib/db/ nas slices do #3500; este set ficou só com as exceções. +// O análogo do Cursor (src/app/api/oauth/cursor/auto-import/route.ts) NÃO +// precisa de entrada aqui: o SQL contra o state.vscdb externo do Cursor vive +// em src/lib/cursor/tokenExtractor.ts, fora do escopo desta checagem (que só +// varre src/app/api/**/route.ts e open-sse/handlers/*.ts). const EXTERNAL_DB_ALLOWED = new Set([ - "src/app/api/oauth/cursor/auto-import/route.ts", // read-only no itemTable do SQLite do Cursor (DB externo) "src/app/api/oauth/kiro/auto-import/route.ts", // read-only no SQLite do Kiro (DB externo) ]); diff --git a/scripts/check/check-forgotten-sibling-tests.mjs b/scripts/check/check-forgotten-sibling-tests.mjs new file mode 100644 index 0000000000..bc9dd7ba4d --- /dev/null +++ b/scripts/check/check-forgotten-sibling-tests.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "tinyglobby"; + +import { resolveImport } from "../quality/build-test-impact-map.mjs"; + +const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const SOURCE_ROOTS = ["src/", "open-sse/", "bin/"]; +const SOURCE_GLOBS = [ + "src/**/*.{ts,tsx,mts,js,mjs}", + "open-sse/**/*.{ts,tsx,mts,js,mjs}", + "bin/**/*.{ts,tsx,mts,js,mjs}", +]; +const IGNORE = [ + "**/__tests__/**", + "**/*.test.*", + "**/*.spec.*", + "**/fixtures/**", + "**/generated/**", +]; +const STATIC_IMPORT_RE = + /(?:import|export)[^'"()]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g; +const DYNAMIC_IMPORT_RE = /import\(\s*['"]([^'"]+)['"]\s*\)/g; +const TEST_MASK_RE = + /^\+.*(?:\b(?:it|test|describe)\.(?:skip|todo)\b|\b(?:xit|xtest|xdescribe)\s*\()/; +const REFERENCE_RE = /^(?:#\d+|https:\/\/github\.com\/[^/]+\/[^/]+\/(?:issues|pull)\/\d+)$/; + +function normalize(file) { + return file.split(path.sep).join("/"); +} + +function isProduction(file) { + return ( + SOURCE_ROOTS.some((root) => file.startsWith(root)) && + !IGNORE.some((pattern) => { + const token = pattern.replaceAll("**/", "").replaceAll("/**", "").replaceAll("*", ""); + return token && file.includes(token); + }) + ); +} + +function isBarrel(file, code) { + return /(?:^|\/)index\.[cm]?[jt]sx?$/.test(file) && /\bexport\s+(?:\*|\{)/.test(code); +} + +function importEdges(root) { + const edges = []; + const files = globSync(SOURCE_GLOBS, { cwd: root, absolute: true, ignore: IGNORE }); + for (const absolute of files) { + const consumer = normalize(path.relative(root, absolute)); + const code = fs.readFileSync(absolute, "utf8"); + for (const match of code.matchAll(STATIC_IMPORT_RE)) { + const resolved = resolveImport(match[1] || match[2], absolute, root); + if (resolved) { + edges.push({ + module: normalize(path.relative(root, resolved)), + consumer, + kind: isBarrel(consumer, code) ? "barrel" : "static", + }); + } + } + for (const match of code.matchAll(DYNAMIC_IMPORT_RE)) { + const resolved = resolveImport(match[1], absolute, root); + if (resolved) { + edges.push({ + module: normalize(path.relative(root, resolved)), + consumer, + kind: "dynamic-import", + }); + } + } + } + return edges.sort((a, b) => + `${a.module}\0${a.consumer}\0${a.kind}`.localeCompare(`${b.module}\0${b.consumer}\0${b.kind}`) + ); +} + +export function validateAllowlist(value) { + const entries = Array.isArray(value) ? value : value?.entries; + if (!Array.isArray(entries)) + throw new Error("forgotten-sibling allowlist must contain an entries array"); + return entries.map((entry, index) => { + for (const field of ["consumer", "candidateTest", "rationale", "reference"]) { + if (typeof entry?.[field] !== "string" || !entry[field].trim()) { + throw new Error(`forgotten-sibling allowlist entry ${index} requires ${field}`); + } + } + if (entry.rationale.trim().length < 20) { + throw new Error(`forgotten-sibling allowlist entry ${index} rationale must be specific`); + } + if (!REFERENCE_RE.test(entry.reference.trim())) { + throw new Error( + `forgotten-sibling allowlist entry ${index} reference must be a GitHub issue or PR` + ); + } + return { + consumer: normalize(entry.consumer.trim()), + candidateTest: normalize(entry.candidateTest.trim()), + rationale: entry.rationale.trim(), + reference: entry.reference.trim(), + }; + }); +} + +export function analyzeForgottenSiblingTests({ + root = DEFAULT_ROOT, + changedEntries, + impactMap, + allowlist, + changedSymbolsByFile = {}, + addedTestLines = [], +}) { + const changed = new Map(changedEntries.map((entry) => [normalize(entry.file), entry.status])); + const changedModules = [...changed.keys()].filter(isProduction).sort(); + const maskingAdded = addedTestLines.some((line) => TEST_MASK_RE.test(line)); + const allow = new Map( + allowlist.map((entry) => [`${entry.consumer}\0${entry.candidateTest}`, entry]) + ); + const findings = []; + const diagnostics = []; + const suppressed = []; + const maskingRisks = []; + + for (const edge of importEdges(root)) { + if (!changedModules.includes(edge.module)) continue; + const tests = [...new Set(impactMap.sources?.[edge.consumer] || [])].sort(); + if (edge.kind !== "static") { + diagnostics.push({ + changedModule: edge.module, + consumer: edge.consumer, + kind: edge.kind, + message: `${edge.kind} resolution is advisory and never blocks`, + }); + continue; + } + for (const candidateTest of tests) { + const status = changed.get(candidateTest); + const masking = status === "D" || (status && maskingAdded); + if (masking) { + maskingRisks.push({ + changedModule: edge.module, + consumer: edge.consumer, + candidateTest, + reason: + status === "D" + ? "candidate sibling test was deleted" + : "candidate sibling test adds skip/todo masking", + }); + continue; + } + if (status) continue; + const finding = { + changedModule: edge.module, + changedSymbols: [...(changedSymbolsByFile[edge.module] || [])].sort(), + consumer: edge.consumer, + candidateTest, + reason: "candidate sibling test is absent from the PR diff", + }; + const exception = allow.get(`${edge.consumer}\0${candidateTest}`); + if (exception) suppressed.push({ ...finding, exception }); + else findings.push(finding); + } + } + return { mode: "advisory", findings, diagnostics, suppressed, maskingRisks }; +} + +function arg(name, fallback = "") { + const index = process.argv.indexOf(name); + return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback; +} + +function git(root, args) { + return execFileSync("git", args, { cwd: root, encoding: "utf8" }); +} + +function changedEntries(root, base) { + return git(root, ["diff", "--name-status", "--diff-filter=ACMRD", `${base}...HEAD`]) + .trim() + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + const [status, ...files] = line.split("\t"); + return { status: status[0], file: files.at(-1) }; + }); +} + +function changedSymbols(root, base, entries) { + const result = {}; + const declaration = + /^\+\s*(?:export\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/; + for (const entry of entries.filter(({ file }) => isProduction(file))) { + const diff = git(root, ["diff", "--unified=0", `${base}...HEAD`, "--", entry.file]); + result[entry.file] = [ + ...new Set( + diff + .split(/\r?\n/) + .map((line) => line.match(declaration)?.[1]) + .filter(Boolean) + ), + ]; + } + return result; +} + +function markdown(result, base) { + const lines = [ + "## Forgotten sibling tests (advisory)", + "", + `Base: \`${base}\``, + `Unallowlisted findings: ${result.findings.length}`, + `Reviewed exceptions: ${result.suppressed.length}`, + `Resolution diagnostics: ${result.diagnostics.length}`, + `Masking/deletion risks (owned by blocking sibling gates): ${result.maskingRisks.length}`, + "", + ]; + if (result.findings.length) { + lines.push("### Candidate tests absent from this diff", ""); + for (const item of result.findings) { + const symbol = item.changedSymbols.length ? ` (${item.changedSymbols.join(", ")})` : ""; + lines.push( + `- \`${item.changedModule}\`${symbol} -> \`${item.consumer}\` -> \`${item.candidateTest}\`` + ); + } + lines.push("", "> Report-only calibration: these findings do not fail the job.", ""); + } + for (const [heading, items] of [ + ["Resolution diagnostics", result.diagnostics], + ["Test masking/deletion risks", result.maskingRisks], + ]) { + if (!items.length) continue; + lines.push(`### ${heading}`, ""); + for (const item of items) + lines.push( + `- \`${item.changedModule}\` -> \`${item.consumer}\`${item.candidateTest ? ` -> \`${item.candidateTest}\`` : ""}: ${item.reason || item.message}` + ); + lines.push(""); + } + return `${lines.join("\n")}\n`; +} + +function main() { + const root = DEFAULT_ROOT; + const base = arg( + "--base", + process.env.GITHUB_BASE_SHA || + (process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "HEAD~1") + ); + const mapPath = arg("--impact-map", path.join(root, "config/quality/test-impact-map.json")); + const allowlistPath = arg( + "--allowlist", + path.join(root, "config/quality/forgotten-sibling-allowlist.json") + ); + const summaryPath = arg("--summary-file", ""); + const jsonPath = arg("--json-file", ""); + const entries = changedEntries(root, base); + const impactMap = JSON.parse(fs.readFileSync(mapPath, "utf8")); + const allowlist = validateAllowlist(JSON.parse(fs.readFileSync(allowlistPath, "utf8"))); + const addedTestLines = git(root, ["diff", "--unified=0", `${base}...HEAD`, "--", "tests/"]) + .split(/\r?\n/) + .filter((line) => line.startsWith("+") && !line.startsWith("+++")); + const result = analyzeForgottenSiblingTests({ + root, + changedEntries: entries, + impactMap, + allowlist, + changedSymbolsByFile: changedSymbols(root, base, entries), + addedTestLines, + }); + const report = markdown(result, base); + process.stdout.write(report); + for (const [target, contents] of [ + [summaryPath, report], + [jsonPath, `${JSON.stringify(result, null, 2)}\n`], + ]) { + if (!target) continue; + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + } +} + +if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) { + try { + main(); + } catch (error) { + console.error( + `forgotten-sibling-tests: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); + } +} diff --git a/scripts/check/check-migration-numbering.mjs b/scripts/check/check-migration-numbering.mjs index 5e174bf0dc..9a64c2da33 100644 --- a/scripts/check/check-migration-numbering.mjs +++ b/scripts/check/check-migration-numbering.mjs @@ -42,12 +42,16 @@ 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, +// 144–145 seguem reservados pelas migrations Radar que já existem na série +// empilhada; a migration 143 já aterrissou. O job registry foi promovido de 139 +// para 146 pela tabela RENAMED_MIGRATION_COMPATIBILITY. A +// 147–149 estão reservadas por migrations atualmente em trânsito nos PRs #8228, +// #9313, #10047 e #10066; esta branch usa 150 para evitar essas colisões conhecidas. +// O stale-enforcement exige que cada reserva seja removida quando os arquivos +// correspondentes aterrissarem 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", "144", "145", "147", "148", "149"]); // 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-open-sse-typecheck.mjs b/scripts/check/check-open-sse-typecheck.mjs new file mode 100644 index 0000000000..d18c588538 --- /dev/null +++ b/scripts/check/check-open-sse-typecheck.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +// scripts/check/check-open-sse-typecheck.mjs +// open-sse workspace typecheck gate (#8781). +// +// The open-sse workspace declares path aliases (e.g. `@/*` → `../src/*`) in its own +// tsconfig.json, but those aliases are not resolvable by Node's bare module resolution — +// they only work because Next.js/Turbopack bundles the entire tree. Additionally, +// package.json historically declared `main`/`exports` entries that do not exist on disk. +// +// This gate runs `tsc -p open-sse/tsconfig.json` and diffs the result against a frozen +// per-file/per-TS-code count baseline (config/quality/open-sse-typecheck-baseline.json), +// following this repo's stale-enforcement allowlist convention. A live count that EXCEEDS +// the baselined count for a given (file, TS code) pair is a regression and fails the gate; +// a live count that is lower is an improvement and does not fail (use --update to ratchet +// the baseline down). +// +// Run: +// node scripts/check/check-open-sse-typecheck.mjs +// node scripts/check/check-open-sse-typecheck.mjs --update # re-freeze baseline + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const TSCONFIG = path.join(ROOT, "open-sse", "tsconfig.json"); +const BASELINE_PATH = path.join(ROOT, "config/quality/open-sse-typecheck-baseline.json"); +const UPDATE = process.argv.includes("--update"); + +// Matches tsc --pretty false output lines, e.g.: +// src/app/api/v1/chat/route.ts(12,7): error TS2304: Cannot find name 'bar'. +// open-sse/handlers/chatCore.ts(45,3): error TS7053: Element implicitly has an 'any'... +const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+):/; + +/** + * Parses raw `tsc --pretty false` stdout into a nested count map: + * { "": { "": } } + * + * Pure/exported for unit testing against synthetic tsc output — no child + * process involved here. + */ +export function parseTscOutput(raw) { + const counts = {}; + const lines = String(raw).split("\n"); + for (const line of lines) { + const match = TSC_ERROR_LINE.exec(line); + if (!match) continue; + const [, file, , , code] = match; + if (!counts[file]) counts[file] = {}; + counts[file][code] = (counts[file][code] || 0) + 1; + } + return counts; +} + +/** + * Compares live (file, TS code) error counts against a frozen baseline. + * Returns `{ regressions, improvements }`: + * - regressions: entries where live count > baselined count (or the pair is + * entirely new/unbaselined) — these fail the gate. + * - improvements: entries where live count < baselined count — informational, + * do not fail (use --update to ratchet the baseline down). + * + * Exported for unit testing. + */ +export function diffAgainstBaseline(live, baseline) { + const regressions = []; + const improvements = []; + + for (const [file, codes] of Object.entries(live)) { + for (const [code, liveCount] of Object.entries(codes)) { + const baselineCount = (baseline[file] && baseline[file][code]) || 0; + if (liveCount > baselineCount) { + regressions.push({ file, code, liveCount, baselineCount }); + } else if (liveCount < baselineCount) { + improvements.push({ file, code, liveCount, baselineCount }); + } + } + } + + for (const [file, codes] of Object.entries(baseline)) { + for (const [code, baselineCount] of Object.entries(codes)) { + const liveCount = (live[file] && live[file][code]) || 0; + if (liveCount === 0 && baselineCount > 0) { + improvements.push({ file, code, liveCount: 0, baselineCount }); + } + } + } + + return { regressions, improvements }; +} + +function runTsc() { + try { + const stdout = execFileSync( + process.platform === "win32" ? "npx.cmd" : "npx", + ["tsc", "--pretty", "false", "--noEmit", "-p", TSCONFIG], + { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, cwd: ROOT } + ); + return stdout; + } catch (err) { + // tsc exits non-zero when there are type errors — stdout still has the report. + if (err.stdout) return String(err.stdout); + throw err; + } +} + +function loadBaseline() { + if (!fs.existsSync(BASELINE_PATH)) return {}; + return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); +} + +function writeBaseline(counts) { + fs.writeFileSync(BASELINE_PATH, JSON.stringify(counts, null, 2) + "\n"); +} + +function main() { + if (!fs.existsSync(TSCONFIG)) { + process.stderr.write(`[open-sse-typecheck] FAIL — tsconfig not found at ${TSCONFIG}\n`); + process.exit(2); + } + + console.log("[open-sse-typecheck] Running tsc scoped to open-sse/ workspace…"); + const stdout = runTsc(); + const live = parseTscOutput(stdout); + const baseline = loadBaseline(); + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + const liveErrorCount = Object.values(live).reduce( + (sum, codes) => sum + Object.values(codes).reduce((s, c) => s + c, 0), + 0 + ); + console.log(`openSseTypecheckErrors=${liveErrorCount}`); + + if (UPDATE) { + writeBaseline(live); + console.log(`[open-sse-typecheck] baseline rewritten (${liveErrorCount} errors frozen).`); + process.exit(0); + } + + if (improvements.length > 0) { + console.log( + `[open-sse-typecheck] ${improvements.length} baselined error(s) no longer present ` + + `— run 'node scripts/check/check-open-sse-typecheck.mjs --update' to ratchet the baseline down:\n` + + improvements + .map((i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})`) + .join("\n") + ); + } + + if (regressions.length > 0) { + process.stderr.write( + `[open-sse-typecheck] FAIL — ${regressions.length} new/regressed TypeScript error(s) ` + + `under open-sse/ workspace not covered by the frozen baseline:\n` + + regressions + .map((r) => ` ✗ ${r.file} ${r.code} (baseline ${r.baselineCount}, live ${r.liveCount})`) + .join("\n") + + `\n\nIf this is a genuine new open-sse type error (e.g. an undeclared @/ alias),\n` + + `fix it in the source, not in the baseline.\n` + + `If it's pre-existing type looseness you're intentionally not fixing in this PR,\n` + + `do NOT widen the baseline for new regressions — that defeats the gate.\n` + ); + process.exit(1); + } + + console.log( + `[open-sse-typecheck] OK — ${liveErrorCount} pre-existing error(s), all within frozen baseline.` + ); + process.exit(0); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + main(); +} 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/ci/should-promote-latest.sh b/scripts/ci/should-promote-latest.sh index e118086c88..e1e9745782 100755 --- a/scripts/ci/should-promote-latest.sh +++ b/scripts/ci/should-promote-latest.sh @@ -26,6 +26,9 @@ VERSION="${1:?version required}" # `main` and `next`, plus every pre-release identifier, fail closed here even if # a caller forgets to short-circuit them first. if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + # Consume the caller's tag stream before exiting. A pre-release decision is + # immediate, but closing stdin early can give a piped producer EPIPE. + cat >/dev/null echo "false" exit 0 fi diff --git a/scripts/dev/generate-adobe-firefly-snapshot.mjs b/scripts/dev/generate-adobe-firefly-snapshot.mjs new file mode 100644 index 0000000000..ccbec0c629 --- /dev/null +++ b/scripts/dev/generate-adobe-firefly-snapshot.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +function usage() { + console.error( + "Usage: node scripts/dev/generate-adobe-firefly-snapshot.mjs " + ); + process.exit(2); +} + +const [, , inputArg, outputArg] = process.argv; +if (!inputArg || !outputArg) usage(); + +const inputPath = path.resolve(inputArg); +const outputPath = path.resolve(outputArg); +const inputBytes = fs.readFileSync(inputPath); +const sourceHash = createHash("sha256").update(inputBytes).digest("hex"); +const root = JSON.parse(inputBytes.toString("utf8")); + +function mergeObjectSchema(schema) { + const merged = { properties: {}, required: [] }; + const visit = (node) => { + if (!node || typeof node !== "object") return; + if (node.properties && typeof node.properties === "object") { + Object.assign(merged.properties, node.properties); + } + if (Array.isArray(node.required)) merged.required.push(...node.required); + if (Array.isArray(node.allOf)) node.allOf.forEach(visit); + }; + visit(schema); + merged.required = [...new Set(merged.required)]; + return merged; +} + +function branches(schema) { + if (!schema || typeof schema !== "object") return []; + return [schema, ...(schema.anyOf || []), ...(schema.oneOf || [])]; +} + +function stringEnums(schema) { + return [ + ...new Set( + branches(schema) + .flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : [])) + .filter((value) => typeof value === "string") + ), + ]; +} + +function integerSchema(schema) { + return branches(schema).find((branch) => branch.type === "integer") || {}; +} + +function publicModelId(modelId, modelVersion) { + const slug = (value, allowDot = false) => + String(value || "") + .trim() + .toLowerCase() + .replace(allowDot ? /[^a-z0-9.]+/g : /[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + const family = slug(modelId); + const publicVersion = + family === "kling" ? String(modelVersion).replace(/^kling_v3_omni/i, "kling_o3") : modelVersion; + const version = slug(publicVersion, true); + if (!version || version === "default" || version === family) return family || "model"; + return `${family}-${version}`; +} + +function normalizeModel(family, modelVersion, version) { + const schema = mergeObjectSchema(version.requestSchema); + const properties = schema.properties; + const referenceSchema = properties.referenceBlobs || {}; + const referenceInputs = []; + for (const media of referenceSchema["x-capabilities"] || []) { + for (const usage of media.usageConstraints || []) { + if (usage.deprecated === true) continue; + referenceInputs.push({ + mediaType: String(media.mediaType || ""), + usageType: String(usage.usageType || ""), + minItems: Number.isInteger(usage.minItems) ? usage.minItems : 0, + maxItems: Number.isInteger(usage.maxItems) ? usage.maxItems : null, + maxFileSizeBytes: Number.isInteger(media.maxFileSizeBytes) ? media.maxFileSizeBytes : null, + }); + } + } + + const supportedSizes = [ + ...new Set( + branches(properties.size) + .flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : [])) + .filter( + (size) => + size && + Number.isInteger(size.width) && + size.width > 0 && + Number.isInteger(size.height) && + size.height > 0 + ) + .map((size) => `${size.width}x${size.height}`) + ), + ]; + const supportedAspectRatios = [ + ...new Set( + branches(properties.generationSettings).flatMap((branch) => + stringEnums(branch?.properties?.aspectRatio) + ) + ), + ]; + const duration = integerSchema(properties.duration); + const supportedDurations = [ + ...new Set( + branches(properties.duration) + .flatMap((branch) => (Array.isArray(branch.enum) ? branch.enum : [])) + .filter(Number.isInteger) + ), + ]; + const prompt = branches(properties.prompt).find((branch) => branch.type === "string") || {}; + const outputCount = integerSchema(properties.n); + + return { + id: publicModelId(family.modelId, modelVersion), + name: String(version.modelDisplayName || version.modelCaiDisplayName || modelVersion), + modality: version.outputModality[0], + upstreamModelId: family.modelId, + upstreamModelVersion: modelVersion, + providerName: String(family.acModelFamilyProviderDisplayName || ""), + releaseReadiness: String(version.releaseReadiness || ""), + healthStatus: String(version.healthStatus || ""), + inputMediaUseCases: (version.inputMediaUseCase || []).map(String), + schemaProperties: Object.keys(properties), + requiredProperties: schema.required, + referenceInputs, + maxReferenceItems: Number.isInteger(referenceSchema.maxItems) ? referenceSchema.maxItems : null, + supportedSizes, + supportedAspectRatios, + supportedResolutions: stringEnums(properties.resolution), + supportedDurations, + durationMin: Number.isInteger(duration.minimum) ? duration.minimum : null, + durationMax: Number.isInteger(duration.maximum) ? duration.maximum : null, + durationDefault: Number.isInteger(duration.default) ? duration.default : null, + outputCountMin: Number.isInteger(outputCount.minimum) ? outputCount.minimum : null, + outputCountMax: Number.isInteger(outputCount.maximum) ? outputCount.maximum : null, + promptMaxLength: Number.isInteger(prompt.maxLength) ? prompt.maxLength : null, + backingModel: String(version.bksGenerationModel || ""), + }; +} + +const rawModels = []; +for (const family of Array.isArray(root.models) ? root.models : []) { + for (const [modelVersion, version] of Object.entries(family.modelVersions || {})) { + if (!version || version.enabled === false) continue; + const modality = Array.isArray(version.outputModality) + ? version.outputModality.map((value) => String(value).toLowerCase())[0] + : ""; + if (modality !== "image" && modality !== "video") continue; + + const schema = mergeObjectSchema(version.requestSchema); + if (!schema.properties.prompt) continue; + const useCases = (version.inputMediaUseCase || []).map((value) => String(value).toLowerCase()); + if (useCases.some((value) => ["upscaling", "sharpening", "denoising"].includes(value))) { + continue; + } + rawModels.push(normalizeModel(family, modelVersion, version)); + } +} + +// Discovery currently repeats a few exact aliases (for example flux/fluxPro and +// fluxPro/1.1). Keep the first canonical wire pair and suppress duplicate cards. +const seen = new Set(); +const models = []; +for (const model of rawModels) { + const semanticKey = JSON.stringify({ + backingModel: model.backingModel, + name: model.name, + modality: model.modality, + schemaProperties: model.schemaProperties, + requiredProperties: model.requiredProperties, + referenceInputs: model.referenceInputs, + maxReferenceItems: model.maxReferenceItems, + supportedSizes: model.supportedSizes, + supportedAspectRatios: model.supportedAspectRatios, + supportedResolutions: model.supportedResolutions, + supportedDurations: model.supportedDurations, + durationMin: model.durationMin, + durationMax: model.durationMax, + }); + if (seen.has(semanticKey)) continue; + seen.add(semanticKey); + models.push(model); +} + +const source = `/** + * Generated from Adobe Firefly POST /v2/models/discovery with resolveSchema=true. + * Source SHA-256: ${sourceHash} + * Regenerate with scripts/dev/generate-adobe-firefly-snapshot.mjs; do not edit by hand. + * The generated literal stays compact to satisfy the repository's line-count gate. + */ +// prettier-ignore +export const ADOBE_FIREFLY_DISCOVERY_SNAPSHOT = ${JSON.stringify(models)} as const; +`; + +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); +fs.writeFileSync(outputPath, source, "utf8"); +console.log(`Wrote ${models.length} models to ${outputPath}`); diff --git a/scripts/dev/responses-ws-proxy.mjs b/scripts/dev/responses-ws-proxy.mjs index 1b585e4618..7e2a5fbc15 100644 --- a/scripts/dev/responses-ws-proxy.mjs +++ b/scripts/dev/responses-ws-proxy.mjs @@ -585,12 +585,18 @@ class ResponsesWsSession { // preparedContext, but never touches this.upstream/this.upstreamReady; the caller decides // whether a new upstream socket is needed. async runPrepare(message, responseBody) { - const prepared = await callInternal(this.fetchImpl, this.baseUrl, this.bridgeSecret, "prepare", { - requestUrl: this.requestUrl, - headers: getAuthHeaders(this.requestUrl, this.requestHeaders), - message, - response: responseBody, - }); + const prepared = await callInternal( + this.fetchImpl, + this.baseUrl, + this.bridgeSecret, + "prepare", + { + requestUrl: this.requestUrl, + headers: getAuthHeaders(this.requestUrl, this.requestHeaders), + message, + response: responseBody, + } + ); if (!prepared.ok) { const message2 = @@ -602,6 +608,7 @@ class ResponsesWsSession { const error = new Error(message2); error.code = code; error.status = prepared.status; + if (code === "responses_websocket_http_fallback") error.httpFallback = true; throw error; } @@ -716,11 +723,28 @@ class ResponsesWsSession { // otherwise every turn after the first bypasses the whole pipeline. This reuses // the already-established upstream transport; it must NOT recreate the socket. const prepared = await this.runPrepare(message, nextTurnBody); - this.upstream.send(jsonStringifySafe(withPreparedResponseCreate(message, prepared.json.response))); + this.upstream.send( + jsonStringifySafe(withPreparedResponseCreate(message, prepared.json.response)) + ); return; } this.upstream.send(jsonStringifySafe(message)); } catch (error) { + if (error?.httpFallback) { + const failurePayload = this.sendFailure( + "responses_websocket_http_fallback", + "Retry this request over HTTP/SSE Responses" + ); + void this.persistHistory({ + status: 426, + success: false, + errorCode: "responses_websocket_http_fallback", + errorMessage: "HTTP/SSE Responses transport required", + terminalMessage: failurePayload, + }); + this.close(1013, "http_fallback_required"); + return; + } const code = error?.code || "upstream_websocket_connect_failed"; const messageText = error instanceof Error ? error.message : String(error); const failurePayload = this.sendFailure(code, messageText); diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index 439a9c5171..ee5f0a1bec 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -3,7 +3,7 @@ import net from "node:net"; import { randomUUID } from "node:crypto"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs"; -import { maybeHandleWebdav } from "./webdav-handler.mjs"; +import { maybeHandleWebdav, WEBDAV_PREFIX } from "./webdav-handler.mjs"; import methodGuard from "./http-method-guard.cjs"; import headResponseGuard from "./head-response-guard.cjs"; import { resolveTlsOptions, createServerListener } from "./tls-options.mjs"; @@ -122,14 +122,20 @@ function wrapUpgradeListener(server, listener) { * Returns true if the request was handled; the wrapped listener is never called. */ function wrapRequestListenerWithWebdav(listener) { - return async function webdavAwareRequestHandler(req, res) { - try { - const handled = await maybeHandleWebdav(req, res); - if (handled) return; - } catch { - // Never block a request on WebDAV errors — fall through to Next + return function webdavAwareRequestHandler(req, res) { + if (!(req.url || "").startsWith(WEBDAV_PREFIX)) { + return listener.call(this, req, res); } - return listener.call(this, req, res); + const self = this; + (async () => { + try { + const handled = await maybeHandleWebdav(req, res); + if (handled) return; + } catch { + // Never block a request on WebDAV errors — fall through to Next + } + return listener.call(self, req, res); + })(); }; } diff --git a/scripts/i18n/check-glossary-consistency.mjs b/scripts/i18n/check-glossary-consistency.mjs index 8acfc288e0..e09ebba708 100644 --- a/scripts/i18n/check-glossary-consistency.mjs +++ b/scripts/i18n/check-glossary-consistency.mjs @@ -10,10 +10,13 @@ * - protected-term-altered: a value renders a protected product/provider/ * protocol/CLI/env identifier (scripts/i18n/glossary/protected-terms.json) * using a known incorrect translation instead of leaving it verbatim. + * Known incorrect renderings come from the legacy KNOWN_MISTRANSLATIONS + * map below (zh-CN) merged with the optional per-locale + * `protectedTermMistranslations` object in the locale's glossary file (ko). * * Usage: * node scripts/i18n/check-glossary-consistency.mjs # zh-CN, exit 1 on drift - * node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN + * node scripts/i18n/check-glossary-consistency.mjs --locale=ko * node scripts/i18n/check-glossary-consistency.mjs --json * node scripts/i18n/check-glossary-consistency.mjs --report # print, always exit 0 */ @@ -30,6 +33,9 @@ const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages"); const GLOSSARY_DIR = path.join(SCRIPT_DIR, "glossary"); const LOG_PREFIX = "[i18n-glossary]"; +// Legacy zh-CN map of known incorrect renderings for protected terms — newer +// locales (ko) keep theirs in `protectedTermMistranslations` inside their +// scripts/i18n/glossary/.json instead of growing this constant. // Small, maintained map of known incorrect renderings for protected terms — // identifiers that must survive translation verbatim. NOT exhaustive by // design (a full back-translation model is out of scope for a static gate), @@ -97,10 +103,16 @@ export function checkGlossaryConsistency(localeMessages, glossary, protectedTerm } } + const localeMistranslations = isPlainObject(glossary?.protectedTermMistranslations) + ? glossary.protectedTermMistranslations + : {}; const protectedList = Array.isArray(protectedTerms) ? protectedTerms : []; for (const term of protectedList) { - const badRenderings = KNOWN_MISTRANSLATIONS[term]; - if (!badRenderings || badRenderings.length === 0) continue; + const fromGlossary = Array.isArray(localeMistranslations[term]) + ? localeMistranslations[term] + : []; + const badRenderings = [...(KNOWN_MISTRANSLATIONS[term] || []), ...fromGlossary]; + if (badRenderings.length === 0) continue; for (const bad of badRenderings) { for (const leaf of leaves) { if (leaf.value.includes(bad)) { diff --git a/scripts/i18n/glossary/ko.json b/scripts/i18n/glossary/ko.json new file mode 100644 index 0000000000..4deb69a721 --- /dev/null +++ b/scripts/i18n/glossary/ko.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "locale": "ko", + "description": "Canonical ko terminology for recurring OmniRoute concepts. Consumed by scripts/i18n/check-glossary-consistency.mjs. Each concept lists the canonical translation plus any non-canonical synonym that is actively normalized (drift enforced by the consistency gate). Concepts whose `synonyms` array is empty are seeded for documentation only — the catalog still uses more than one legitimate rendering for them today (e.g. 공급자/제공자, 폴백/대체), so enforcement is deferred to a follow-up normalization pass. Every enforced synonym and mistranslation below was verified to have zero legitimate occurrences in the real src + bin/cli ko catalogs before being added (collision policy mirrors the KNOWN_MISTRANSLATIONS note in the checker script — e.g. 안타 (Hits) is deliberately NOT enforced because it is a substring of the legitimate 안타깝게도).", + "terms": { + "provider": { + "canonical": "공급자", + "synonyms": [] + }, + "fallback": { + "canonical": "폴백", + "synonyms": [] + }, + "running (status)": { + "canonical": "실행 중", + "synonyms": ["달리기"] + }, + "disabled (status)": { + "canonical": "비활성화됨", + "synonyms": ["장애인"] + }, + "key (credential)": { + "canonical": "키", + "synonyms": ["열쇠"] + }, + "export (action)": { + "canonical": "내보내기", + "synonyms": ["수출"] + }, + "healthcheck": { + "canonical": "상태 확인", + "synonyms": ["건강검진"] + }, + "port (network)": { + "canonical": "포트", + "synonyms": ["항구"] + }, + "artifacts": { + "canonical": "아티팩트", + "synonyms": ["유물"] + } + }, + "protectedTermMistranslations": { + "ngrok": ["응록"], + "Anthropic": ["인류", "앤트로픽"], + "Claude": ["클로드"], + "Gemini": ["쌍둥이자리"], + "Antigravity": ["반중력"], + "OmniRoute": ["옴니루트"], + "Tailscale": ["꼬리비늘"], + "VACUUM": ["진공"], + "socks5": ["양말5"], + "ZIP": ["우편번호"] + } +} diff --git a/scripts/i18n/glossary/protected-terms.json b/scripts/i18n/glossary/protected-terms.json index b99d4aebe0..4d30facb49 100644 --- a/scripts/i18n/glossary/protected-terms.json +++ b/scripts/i18n/glossary/protected-terms.json @@ -1,5 +1,5 @@ { - "description": "Product/provider/model/protocol/header/CLI/env/identifier names that must appear verbatim (untranslated) inside any zh-CN localized string that mentions them. Distinct from untranslatable-keys.json, which excludes whole KEYS from drift checks at key-granularity; this list is consumed by scripts/i18n/check-glossary-consistency.mjs to flag a VALUE that mentions the concept but altered/translated the protected term itself.", + "description": "Product/provider/model/protocol/header/CLI/env/identifier names that must appear verbatim (untranslated) inside any localized string that mentions them (gated locales: zh-CN, ko). Distinct from untranslatable-keys.json, which excludes whole KEYS from drift checks at key-granularity; this list is consumed by scripts/i18n/check-glossary-consistency.mjs to flag a VALUE that mentions the concept but altered/translated the protected term itself. Known incorrect renderings live per-locale: legacy zh-CN entries in the checker's KNOWN_MISTRANSLATIONS map, newer locales in `protectedTermMistranslations` inside scripts/i18n/glossary/.json.", "terms": [ "OmniRoute", "OAuth", @@ -20,6 +20,15 @@ "CLI", "Docker", "Electron", - "Playwright" + "Playwright", + "ngrok", + "Anthropic", + "Claude", + "Gemini", + "Antigravity", + "Tailscale", + "VACUUM", + "socks5", + "ZIP" ] } diff --git a/scripts/ops/alibabafreeaudio-quota.sample.json b/scripts/ops/alibabafreeaudio-quota.sample.json new file mode 100644 index 0000000000..a9fe14e7e8 --- /dev/null +++ b/scripts/ops/alibabafreeaudio-quota.sample.json @@ -0,0 +1,427 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "data": { + "freeTierQuotas": [ + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-2025-09-08", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-30b-a3b-captioner", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vd-2026-01-26", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vc-realtime-2026-01-15", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "cosyvoice-v3-flash", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash-realtime", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-voice-enrollment", + "quotaTotal": 1000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-2025-08-25", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-mtl", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-realtime-2025-11-07", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash-2025-09-18", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash-realtime-2025-09-18", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-realtime-2026-02-10", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-livetranslate-flash-realtime-2025-09-22", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vc-2026-01-22", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1791820800000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-audio-3.0-tts-flash", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-instruct-flash-2026-01-26", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-2025-11-07", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash-2025-11-27", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vc-realtime-2025-11-27", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-realtime-2025-10-27", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-realtime", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vd-realtime-2025-12-16", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "cosyvoice-v3-plus", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-instruct-flash-realtime-2026-01-22", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-2026-02-10", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-instruct-flash", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-instruct-flash-realtime", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-mtl-2025-08-25", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1791820800000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-audio-3.0-tts-plus", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-vd-realtime-2026-01-15", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-livetranslate-flash", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-realtime", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-filetrans", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash-filetrans-2025-11-17", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-livetranslate-flash-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-tts-flash-realtime-2025-11-27", + "quotaTotal": 10000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-livetranslate-flash-realtime-2026-05-19", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-livetranslate-flash-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-livetranslate-flash-2025-12-01", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-asr-flash", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 36000, + "quotaValidityPeriod": 1789488000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "fun-asr-flash-2026-06-15", + "quotaTotal": 36000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 40, + "model": "qwen-voice-design", + "quotaTotal": 4, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 0, + "freeTierOnly": false, + "quotaTotalPercentage": 0, + "model": "voice-enrollment", + "quotaTotal": 0, + "quotaStatus": "VALID" + } + ] + }, + "success": true + } + } + } +} diff --git a/scripts/ops/alibabafreemultimodal-quota.sample.json b/scripts/ops/alibabafreemultimodal-quota.sample.json new file mode 100644 index 0000000000..2312dd9383 --- /dev/null +++ b/scripts/ops/alibabafreemultimodal-quota.sample.json @@ -0,0 +1,201 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "data": { + "freeTierQuotas": [ + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-plus-2026-03-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash-realtime-2025-09-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-omni-turbo-realtime-2025-05-08", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-flash-realtime-2026-03-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-plus", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash-2025-12-01", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-omni-turbo-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-omni-turbo", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen2.5-omni-7b", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-plus-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen-omni-turbo-2025-03-26", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-flash", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash-realtime-2025-12-01", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-plus-realtime-2026-03-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-flash-realtime", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3.5-omni-flash-2026-03-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 1000000, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": true, + "quotaTotalPercentage": 100, + "model": "qwen3-omni-flash-2025-09-15", + "quotaTotal": 1000000, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 0, + "freeTierOnly": false, + "quotaTotalPercentage": 0, + "model": "qwen-omni-turbo-realtime-latest", + "quotaTotal": 0, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 0, + "freeTierOnly": false, + "quotaTotalPercentage": 0, + "model": "qwen-omni-turbo-latest", + "quotaTotal": 0, + "quotaStatus": "VALID" + } + ] + }, + "success": true + } + } + } +} diff --git a/scripts/ops/alibabafreevision-quota.sample.json b/scripts/ops/alibabafreevision-quota.sample.json new file mode 100644 index 0000000000..87d9022843 --- /dev/null +++ b/scripts/ops/alibabafreevision-quota.sample.json @@ -0,0 +1,573 @@ +{ + "code": "200", + "data": { + "DataV2": { + "ret": ["SUCCESS::接口调用成功"], + "data": { + "data": { + "freeTierQuotas": [ + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-vace-plus", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-videoedit", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-kf2v-plus", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit-plus", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1789920000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.1-i2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit-plus-2025-10-30", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-i2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-image", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-t2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-t2v-plus", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-t2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "z-image-turbo", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-max-2025-12-30", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-r2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-animate-move", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit-max-2026-01-16", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-i2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.5-t2v-preview", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-t2v-plus", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.0-t2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.0-r2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.5-t2i-preview", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1790092800000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0-pro-2026-06-22", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-i2v-turbo", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-t2v-turbo", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-t2i-turbo", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.0-video-edit", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1789920000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.1-r2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-i2v-2026-04-25", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1789920000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.1-t2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.5-i2v-preview", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-t2i-flash", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-i2v-plus", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-t2i-plus", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-i2v-flash", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1790697600000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-t2v-2026-06-12", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1790697600000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-r2v-2026-06-12", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0-2026-03-03", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit-max", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-max", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0-pro-2026-03-03", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.5-i2i-preview", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-plus", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0-pro", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-plus-2026-01-09", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-i2v-plus", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-image-pro", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-r2v", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 200, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.1-t2i-plus", + "quotaTotal": 200, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.2-animate-mix", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-t2i", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-2.0-pro-2026-04-22", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-i2v-flash", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-t2v-2026-04-25", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 100, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "qwen-image-edit-plus-2025-12-15", + "quotaTotal": 100, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.6-r2v-flash", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 10, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "happyhorse-1.0-i2v", + "quotaTotal": 10, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 50, + "quotaValidityPeriod": 1786896000000, + "freeTierOnly": false, + "quotaTotalPercentage": 100, + "model": "wan2.7-image", + "quotaTotal": 50, + "quotaStatus": "VALID" + }, + { + "quotaInitTotal": 0, + "freeTierOnly": false, + "quotaTotalPercentage": 0, + "model": "qwen-image-3.0-pro", + "quotaTotal": 0, + "quotaStatus": "VALID" + } + ] + }, + "success": true + } + }, + "success": true + } +} diff --git a/scripts/ops/sync-alibaba-allowlist.mjs b/scripts/ops/sync-alibaba-allowlist.mjs new file mode 100644 index 0000000000..cb329c8607 --- /dev/null +++ b/scripts/ops/sync-alibaba-allowlist.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * @file sync-alibaba-allowlist.mjs + * @description Build config/alibaba-free-tier-allowlist.json from Bailian console quota JSON exports. + * + * Usage: + * node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs path/to/quota.json [...] + * node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs --from-samples + * + * Writes: + * - config/alibaba-free-tier-allowlist.json (repo baseline) + * - ~/.omniroute/alibaba-free-tier-allowlist.json when DATA_DIR unset + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + classifyAlibabaFreeTierQuotaEntries, + parseAlibabaFreeTierQuotaEntries, +} from "../../open-sse/services/alibabaFreeTierQuotaFetcher.ts"; +import { isDashscopeTextModelId } from "../../open-sse/services/dashscopeTextModels.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "../.."); + +const SAMPLE_FILES = [ + "scripts/ops/alibabafreeaudio-quota.sample.json", + "scripts/ops/alibabafreemultimodal-quota.sample.json", + "scripts/ops/alibabafreevision-quota.sample.json", +].map((relativePath) => path.join(repoRoot, relativePath)); + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function collectInputs(argv) { + if (argv.includes("--from-samples")) { + return SAMPLE_FILES.filter((filePath) => fs.existsSync(filePath)); + } + return argv.filter((arg) => !arg.startsWith("-")); +} + +function classifyTextEntries(allEntries) { + const capable = new Set(); + const noFreeTier = new Set(); + + for (const entry of allEntries) { + if (!isDashscopeTextModelId(entry.model)) continue; + const classified = classifyAlibabaFreeTierQuotaEntries([entry], { textOnly: true }); + for (const modelId of classified.capableModels) capable.add(modelId); + for (const modelId of classified.noFreeTierModels) noFreeTier.add(modelId); + } + + return { + capable: [...capable].sort(), + noFreeTier: [...noFreeTier].sort(), + }; +} + +function main() { + const inputs = collectInputs(process.argv.slice(2)); + if (inputs.length === 0) { + console.error("Usage: sync-alibaba-allowlist.mjs [...] | --from-samples"); + process.exit(1); + } + + const allEntries = []; + for (const inputPath of inputs) { + const payload = readJsonFile(inputPath); + allEntries.push(...parseAlibabaFreeTierQuotaEntries(payload)); + } + + const { capable, noFreeTier } = classifyTextEntries(allEntries); + if (capable.length === 0) { + console.error("No text free-tier models found in input payloads."); + process.exit(1); + } + + const asOf = new Date().toISOString().slice(0, 10); + const validUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const pack = { asOf, validUntil, capable, noFreeTier }; + const serialized = `${JSON.stringify(pack, null, 2)}\n`; + + const configPath = path.join(repoRoot, "config", "alibaba-free-tier-allowlist.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, serialized); + + const dataDir = process.env.DATA_DIR?.trim() || path.join(os.homedir(), ".omniroute"); + const runtimePath = path.join(dataDir, "alibaba-free-tier-allowlist.json"); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(runtimePath, serialized); + + console.log(`Wrote ${capable.length} capable + ${noFreeTier.length} blocked models`); + console.log(` config: ${configPath}`); + console.log(` runtime: ${runtimePath}`); + console.log(` validUntil: ${validUntil}`); +} + +main(); diff --git a/scripts/quality/build-test-impact-map.mjs b/scripts/quality/build-test-impact-map.mjs index 0120f95dc6..cdc1c91fed 100644 --- a/scripts/quality/build-test-impact-map.mjs +++ b/scripts/quality/build-test-impact-map.mjs @@ -9,11 +9,11 @@ const IMPORT_RE = /(?:import|export)[^'"]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g; const EXTS = [".ts", ".tsx", ".mts", ".js", ".mjs"]; -function resolveImport(spec, fromFile) { +export function resolveImport(spec, fromFile, root = ROOT) { let base; - if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2)); + if (spec.startsWith("@/")) base = path.join(root, "src", spec.slice(2)); else if (spec.startsWith("@omniroute/open-sse")) - base = path.join(ROOT, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, "")); + base = path.join(root, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, "")); else if (spec.startsWith(".")) base = path.resolve(path.dirname(fromFile), spec); else return null; for (const e of EXTS) { @@ -26,7 +26,7 @@ function resolveImport(spec, fromFile) { return fs.existsSync(base) && fs.statSync(base).isFile() ? base : null; } -function sourceDepsOf(entry) { +export function sourceDepsOf(entry, root = ROOT) { const seen = new Set(); const stack = [entry]; const sources = new Set(); @@ -43,9 +43,9 @@ function sourceDepsOf(entry) { for (const m of code.matchAll(IMPORT_RE)) { const spec = m[1] || m[2] || m[3]; if (!spec) continue; - const r = resolveImport(spec, f); + const r = resolveImport(spec, f, root); if (!r) continue; - const rel = path.relative(ROOT, r); + const rel = path.relative(root, r); if (SRC_ROOTS.some((s) => rel.startsWith(s + path.sep))) sources.add(rel); stack.push(r); } @@ -59,27 +59,35 @@ function sourceDepsOf(entry) { // e2e/integration tests, which can't run under node:test (they 99-false-failed before). // Mirror EXACTLY the package.json `test:unit` / `test:unit:ci` globs (incl. memory, // usage, combo, dashboard, serial, and *.test.mjs). Drift here → false __RUN_ALL__. -const testFiles = globSync( - [ - "tests/unit/*.test.ts", - "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts", - "tests/unit/**/*.test.mjs", - "tests/unit/dashboard/**/*.test.ts", - // Quarentena serial (P0.3): também são node:test — a TIA precisa mapeá-los. - "tests/unit/serial/**/*.test.ts", - ], - { cwd: ROOT, absolute: true } -); -const map = {}; -for (const tf of testFiles) { - const relTest = path.relative(ROOT, tf); - for (const src of sourceDepsOf(tf)) { - (map[src] ||= []).push(relTest); +export function buildTestImpactMap(root = ROOT) { + const testFiles = globSync( + [ + "tests/unit/*.test.ts", + "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts", + "tests/unit/**/*.test.mjs", + "tests/unit/dashboard/**/*.test.ts", + // Quarentena serial (P0.3): também são node:test — a TIA precisa mapeá-los. + "tests/unit/serial/**/*.test.ts", + ], + { cwd: root, absolute: true } + ); + const map = {}; + for (const tf of testFiles) { + const relTest = path.relative(root, tf); + for (const src of sourceDepsOf(tf, root)) { + (map[src] ||= []).push(relTest); + } } + for (const k of Object.keys(map)) map[k].sort(); + return { generatedFrom: "import-graph", sources: map, testFileCount: testFiles.length }; +} + +if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) { + const result = buildTestImpactMap(); + const { testFileCount, ...map } = result; + const out = path.join(ROOT, "config/quality/test-impact-map.json"); + fs.writeFileSync(out, JSON.stringify(map, null, 2) + "\n"); + console.log( + `test-impact-map: ${Object.keys(map.sources).length} source files mapped from ${testFileCount} test files` + ); } -for (const k of Object.keys(map)) map[k].sort(); -const out = path.join(ROOT, "config/quality/test-impact-map.json"); -fs.writeFileSync(out, JSON.stringify({ generatedFrom: "import-graph", sources: map }, null, 2) + "\n"); -console.log( - `test-impact-map: ${Object.keys(map).length} source files mapped from ${testFiles.length} test files` -); diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index f9693aa92e..725ac93d9f 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",