From 9c5dd6760c6c4e163f2dc06e83e40d7663e9c855 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:13:19 -0300 Subject: [PATCH] fix(providers): retire common ChatGPT Web provider --- .env.example | 46 +- Dockerfile | 2 +- README.md | 6 +- .../fixes/pending-retire-chatgpt-web.md | 1 + config/quality/eslint-suppressions.json | 5 - config/quality/file-size-baseline.json | 12 +- config/quality/test-masking-allowlist.json | 62 +- docs/README.md | 2 +- docs/architecture/ARCHITECTURE.md | 181 +- docs/architecture/CODEBASE_DOCUMENTATION.md | 11 +- docs/diagrams/free-tier-budget.svg | 4 +- docs/diagrams/promise-pillars.svg | 2 +- docs/getting-started/FREE-TIERS-GUIDE.md | 6 +- docs/getting-started/PROVIDERS-GUIDE.md | 2 +- docs/getting-started/WEB-COOKIE-GUIDE.md | 16 +- .../i18n/pl/docs/architecture/ARCHITECTURE.md | 179 +- .../architecture/CODEBASE_DOCUMENTATION.md | 9 +- .../docs/getting-started/PROVIDERS-GUIDE.md | 24 +- .../docs/getting-started/WEB-COOKIE-GUIDE.md | 16 +- docs/i18n/pl/docs/security/BAN_DETECTION.md | 2 +- docs/i18n/pl/docs/security/STEALTH_GUIDE.md | 26 +- .../pt-BR/docs/architecture/ARCHITECTURE.md | 179 +- .../zh-CN/docs/architecture/ARCHITECTURE.md | 221 +- .../architecture/CODEBASE_DOCUMENTATION.md | 265 +- docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md | 168 +- .../zh-TW/docs/architecture/ARCHITECTURE.md | 178 +- .../architecture/CODEBASE_DOCUMENTATION.md | 9 +- docs/providers/CHATGPT_WEB.md | 272 +- docs/reference/ENVIRONMENT.md | 10 +- docs/reference/PROVIDER_REFERENCE.md | 3 +- docs/screenshots/free-tier-budget-card.svg | 8 +- docs/security/BAN_DETECTION.md | 2 +- docs/security/STEALTH_GUIDE.md | 26 +- electron/loginManager.js | 2 +- open-sse/config/freeModelCatalog.data.ts | 2 - open-sse/config/imageRegistry.ts | 16 +- open-sse/config/providers/index.ts | 2 - .../providers/registry/chatgpt-web/index.ts | 91 - open-sse/executors/chatgpt-web.ts | 3142 ----------------- open-sse/executors/chatgpt-web/citations.ts | 395 --- open-sse/executors/chatgpt-web/handoff.ts | 154 - open-sse/executors/chatgpt-web/models.ts | 82 - open-sse/executors/chatgptWebErrors.ts | 18 - open-sse/executors/chatgptWebTools.ts | 2 +- open-sse/executors/edgeTts.ts | 2 +- open-sse/executors/gemini-web.ts | 4 +- open-sse/executors/index.ts | 5 +- open-sse/executors/perplexity-web.ts | 19 +- open-sse/handlers/chatCore/executorProxy.ts | 8 +- open-sse/handlers/imageGeneration.ts | 225 +- .../imageGeneration/providers/chatgptWeb.ts | 187 - .../imageGeneration/providers/geminiWeb.ts | 4 +- .../__tests__/chatgptTlsClient.test.ts | 96 - .../services/__tests__/grokTlsClient.test.ts | 7 +- open-sse/services/autoCombo/virtualFactory.ts | 13 +- open-sse/services/chatgptImageCache.ts | 143 - open-sse/services/chatgptTlsClient.ts | 48 - open-sse/services/combo/autoStrategy.ts | 13 +- open-sse/services/combo/comboStructure.ts | 2 +- open-sse/services/errorClassifier.ts | 4 +- open-sse/services/inAppLoginService.ts | 2 +- .../services/sessionPool/sessionFactory.ts | 2 +- .../sessionPool/webExecutorWrapper.ts | 2 +- open-sse/services/tokenExtractionConfig.ts | 10 - open-sse/translator/webTools.ts | 8 +- open-sse/utils/error.ts | 23 +- open-sse/utils/nextAuthCookie.ts | 2 +- open-sse/utils/sha3-512.ts | 164 - open-sse/utils/streamHandler.ts | 4 +- scripts/build/fixTlsClientNodeBinary.mjs | 4 +- scripts/build/pack-artifact-policy.ts | 2 +- scripts/build/postinstall.mjs | 2 +- scripts/packs/optionalPackManifest.mjs | 2 +- src/app/api/providers/[id]/route.ts | 8 +- src/app/api/providers/[id]/test/route.ts | 18 +- .../api/providers/bulk-web-session/route.ts | 4 + src/app/api/providers/bulk/route.ts | 4 + src/app/api/providers/import/route.ts | 22 +- src/app/api/providers/route.ts | 16 + src/app/api/providers/validate/route.ts | 4 + src/app/api/v1/chat/completions/route.ts | 18 + .../api/v1/chatgpt-web/image/[id]/route.ts | 42 - src/app/api/v1/images/edits/route.ts | 98 +- src/app/api/v1/images/generations/route.ts | 52 +- src/app/api/v1/messages/count_tokens/route.ts | 16 + src/app/api/v1/models/[...model]/route.ts | 7 +- .../[provider]/chat/completions/route.ts | 3 + .../[provider]/images/generations/route.ts | 3 + src/app/api/v1/session-leases/route.ts | 21 +- .../db/migrations/163_retire_chatgpt_web.sql | 195 + src/lib/db/providers.ts | 26 +- src/lib/images/imageRouteModel.ts | 5 +- src/lib/logPayloads.ts | 2 +- src/lib/providerNodePrefixes.ts | 18 +- .../providers/chatgptWebRetirementResponse.ts | 30 + src/lib/providers/modelListingCapability.ts | 2 +- src/lib/providers/validation.ts | 10 +- src/lib/providers/validation/transport.ts | 1 - src/lib/providers/validation/webProvidersA.ts | 110 +- src/lib/usage/flatRateProviders.ts | 2 +- src/shared/components/lobeProviderIcons.ts | 1 - src/shared/constants/chatgptWebRetirement.ts | 46 + src/shared/constants/providers/web-cookie.ts | 13 - .../constants/reservedProviderPrefixes.ts | 29 +- src/shared/providers/webSessionCredentials.ts | 7 - src/shared/validation/schemas/provider.ts | 2 +- src/sse/handlers/chat.ts | 9 +- src/sse/handlers/chatHelpers.ts | 10 +- src/sse/services/auth.ts | 7 +- src/sse/services/model.ts | 37 +- src/sse/services/tokenRefresh.ts | 2 +- src/types/global.d.ts | 2 - tests/snapshots/executors/executor-map.json | 12 +- tests/snapshots/provider/translate-path.json | 23 - ...200-perplexity-web-cookie-rotation.test.ts | 24 +- ...8488-capability-filter-fail-closed.test.ts | 26 +- tests/unit/bulk-web-session-import.test.ts | 30 +- tests/unit/capability-filter.test.ts | 39 +- tests/unit/chatcore-executor-proxy.test.ts | 27 +- tests/unit/chatgpt-image-cache.test.ts | 87 - ...gpt-web-async-image-ws-shapes-7357.test.ts | 445 --- .../unit/chatgpt-web-citations-escape.test.ts | 50 - tests/unit/chatgpt-web-citations.test.ts | 390 -- tests/unit/chatgpt-web-handoff-resume.test.ts | 260 -- ...atgpt-web-image-handler-retirement.test.ts | 55 + .../unit/chatgpt-web-image-silentdrop.test.ts | 96 - .../chatgpt-web-management-retirement.test.ts | 152 + .../chatgpt-web-max-thinking-effort.test.ts | 34 - tests/unit/chatgpt-web-models-split.test.ts | 35 - tests/unit/chatgpt-web-retirement.test.ts | 33 + tests/unit/chatgpt-web-runtime-block.test.ts | 299 ++ .../chatgpt-web-sha3-boringssl-5531.test.ts | 82 - .../chatgpt-web-source-retirement.test.ts | 31 + tests/unit/chatgpt-web-tools-5240.test.ts | 269 -- tests/unit/chatgpt-web.test.ts | 2924 --------------- .../combo-auto-candidate-expansion.test.ts | 28 + .../errorClassifier-noauth-403-6315.test.ts | 34 +- tests/unit/executor-web-cookie-sweep.test.ts | 1 - tests/unit/flat-rate-cost-5552.test.ts | 13 +- .../gemini-web-cookie-rotation-7676.test.ts | 10 +- ...ard-session-lease-bypass-inventory.test.ts | 2 - tests/unit/image-generation-route.test.ts | 94 +- tests/unit/image-registry-gpt56.test.ts | 12 +- ...image-routes-combo-edits-3214-3215.test.ts | 14 +- tests/unit/keepalive-threshold.test.ts | 12 +- ...-model-catalog-reconciliation-8926.test.ts | 42 - tests/unit/mcp-model-catalog.test.ts | 14 +- .../unit/messages-count-tokens-route.test.ts | 50 + .../migration-163-retire-chatgpt-web.test.ts | 531 +++ .../model-listing-capability-5420.test.ts | 4 +- ...erplexity-web-streaming-tools-5927.test.ts | 8 +- tests/unit/provider-apikey-cap-6715.test.ts | 2 +- .../provider-node-reserved-prefix.test.ts | 58 + ...er-test-token-web-session-dispatch.test.ts | 11 +- .../provider-validation-specialty.test.ts | 158 - ...ider-validation-web-cookie-auth007.test.ts | 12 +- tests/unit/radar-guided-setup-action.test.tsx | 2 +- tests/unit/resolve-web-provider-host.test.ts | 19 +- tests/unit/session-leases-route.test.ts | 39 + .../unit/tls-client-download-dir-8579.test.ts | 3 +- ...tls-client-node-docker-binary-7802.test.ts | 2 +- tests/unit/tokenExtractionConfig.test.ts | 7 +- ...i-key-modal-validation-error-5088.test.tsx | 17 +- ...eb-session-cookie-modal-size-6265.test.tsx | 4 +- .../unprefixed-scan-web-cookie-10848.test.ts | 34 - tests/unit/v1-models-by-id-4674.test.ts | 11 +- .../validation-web-providers-split.test.ts | 4 +- tests/unit/virtual-auto-combo.test.ts | 46 +- tests/unit/vnc-session.test.ts | 8 +- .../web-cookie-validation-proxy-7058.test.ts | 2 +- tests/unit/web-session-credentials.test.ts | 6 +- ...est.ts => web-tools-contract-7679.test.ts} | 4 +- 172 files changed, 3193 insertions(+), 11318 deletions(-) create mode 100644 changelog.d/fixes/pending-retire-chatgpt-web.md delete mode 100644 open-sse/config/providers/registry/chatgpt-web/index.ts delete mode 100644 open-sse/executors/chatgpt-web.ts delete mode 100644 open-sse/executors/chatgpt-web/citations.ts delete mode 100644 open-sse/executors/chatgpt-web/handoff.ts delete mode 100644 open-sse/executors/chatgpt-web/models.ts delete mode 100644 open-sse/executors/chatgptWebErrors.ts delete mode 100644 open-sse/handlers/imageGeneration/providers/chatgptWeb.ts delete mode 100644 open-sse/services/__tests__/chatgptTlsClient.test.ts delete mode 100644 open-sse/services/chatgptImageCache.ts delete mode 100644 open-sse/services/chatgptTlsClient.ts delete mode 100644 open-sse/utils/sha3-512.ts delete mode 100644 src/app/api/v1/chatgpt-web/image/[id]/route.ts create mode 100644 src/lib/db/migrations/163_retire_chatgpt_web.sql create mode 100644 src/lib/providers/chatgptWebRetirementResponse.ts create mode 100644 src/shared/constants/chatgptWebRetirement.ts delete mode 100644 tests/unit/chatgpt-image-cache.test.ts delete mode 100644 tests/unit/chatgpt-web-async-image-ws-shapes-7357.test.ts delete mode 100644 tests/unit/chatgpt-web-citations-escape.test.ts delete mode 100644 tests/unit/chatgpt-web-citations.test.ts delete mode 100644 tests/unit/chatgpt-web-handoff-resume.test.ts create mode 100644 tests/unit/chatgpt-web-image-handler-retirement.test.ts delete mode 100644 tests/unit/chatgpt-web-image-silentdrop.test.ts create mode 100644 tests/unit/chatgpt-web-management-retirement.test.ts delete mode 100644 tests/unit/chatgpt-web-max-thinking-effort.test.ts delete mode 100644 tests/unit/chatgpt-web-models-split.test.ts create mode 100644 tests/unit/chatgpt-web-retirement.test.ts create mode 100644 tests/unit/chatgpt-web-runtime-block.test.ts delete mode 100644 tests/unit/chatgpt-web-sha3-boringssl-5531.test.ts create mode 100644 tests/unit/chatgpt-web-source-retirement.test.ts delete mode 100644 tests/unit/chatgpt-web-tools-5240.test.ts delete mode 100644 tests/unit/chatgpt-web.test.ts create mode 100644 tests/unit/migration-163-retire-chatgpt-web.test.ts delete mode 100644 tests/unit/unprefixed-scan-web-cookie-10848.test.ts rename tests/unit/{chatgpt-web-tools-7679.test.ts => web-tools-contract-7679.test.ts} (97%) diff --git a/.env.example b/.env.example index 85967b6d58..f598741618 100644 --- a/.env.example +++ b/.env.example @@ -612,11 +612,9 @@ CLOUD_URL= # Default: http://localhost:20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 -# Browser-facing OmniRoute origin for generated assets in API responses. -# Highest-priority public origin override; also used by non-dashboard public-origin validation. -# Used by: chatgpt-web image generation cache URLs (/v1/chatgpt-web/image/). -# Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL -# but the user's browser must fetch images from a LAN, tunnel, or public origin. +# Highest-priority OmniRoute public origin override, also used by non-dashboard +# public-origin validation. Set it when external clients reach OmniRoute through +# a stable LAN, tunnel, or public origin that differs from its internal URL. # Do not include /v1; if included accidentally it will be normalized away. # OMNIROUTE_PUBLIC_BASE_URL=http://192.168.0.15:20128 @@ -629,28 +627,6 @@ NEXT_PUBLIC_BASE_URL=http://localhost:20128 # Used by: open-sse/config/providerPluginManifestUrl.ts. Defaults to http. # OMNIROUTE_PUBLIC_PROTOCOL=http -# Max wait time for an async chatgpt-web image to land via the celsius -# WebSocket, in milliseconds. Default 180000 (3 minutes). Increase during -# upstream queue-deep windows ("Lots of people are creating images right now"). -# OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS=180000 - -# Total in-memory byte budget for the chatgpt-web image cache (used to serve -# /v1/chatgpt-web/image/), in megabytes. Default 256. Lower this if you -# run OmniRoute on a memory-constrained host; raise it if image generation -# is heavy and clients are racing the 30-minute TTL. -# OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB=256 - -# Overall wait budget for a chatgpt-web GPT-5.5 Pro background-poll handoff, -# in milliseconds. Default 1200000 (20 minutes). Pro reasoning runs are slow -# and complete out-of-band, so OmniRoute polls until the answer lands or this -# budget elapses. Raise it if Pro requests time out before finishing. -# OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS=1200000 - -# Interval between chatgpt-web GPT-5.5 Pro background-poll attempts, in -# milliseconds. Default 4000 (4 seconds). Lower for snappier completion at the -# cost of more upstream polling; raise to reduce request volume. -# OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS=4000 - # Public cloud URL — client-side mirror of CLOUD_URL. NEXT_PUBLIC_CLOUD_URL= @@ -1483,17 +1459,6 @@ CURSOR_USER_AGENT="Cursor/3.4" # FIRECRAWL_BASE_URL=https://api.firecrawl.dev # FIRECRAWL_TIMEOUT_MS=30000 # Per-request timeout (default: 30000 = 30s) -# ── ChatGPT TLS sidecar (Firefox-fingerprinted client) ── -# Used by: open-sse/services/chatgptTlsClient.ts — wire-level timeout for -# the bogdanfinn/tls-client koffi binding and the JS-side grace window -# layered on top of it when the native library is wedged. -# OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS=60000 -# OMNIROUTE_CHATGPT_TLS_GRACE_MS=10000 -# Max wait for the FIRST streamed byte from the ChatGPT TLS sidecar before the -# request is aborted as a dead stream, in milliseconds. Default 30000 (30s). -# Raise it if upstream cold-starts routinely exceed the window. -# OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS=30000 - # ── Claude TLS sidecar (Chromium-fingerprinted client) ── # Used by: open-sse/services/claudeTlsClient.ts — wire-level timeout for # the bogdanfinn/tls-client koffi binding and the JS-side grace window @@ -2566,11 +2531,6 @@ APP_LOG_TO_FILE=true # Used by: src/lib/jobs/backupScheduleJob.ts # OMNIROUTE_BACKUP_SCHEDULE_JOB_INTERVAL_MS=30000 -# ── TLS sidecar override ── -# Used by: open-sse/services/chatgptTlsClient.ts tests. Production deployments -# should leave this unset; the sidecar is auto-managed. -# OMNIROUTE_TLS_PROXY_URL= - # ── Skills sandbox (experimental) ── # Used by: src/lib/skills/builtins.ts. All values support comma lists where # noted in the source. diff --git a/Dockerfile b/Dockerfile index 97a4b75d50..43d9a309ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -104,7 +104,7 @@ RUN test -f package-lock.json \ # instead of `npx --yes`, which would install an arbitrary registry version # on-demand and run its lifecycle scripts (Sonar docker:S6505). # -# tls-client-node (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web TLS +# tls-client-node (claude-web/grok-web/lmarena/perplexity-web TLS # impersonation) hits the same --ignore-scripts wall: its own postinstall.js # fetches a platform .so/.dylib/.dll from the bogdanfinn/tls-client GitHub # Releases API and is never invoked when npm ci skips lifecycle scripts. Unlike diff --git a/README.md b/README.md index 11220ca00f..e5805026cb 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ -> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **455 free-tier entries across 40 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`). +> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **454 free-tier entries across 40 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`). -OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 40 documented recurring pool keys covering 455 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 15 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers. +OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 40 documented recurring pool keys covering 454 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 15 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers. > Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**. > @@ -1265,7 +1265,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi Resilience GuideCircuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing Auto-Combo Engine15-factor scoring, mode packs, self-healing Proxy Guide3-level proxy system, 1proxy marketplace, registry CRUD - Free TiersConsolidated directory: 40 documented recurring pools / 455 cataloged free-tier entries + Free TiersConsolidated directory: 40 documented recurring pools / 454 cataloged free-tier entries Features GalleryVisual dashboard tour with screenshots Codebase DocumentationBeginner-friendly codebase walkthrough diff --git a/changelog.d/fixes/pending-retire-chatgpt-web.md b/changelog.d/fixes/pending-retire-chatgpt-web.md new file mode 100644 index 0000000000..15660daded --- /dev/null +++ b/changelog.d/fixes/pending-retire-chatgpt-web.md @@ -0,0 +1 @@ +- **fix(providers):** retire the common `chatgpt-web` / `cgpt-web` provider because its pre-key and proof-of-work provenance could not be cleared ([#PENDING](https://github.com/diegosouzapw/OmniRoute/pull/PENDING)) — explicit chat and image requests, slash-prefixed models, persisted aliases, proxy overrides, and combo targets now fail closed with a sanitized HTTP `410` / `PROVIDER_RETIRED` before upstream dispatch. Migration 163 tombstones matching connections and invalidates their active leases without deleting history or widening API-key allowlists. The independent `chatgpt-web-codex` / `cgpt-codex` provider and its MIT-noticed vendored implementation remain available. diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 86fc92a447..da249f89c5 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -3890,11 +3890,6 @@ "count": 31 } }, - "tests/unit/chatgpt-web-tools-5240.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, "tests/unit/check-docs-symbols.test.ts": { "@typescript-eslint/no-unused-vars": { "count": 3 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 33adce912a..2ab413eac1 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -15,7 +15,7 @@ "_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_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.", "_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent's conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR's own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.", - "_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.", + "_rebaseline_2026_07_25_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). 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).", "_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 — 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) — 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.", @@ -83,7 +83,6 @@ "_rebaseline_2026_06_20_reviewprs_mine_r2_filesize": "Reconciliacao file-size pos-lote /review-prs 'apenas minhas' r2: dois frozen cresceram cumulativamente sem bump (cada PR media OK na sua base, mas o crescimento empilhou acima do frozen no tip de merge; o fast-path do release nao roda check:file-size, so release->main). (1) src/shared/constants/pricing.ts 1620->1623 (+3 = linhas de pricing Claude Code (cc) do #4440, sobre o 1620 que o #4447 ja setara para gpt-4.1-mini/nano + o3/o4-mini). (2) open-sse/executors/base.ts 1399->1407 (+8 = handling granular de reasoning_effort para Claude no Copilot do #4443). Ambos dados/wiring coesos nos chokepoints existentes; nao extraiveis. Cobertos por tests/unit (claude-code pricing / base-executor-sanitize-effort + github-claude-reasoning-effort-granular).", "_rebaseline_2026_06_22_4647_opencode_go_deepseek": "PR #4647 (DevEstacion/opencode-go DeepSeek V4 Pro effort variants) review feedback: open-sse/executors/base.ts 1407->1414 (+7 = supportsMaxEffortForProvider now opt-ins opencode-go+deepseek so the literal 'max' effort survives the post-transformReasoningEffortForProvider pass — without this, max was silently rewritten to xhigh (OmniRoute's internal top tier) and the opencode-go upstream rejected it. The check is scoped to opencode-go deliberately to preserve the OpenRouter-DeepSeek inverse invariant (pi#4055, asserted by base-executor-sanitize-effort test:OpenRouter DeepSeek normalizes max -> xhigh). The +5 explanatory comment is required: a naive maintainer could otherwise broaden the check to all deepseek models and break the OpenRouter contract. Cohesive at the existing supportsMaxEffortForProvider chokepoint, next to the Claude/CC-compatible check; not extractable. Covered by tests/unit/base-executor-sanitize-effort.test.ts (3 new opencode-go deepseek cases).", "_rebaseline_2026_06_30_v3842_release_basetsl_5480": "v3.8.42 cycle-close file-size reconciliation: open-sse/executors/base.ts 1497->1500 (+3 net = #5480 'gate claude adaptive thinking defaults' — the adaptive-thinking injection is now gated behind the operator's thinking-budget config at the existing transform chokepoint, so default/passthrough no longer force-injects). Cohesive at the existing reasoning/thinking transform site; not extractable. The fast-path release gate (PR->release/**) does not run check:file-size, so this surfaced only on the release PR (PR->main). Covered by tests/unit/base-thinking-budget-config-5312.test.ts + the #5480 gate test.", - "_rebaseline_2026_06_30_v3842_release_chatgptweb_compression": "v3.8.42 cycle-close file-size reconciliation (DRIFT measured OK on each PR's base, stacked above frozen at the merge tip; fast-path PR->release/** does not run check:file-size). (1) open-sse/executors/chatgpt-web.ts 2870->3206 (+336 = #5531 portable SHA3-512 sentinel-PoW wiring with the native-vs-fallback digest path + #5536 GPT-5.5 Pro handoff branch; the pure Keccak-f[1600] fallback itself already lives in the separate leaf open-sse/utils/sha3-512.ts — the executor growth is the cohesive call-site/handoff logic, not extractable without hiding the sentinel chokepoint). (2) tests/unit/chatgpt-web.test.ts 2855->3159 (+304 = #5536 GPT-5.5 Pro handoff coverage; pair-file with its executor). (3) open-sse/services/compression/strategySelector.ts 997->1022 (+25 = #5527 T02 honest default-on pipeline inflation guard wiring at the existing finalizeStackedResult choke). All cohesive at existing chokepoints; covered by tests/unit/chatgpt-web-sha3-boringssl-5531.test.ts, chatgpt-web.test.ts (GPT-5.5 Pro), compression-pipeline-inflation-guard.test.ts.", "_rebaseline_2026_06_20_4023_web_cookie_noauth_validation": "PR #4023 (oyi77) own growth: src/lib/providers/validation.ts 4450->4518 (+68 = a new validateWebCookieProvider that probes the provider's /models endpoint — 401/403 => AUTH_007 SESSION_EXPIRED, any other status => valid session, empty cookie => invalid, provider-not-in-registry => unsupported — plus a local STANDARD_USER_AGENT const for the probe). Cohesive validator at the validateProviderApiKey dispatch; not extractable. Covered by tests/unit/provider-validation-web-cookie-auth007.test.ts. Heavily curated on merge — the PR's branch was badly stale-based (squash-base-stale), so its tree was DESTRUCTIVE: providers/index.ts deleted live providers openadapter/dit/tokenrouter (added by #4313) and the executor/base.ts edits reverted release fixes (#4037 duckduckgo host, theoldllm gpt5 models, base.ts fetch-start-timeout). Only the purely-additive validation feature was kept (validation.ts validateWebCookieProvider + errorCodes AUTH_007 + the test). Dropped: 5 malformed new registry entries (used non-RegistryEntry fields defaultModel/auth + referenced non-existent executors -> tsc TS2353), the destructive providers/index.ts + executor reverts, the unrelated pr-*.sh automation scripts, and evals/types.ts (belongs to the deferred evals modularization #4422). Also removed the PR's fragile 'Phase 2' executor probe (ran a live upstream chat during validation + classified any 'auth'-containing error as SESSION_EXPIRED) and rewrote the test to install its fetch mock before module load (the original mocked too late and silently hit live chatgpt.com).", "_rebaseline_2026_06_20_1308_model_lockout_honors_reset": "port from 9router#1308 own growth: open-sse/services/accountFallback.ts 1731->1752 (+21 = the new exported pure helper selectLockoutCooldownMs + its doc comment — picks the parsed upstream reset as the model-lockout exactCooldownMs when it exceeds the base cooldown, e.g. Antigravity \"Resets in 160h\", else preserves the existing 0/base behavior) and open-sse/executors/antigravity.ts 1680->1686 (this PR +1 = parseRetryFromErrorMessage regex `reset` -> `resets?` so plural \"Resets in 160h27m24s\" matches, plus a comment line; frozen set to the SUM 1686 with the concurrent #1944 which adds +5 at the disjoint passthroughFields region of the same file, so either merge order passes — pair-file rule). The combo lockout call sites in combo.ts now pass selectLockoutCooldownMs(cooldownMs, mlSettings) instead of always base/exponential, so an exhausted model honors the real upstream reset instead of being retried within minutes. Both edits are cohesive at the existing lockout/parse chokepoints; the helper is its own pure function (not extractable further). Covered by tests/unit/combo-model-lockout-honors-reset-1308.test.ts.", "_rebaseline_2026_06_20_1944_antigravity_strip_output_config": "port from 9router#1944: open-sse/executors/antigravity.ts frozen set to the measured cumulative 1687 of two concurrent PRs that touch disjoint regions of this file, so either merge order passes (pair-file rule). #1944 adds +6 at the envelope passthroughFields destructuring (~line 759: drop output_config/output_format — Anthropic/Claude-Code-only fields that Google's Cloud Code envelope rejects with `400 Unknown name \"output_config\"`, which broke every Claude model on Antigravity); #1308 adds +1 at parseRetryFromErrorMessage (~line 889: regex reset->resets?). Base 1680 + 6 + 1 = 1687 (re-measured on the real merge tip — the earlier 1686 estimate was off by one). Both edits are cohesive at their chokepoints; not extractable. Covered by tests/unit/antigravity-strip-output-config-1944.test.ts.", @@ -201,7 +200,6 @@ "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, @@ -275,7 +273,7 @@ "_rebaseline_2026_07_02_5899_airforce_v1_discovery": "PR #5904 own growth: provider-models-route.test.ts 1628->1752 (+124 = test-only Rule #18 regression guards for the Api Airforce /v1/v1/models discovery bug (#5899): (a) a baseUrl ending in /v1/chat/completions must probe .../v1/models not the doubled .../v1/v1/models, and the host-guard case http://v1; (b) a REDIRECT_BLOCKED on one candidate must continue to the next endpoint instead of aborting the probe loop. Both guards fail on the pre-fix code. Test-only additions cohesive with the existing provider-models discovery suite (shared seedConnection/callRoute harness); not separately extractable without duplicating the harness.", "_rebaseline_2026_06_19_4293_codex_spark_scope": "PR #4293 (isolate Codex Spark quota scope) own growth, MEASURED on the actual merged tree (release/v3.8.30 + #4293). Production: auth.ts 2219->2279 (+60) threads requestedModel into Codex quota-policy/headroom/preflight/P2C scoring so normal Codex and GPT-5.3-Codex-Spark windows are evaluated independently; chatCore.ts 5116->5125 (+9) passes the failing model scope into Codex 429 failover (markCodexScopeRateLimited) instead of a connection-wide rateLimitedUntil write; accountFallback.ts 1727->1731 (+4) scopes Codex model-lock keys to codex vs spark. Heavy parsing/display logic lives in new leaf helpers under the cap (codexQuotaScopes.ts, codexUsageQuotas.ts, codexFailover.ts). Tests: account-fallback-service 1544->1569, executor-codex 1336->1339, sse-auth 1527->1553, usage-service-hardening 1612->1633 (added Spark-scope regression coverage). Cohesive wiring at existing selection/failover lockout boundaries; not extractable.", "_rebaseline_2026_06_20_4447_openai_gpt41mini_o_mini_pricing": "PR #4447 own growth: pricing.ts 1592->1620 (+28 = pure-data pricing rows closing the null/$0 gap for registry-exposed OpenAI ids gpt-4.1-mini, gpt-4.1-nano, o3-mini, o4-mini that tripped the catalog pricing gate; getPricingForModel does an exact lookup, so a missing key resolves to null. Official OpenAI per-1M prices + the table's derived-field convention (reasoning=output*1.5, cache_creation=input, cached=official). Restore-green for a pre-existing release/v3.8.32 red surfaced by #4432's __RUN_ALL__ run. Cohesive data; not extractable.", - "_rebaseline_2026_06_20_web_cookie_validator_shadow_fix": "validation.ts 4518->4522 (+4 = move the generic web-cookie validateWebCookieProvider dispatch from the TOP of validateProviderApiKey to a FALLBACK after SPECIALTY_VALIDATORS, plus a comment, so #4023's generic AUTH_007 ping no longer shadows the rich per-provider validators (grok-web #3474 IP-reputation/Cloudflare, chatgpt-web cf-mitigated, claude/gemini/copilot/qwen/t3-web). Restores provider-validation-specialty.test.ts (112/112) while keeping web-cookie-auth007 (5/5). Behavior fix at an existing dispatch boundary; not extractable.", + "_rebaseline_2026_06_20_web_cookie_validator_shadow_fix": "validation.ts 4518->4522 (+4 = move the generic web-cookie validateWebCookieProvider dispatch from the TOP of validateProviderApiKey to a FALLBACK after SPECIALTY_VALIDATORS, plus a comment, so #4023's generic AUTH_007 ping no longer shadows the rich per-provider validators (grok-web #3474 IP-reputation/Cloudflare and the active claude/gemini/copilot/qwen/t3-web validators). Restores provider-validation-specialty.test.ts (112/112) while keeping web-cookie-auth007 (5/5). Behavior fix at an existing dispatch boundary; not extractable.", "_rebaseline_2026_06_22_phase4b_slm_tier_ultra": "Compression Phase 4 (B) SLM tier own growth: open-sse/services/compression/strategySelector.ts 783->818 (+35 at the existing applyUltraAsync chokepoint). The no-modelPath ultra branch (previously a one-line passthrough to the sync applyCompression) now runs the two-tier resolver: it adapts the body, builds the ultraConfig (threading config.ultraEngine + preserveSystemPrompt), awaits the now-async ultraCompress (SLM Tier-B when ultraEngine===slm and the worker backend is available, else fail-open to the Tier-A heuristic), and threads result.stats.ultraTier into the returned CompressionStats so the resolved tier reaches the D0 telemetry persister. The sync applyCompression ultra branch is also re-pointed to the new pure ultraCompressHeuristic. The two-tier resolver + the pure heuristic live in open-sse/services/compression/ultra.ts and the thin SLM entry in engines/llmlingua/ultraEntry.ts (both 848 (+30 at the existing selectCompressionPlan dispatch chokepoint). selectCompressionPlan gains an 8th optional `adaptiveOptions` param (modelContextLimit/requestMaxTokens/onAdaptive sink) and, after resolveBasePlan and before the caching-aware pass, runs the PURE resolveAdaptivePlan when config.contextBudget.mode is floor|replace-autotrigger; the new adaptiveEnabled(config) helper also gates the legacy shouldAutoTrigger branch inside resolveBasePlan off when adaptive owns automatic-by-size escalation (D-C4). The escalation ladder, target computation, and the resolver itself live in open-sse/services/compression/adaptiveCompression/{computeTarget,ladder,resolveAdaptivePlan,types}.ts (all 1122 (+19 = SanitizeOpenAIResponseOptions interface + stripReasoning option, #4678); tokenRefresh.ts 2070->2090 (+20 = codex 401 defense-in-depth unrecoverable-refresh guard, #4686); token-refresh-service.test.ts 1322->1353 (+31 = 401-unfamiliar-payload regression case, #4686); translator-openai-responses-req.test.ts 1047->1050 (+3 = reasoning_effort non-Copilot assertion update, #4688). All are the merged PRs own surgical additions at existing chokepoints.", @@ -372,7 +370,6 @@ "_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": 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, @@ -449,7 +446,7 @@ "open-sse/utils/sseHeartbeat.ts": 194, "open-sse/utils/proxyFetch.ts": 1244, "_rebaseline_2026_08_23_11177_dns_retry_classification": "PR #11177 (rqzbeh) own growth: proxyFetch.ts 1239->1244 (+5, EAI_AGAIN/ENOTFOUND/ETIMEDOUT join the retryable dispatcher classification alongside ECONNREFUSED — bounded socket retries for transient DNS failures, part of the #10443 Hermes→Antigravity stream-drop fixes). Covered by tests/unit/proxy-fetch-dns-retry-10443.test.ts. Owner pre-authorized baseline bumps 2026-08-22.", - "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry: DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legítima acima do cap; gateways.ts = god-file de catálogo de providers que cresceu com os PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o próprio PR #9421 foi o que quebrou o arquivo; sem split até o release, congelado no tamanho atual). Owner autorizou rebaseline com anotação (2026-08-11).": { + "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry: DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web (Codex) provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legítima acima do cap; gateways.ts = god-file de catálogo de providers que cresceu com os PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o próprio PR #9421 foi o que quebrou o arquivo; sem split até o release, congelado no tamanho atual). Owner autorizou rebaseline com anotação (2026-08-11).": { "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1062, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051, "src/shared/components/ModelSelectModal.tsx": 1138, @@ -462,7 +459,7 @@ "src/shared/components/ModelSelectModal.tsx": 1138, "src/shared/constants/providers/apikey/gateways.ts": 1330, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387, - "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", + "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web (Codex) provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", "src/lib/modelCapabilities.ts": 1072, "_rebaseline_2026_08_21_11034_effort_variants": "DRIFT do tip (base-red #9985): modelCapabilities.ts 1016->1072 (+56) acumulado por PRs ja mergeadas no release/v3.8.50 — principalmente #11034 (resolve effort-variant capabilities a partir do modelo base), alem de #10963/#11040/#10987 growth dos catalogos. Tip puro ficou vermelho neste gate; rebaseline no tip por push direto (owner pre-autorizou crescimento legitimo). Nao tocou no arquivo da #11038.", "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014, @@ -564,7 +561,6 @@ "_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", diff --git a/config/quality/test-masking-allowlist.json b/config/quality/test-masking-allowlist.json index fd828b6d5f..0257c0d765 100644 --- a/config/quality/test-masking-allowlist.json +++ b/config/quality/test-masking-allowlist.json @@ -8,10 +8,8 @@ "tests/unit/compression/ultra.test.ts": "v3.8.29 #4253: the vestigial SLM seam + dead deprecated alias were removed from the ultra compression engine; 6 asserts covering the removed seam were removed accordingly (49→43). Verified legitimate, not masking. Prune after v3.8.29 merges to main.", "tests/unit/db-backup-extended.test.ts": "v3.8.29 #4132: db-backup de-flake — 1 timing-sensitive assertion on fire-and-forget backup completion was removed in favor of awaiting actual completion (44→43). Verified legitimate, not masking. Prune after v3.8.29 merges to main.", "@omniroute/opencode-plugin/tests/combos.test.ts": "v3.8.31 #4384: the plugin now prefixes every catalog key with the `omniroute` provider id and drops the legacy `combo/` namespace; the test asserting raw-deletion + a `combo/` key (a namespace that no longer exists) was removed and the remaining asserts switched to `omniroute/` keys (82→81). Asserts updated to the new key contract, not weakened. Verified legitimate. Prune after v3.8.31 merges to main.", - "tests/unit/chatgpt-web.test.ts": "v3.8.43 #5549: fix(chatgpt-web) restore dot-form Pro model ids — dois assert.equal separados (base Pro slug + pass-through slug) consolidados num único assert.equal(body.model, expectedSlugById[omniId], ...) orientado por tabela de mapeamento (281→280). Asserts consolidados, não enfraquecidos. Verificado legítimo. Prune após v3.8.43 mergear para main.", "tests/unit/chatcore-sanitization.test.ts": "v3.8.43 #5805: fix(translator) strip orphaned tool results — orphaned tool_result blocks (no matching tool_use) are now removed by stripOrphanedToolResults BEFORE content normalization, so the 3 positive `[Tool Result: …]`-text asserts were replaced by removal asserts (no tool_result block, no text), net 65→64. Behavior aligned to the merged #5805 contract; the sibling 'preserves Claude passthrough tool_result' assert (matching tool_use) is untouched. Verified legitimate, not masking. Prune after v3.8.43 merges to main.", "src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelVisibilityHandlers.test.tsx": "v3.8.43 #5856: fix(dashboard) unify CSRF origin fallback — the model-visibility handler no longer issues a separate `/api/auth/csrf` fetch, so the two asserts pinning the 2nd fetch + its CSRF header were removed and the fetch-count assert updated 2→1 (7→5). Asserts follow the reduced fetch behavior, not weakened. Verified legitimate. Prune after v3.8.43 merges to main.", - "tests/unit/provider-validation-specialty.test.ts": "v3.8.43 #5855: fix(qwen-web) unblock validator (retired endpoint) — the old `chat.qwen.ai/api/v2/user` probe asserts (exact URL / Authorization / source / Cookie / WAF-error) no longer apply after the endpoint migration and were replaced by new chathub-path behavior asserts (valid/error/warning), net 406→400. Asserts migrated to the new API surface (#5855/#5432), not weakened. Verified legitimate. Prune after v3.8.43 merges to main.", "tests/unit/chatcore-translation-paths.test.ts": "v3.8.44 #5958: fix(cc-compatible) send SSE accept for streamed requests — o assert do header Accept mudou de application/json para text/event-stream e dois asserts (stream + context_management) foram consolidados num único assert.deepEqual de tupla (297→296). Asserts consolidados/atualizados ao novo contrato, não enfraquecidos. Verificado legítimo. Prune após v3.8.44 mergear para main.", "tests/unit/combo-context-window-filter.test.ts": "v3.8.44 #6088: fix(combo) prefer known context capacity over unknown — a semântica do filtro mudou (unknown-context só sobrevive como fallback), o arquivo foi reescrito: 18 asserts pequenos (length/didFallback/ok) viraram 4 assert.deepEqual na LISTA ORDENADA completa de sobreviventes por caso (mais forte por caso). Asserts migrados ao novo contrato, não enfraquecidos. Verificado legítimo. Prune após v3.8.44 mergear para main.", "tests/unit/qoder-executor.test.ts": "v3.8.44 #5816: feat(qoder) drive PAT auth via qodercli — o executor migrou de chamadas HTTP diretas (api.qoder.com/api1.qoder.sh, headers Cosy-*) para o contrato stdio qodercli://; os asserts que pinavam URLs/headers da superfície aposentada foram substituídos por asserts do novo contrato (73→65). Asserts migrados à nova superfície, não enfraquecidos. Verificado legítimo. Prune após v3.8.44 mergear para main.", @@ -23,8 +21,67 @@ "tests/unit/provider-page-helpers-3501.test.ts": "v3.8.47 #6862: feat GPT-5.6 family — 2 assert.ok(values.includes(...)) substituídos por 1 assert.deepEqual(values, [lista completa ordenada de effort tiers]) — asserção estritamente MAIS FORTE, net 69→68. Verificado legítimo. Prune após v3.8.47 mergear para main.", "tests/unit/vscode-token-routes.test.ts": "v3.8.47 #6862: feat GPT-5.6 family — a matriz de modelos do VS Code token route migrou de GPT-5.4/5.5 para a família 5.6 (tiers consolidados, context 200k→500k, novos slugs sol); os asserts da matriz aposentada foram substituídos pelos da nova (net 247→200). Asserts migrados ao novo catálogo, não enfraquecidos. Verificado legítimo. Prune após v3.8.47 mergear para main.", "tests/unit/providers-page-utils.test.ts": "v3.8.47 #6675: remoção dos providers obsoletos glhf/kluster/cablyai/inclusionai — os 2 asserts que citavam providers removidos do catálogo foram removidos junto (net 276→274). Superfície aposentada, não mascaramento. Verificado legítimo. Prune após v3.8.47 mergear para main.", + "tests/unit/errorClassifier-noauth-403-6315.test.ts": "v3.8.50 #PENDING: the two terminal SENTINEL_BLOCKED assertions covered only the retired common ChatGPT Web executor and its removed sentinel path (4→2). The remaining no-auth/apikey 403 controls are unchanged; retirement behavior is now covered by deterministic 410/no-network regression tests. Provider surface removed, not weakened.", + "tests/unit/live-model-catalog-reconciliation-8926.test.ts": "v3.8.50 #PENDING: six static assertions for curated common ChatGPT Web variants were removed with that provider's catalog (41→35). The remaining live-catalog reconciliation matrix is unchanged; dedicated retirement tests now assert the common ids are absent and fail closed while Codex Web controls remain. Provider surface removed, not weakened.", + "tests/unit/provider-validation-specialty.test.ts": "v3.8.50 #PENDING: seventeen assertions exercised the removed common ChatGPT Web TLS/session validator (425→408). The validator and exclusive TLS implementation no longer ship; management retirement regressions now prove both ids return sanitized 410 without network, while other specialty validators remain covered. Provider surface removed, not weakened.", "_deletedWithReplacement": { "_comment": "Deleções de arquivo de teste com SUBSTITUTO verificado (o gate exige que o replacement exista no HEAD e seja arquivo de teste). Uso restrito ao caso 'reescrito em outro path sem rename detectável pelo -M do git'. Cada entrada precisa de reason com PR ref e passa por revisão humana no release PR. Prune após o release mergear para main.", + "open-sse/services/__tests__/chatgptTlsClient.test.ts": { + "replacement": "tests/unit/chatgpt-web-source-retirement.test.ts", + "reason": "v3.8.50 #PENDING: the common ChatGPT Web provider and its exclusive TLS wrapper were retired because provenance could not be cleared. The replacement pins source absence while the independent Codex Web transport remains covered by its dedicated tests." + }, + "tests/unit/chatgpt-image-cache.test.ts": { + "replacement": "tests/unit/chatgpt-web-source-retirement.test.ts", + "reason": "v3.8.50 #PENDING: the cache existed only for the retired common ChatGPT Web image implementation. The replacement proves that exclusive source stays absent; Codex Web is unaffected." + }, + "tests/unit/chatgpt-web-async-image-ws-shapes-7357.test.ts": { + "replacement": "tests/unit/chatgpt-web-image-handler-retirement.test.ts", + "reason": "v3.8.50 #PENDING: the common provider's WebSocket image protocol was removed with the non-clearable implementation. The replacement proves deterministic fail-closed image handling without upstream network access." + }, + "tests/unit/chatgpt-web-citations-escape.test.ts": { + "replacement": "tests/unit/chatgpt-web-source-retirement.test.ts", + "reason": "v3.8.50 #PENDING: citation parsing was exclusive to the retired common provider. The replacement pins removal of that derived source and its registry/executor surface." + }, + "tests/unit/chatgpt-web-citations.test.ts": { + "replacement": "tests/unit/chatgpt-web-source-retirement.test.ts", + "reason": "v3.8.50 #PENDING: citation parsing was exclusive to the retired common provider. The replacement pins removal of that derived source and its registry/executor surface." + }, + "tests/unit/chatgpt-web-handoff-resume.test.ts": { + "replacement": "tests/unit/chatgpt-web-source-retirement.test.ts", + "reason": "v3.8.50 #PENDING: handoff/resume was exclusive to the retired common provider. The replacement pins removal of the implementation instead of preserving a functional path to it." + }, + "tests/unit/chatgpt-web-image-silentdrop.test.ts": { + "replacement": "tests/unit/chatgpt-web-image-handler-retirement.test.ts", + "reason": "v3.8.50 #PENDING: the old image success/error contract no longer exists. The replacement asserts a sanitized 410 PROVIDER_RETIRED response and zero upstream dispatch." + }, + "tests/unit/chatgpt-web-max-thinking-effort.test.ts": { + "replacement": "tests/unit/chatgpt-web-runtime-block.test.ts", + "reason": "v3.8.50 #PENDING: model-specific thinking behavior belonged to the retired common executor. The replacement proves every explicit retired model path is blocked before dispatch." + }, + "tests/unit/chatgpt-web-models-split.test.ts": { + "replacement": "tests/unit/chatgpt-web-retirement.test.ts", + "reason": "v3.8.50 #PENDING: the common provider model catalog was removed. The replacement guards catalog/registry/alias retirement and preserves positive Codex Web controls." + }, + "tests/unit/chatgpt-web-sha3-boringssl-5531.test.ts": { + "replacement": "tests/unit/chatgpt-web-source-retirement.test.ts", + "reason": "v3.8.50 #PENDING: the SHA3 helper existed only for the removed common provider's sentinel proof-of-work path. The replacement pins absence of both source surfaces." + }, + "tests/unit/chatgpt-web-tools-5240.test.ts": { + "replacement": "tests/unit/chatgpt-web-source-retirement.test.ts", + "reason": "v3.8.50 #PENDING: the functional tool path depended on the retired common executor. The replacement prevents reintroduction of that executor; provider-neutral web-tools coverage remains separate." + }, + "tests/unit/chatgpt-web-tools-7679.test.ts": { + "replacement": "tests/unit/web-tools-contract-7679.test.ts", + "reason": "v3.8.50 #PENDING: shared web-tools contract coverage moved to a provider-neutral filename while the common ChatGPT Web executor was retired. This is a coverage-preserving relocation." + }, + "tests/unit/chatgpt-web.test.ts": { + "replacement": "tests/unit/chatgpt-web-retirement.test.ts", + "reason": "v3.8.50 #PENDING: the functional suite targeted a common executor that no longer ships due non-clearable provenance. The replacement guards deterministic retirement across registry, dispatch and Codex Web controls." + }, + "tests/unit/unprefixed-scan-web-cookie-10848.test.ts": { + "replacement": "tests/unit/image-registry-gpt56.test.ts", + "reason": "v3.8.50 #PENDING: the common provider no longer participates in image catalog scans. The replacement proves its catalog entry is absent, its slashful and bare image models remain unresolved, and the Codex OAuth image catalog remains intact." + }, "open-sse/services/combo/__tests__/targetExhaustion.test.ts": { "replacement": "tests/unit/combo/combo-target-exhaustion.test.ts", "reason": "v3.8.44 #5976: os testes de exaustão eram flake-prone (delays Math.random, timeouts 30s, >3min no CI) e foram REESCRITOS como unit determinístico com MAIS cobertura (21 casos/52 asserts vs 13 casos/37 asserts). Documentado no commit 5fe225850. Revisão humana: apresentado ao operador no STOP #1 do release v3.8.44." @@ -111,7 +168,6 @@ "tests/unit/translator-openai-to-kiro.test.ts": "v3.8.49 #8565: auto-kiro passou a ser REJEITADO com erro (KIRO_REMOVED_AUTO_ALIAS_MESSAGE, \"not a real Kiro upstream model\") em vez de mapeado silenciosamente para \"auto\"; o teste do comportamento antigo foi removido porque hoje ele asseriria o comportamento errado. A rejeição está coberta em kiro-model-aliases.test.ts (assert.throws /not a real Kiro/) e kiro-available-models.test.ts. Net 119→118. Verificado legítimo. Prune após v3.8.49 mergear para main.", "tests/unit/usage-providers.test.ts": "v3.8.49 #7866: o case \"qwen\" saiu de getUsageForProvider (não há mais case \"qwen\" no switch de open-sse/services/usage.ts); o teste cobria esse ramo extinto (net 20→19). Verificado legítimo. Prune após v3.8.49 mergear para main.", "tests/unit/usage-service-hardening.test.ts": "v3.8.49 #7866/#8565/#8013: qwen removido (−3 asserts); o Kimi/Kiro builder-id (uso profileless) passou a ter SUCESSO real em vez de erro de ARN — supportsProfilelessKiroUsage(\"builder-id\") retorna true —, trocando 1 assert de regex de erro por 3 asserts de valor; e os ids de bucket de quota do Antigravity foram atualizados para o catálogo atual. Rodado no HEAD: 23/23 passam. Net 210→209. Verificado legítimo. Prune após v3.8.49 mergear para main.", - "tests/unit/virtual-auto-combo.test.ts": "v3.8.49 #7928/#8183: o pooling de contas passou a agrupar conexões web-session do mesmo provider numa entrada lógica com allowedConnectionIds (campo confirmado em open-sse/services/autoCombo/virtualFactory.ts), e o pool no-auth virou uma allowlist fixa (AUTO_COMBO_NOAUTH_ALLOWLIST = opencode, felo-web) — os testes antigos esperavam duplicatas e a inclusão de duckduckgo-web/theoldllm/chipotle, que hoje são corretamente excluídos. Guard dedicado em noauth-autocombo-allowlist.test.ts. Rodado no HEAD: 10/10 passam. Net 39→31. Verificado legítimo. Prune após v3.8.49 mergear para main.", "open-sse/services/__tests__/tierResolver.test.ts": "v3.8.49 #7866: refactor(qwen) remove o provider OAuth legado — o teste \"classifies Qwen as free\" e a entrada de qwen na lista do batch saíram junto com o provider, e os índices do batch desceram de 10 para 9 elementos (net 61→59). Superfície extinta, não enfraquecimento. Verificado legítimo. Prune após v3.8.49 mergear para main.", "tests/unit/plugins-welcome-banner-e2e.test.ts": "v3.8.50 #9126 (commit 8fac6bcd48): o teste único 'BUILTIN_EVENTS has all 14 events' (13 asserts .ok/.equal) foi reestruturado em 3 testes mais específicos — 'contains only emitted/public events' (assert.deepEqual da lista completa), 'does not advertise dead events' (7 asserts .equal(false) para eventos sem emissor real: onModelSelect/onComboResolve/onRateLimit/onQuotaExhaust/onProviderError/onStreamStart/onStreamEnd) e 'lifecycle events remain represented' (4 asserts .ok). Contrato mais forte (agora também nega presença dos eventos mortos), não mais fraco — a contagem líquida cai (73→61) porque o assert.deepEqual único substitui múltiplos assert.ok redundantes com a mesma cobertura. Asserts restruturados, não removidos sem substituição. Verificado legítimo.", "tests/unit/web-tools-translation-2820.test.ts": "v3.8.50 #9343 (commit d969555417): fix(security) exige envelope explicito — JSON puro NAO deve mais ser promovido a tool_calls. Os 5 testes foram REESCRITOS para o contrato oposto (antes: 'promove e valida name/arguments'; agora: 'toolCalls === null e content preservado'), o que naturalmente usa menos asserts: verificar a NAO-promocao custa 2 asserts, verificar o objeto promovido custava 4. Contrato mais restritivo, nao mais fraco (39->35). Verificado legitimo — a inversao esta explicita nos proprios nomes dos testes ('does NOT promote ... (#9343)').", diff --git a/docs/README.md b/docs/README.md index 2fe8a425cd..16ac8aaab4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -163,7 +163,7 @@ Prompt compression engines, rules, and language packs. Provider-specific integration guides. - [CLAUDE_WEB.md](providers/CLAUDE_WEB.md) — Claude Web (cookie-auth) provider. -- [CHATGPT_WEB.md](providers/CHATGPT_WEB.md) — ChatGPT Web (Plus/Pro + Codex) providers. +- [CHATGPT_WEB.md](providers/CHATGPT_WEB.md) — ChatGPT Web (Codex) provider and common-provider retirement note. - [ALIBABA-QWEN-PROVIDER-FAMILIES.md](providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md) — Alibaba and Qwen provider families. - [AGENTROUTER.md](providers/AGENTROUTER.md) — AgentRouter setup. - [ZED-DOCKER.md](providers/ZED-DOCKER.md) — Zed IDE integration under Docker. diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 790cf35de7..f6b86bad22 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -17,7 +17,7 @@ It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic acr Core capabilities: -- OpenAI-compatible API surface for CLI/tools (349 providers, 107 executors) +- OpenAI-compatible API surface for CLI/tools (351 providers, 107 executors) - Request/response translation across provider formats - Model combo fallback (multi-model sequence) - Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers` @@ -488,9 +488,6 @@ the global circuit breaker / connection cooldown / model lockout layers: `claudeCodeToolRemapper.ts` - Claude Code fingerprint shaping: `open-sse/services/claudeCodeFingerprint.ts` - Claude Code obfuscation: `open-sse/services/claudeCodeObfuscation.ts` -- ChatGPT TLS client: `open-sse/services/chatgptTlsClient.ts` (curl-impersonate - style for ChatGPT-Web sessions) -- ChatGPT image cache: `open-sse/services/chatgptImageCache.ts` For the full stealth playbook and operational guidance, see [`docs/security/STEALTH_GUIDE.md`](../security/STEALTH_GUIDE.md). @@ -898,103 +895,103 @@ flowchart LR Each provider has a specialized executor extending `BaseExecutor` (in `open-sse/executors/base.ts`), which provides URL building, header construction, retry with exponential backoff, credential refresh hooks, and the `execute()` orchestration method. -| Executor | Provider(s) | Special Handling | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA, etc. | Dynamic URL/header config per provider | -| `AntigravityExecutor` | Google Antigravity | Custom project/session IDs, Retry-After parsing, 429 obfuscation | -| `AzureOpenAIExecutor` | Azure OpenAI | Deployment-based routing, api-version query enforcement | -| `BlackboxWebExecutor` | Blackbox AI (web-mode) | Web-session reverse with TLS fingerprint emulation | -| `ChatGPTWebExecutor` | ChatGPT web | TLS client + session cookie management (`chatgptTlsClient.ts`) | -| `ClaudeIdentityExecutor` | Claude.ai (CCH path) | Constraint + tool-remap pipelines, fingerprint shaping | -| `CliProxyApiExecutor` | CLIProxyAPI-compatible providers | Custom auth and protocol handling | -| `CloudflareAiExecutor` | Cloudflare Workers AI | Account ID injection, Neurons-based usage tracking | -| `CodexExecutor` | OpenAI Codex | Injects system instructions, forces reasoning effort | -| `CommandCodeExecutor` | Command Code | OAuth + per-session header rotation | -| `CursorExecutor` | Cursor IDE | ConnectRPC protocol, Protobuf encoding, request signing via checksum | -| `DevinCliExecutor` | Devin CLI | Devin task lifecycle bridging via cloud agent module | -| `GithubExecutor` | GitHub Copilot | Copilot token refresh, VSCode-mimicking headers | -| `GitlabExecutor` | GitLab Duo | GitLab OAuth + project-scoped routing | -| `GlmExecutor` | Z.AI GLM (incl. `glmt` preset) | Thinking-budget aware, GLMT preset constants | -| `GrokWebExecutor` | xAI Grok web | Web-session reverse, mode selection (think/standard) | -| `KieExecutor` | KIE | Custom token issuance with rotating session anchors | -| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream binary format → SSE conversion | -| `MuseSparkWebExecutor` | Muse Spark (web) | Web-session reverse with image-message bridging | -| `NlpCloudExecutor` | NLP Cloud | Provider-specific request body shape | -| `OpenCodeExecutor` | OpenCode | AI SDK compatible provider setup | -| `PerplexityWebExecutor` | Perplexity web | Web-session reverse for chat continuation | -| `PetalsExecutor` | Petals distributed inference | Decentralized swarm routing | -| `PollinationsExecutor` | Pollinations AI | No API key required, rate-limited requests | -| `QoderExecutor` | Qoder AI | PAT and OAuth support, multi-model free tier | -| `VertexExecutor` | Google Vertex AI | Service account auth, region-based endpoints | -| `DevinDesktopExecutor` | Devin Desktop | Imported API key + Connect-protobuf chat streaming | +| Executor | Provider(s) | Special Handling | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA, etc. | Dynamic URL/header config per provider | +| `AntigravityExecutor` | Google Antigravity | Custom project/session IDs, Retry-After parsing, 429 obfuscation | +| `AzureOpenAIExecutor` | Azure OpenAI | Deployment-based routing, api-version query enforcement | +| `BlackboxWebExecutor` | Blackbox AI (web-mode) | Web-session reverse with TLS fingerprint emulation | +| `ClaudeIdentityExecutor` | Claude.ai (CCH path) | Constraint + tool-remap pipelines, fingerprint shaping | +| `CliProxyApiExecutor` | CLIProxyAPI-compatible providers | Custom auth and protocol handling | +| `CloudflareAiExecutor` | Cloudflare Workers AI | Account ID injection, Neurons-based usage tracking | +| `CodexExecutor` | OpenAI Codex | Injects system instructions, forces reasoning effort | +| `ChatGptWebCodexExecutor` | ChatGPT Web (Codex) | Browser-session Responses API bridge with thread/turn pinning | +| `CommandCodeExecutor` | Command Code | OAuth + per-session header rotation | +| `CursorExecutor` | Cursor IDE | ConnectRPC protocol, Protobuf encoding, request signing via checksum | +| `DevinCliExecutor` | Devin CLI | Devin task lifecycle bridging via cloud agent module | +| `GithubExecutor` | GitHub Copilot | Copilot token refresh, VSCode-mimicking headers | +| `GitlabExecutor` | GitLab Duo | GitLab OAuth + project-scoped routing | +| `GlmExecutor` | Z.AI GLM (incl. `glmt` preset) | Thinking-budget aware, GLMT preset constants | +| `GrokWebExecutor` | xAI Grok web | Web-session reverse, mode selection (think/standard) | +| `KieExecutor` | KIE | Custom token issuance with rotating session anchors | +| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream binary format → SSE conversion | +| `MuseSparkWebExecutor` | Muse Spark (web) | Web-session reverse with image-message bridging | +| `NlpCloudExecutor` | NLP Cloud | Provider-specific request body shape | +| `OpenCodeExecutor` | OpenCode | AI SDK compatible provider setup | +| `PerplexityWebExecutor` | Perplexity web | Web-session reverse for chat continuation | +| `PetalsExecutor` | Petals distributed inference | Decentralized swarm routing | +| `PollinationsExecutor` | Pollinations AI | No API key required, rate-limited requests | +| `QoderExecutor` | Qoder AI | PAT and OAuth support, multi-model free tier | +| `VertexExecutor` | Google Vertex AI | Service account auth, region-based endpoints | +| `DevinDesktopExecutor` | Devin Desktop | Imported API key + Connect-protobuf chat streaming | All other providers (including custom compatible nodes) use the `DefaultExecutor`. ## Provider Compatibility Matrix -> **Note:** The matrix below is a representative sample of the 237 registered providers in +> **Note:** The matrix below is a representative sample of the 351 registered providers in > OmniRoute v3.8.0. For the canonical and continuously-updated list, refer to > [`docs/reference/PROVIDER_REFERENCE.md`](../reference/PROVIDER_REFERENCE.md) (auto-generated) or the source of > truth at `src/shared/constants/providers.ts` (Zod-validated at load). -| Provider | Format | Auth | Stream | Non-Stream | Token Refresh | Usage API | -| ----------------- | ---------------- | --------------------- | ---------------- | ---------- | ------------- | ------------------ | -| Claude | claude | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Admin only | -| Gemini | gemini | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ Full quota API | -| OpenAI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Codex | openai-responses | OAuth | ✅ forced | ❌ | ✅ | ✅ Rate limits | -| GitHub Copilot | openai | OAuth + Copilot Token | ✅ | ✅ | ✅ | ✅ Quota snapshots | -| Cursor | cursor | Custom checksum | ✅ | ✅ | ❌ | ❌ | -| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ Usage limits | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Per request | -| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| OpenRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| GLM/Kimi/MiniMax | claude | API Key | ✅ | ✅ | ❌ | ❌ | -| DeepSeek | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Groq | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| xAI (Grok) | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Mistral | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Perplexity | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Together AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Fireworks AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cerebras | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cohere | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| NVIDIA NIM | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cloudflare AI | openai | API Token + Acct ID | ✅ | ✅ | ❌ | ❌ | -| Pollinations | openai | None (no key) | ✅ | ✅ | ❌ | ❌ | -| Scaleway AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| LongCat | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Ollama Cloud | openai | API Key (optional) | ✅ | ✅ | ❌ | ❌ | -| HuggingFace | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Nebius | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| SiliconFlow | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Hyperbolic | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Vertex AI | gemini | Service Account | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Per request | -| Z.AI / GLM | openai | API Key / OAuth | ✅ | ✅ | ❌ | ❌ | -| GLMT (preset) | claude | API Key | ✅ | ✅ | ❌ | ⚠️ Per request | -| Kimi Coding | openai | OAuth / API Key | ✅ | ✅ | ✅ | ❌ | -| KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Devin Desktop | openai | Imported API key | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Per request | -| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | -| Devin CLI | openai | Local CLI login | ✅ | ✅ | ❌ | ✅ Task API | -| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Rate limits | -| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ Task API | -| AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| ChatGPT-Web | openai | Session cookie + TLS | ✅ | ✅ | ❌ | ❌ | -| Grok-Web | openai | Session cookie | ✅ | ✅ | ❌ | ❌ | -| Perplexity-Web | openai | Session cookie | ✅ | ✅ | ❌ | ❌ | -| BlackBox-Web | openai | Session cookie + TLS | ✅ | ✅ | ❌ | ❌ | -| Muse-Spark-Web | openai | Session cookie | ✅ | ✅ | ❌ | ❌ | -| ModelScope | openai | API Key | ✅ | ✅ | ❌ | ⚠️ Quota policy | -| BazaarLink | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Petals | openai | None | ✅ | ✅ | ❌ | ❌ | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Per request | -| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| CLIProxyAPI | openai | Custom | ✅ | ✅ | ❌ | ❌ | +| Provider | Format | Auth | Stream | Non-Stream | Token Refresh | Usage API | +| ------------------- | ---------------- | --------------------- | ---------------- | ---------- | ------------- | ------------------ | +| Claude | claude | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Admin only | +| Gemini | gemini | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ Full quota API | +| OpenAI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Codex | openai-responses | OAuth | ✅ forced | ❌ | ✅ | ✅ Rate limits | +| ChatGPT Web (Codex) | openai-responses | Browser session | ✅ forced | ❌ | ❌ | ❌ | +| GitHub Copilot | openai | OAuth + Copilot Token | ✅ | ✅ | ✅ | ✅ Quota snapshots | +| Cursor | cursor | Custom checksum | ✅ | ✅ | ❌ | ❌ | +| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ Usage limits | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Per request | +| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| OpenRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| GLM/Kimi/MiniMax | claude | API Key | ✅ | ✅ | ❌ | ❌ | +| DeepSeek | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Groq | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| xAI (Grok) | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Mistral | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Perplexity | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Together AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Fireworks AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cerebras | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cohere | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| NVIDIA NIM | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cloudflare AI | openai | API Token + Acct ID | ✅ | ✅ | ❌ | ❌ | +| Pollinations | openai | None (no key) | ✅ | ✅ | ❌ | ❌ | +| Scaleway AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| LongCat | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Ollama Cloud | openai | API Key (optional) | ✅ | ✅ | ❌ | ❌ | +| HuggingFace | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Nebius | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| SiliconFlow | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Hyperbolic | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Vertex AI | gemini | Service Account | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Per request | +| Z.AI / GLM | openai | API Key / OAuth | ✅ | ✅ | ❌ | ❌ | +| GLMT (preset) | claude | API Key | ✅ | ✅ | ❌ | ⚠️ Per request | +| Kimi Coding | openai | OAuth / API Key | ✅ | ✅ | ✅ | ❌ | +| KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Devin Desktop | openai | Imported API key | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Per request | +| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | +| Devin CLI | openai | Local CLI login | ✅ | ✅ | ❌ | ✅ Task API | +| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Rate limits | +| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ Task API | +| AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Grok-Web | openai | Session cookie | ✅ | ✅ | ❌ | ❌ | +| Perplexity-Web | openai | Session cookie | ✅ | ✅ | ❌ | ❌ | +| BlackBox-Web | openai | Session cookie + TLS | ✅ | ✅ | ❌ | ❌ | +| Muse-Spark-Web | openai | Session cookie | ✅ | ✅ | ❌ | ❌ | +| ModelScope | openai | API Key | ✅ | ✅ | ❌ | ⚠️ Quota policy | +| BazaarLink | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Petals | openai | None | ✅ | ✅ | ❌ | ❌ | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Per request | +| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| CLIProxyAPI | openai | Custom | ✅ | ✅ | ❌ | ❌ | ## Format Translation Coverage diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index a0d9ff603e..62da001ac0 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -233,7 +233,6 @@ v1/ ├── audio/{speech, transcriptions}/ TTS + STT ├── batches/[id]/{cancel}, batches/ OpenAI Batches API ├── chat/completions/ Chat Completions (the main endpoint) -├── chatgpt-web/ ChatGPT-Web compat ├── completions/ Legacy text completions ├── embeddings/ Embeddings ├── files/[id]/, files/ Files API @@ -292,7 +291,7 @@ table groups the actual directories and notable top-level files. | `jobs/` | Background jobs (`autoUpdate.ts`, …) | | `memory/` | Persistent memory: `store.ts`, `cache.ts`, `retrieval.ts`, `summarization.ts`, `extraction.ts`, `injection.ts`, `qdrant.ts`, `settings.ts`, `verify.ts`, `schemas.ts`, `types.ts` | | `monitoring/` | `observability.ts` | -| `oauth/` | OAuth/import provider modules (22): `agy`, `antigravity`, `claude`, `cline`, `codebuddy-cn`, `codex`, `cursor`, `devin-desktop`, `ghe-copilot`, `github`, `gitlab-duo`, `grok-cli-oauth`, `grok-cli`, `kilocode`, `kimi-coding`, `kiro`, `qoder`, `raycast`, `trae`, `xai-oauth`, `zed-hosted`, `zed`, plus `services/`, `utils/`, and `constants/oauth.ts` | +| `oauth/` | OAuth/import provider modules (22): `agy`, `antigravity`, `claude`, `cline`, `codebuddy-cn`, `codex`, `cursor`, `devin-desktop`, `ghe-copilot`, `github`, `gitlab-duo`, `grok-cli-oauth`, `grok-cli`, `kilocode`, `kimi-coding`, `kiro`, `qoder`, `raycast`, `trae`, `xai-oauth`, `zed-hosted`, `zed`, plus `services/`, `utils/`, and `constants/oauth.ts` | | `plugins/` | Plugin loader (`index.ts`) | | `promptCache/` | `prefixAnalyzer.ts`, `index.ts` | | `providerModels/` | Managed model lifecycle: `modelDiscovery.ts`, `managedModelImport.ts`, `managedAvailableModels.ts`, `cursorAgent.ts` | @@ -481,16 +480,16 @@ open-sse/ ### 4.2 `open-sse/executors/` -101 provider executors, each extending `BaseExecutor` (`base.ts`): +107 provider executors, each extending `BaseExecutor` (`base.ts`): -`antigravity`, `azure-openai`, `blackbox-web`, `chatgpt-web`, `cliproxyapi`, -`cloudflare-ai`, `codex`, `commandCode`, `cursor`, `default`, `devin-cli`, +`antigravity`, `azure-openai`, `blackbox-web`, `cliproxyapi`, +`chatgpt-web-codex`, `cloudflare-ai`, `codex`, `commandCode`, `cursor`, `default`, `devin-cli`, `muse-spark-web`, `nlpcloud`, `opencode`, `perplexity-web`, `petals`, `pollinations`, `qoder`, `vertex`, `devin-desktop`, plus `claudeIdentity.ts` (shared identity helper) and `index.ts` (registry). > Note: providers not listed here are served by `default.ts` using the generic -> OpenAI-compatible executor. The full provider catalog (338 providers) lives in +> OpenAI-compatible executor. The full provider catalog (351 providers) lives in > `src/shared/constants/providers.ts`. ### 4.3 `open-sse/translator/` diff --git a/docs/diagrams/free-tier-budget.svg b/docs/diagrams/free-tier-budget.svg index b96da3272d..986dee8da9 100644 --- a/docs/diagrams/free-tier-budget.svg +++ b/docs/diagrams/free-tier-budget.svg @@ -1,4 +1,4 @@ - + Pool-deduplicated chart of the 20 recurring free-token pools with positive published budgets, plus signup credits and uncapped providers shown separately. @@ -64,7 +64,7 @@ ~1.51B FREE TOKENS / MONTH · STEADY up to ~2.13B in your first month — signup credits - documented free tiers · 40 recurring pools · 455 catalog entries · one endpoint + documented free tiers · 40 recurring pools · 454 catalog entries · one endpoint diff --git a/docs/diagrams/promise-pillars.svg b/docs/diagrams/promise-pillars.svg index 3952ed7fd5..0789cfa323 100644 --- a/docs/diagrams/promise-pillars.svg +++ b/docs/diagrams/promise-pillars.svg @@ -73,7 +73,7 @@ $0 to start - 90+ providers with a free tier, 56 free + 90+ providers with a free tier, 55 free forever — Qoder, Pollinations, Cloudflare, SiliconFlow… No card needed. diff --git a/docs/getting-started/FREE-TIERS-GUIDE.md b/docs/getting-started/FREE-TIERS-GUIDE.md index 400343affe..b88ce3d3fc 100644 --- a/docs/getting-started/FREE-TIERS-GUIDE.md +++ b/docs/getting-started/FREE-TIERS-GUIDE.md @@ -1,6 +1,6 @@ # Free Tiers Guide: Understand and Combine Free AI Access -> **TL;DR**: OmniRoute registers 350 provider IDs, with **154 provider-catalog entries marked `hasFree`**. The stricter audited free-model catalog covers **40 recurring pool keys / 455 entries** (448 active + 7 discontinued). Connect several suitable providers for broader fallback capacity; every quota, approval rule, privacy policy, and paid-overage condition still applies. +> **TL;DR**: OmniRoute registers 351 provider IDs, with **154 provider-catalog entries marked `hasFree`**. The stricter audited free-model catalog covers **40 recurring pool keys / 454 entries** (447 active + 7 discontinued). Connect several suitable providers for broader fallback capacity; every quota, approval rule, privacy policy, and paid-overage condition still applies. --- @@ -163,8 +163,8 @@ The live, pool-deduplicated catalog currently reports: | ---------------------------------------------------- | -----------------------------------------------: | ----------------------------------------------------------------------------------------- | | Recurring quantified grant | **~1.51B tokens/month** | Shared pools counted once; excludes uncapped providers from the sum | | First month with signup grants | **~2.13B tokens** | Recurring total plus one-time and recurring credits | -| Audited free-model inventory | **40 recurring pool keys / 455 catalog entries** | 448 active + 7 discontinued; distinct from the 350-provider catalog | -| Recurring/keyless free-forever providers represented | **56** | Unique providers across recurring daily/monthly/credit/uncapped and keyless catalog types | +| Audited free-model inventory | **40 recurring pool keys / 454 catalog entries** | 447 active + 7 discontinued; distinct from the 351-provider catalog | +| Recurring/keyless free-forever providers represented | **55** | Unique providers across recurring daily/monthly/credit/uncapped and keyless catalog types | | Provider catalog entries marked `hasFree` | **154 / 350** | Broader provider metadata; not all have a quantifiable recurring quota | These values are computed from `open-sse/config/freeModelCatalog.ts`; see the diff --git a/docs/getting-started/PROVIDERS-GUIDE.md b/docs/getting-started/PROVIDERS-GUIDE.md index d6d20906c5..bb69cf4fd5 100644 --- a/docs/getting-started/PROVIDERS-GUIDE.md +++ b/docs/getting-started/PROVIDERS-GUIDE.md @@ -21,7 +21,7 @@ Think of a provider like a **phone carrier**. Just as you need a phone carrier t | **Free** | No payment required | Kiro, OpenCode Free, Pollinations | $0 | | **API Key** | You need an API key | OpenAI, Anthropic, Google | Pay per use | | **OAuth** | Login with your account | Claude Code, GitHub Copilot | Subscription | -| **Web Cookie** | Uses your browser session | ChatGPT Web, Gemini Web | $0 (uses your account) | +| **Web Cookie** | Uses your browser session | ChatGPT Web (Codex), Gemini Web | $0 (uses your account) | ### Web Cookie Providers diff --git a/docs/getting-started/WEB-COOKIE-GUIDE.md b/docs/getting-started/WEB-COOKIE-GUIDE.md index 0a44b5fa6b..2f7d646030 100644 --- a/docs/getting-started/WEB-COOKIE-GUIDE.md +++ b/docs/getting-started/WEB-COOKIE-GUIDE.md @@ -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 | Full Cookie header or `__Secure-next-auth.session-token` value | `docs/providers/CHATGPT_WEB.md` | -| 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 (Codex) | Full Cookie header | `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/i18n/pl/docs/architecture/ARCHITECTURE.md b/docs/i18n/pl/docs/architecture/ARCHITECTURE.md index 6997bd716a..9d84891804 100644 --- a/docs/i18n/pl/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/pl/docs/architecture/ARCHITECTURE.md @@ -488,9 +488,6 @@ globalnych warstwach circuit breaker / connection cooldown / model lockout: `claudeCodeToolRemapper.ts` - Kształtowanie fingerprint Claude Code: `open-sse/services/claudeCodeFingerprint.ts` - Obfuskacja Claude Code: `open-sse/services/claudeCodeObfuscation.ts` -- Klient TLS ChatGPT: `open-sse/services/chatgptTlsClient.ts` (styl curl-impersonate - dla sesji ChatGPT-Web) -- Cache obrazów ChatGPT: `open-sse/services/chatgptImageCache.ts` Pełny playbook stealth i wskazówki operacyjne: zob. [`docs/security/STEALTH_GUIDE.md`](../security/STEALTH_GUIDE.md). @@ -898,103 +895,103 @@ flowchart LR Każdy dostawca ma wyspecjalizowany executor rozszerzający `BaseExecutor` (w `open-sse/executors/base.ts`), który zapewnia budowanie URL, konstrukcję nagłówków, retry z exponential backoff, hooki odświeżania poświadczeń oraz metodę orkiestracji `execute()`. -| Executor | Provider(s) | Specjalna obsługa | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA, etc. | Dynamiczna konfiguracja URL/nagłówków per dostawca | -| `AntigravityExecutor` | Google Antigravity | Niestandardowe ID project/session, parsowanie Retry-After, obfuskacja 429 | -| `AzureOpenAIExecutor` | Azure OpenAI | Routing oparty na deployment, egzekwowanie query api-version | -| `BlackboxWebExecutor` | Blackbox AI (web-mode) | Reverse sesji web z emulacją fingerprint TLS | -| `ChatGPTWebExecutor` | ChatGPT web | Klient TLS + zarządzanie cookie sesji (`chatgptTlsClient.ts`) | -| `ClaudeIdentityExecutor` | Claude.ai (CCH path) | Potoki constraint + tool-remap, kształtowanie fingerprint | -| `CliProxyApiExecutor` | CLIProxyAPI-compatible providers | Niestandardowa obsługa auth i protokołu | -| `CloudflareAiExecutor` | Cloudflare Workers AI | Injekcja Account ID, śledzenie użycia oparte na Neurons | -| `CodexExecutor` | OpenAI Codex | Wstrzykuje instrukcje systemowe, wymusza reasoning effort | -| `CommandCodeExecutor` | Command Code | OAuth + rotacja nagłówków per sesja | -| `CursorExecutor` | Cursor IDE | Protokół ConnectRPC, kodowanie Protobuf, podpisywanie żądań przez checksum | -| `DevinCliExecutor` | Devin CLI | Mostkowanie cyklu życia zadań Devin przez moduł cloud agent | -| `GithubExecutor` | GitHub Copilot | Odświeżanie tokenu Copilot, nagłówki imitujące VSCode | -| `GitlabExecutor` | GitLab Duo | OAuth GitLab + routing w zakresie projektu | -| `GlmExecutor` | Z.AI GLM (incl. `glmt` preset) | Świadomy thinking-budget, stałe presetu GLMT | -| `GrokWebExecutor` | xAI Grok web | Reverse sesji web, wybór trybu (think/standard) | -| `KieExecutor` | KIE | Niestandardowe wydawanie tokenów z rotującymi kotwicami sesji | -| `KiroExecutor` | AWS CodeWhisperer/Kiro | Konwersja binarnego formatu AWS EventStream → SSE | -| `MuseSparkWebExecutor` | Muse Spark (web) | Reverse sesji web z mostkowaniem image-message | -| `NlpCloudExecutor` | NLP Cloud | Kształt body żądania specyficzny dla dostawcy | -| `OpenCodeExecutor` | OpenCode | Konfiguracja dostawcy zgodna z AI SDK | -| `PerplexityWebExecutor` | Perplexity web | Reverse sesji web dla kontynuacji czatu | -| `PetalsExecutor` | Petals distributed inference | Zdecentralizowany routing swarm | -| `PollinationsExecutor` | Pollinations AI | Klucz API niewymagany, żądania z rate limitem | -| `QoderExecutor` | Qoder AI | Wsparcie PAT i OAuth, darmowy tier multi-model | -| `VertexExecutor` | Google Vertex AI | Auth service account, endpointy oparte na regionie | -| `DevinDesktopExecutor` | Devin Desktop | Importowany klucz API + strumieniowanie czatu Connect-protobuf | +| Executor | Provider(s) | Specjalna obsługa | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA, etc. | Dynamiczna konfiguracja URL/nagłówków per dostawca | +| `AntigravityExecutor` | Google Antigravity | Niestandardowe ID project/session, parsowanie Retry-After, obfuskacja 429 | +| `AzureOpenAIExecutor` | Azure OpenAI | Routing oparty na deployment, egzekwowanie query api-version | +| `BlackboxWebExecutor` | Blackbox AI (web-mode) | Reverse sesji web z emulacją fingerprint TLS | +| `ClaudeIdentityExecutor` | Claude.ai (CCH path) | Potoki constraint + tool-remap, kształtowanie fingerprint | +| `CliProxyApiExecutor` | CLIProxyAPI-compatible providers | Niestandardowa obsługa auth i protokołu | +| `CloudflareAiExecutor` | Cloudflare Workers AI | Injekcja Account ID, śledzenie użycia oparte na Neurons | +| `CodexExecutor` | OpenAI Codex | Wstrzykuje instrukcje systemowe, wymusza reasoning effort | +| `ChatGptWebCodexExecutor` | ChatGPT Web (Codex) | Most Responses API przez sesję przeglądarki z przypięciem wątku/turn | +| `CommandCodeExecutor` | Command Code | OAuth + rotacja nagłówków per sesja | +| `CursorExecutor` | Cursor IDE | Protokół ConnectRPC, kodowanie Protobuf, podpisywanie żądań przez checksum | +| `DevinCliExecutor` | Devin CLI | Mostkowanie cyklu życia zadań Devin przez moduł cloud agent | +| `GithubExecutor` | GitHub Copilot | Odświeżanie tokenu Copilot, nagłówki imitujące VSCode | +| `GitlabExecutor` | GitLab Duo | OAuth GitLab + routing w zakresie projektu | +| `GlmExecutor` | Z.AI GLM (incl. `glmt` preset) | Świadomy thinking-budget, stałe presetu GLMT | +| `GrokWebExecutor` | xAI Grok web | Reverse sesji web, wybór trybu (think/standard) | +| `KieExecutor` | KIE | Niestandardowe wydawanie tokenów z rotującymi kotwicami sesji | +| `KiroExecutor` | AWS CodeWhisperer/Kiro | Konwersja binarnego formatu AWS EventStream → SSE | +| `MuseSparkWebExecutor` | Muse Spark (web) | Reverse sesji web z mostkowaniem image-message | +| `NlpCloudExecutor` | NLP Cloud | Kształt body żądania specyficzny dla dostawcy | +| `OpenCodeExecutor` | OpenCode | Konfiguracja dostawcy zgodna z AI SDK | +| `PerplexityWebExecutor` | Perplexity web | Reverse sesji web dla kontynuacji czatu | +| `PetalsExecutor` | Petals distributed inference | Zdecentralizowany routing swarm | +| `PollinationsExecutor` | Pollinations AI | Klucz API niewymagany, żądania z rate limitem | +| `QoderExecutor` | Qoder AI | Wsparcie PAT i OAuth, darmowy tier multi-model | +| `VertexExecutor` | Google Vertex AI | Auth service account, endpointy oparte na regionie | +| `DevinDesktopExecutor` | Devin Desktop | Importowany klucz API + strumieniowanie czatu Connect-protobuf | Wszystkie pozostałe dostawcy (w tym niestandardowe węzły kompatybilne) używają `DefaultExecutor`. ## Macierz kompatybilności dostawców -> **Uwaga:** Poniższa macierz to reprezentatywna próbka spośród 237 zarejestrowanych dostawców w +> **Uwaga:** Poniższa macierz to reprezentatywna próbka spośród 351 zarejestrowanych dostawców w > OmniRoute v3.8.0. Kanoniczna i stale aktualizowana lista: zob. > [`docs/reference/PROVIDER_REFERENCE.md`](../reference/PROVIDER_REFERENCE.md) (auto-generowana) lub źródło > prawdy w `src/shared/constants/providers.ts` (walidowane Zod przy ładowaniu). -| Dostawca | Format | Auth | Stream | Non-Stream | Token Refresh | Usage API | -| ----------------- | ---------------- | ----------------------- | ---------------- | ---------- | ------------- | ------------------ | -| Claude | claude | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Tylko Admin | -| Gemini | gemini | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ Pełne API quota | -| OpenAI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Codex | openai-responses | OAuth | ✅ wymuszony | ❌ | ✅ | ✅ Rate limity | -| GitHub Copilot | openai | OAuth + Copilot Token | ✅ | ✅ | ✅ | ✅ Snapshoty quota | -| Cursor | cursor | Niestandardowy checksum | ✅ | ✅ | ❌ | ❌ | -| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ Limity użycia | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Per żądanie | -| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| OpenRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| GLM/Kimi/MiniMax | claude | API Key | ✅ | ✅ | ❌ | ❌ | -| DeepSeek | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Groq | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| xAI (Grok) | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Mistral | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Perplexity | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Together AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Fireworks AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cerebras | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cohere | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| NVIDIA NIM | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cloudflare AI | openai | API Token + Acct ID | ✅ | ✅ | ❌ | ❌ | -| Pollinations | openai | Brak (bez klucza) | ✅ | ✅ | ❌ | ❌ | -| Scaleway AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| LongCat | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Ollama Cloud | openai | API Key (opcjonalny) | ✅ | ✅ | ❌ | ❌ | -| HuggingFace | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Nebius | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| SiliconFlow | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Hyperbolic | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Vertex AI | gemini | Service Account | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Per żądanie | -| Z.AI / GLM | openai | API Key / OAuth | ✅ | ✅ | ❌ | ❌ | -| GLMT (preset) | claude | API Key | ✅ | ✅ | ❌ | ⚠️ Per żądanie | -| Kimi Coding | openai | OAuth / API Key | ✅ | ✅ | ✅ | ❌ | -| KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Devin Desktop | openai | Importowany klucz API | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Na żądanie | -| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | -| Devin CLI | openai | Lokalne logowanie CLI | ✅ | ✅ | ❌ | ✅ Task API | -| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Rate limity | -| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ Task API | -| AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| ChatGPT-Web | openai | Cookie sesji + TLS | ✅ | ✅ | ❌ | ❌ | -| Grok-Web | openai | Cookie sesji | ✅ | ✅ | ❌ | ❌ | -| Perplexity-Web | openai | Cookie sesji | ✅ | ✅ | ❌ | ❌ | -| BlackBox-Web | openai | Cookie sesji + TLS | ✅ | ✅ | ❌ | ❌ | -| Muse-Spark-Web | openai | Cookie sesji | ✅ | ✅ | ❌ | ❌ | -| ModelScope | openai | API Key | ✅ | ✅ | ❌ | ⚠️ Polityka quota | -| BazaarLink | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Petals | openai | Brak | ✅ | ✅ | ❌ | ❌ | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Per żądanie | -| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| CLIProxyAPI | openai | Custom | ✅ | ✅ | ❌ | ❌ | +| Dostawca | Format | Auth | Stream | Non-Stream | Token Refresh | Usage API | +| ------------------- | ---------------- | ----------------------- | ---------------- | ---------- | ------------- | ------------------ | +| Claude | claude | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Tylko Admin | +| Gemini | gemini | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ Pełne API quota | +| OpenAI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Codex | openai-responses | OAuth | ✅ wymuszony | ❌ | ✅ | ✅ Rate limity | +| ChatGPT Web (Codex) | openai-responses | Sesja przeglądarki | ✅ wymuszony | ❌ | ❌ | ❌ | +| GitHub Copilot | openai | OAuth + Copilot Token | ✅ | ✅ | ✅ | ✅ Snapshoty quota | +| Cursor | cursor | Niestandardowy checksum | ✅ | ✅ | ❌ | ❌ | +| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ Limity użycia | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Per żądanie | +| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| OpenRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| GLM/Kimi/MiniMax | claude | API Key | ✅ | ✅ | ❌ | ❌ | +| DeepSeek | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Groq | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| xAI (Grok) | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Mistral | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Perplexity | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Together AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Fireworks AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cerebras | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cohere | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| NVIDIA NIM | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cloudflare AI | openai | API Token + Acct ID | ✅ | ✅ | ❌ | ❌ | +| Pollinations | openai | Brak (bez klucza) | ✅ | ✅ | ❌ | ❌ | +| Scaleway AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| LongCat | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Ollama Cloud | openai | API Key (opcjonalny) | ✅ | ✅ | ❌ | ❌ | +| HuggingFace | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Nebius | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| SiliconFlow | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Hyperbolic | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Vertex AI | gemini | Service Account | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Per żądanie | +| Z.AI / GLM | openai | API Key / OAuth | ✅ | ✅ | ❌ | ❌ | +| GLMT (preset) | claude | API Key | ✅ | ✅ | ❌ | ⚠️ Per żądanie | +| Kimi Coding | openai | OAuth / API Key | ✅ | ✅ | ✅ | ❌ | +| KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Devin Desktop | openai | Importowany klucz API | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Na żądanie | +| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | +| Devin CLI | openai | Lokalne logowanie CLI | ✅ | ✅ | ❌ | ✅ Task API | +| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Rate limity | +| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ Task API | +| AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Grok-Web | openai | Cookie sesji | ✅ | ✅ | ❌ | ❌ | +| Perplexity-Web | openai | Cookie sesji | ✅ | ✅ | ❌ | ❌ | +| BlackBox-Web | openai | Cookie sesji + TLS | ✅ | ✅ | ❌ | ❌ | +| Muse-Spark-Web | openai | Cookie sesji | ✅ | ✅ | ❌ | ❌ | +| ModelScope | openai | API Key | ✅ | ✅ | ❌ | ⚠️ Polityka quota | +| BazaarLink | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Petals | openai | Brak | ✅ | ✅ | ❌ | ❌ | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Per żądanie | +| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| CLIProxyAPI | openai | Custom | ✅ | ✅ | ❌ | ❌ | ## Pokrycie tłumaczenia formatów diff --git a/docs/i18n/pl/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/i18n/pl/docs/architecture/CODEBASE_DOCUMENTATION.md index 8e54d2b849..e44a225aff 100644 --- a/docs/i18n/pl/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/i18n/pl/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -234,7 +234,6 @@ v1/ ├── audio/{speech, transcriptions}/ TTS + STT ├── batches/[id]/{cancel}, batches/ OpenAI Batches API ├── chat/completions/ Chat Completions (główny endpoint) -├── chatgpt-web/ compat ChatGPT-Web ├── completions/ Legacy text completions ├── embeddings/ Embeddings ├── files/[id]/, files/ Pliki API @@ -482,16 +481,16 @@ open-sse/ ### 4.2 `open-sse/executors/` -84 executory providerów, każdy rozszerza `BaseExecutor` (`base.ts`): +107 executorów providerów, każdy rozszerza `BaseExecutor` (`base.ts`): -`antigravity`, `azure-openai`, `blackbox-web`, `chatgpt-web`, `cliproxyapi`, -`cloudflare-ai`, `codex`, `commiCode`, `cursor`, `default`, `devin-cli`, +`antigravity`, `azure-openai`, `blackbox-web`, `cliproxyapi`, +`chatgpt-web-codex`, `cloudflare-ai`, `codex`, `commiCode`, `cursor`, `default`, `devin-cli`, `muse-spark-web`, `nlpcloud`, `opencode`, `perplexity-web`, `petals`, `pollinations`, `qoder`, `vertex`, `windsurf`, plus `claudeIdentity.ts` (współdzielony helper identity) i `index.ts` (rejestr). > Uwaga: providery niewymienione tutaj są obsługiwane przez `default.ts` z generycznym -> executorem zgodnym z OpenAI. Pełny katalog providerów (329 wpisów) jest w +> executorem zgodnym z OpenAI. Pełny katalog providerów (351 wpisów) jest w > `src/shared/constants/providers.ts`. ### 4.3 `open-sse/translator/` diff --git a/docs/i18n/pl/docs/getting-started/PROVIDERS-GUIDE.md b/docs/i18n/pl/docs/getting-started/PROVIDERS-GUIDE.md index 81624da58e..b94e71c14d 100644 --- a/docs/i18n/pl/docs/getting-started/PROVIDERS-GUIDE.md +++ b/docs/i18n/pl/docs/getting-started/PROVIDERS-GUIDE.md @@ -15,7 +15,7 @@ Traktuj providera jak **operatora komórkowego**. Tak jak potrzebujesz operatora | **Free** | Bez płatności | Kiro, OpenCode Free, Pollinations | $0 | | **API Key** | Wymagany klucz API | OpenAI, Anthropic, Google | Płatność za użycie | | **OAuth** | Logowanie kontem | Claude Code, GitHub Copilot | Subskrypcja | -| **Web Cookie** | Używa sesji przeglądarki | ChatGPT Web, Gemini Web | $0 (korzysta z Twojego konta) | +| **Web Cookie** | Używa sesji przeglądarki | ChatGPT Web (Codex), Gemini Web | $0 (korzysta z Twojego konta) | ### Providery Web Cookie @@ -67,17 +67,17 @@ Zobacz **[WEB-COOKIE-GUIDE.md](./WEB-COOKIE-GUIDE.md)** — ogólna konfiguracja Te providery oferują **darmowy dostęp** bez karty kredytowej: -| Provider | Darmowy limit | Modele | Jak połączyć | -| ----------------- | ----------------- | ---------------------------------------- | ------------------ | -| **Kiro AI** | 50 credits/mies. | Claude Sonnet 4.5, Haiku 4.5, Opus 4.6 | Bez auth | -| **OpenCode Free** | Brak opublikowanego capu; limity obowiązują | GPT-4o, Claude, Gemini | Bez auth | -| **Pollinations** | Bez klucza | GPT-5, Claude, Gemini, DeepSeek, Llama 4 | Bez auth | -| **LongCat** | 10M jednorazowo | LongCat-2.0 | Klucz API + KYC | -| **Cloudflare AI** | 10K neurons/dzień | 50+ modeli | Bez auth | -| **NVIDIA NIM** | ~40 RPM | 129 modeli | Wymagany klucz API | -| **Cerebras** | 1M tokenów/dzień | Qwen3 235B, GPT-OSS 120B | Wymagany klucz API | -| **Qwen** | Brak opublikowanego capu; limity obowiązują | Qwen3-coder-plus/flash/next | Bez auth | -| **Qoder** | Brak opublikowanego capu; limity obowiązują | Kimi-K2, DeepSeek-R1, Qwen3-coder | Bez auth | +| Provider | Darmowy limit | Modele | Jak połączyć | +| ----------------- | ------------------------------------------- | ---------------------------------------- | ------------------ | +| **Kiro AI** | 50 credits/mies. | Claude Sonnet 4.5, Haiku 4.5, Opus 4.6 | Bez auth | +| **OpenCode Free** | Brak opublikowanego capu; limity obowiązują | GPT-4o, Claude, Gemini | Bez auth | +| **Pollinations** | Bez klucza | GPT-5, Claude, Gemini, DeepSeek, Llama 4 | Bez auth | +| **LongCat** | 10M jednorazowo | LongCat-2.0 | Klucz API + KYC | +| **Cloudflare AI** | 10K neurons/dzień | 50+ modeli | Bez auth | +| **NVIDIA NIM** | ~40 RPM | 129 modeli | Wymagany klucz API | +| **Cerebras** | 1M tokenów/dzień | Qwen3 235B, GPT-OSS 120B | Wymagany klucz API | +| **Qwen** | Brak opublikowanego capu; limity obowiązują | Qwen3-coder-plus/flash/next | Bez auth | +| **Qoder** | Brak opublikowanego capu; limity obowiązują | Kimi-K2, DeepSeek-R1, Qwen3-coder | Bez auth | **Wskazówka**: Podłącz kilka darmowych providerów, aby zwiększyć pokrycie fallbacku. Brak opublikowanego limitu tokenów nie oznacza nieograniczonej dostępności. diff --git a/docs/i18n/pl/docs/getting-started/WEB-COOKIE-GUIDE.md b/docs/i18n/pl/docs/getting-started/WEB-COOKIE-GUIDE.md index 1ee9309b03..7ca3173c6c 100644 --- a/docs/i18n/pl/docs/getting-started/WEB-COOKIE-GUIDE.md +++ b/docs/i18n/pl/docs/getting-started/WEB-COOKIE-GUIDE.md @@ -80,14 +80,14 @@ Dokładne wymagane poświadczenia zależą od dostawcy. Różne witryny przechowują uwierzytelnianie na różne sposoby. Niektóre wymagają tylko ciasteczek, inne mogą wymagać dodatkowych nagłówków lub tokenów. -| Dostawca | Format poświadczeń | Przewodnik dostawcy | -| ----------- | ----------------------------- | ------------------------------ | -| Claude Web | Pełny nagłówek żądania Cookie | `docs/providers/CLAUDE_WEB.md` | -| ChatGPT Web | _(verify)_ | | -| Gemini Web | _(verify)_ | | -| Copilot Web | _(verify)_ | | -| Grok Web | _(verify)_ | | -| ... | ... | ... | +| Dostawca | Format poświadczeń | Przewodnik dostawcy | +| ------------------- | ----------------------------- | ------------------------------- | +| Claude Web | Pełny nagłówek żądania Cookie | `docs/providers/CLAUDE_WEB.md` | +| ChatGPT Web (Codex) | Pełny nagłówek Cookie | `docs/providers/CHATGPT_WEB.md` | +| Gemini Web | _(verify)_ | | +| Copilot Web | _(verify)_ | | +| Grok Web | _(verify)_ | | +| ... | ... | ... | > Aktualizuj tę tabelę, gdy dodawani są nowi dostawcy Web Cookie lub gdy istniejący dostawcy zmieniają wymagania uwierzytelniania. diff --git a/docs/i18n/pl/docs/security/BAN_DETECTION.md b/docs/i18n/pl/docs/security/BAN_DETECTION.md index 15e2373437..9b8d1235e8 100644 --- a/docs/i18n/pl/docs/security/BAN_DETECTION.md +++ b/docs/i18n/pl/docs/security/BAN_DETECTION.md @@ -87,7 +87,7 @@ do scraperów OAuth/subskrypcyjnych. Wynikowy stan terminalny dotyczy **połącz nie providera. Przy tym wbudowane _łańcuchy_ są zorientowane na providery subskrypcyjne/OAuth -z realnym ryzykiem bana (ChatGPT Web, Claude Web, Codex, Muse Spark, Antigravity). +z realnym ryzykiem bana (ChatGPT Web Codex, Claude Web, Codex, Muse Spark, Antigravity). Provider z kluczem API odpali detektor tylko wtedy, gdy ciało błędu dosłownie zawiera jeden z podciągów. diff --git a/docs/i18n/pl/docs/security/STEALTH_GUIDE.md b/docs/i18n/pl/docs/security/STEALTH_GUIDE.md index 1f8d735724..c11dd4aac0 100644 --- a/docs/i18n/pl/docs/security/STEALTH_GUIDE.md +++ b/docs/i18n/pl/docs/security/STEALTH_GUIDE.md @@ -6,7 +6,7 @@ lastUpdated: 2026-06-28 # Przewodnik po stealth -> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{chatgptTlsClient,claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/` +> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/` > **Last updated:** 2026-06-28 — v3.8.40 > **Audience:** Engineers maintaining provider-specific stealth integrations. @@ -14,7 +14,7 @@ OmniRoute integruje się z providerami, których edge aktywnie fingerprintuje ni ## Uwaga prawna i etyczna -Funkcje stealth istnieją po to, by OmniRoute mógł działać jako warstwa kompatybilności między oficjalnymi kontami użytkownika (Claude Code CLI, ChatGPT Desktop/Web, Antigravity, Cursor itd.) a ujednoliconym API OmniRoute. **Nie** służą do omijania fraud detection, współdzielenia poświadczeń ani naruszania Terms of Service providera. Maintainerzy oczekują, że operatorzy będą przestrzegać upstream ToS, które zaakceptowali przy tworzeniu kont. +Funkcje stealth istnieją po to, by OmniRoute mógł działać jako warstwa kompatybilności między oficjalnymi kontami użytkownika (Claude Code CLI, Codex, Antigravity, Cursor itd.) a ujednoliconym API OmniRoute. **Nie** służą do omijania fraud detection, współdzielenia poświadczeń ani naruszania Terms of Service providera. Maintainerzy oczekują, że operatorzy będą przestrzegać upstream ToS, które zaakceptowali przy tworzeniu kont. --- @@ -29,20 +29,6 @@ Lazy-loaded sesja `wreq-js`, która impersonuje **Chrome 124 na macOS**. Używan - Timeout: `TLS_CLIENT_TIMEOUT_MS` (dziedziczy z `FETCH_TIMEOUT_MS`, domyślnie 600000) - Response z `wreq-js` jest zgodny z fetch (`headers`, `text()`, `json()`, `clone()`, `body`). -### `open-sse/services/chatgptTlsClient.ts` — tls-client-node (Firefox 148) - -Dedykowany impersonator TLS dla `chatgpt.com`. Konfiguracja Cloudflare ChatGPT pinuje `cf_clearance` do JA3/JA4 + kolejności ramek HTTP/2 SETTINGS — handshake undici dostaje `cf-mitigated: challenge` nawet przy poprawnych cookies. - -- Profile: `firefox_148` (musi pasować do wysyłanego `User-Agent` Firefox 148) -- Mode: `runtimeMode: "native"` (shared library ładowana przez koffi; unika managed sidecar HTTP) -- `withRandomTLSExtensionOrder: true` -- `tlsFetchChatGpt(url, options)` obsługuje streaming (zapisuje body do pliku tymczasowego, tailed jako `ReadableStream`) -- Hang detection: `raceWithTimeout` + `TlsClientHangError` wywołuje `resetClientCache()`, więc kolejne wywołanie respawnuje binding -- Proxy resolution (priority): per-call `proxyUrl` → `OMNIROUTE_TLS_PROXY_URL` → `HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY` (natywny binding **nie** czyta tych env sam; trzeba je przekazać) -- Errors: `TlsClientUnavailableError` (brak binary), `TlsClientHangError` (binding w deadlocku) - ---- - ## Pakiet stealth Claude Code Gdy `cliCompatMode` jest włączony, OmniRoute przekształca wychodzące żądania Claude tak, by były nieodróżnialne od ruchu `claude-cli`. Współpracują trzy moduły: @@ -253,17 +239,17 @@ OmniRoute czyści inbound nagłówki klienta przed forwardem, by żądanie przyc 2. Wyodrębnij JA3/JA4 i literową kolejność nagłówków 3. Zaktualizuj odpowiedni wpis `CLI_FINGERPRINTS[...]` 4. Podbij pasujący domyślny `*_USER_AGENT` w `.env.example` -5. Jeśli zmienił się sam TLS handshake: zaktualizuj `chatgptTlsClient.ts::CHATGPT_PROFILE` lub opcję wreq-js `browser:` -6. Odpal `chatgptTlsClient.test.ts` i ręcznego canary przeciwko żywemu providerowi +5. Jeśli zmienił się sam TLS handshake, zaktualizuj odpowiedni wrapper providera lub opcję wreq-js `browser:` +6. Odpal testy TLS danego providera i ręcznego canary przeciwko żywemu providerowi 7. Wypuść w patch release; udokumentuj w `CHANGELOG.md` --- ## Testy -- `open-sse/services/__tests__/chatgptTlsClient.test.ts` — priorytet resolution proxy, obsługa abort, hang recovery +- `open-sse/services/__tests__/claudeTlsClient.test.ts` — zachowanie współdzielonego wrappera TLS - `tests/unit/anthropic-cache-fingerprint.test.ts` — determinizm fingerprintu -- `tests/unit/chatgpt-web.test.ts` — end-to-end ścieżka stealth dla ChatGPT +- `tests/unit/chatgpt-web-source-retirement.test.ts` — wspólne źródło ChatGPT Web pozostaje nieobecne, a Codex Web pozostaje zachowany --- diff --git a/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md b/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md index ae89dd97ca..3b8d7ad07c 100644 --- a/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md @@ -469,9 +469,6 @@ camadas globais de disjuntor / cooldown de conexão / bloqueio de modelo: `claudeCodeToolRemapper.ts` - Modelagem de impressão digital do Claude Code: `open-sse/services/claudeCodeFingerprint.ts` - Ofuscação do Claude Code: `open-sse/services/claudeCodeObfuscation.ts` -- Cliente TLS do ChatGPT: `open-sse/services/chatgptTlsClient.ts` (estilo de - impersonação curl para sessões do ChatGPT-Web) -- Cache de imagem do ChatGPT: `open-sse/services/chatgptImageCache.ts` Para o guia completo de furtividade e orientações operacionais, veja [`docs/security/STEALTH_GUIDE.md`](../security/STEALTH_GUIDE.md). @@ -879,35 +876,35 @@ flowchart LR Cada provedor tem um executor especializado que estende `BaseExecutor` (em `open-sse/executors/base.ts`), que fornece construção de URL, construção de cabeçalhos, tentativas com retrocesso exponencial, ganchos de atualização de credenciais e o método de orquestração `execute()`. -| Executor | Provedor(es) | Tratamento Especial | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA, etc. | Configuração dinâmica de URL/cabeçalho por provedor | -| `AntigravityExecutor` | Google Antigravity | IDs de projeto/sessão personalizados, análise de Retry-After, ofuscação de 429 | -| `AzureOpenAIExecutor` | Azure OpenAI | Roteamento baseado em implantação, aplicação de consulta de api-version | -| `BlackboxWebExecutor` | Blackbox AI (modo web) | Reversão de sessão web com emulação de impressão digital TLS | -| `ChatGPTWebExecutor` | ChatGPT web | Gerenciamento de cliente TLS + cookie de sessão (`chatgptTlsClient.ts`) | -| `ClaudeIdentityExecutor` | Claude.ai (caminho CCH) | Pipelines de restrição + remapeamento de ferramentas, modelagem de impressão digital | -| `CliProxyApiExecutor` | Provedores compatíveis com CLIProxyAPI | Manipulação personalizada de autenticação e protocolo | -| `CloudflareAiExecutor` | Cloudflare Workers AI | Injeção de ID de conta, rastreamento de uso baseado em Neurons | -| `CodexExecutor` | OpenAI Codex | Injeções de instruções do sistema, força de esforço de raciocínio | -| `CommandCodeExecutor` | Código de Comando | Rotação de cabeçalho por sessão + OAuth | -| `CursorExecutor` | Cursor IDE | Protocolo ConnectRPC, codificação Protobuf, assinatura de requisições via checksum | -| `DevinCliExecutor` | Devin CLI | Conexão do ciclo de vida da tarefa Devin via módulo de agente em nuvem | -| `GithubExecutor` | GitHub Copilot | Atualização de token do Copilot, cabeçalhos imitando VSCode | -| `GitlabExecutor` | GitLab Duo | Roteamento baseado em projeto + OAuth do GitLab | -| `GlmExecutor` | Z.AI GLM (incl. preset `glmt`) | Consciente do orçamento de pensamento, constantes do preset GLMT | -| `GrokWebExecutor` | xAI Grok web | Reversão de sessão web, seleção de modo (pensar/padrão) | -| `KieExecutor` | KIE | Emissão de token personalizada com âncoras de sessão rotativas | -| `KiroExecutor` | AWS CodeWhisperer/Kiro | Formato binário do AWS EventStream → conversão para SSE | -| `MuseSparkWebExecutor` | Muse Spark (web) | Reversão de sessão web com integração de imagem-mensagem | -| `NlpCloudExecutor` | NLP Cloud | Formato de corpo de requisição específico do provedor | -| `OpenCodeExecutor` | OpenCode | Configuração de provedor compatível com AI SDK | -| `PerplexityWebExecutor` | Perplexity web | Reversão de sessão web para continuidade de chat | -| `PetalsExecutor` | Inferência distribuída Petals | Roteamento de enxame descentralizado | -| `PollinationsExecutor` | Pollinations AI | Nenhuma chave de API necessária, requisições limitadas por taxa | -| `QoderExecutor` | Qoder AI | Suporte a PAT e OAuth, nível gratuito multi-modelo | -| `VertexExecutor` | Google Vertex AI | Autenticação de conta de serviço, endpoints baseados em região | -| `DevinDesktopExecutor` | Devin Desktop | Chave de API importada + streaming de chat Connect-protobuf | +| Executor | Provedor(es) | Tratamento Especial | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA, etc. | Configuração dinâmica de URL/cabeçalho por provedor | +| `AntigravityExecutor` | Google Antigravity | IDs de projeto/sessão personalizados, análise de Retry-After, ofuscação de 429 | +| `AzureOpenAIExecutor` | Azure OpenAI | Roteamento baseado em implantação, aplicação de consulta de api-version | +| `BlackboxWebExecutor` | Blackbox AI (modo web) | Reversão de sessão web com emulação de impressão digital TLS | +| `ClaudeIdentityExecutor` | Claude.ai (caminho CCH) | Pipelines de restrição + remapeamento de ferramentas, modelagem de impressão digital | +| `CliProxyApiExecutor` | Provedores compatíveis com CLIProxyAPI | Manipulação personalizada de autenticação e protocolo | +| `CloudflareAiExecutor` | Cloudflare Workers AI | Injeção de ID de conta, rastreamento de uso baseado em Neurons | +| `CodexExecutor` | OpenAI Codex | Injeções de instruções do sistema, força de esforço de raciocínio | +| `ChatGptWebCodexExecutor` | ChatGPT Web (Codex) | Ponte Responses API por sessão de navegador com fixação de thread/turn | +| `CommandCodeExecutor` | Código de Comando | Rotação de cabeçalho por sessão + OAuth | +| `CursorExecutor` | Cursor IDE | Protocolo ConnectRPC, codificação Protobuf, assinatura de requisições via checksum | +| `DevinCliExecutor` | Devin CLI | Conexão do ciclo de vida da tarefa Devin via módulo de agente em nuvem | +| `GithubExecutor` | GitHub Copilot | Atualização de token do Copilot, cabeçalhos imitando VSCode | +| `GitlabExecutor` | GitLab Duo | Roteamento baseado em projeto + OAuth do GitLab | +| `GlmExecutor` | Z.AI GLM (incl. preset `glmt`) | Consciente do orçamento de pensamento, constantes do preset GLMT | +| `GrokWebExecutor` | xAI Grok web | Reversão de sessão web, seleção de modo (pensar/padrão) | +| `KieExecutor` | KIE | Emissão de token personalizada com âncoras de sessão rotativas | +| `KiroExecutor` | AWS CodeWhisperer/Kiro | Formato binário do AWS EventStream → conversão para SSE | +| `MuseSparkWebExecutor` | Muse Spark (web) | Reversão de sessão web com integração de imagem-mensagem | +| `NlpCloudExecutor` | NLP Cloud | Formato de corpo de requisição específico do provedor | +| `OpenCodeExecutor` | OpenCode | Configuração de provedor compatível com AI SDK | +| `PerplexityWebExecutor` | Perplexity web | Reversão de sessão web para continuidade de chat | +| `PetalsExecutor` | Inferência distribuída Petals | Roteamento de enxame descentralizado | +| `PollinationsExecutor` | Pollinations AI | Nenhuma chave de API necessária, requisições limitadas por taxa | +| `QoderExecutor` | Qoder AI | Suporte a PAT e OAuth, nível gratuito multi-modelo | +| `VertexExecutor` | Google Vertex AI | Autenticação de conta de serviço, endpoints baseados em região | +| `DevinDesktopExecutor` | Devin Desktop | Chave de API importada + streaming de chat Connect-protobuf | Todos os outros provedores (incluindo nós compatíveis personalizados) usam o `DefaultExecutor`. @@ -918,65 +915,65 @@ Todos os outros provedores (incluindo nós compatíveis personalizados) usam o ` > [`docs/reference/PROVIDER_REFERENCE.md`](../reference/PROVIDER_REFERENCE.md) (gerada automaticamente) ou a fonte > de verdade em `src/shared/constants/providers.ts` (validada pelo Zod na carga). -| Provedor | Formato | Autenticação | Stream | Não-Stream | Atualização de Token | API de Uso | -| ----------------- | ---------------- | -------------------------- | ---------------- | ---------- | -------------------- | -------------------- | -| Claude | claude | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Somente Admin | -| Gemini | gemini | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem | -| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ API de cota total | -| OpenAI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Codex | openai-responses | OAuth | ✅ forçado | ❌ | ✅ | ✅ Limites de taxa | -| GitHub Copilot | openai | OAuth + Token Copilot | ✅ | ✅ | ✅ | ✅ Capturas de cota | -| Cursor | cursor | Checksum personalizado | ✅ | ✅ | ❌ | ❌ | -| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ Limites de uso | -| Qwen | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação | -| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| OpenRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| GLM/Kimi/MiniMax | claude | Chave de API | ✅ | ✅ | ❌ | ❌ | -| DeepSeek | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Groq | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| xAI (Grok) | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Mistral | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Perplexity | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Together AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Fireworks AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Cerebras | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Cohere | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| NVIDIA NIM | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Cloudflare AI | openai | Token de API + ID da conta | ✅ | ✅ | ❌ | ❌ | -| Pollinations | openai | Nenhum (sem chave) | ✅ | ✅ | ❌ | ❌ | -| Scaleway AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| LongCat | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Ollama Cloud | openai | Chave de API (opcional) | ✅ | ✅ | ❌ | ❌ | -| HuggingFace | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Nebius | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| SiliconFlow | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Hyperbolic | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Vertex AI | gemini | Conta de Serviço | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem | -| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação | -| Z.AI / GLM | openai | Chave de API / OAuth | ✅ | ✅ | ❌ | ❌ | -| GLMT (preset) | claude | Chave de API | ✅ | ✅ | ❌ | ⚠️ Por solicitação | -| Kimi Coding | openai | OAuth / Chave de API | ✅ | ✅ | ✅ | ❌ | -| KIE | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Devin Desktop | openai | Chave de API importada | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Por solicitação | -| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | -| Devin CLI | openai | Login local da CLI | ✅ | ✅ | ❌ | ✅ API de Tarefas | -| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Limites de taxa | -| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ API de Tarefas | -| AgentRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| ChatGPT-Web | openai | Cookie de sessão + TLS | ✅ | ✅ | ❌ | ❌ | -| Grok-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ | -| Perplexity-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ | -| BlackBox-Web | openai | Cookie de sessão + TLS | ✅ | ✅ | ❌ | ❌ | -| Muse-Spark-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ | -| ModelScope | openai | Chave de API | ✅ | ✅ | ❌ | ⚠️ Política de cota | -| BazaarLink | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Petals | openai | Nenhum | ✅ | ✅ | ❌ | ❌ | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação | -| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| CLIProxyAPI | openai | Personalizado | ✅ | ✅ | ❌ | ❌ | +| Provedor | Formato | Autenticação | Stream | Não-Stream | Atualização de Token | API de Uso | +| ------------------- | ---------------- | -------------------------- | ---------------- | ---------- | -------------------- | -------------------- | +| Claude | claude | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Somente Admin | +| Gemini | gemini | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem | +| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ API de cota total | +| OpenAI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Codex | openai-responses | OAuth | ✅ forçado | ❌ | ✅ | ✅ Limites de taxa | +| ChatGPT Web (Codex) | openai-responses | Sessão de navegador | ✅ forçado | ❌ | ❌ | ❌ | +| GitHub Copilot | openai | OAuth + Token Copilot | ✅ | ✅ | ✅ | ✅ Capturas de cota | +| Cursor | cursor | Checksum personalizado | ✅ | ✅ | ❌ | ❌ | +| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ Limites de uso | +| Qwen | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação | +| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| OpenRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| GLM/Kimi/MiniMax | claude | Chave de API | ✅ | ✅ | ❌ | ❌ | +| DeepSeek | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Groq | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| xAI (Grok) | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Mistral | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Perplexity | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Together AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Fireworks AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Cerebras | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Cohere | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| NVIDIA NIM | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Cloudflare AI | openai | Token de API + ID da conta | ✅ | ✅ | ❌ | ❌ | +| Pollinations | openai | Nenhum (sem chave) | ✅ | ✅ | ❌ | ❌ | +| Scaleway AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| LongCat | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Ollama Cloud | openai | Chave de API (opcional) | ✅ | ✅ | ❌ | ❌ | +| HuggingFace | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Nebius | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| SiliconFlow | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Hyperbolic | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Vertex AI | gemini | Conta de Serviço | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem | +| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação | +| Z.AI / GLM | openai | Chave de API / OAuth | ✅ | ✅ | ❌ | ❌ | +| GLMT (preset) | claude | Chave de API | ✅ | ✅ | ❌ | ⚠️ Por solicitação | +| Kimi Coding | openai | OAuth / Chave de API | ✅ | ✅ | ✅ | ❌ | +| KIE | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Devin Desktop | openai | Chave de API importada | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Por solicitação | +| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | +| Devin CLI | openai | Login local da CLI | ✅ | ✅ | ❌ | ✅ API de Tarefas | +| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Limites de taxa | +| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ API de Tarefas | +| AgentRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Grok-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ | +| Perplexity-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ | +| BlackBox-Web | openai | Cookie de sessão + TLS | ✅ | ✅ | ❌ | ❌ | +| Muse-Spark-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ | +| ModelScope | openai | Chave de API | ✅ | ✅ | ❌ | ⚠️ Política de cota | +| BazaarLink | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | +| Petals | openai | Nenhum | ✅ | ✅ | ❌ | ❌ | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação | +| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| CLIProxyAPI | openai | Personalizado | ✅ | ✅ | ❌ | ❌ | ## Cobertura de Tradução de Formato diff --git a/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md b/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md index 691bfae8c1..2a6890fa1d 100644 --- a/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md @@ -19,7 +19,7 @@ OmniRoute 是基于 Next.js 构建的本地 AI 路由网关和控制台。 核心能力: -- OpenAI 兼容的 API 接口,供 CLI/工具使用(237 个服务商、68 个执行器) +- OpenAI 兼容的 API 接口,供 CLI/工具使用(351 个服务商、107 个执行器) - 跨服务商格式的请求/响应转换 - 模型 Combo 容灾(多模型序列) - 结构化 Combo 步骤(`服务商 + 模型 + 连接`),通过 `compositeTiers` 在运行时排序 @@ -487,9 +487,6 @@ FSM 状态转换反馈到 Auto Combo 的评分中,使后台/自动化任务偏 `claudeCodeToolRemapper.ts` - Claude Code 指纹塑造:`open-sse/services/claudeCodeFingerprint.ts` - Claude Code 混淆:`open-sse/services/claudeCodeObfuscation.ts` -- ChatGPT TLS 客户端:`open-sse/services/chatgptTlsClient.ts`(为 ChatGPT-Web 会话 - 提供 curl-impersonate 风格的 TLS 指纹) -- ChatGPT 图片缓存:`open-sse/services/chatgptImageCache.ts` 完整隐身策略和操作指南参见 [`docs/security/STEALTH_GUIDE.md`](../security/STEALTH_GUIDE.md)。 @@ -896,104 +893,104 @@ flowchart LR 每个服务商都有一个继承 `BaseExecutor`(在 `open-sse/executors/base.ts` 中)的专用执行器,该基类提供了 URL 构建、Header 构造、带指数退避的重试、凭据刷新钩子以及 `execute()` 编排方法。 -| 执行器 | 服务商 | 特殊处理 | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA 等 | 每服务商动态 URL/Header 配置 | -| `AntigravityExecutor` | Google Antigravity | 自定义项目/会话 ID、Retry-After 解析、429 混淆 | -| `AzureOpenAIExecutor` | Azure OpenAI | 基于部署的路由、api-version 查询参数强制执行 | -| `BlackboxWebExecutor` | Blackbox AI (web-mode) | Web 会话反向 + TLS 指纹模拟 | -| `ChatGPTWebExecutor` | ChatGPT web | TLS 客户端 + 会话 Cookie 管理(`chatgptTlsClient.ts`)| -| `ClaudeIdentityExecutor` | Claude.ai (CCH 通道) | 约束 + Tool 重映射管线、指纹塑造 | -| `CliProxyApiExecutor` | CLIProxyAPI 兼容服务商 | 自定义认证和协议处理 | -| `CloudflareAiExecutor` | Cloudflare Workers AI | 帐户 ID 注入、基于 Neurons 的用量追踪 | -| `CodexExecutor` | OpenAI Codex | 注入系统指令、强制推理力度 | -| `CommandCodeExecutor` | Command Code | OAuth + 每会话 Header 轮换 | -| `CursorExecutor` | Cursor IDE | ConnectRPC 协议、Protobuf 编码、基于校验和的请求签名| -| `DevinCliExecutor` | Devin CLI | Devin 任务生命周期桥接(通过云代理模块) | -| `GithubExecutor` | GitHub Copilot | Copilot Token 刷新、VSCode 模仿 Header | -| `GitlabExecutor` | GitLab Duo | GitLab OAuth + 项目级路由 | -| `GlmExecutor` | Z.AI GLM(含 `glmt` 预设) | Thinking Budget 感知、GLMT 预设常量 | -| `GrokWebExecutor` | xAI Grok web | Web 会话反向、模式选择(think/standard) | -| `KieExecutor` | KIE | 自定义 Token 签发 + 轮换会话锚点 | -| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream 二进制格式 → SSE 转换 | -| `MuseSparkWebExecutor` | Muse Spark (web) | Web 会话反向 + 图片消息桥接 | -| `NlpCloudExecutor` | NLP Cloud | 服务商专属请求体形状 | -| `OpenCodeExecutor` | OpenCode | AI SDK 兼容服务商初始化 | -| `PerplexityWebExecutor` | Perplexity web | Web 会话反向,用于聊延续 | -| `PetalsExecutor` | Petals distributed inference | 去中心化集群路由 | -| `PollinationsExecutor` | Pollinations AI | 无需 API Key、带速率限制的请求 | -| `QoderExecutor` | Qoder AI | PAT 和 OAuth 支持、多模型免费层 | -| `VertexExecutor` | Google Vertex AI | 服务帐户认证、基于区域的端点 | -| `DevinDesktopExecutor` | Devin Desktop | 导入的 API 密钥 + Connect-protobuf 聊天流 | +| 执行器 | 服务商 | 特殊处理 | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA 等 | 每服务商动态 URL/Header 配置 | +| `AntigravityExecutor` | Google Antigravity | 自定义项目/会话 ID、Retry-After 解析、429 混淆 | +| `AzureOpenAIExecutor` | Azure OpenAI | 基于部署的路由、api-version 查询参数强制执行 | +| `BlackboxWebExecutor` | Blackbox AI (web-mode) | Web 会话反向 + TLS 指纹模拟 | +| `ClaudeIdentityExecutor` | Claude.ai (CCH 通道) | 约束 + Tool 重映射管线、指纹塑造 | +| `CliProxyApiExecutor` | CLIProxyAPI 兼容服务商 | 自定义认证和协议处理 | +| `CloudflareAiExecutor` | Cloudflare Workers AI | 帐户 ID 注入、基于 Neurons 的用量追踪 | +| `CodexExecutor` | OpenAI Codex | 注入系统指令、强制推理力度 | +| `ChatGptWebCodexExecutor` | ChatGPT Web (Codex) | 浏览器会话 Responses API 桥接与线程/轮次固定 | +| `CommandCodeExecutor` | Command Code | OAuth + 每会话 Header 轮换 | +| `CursorExecutor` | Cursor IDE | ConnectRPC 协议、Protobuf 编码、基于校验和的请求签名 | +| `DevinCliExecutor` | Devin CLI | Devin 任务生命周期桥接(通过云代理模块) | +| `GithubExecutor` | GitHub Copilot | Copilot Token 刷新、VSCode 模仿 Header | +| `GitlabExecutor` | GitLab Duo | GitLab OAuth + 项目级路由 | +| `GlmExecutor` | Z.AI GLM(含 `glmt` 预设) | Thinking Budget 感知、GLMT 预设常量 | +| `GrokWebExecutor` | xAI Grok web | Web 会话反向、模式选择(think/standard) | +| `KieExecutor` | KIE | 自定义 Token 签发 + 轮换会话锚点 | +| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream 二进制格式 → SSE 转换 | +| `MuseSparkWebExecutor` | Muse Spark (web) | Web 会话反向 + 图片消息桥接 | +| `NlpCloudExecutor` | NLP Cloud | 服务商专属请求体形状 | +| `OpenCodeExecutor` | OpenCode | AI SDK 兼容服务商初始化 | +| `PerplexityWebExecutor` | Perplexity web | Web 会话反向,用于聊延续 | +| `PetalsExecutor` | Petals distributed inference | 去中心化集群路由 | +| `PollinationsExecutor` | Pollinations AI | 无需 API Key、带速率限制的请求 | +| `QoderExecutor` | Qoder AI | PAT 和 OAuth 支持、多模型免费层 | +| `VertexExecutor` | Google Vertex AI | 服务帐户认证、基于区域的端点 | +| `DevinDesktopExecutor` | Devin Desktop | 导入的 API 密钥 + Connect-protobuf 聊天流 | 其余所有服务商(含自定义兼容节点)使用 `DefaultExecutor`。 ## 服务商兼容性矩阵 -> **注意:** 下表是 OmniRoute v3.8.0 中 237 个已注册服务商的代表性样本。 +> **注意:** 下表是 OmniRoute v3.8.0 中 351 个已注册服务商的代表性样本。 > 完整且持续更新的列表请参阅 > [`docs/reference/PROVIDER_REFERENCE.md`](../reference/PROVIDER_REFERENCE.md)(自动生成)或数据源头 > `src/shared/constants/providers.ts`(加载时通过 Zod 校验)。 -| 服务商 | 格式 | 认证 | 流式 | 非流式 | Token 刷新 | 用量 API | -| ----------------- | ---------------- | --------------------- | -------------- | ---------- | ----------- | ----------------- | -| Claude | claude | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ 仅管理员 | -| Gemini | gemini | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ 完整配额 API | -| OpenAI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Codex | openai-responses | OAuth | ✅ 强制 | ❌ | ✅ | ✅ 速率限制 | -| GitHub Copilot | openai | OAuth + Copilot Token | ✅ | ✅ | ✅ | ✅ 配额快照 | -| Cursor | cursor | 自定义校验和 | ✅ | ✅ | ❌ | ❌ | -| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream)| ❌ | ✅ | ✅ 用量限制 | -| Qwen | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每请求 | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每请求 | -| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| OpenRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| GLM/Kimi/MiniMax | claude | API Key | ✅ | ✅ | ❌ | ❌ | -| DeepSeek | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Groq | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| xAI (Grok) | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Mistral | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Perplexity | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Together AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Fireworks AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cerebras | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cohere | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| NVIDIA NIM | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cloudflare AI | openai | API Token + 帐户 ID | ✅ | ✅ | ❌ | ❌ | -| Pollinations | openai | 无需密钥 | ✅ | ✅ | ❌ | ❌ | -| Scaleway AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| LongCat | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Ollama Cloud | openai | API Key(可选) | ✅ | ✅ | ❌ | ❌ | -| HuggingFace | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Nebius | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| SiliconFlow | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Hyperbolic | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Vertex AI | gemini | 服务帐户 | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每请求 | -| Z.AI / GLM | openai | API Key / OAuth | ✅ | ✅ | ❌ | ❌ | -| GLMT (preset) | claude | API Key | ✅ | ✅ | ❌ | ⚠️ 每请求 | -| Kimi Coding | openai | OAuth / API Key | ✅ | ✅ | ✅ | ❌ | -| KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Devin Desktop | openai | 导入的 API 密钥 | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ 每请求 | -| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | -| Devin CLI | openai | 本地 CLI 登录 | ✅ | ✅ | ❌ | ✅ 任务 API | -| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ 速率限制 | -| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任务 API | -| AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| ChatGPT-Web | openai | 会话 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | -| Grok-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | -| Perplexity-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | -| BlackBox-Web | openai | 会话 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | -| Muse-Spark-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | -| ModelScope | openai | API Key | ✅ | ✅ | ❌ | ⚠️ 配额策略 | -| BazaarLink | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Petals | openai | 无需密钥 | ✅ | ✅ | ❌ | ❌ | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每请求 | -| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| CLIProxyAPI | openai | 自定义 | ✅ | ✅ | ❌ | ❌ | +| 服务商 | 格式 | 认证 | 流式 | 非流式 | Token 刷新 | 用量 API | +| ------------------- | ---------------- | --------------------- | ---------------- | ------ | ---------- | ---------------- | +| Claude | claude | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ 仅管理员 | +| Gemini | gemini | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ 完整配额 API | +| OpenAI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Codex | openai-responses | OAuth | ✅ 强制 | ❌ | ✅ | ✅ 速率限制 | +| ChatGPT Web (Codex) | openai-responses | 浏览器会话 | ✅ 强制 | ❌ | ❌ | ❌ | +| GitHub Copilot | openai | OAuth + Copilot Token | ✅ | ✅ | ✅ | ✅ 配额快照 | +| Cursor | cursor | 自定义校验和 | ✅ | ✅ | ❌ | ❌ | +| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ 用量限制 | +| Qwen | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每请求 | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每请求 | +| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| OpenRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| GLM/Kimi/MiniMax | claude | API Key | ✅ | ✅ | ❌ | ❌ | +| DeepSeek | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Groq | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| xAI (Grok) | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Mistral | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Perplexity | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Together AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Fireworks AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cerebras | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cohere | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| NVIDIA NIM | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cloudflare AI | openai | API Token + 帐户 ID | ✅ | ✅ | ❌ | ❌ | +| Pollinations | openai | 无需密钥 | ✅ | ✅ | ❌ | ❌ | +| Scaleway AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| LongCat | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Ollama Cloud | openai | API Key(可选) | ✅ | ✅ | ❌ | ❌ | +| HuggingFace | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Nebius | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| SiliconFlow | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Hyperbolic | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Vertex AI | gemini | 服务帐户 | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每请求 | +| Z.AI / GLM | openai | API Key / OAuth | ✅ | ✅ | ❌ | ❌ | +| GLMT (preset) | claude | API Key | ✅ | ✅ | ❌ | ⚠️ 每请求 | +| Kimi Coding | openai | OAuth / API Key | ✅ | ✅ | ✅ | ❌ | +| KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Devin Desktop | openai | 导入的 API 密钥 | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ 每请求 | +| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | +| Devin CLI | openai | 本地 CLI 登录 | ✅ | ✅ | ❌ | ✅ 任务 API | +| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ 速率限制 | +| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任务 API | +| AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Grok-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | +| Perplexity-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | +| BlackBox-Web | openai | 会话 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | +| Muse-Spark-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | +| ModelScope | openai | API Key | ✅ | ✅ | ❌ | ⚠️ 配额策略 | +| BazaarLink | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Petals | openai | 无需密钥 | ✅ | ✅ | ❌ | ❌ | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每请求 | +| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| CLIProxyAPI | openai | 自定义 | ✅ | ✅ | ❌ | ❌ | ## 格式转换覆盖 @@ -1029,25 +1026,25 @@ flowchart LR ## 支持的 API 端点 -| 端点 | 格式 | 处理器 | -| --------------------------------------------------- | ------------------ | ----------------------------------------------------------------- | -| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` | -| `POST /v1/messages` | Claude Messages | 同一处理器(自动检测) | -| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` | -| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` | -| `GET /v1/embeddings` | 模型列表 | API 路由 | -| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` | -| `GET /v1/images/generations` | 模型列表 | API 路由 | -| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | 带模型校验的专用每服务商路由 | -| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | 带模型校验的专用每服务商路由 | -| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | 带模型校验的专用每服务商路由 | -| `POST /v1/messages/count_tokens` | Claude Token Count | API 路由 | -| `GET /v1/models` | OpenAI Models 列表 | API 路由(聊 + 向量嵌入 + 图片 + 自定义模型) | -| `GET /api/models/catalog` | 目录 | 按服务商 + 类型分组的所有模型 | -| `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API 路由 | -| `GET/PUT/DELETE /api/settings/proxy` | 代理配置 | 网络代理配置 | -| `POST /api/settings/proxy/test` | 代理连通性 | 代理健康/连通性测试端点 | -| `GET/POST/DELETE /api/provider-models` | 服务商模型 | 服务商模型元数据,支撑自定义和管理可用模型 | +| 端点 | 格式 | 处理器 | +| -------------------------------------------------- | ------------------ | --------------------------------------------- | +| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` | +| `POST /v1/messages` | Claude Messages | 同一处理器(自动检测) | +| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` | +| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` | +| `GET /v1/embeddings` | 模型列表 | API 路由 | +| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` | +| `GET /v1/images/generations` | 模型列表 | API 路由 | +| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | 带模型校验的专用每服务商路由 | +| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | 带模型校验的专用每服务商路由 | +| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | 带模型校验的专用每服务商路由 | +| `POST /v1/messages/count_tokens` | Claude Token Count | API 路由 | +| `GET /v1/models` | OpenAI Models 列表 | API 路由(聊 + 向量嵌入 + 图片 + 自定义模型) | +| `GET /api/models/catalog` | 目录 | 按服务商 + 类型分组的所有模型 | +| `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API 路由 | +| `GET/PUT/DELETE /api/settings/proxy` | 代理配置 | 网络代理配置 | +| `POST /api/settings/proxy/test` | 代理连通性 | 代理健康/连通性测试端点 | +| `GET/POST/DELETE /api/provider-models` | 服务商模型 | 服务商模型元数据,支撑自定义和管理可用模型 | ## 旁路处理器 diff --git a/docs/i18n/zh-CN/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/i18n/zh-CN/docs/architecture/CODEBASE_DOCUMENTATION.md index 613cfa2339..78c1f05e31 100644 --- a/docs/i18n/zh-CN/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/i18n/zh-CN/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -10,18 +10,18 @@ ## 1. 技术栈 -| 关注领域 | 技术选型 | -| ---------- | ---------------------------------------------------------------------------------------------------------------------- | -| Web 框架 | **Next.js 16**(App Router,独立输出,无全局中间件) | -| 语言 | **TypeScript 6.0+** — 目标 `ES2022`,`module: esnext`,`moduleResolution: bundler`,`strict: false` | -| 运行时 | **Node.js** `>=22.22.2 <23` 或 `>=24.0.0 <27`(通过 `engines` + `SUPPORTED_NODE_RANGE` 强制) | -| 数据库 | **SQLite**,基于 `better-sqlite3`(单例,WAL 日志模式) | -| 桌面端 | **Electron 41** + `electron-builder` 26.10(独立工作空间 `electron/`) | -| 测试 | **Node 原生测试运行器**(单元/集成)、**Vitest**(MCP、autoCombo、缓存)、**Playwright**(端到端 + 协议端到端) | -| 构建 | Next.js 独立模式,通过 `scripts/build/build-next-isolated.mjs` | -| 代码检查 | ESLint flat 配置 + Prettier(Husky pre-commit 触发 `lint-staged`) | -| 模块系统 | 全局 ESM(`"type": "module"`) | -| 工作空间 | npm workspace — `open-sse` 是唯一的子工作空间 | +| 关注领域 | 技术选型 | +| -------- | --------------------------------------------------------------------------------------------------------------- | +| Web 框架 | **Next.js 16**(App Router,独立输出,无全局中间件) | +| 语言 | **TypeScript 6.0+** — 目标 `ES2022`,`module: esnext`,`moduleResolution: bundler`,`strict: false` | +| 运行时 | **Node.js** `>=22.22.2 <23` 或 `>=24.0.0 <27`(通过 `engines` + `SUPPORTED_NODE_RANGE` 强制) | +| 数据库 | **SQLite**,基于 `better-sqlite3`(单例,WAL 日志模式) | +| 桌面端 | **Electron 41** + `electron-builder` 26.10(独立工作空间 `electron/`) | +| 测试 | **Node 原生测试运行器**(单元/集成)、**Vitest**(MCP、autoCombo、缓存)、**Playwright**(端到端 + 协议端到端) | +| 构建 | Next.js 独立模式,通过 `scripts/build/build-next-isolated.mjs` | +| 代码检查 | ESLint flat 配置 + Prettier(Husky pre-commit 触发 `lint-staged`) | +| 模块系统 | 全局 ESM(`"type": "module"`) | +| 工作空间 | npm workspace — `open-sse` 是唯一的子工作空间 | 路径别名(`tsconfig.json`): @@ -85,20 +85,20 @@ App Router 同时暴露仪表盘 UI 和公开/管理 HTTP API。**不存在全 `src/app/` 下的顶层段: -| 路径 | 用途 | -| ------------------------------------------------------------------------------ | --------------------------------- | -| `api/` | 所有 HTTP API 路由(详见下文分解)| -| `a2a/` | A2A JSON-RPC 2.0 端点(`POST /a2a`)| -| `.well-known/agent.json/` | A2A Agent Card 发现文档 | -| `(dashboard)/` | 仪表盘 UI(路由组,无 URL 前缀) | -| `auth/`、`login/`、`forgot-password/`、`callback/` | 认证流程 | -| `landing/` | 市场/落地页 | -| `docs/` | 嵌入式 API 文档查看器 | -| `status/`、`maintenance/`、`offline/` | 运维页面 | -| `privacy/`、`terms/` | 法律页面 | -| `400/`、`401/`、`403/`、`408/`、`429/`、`500/`、`502/`、`503/` | 静态错误页面 | -| `error.tsx`、`global-error.tsx`、`not-found.tsx`、`forbidden/`、`loading.tsx` | 框架错误/加载边界 | -| `layout.tsx`、`page.tsx`、`globals.css`、`manifest.ts` | 根外壳 | +| 路径 | 用途 | +| ----------------------------------------------------------------------------- | ------------------------------------ | +| `api/` | 所有 HTTP API 路由(详见下文分解) | +| `a2a/` | A2A JSON-RPC 2.0 端点(`POST /a2a`) | +| `.well-known/agent.json/` | A2A Agent Card 发现文档 | +| `(dashboard)/` | 仪表盘 UI(路由组,无 URL 前缀) | +| `auth/`、`login/`、`forgot-password/`、`callback/` | 认证流程 | +| `landing/` | 市场/落地页 | +| `docs/` | 嵌入式 API 文档查看器 | +| `status/`、`maintenance/`、`offline/` | 运维页面 | +| `privacy/`、`terms/` | 法律页面 | +| `400/`、`401/`、`403/`、`408/`、`429/`、`500/`、`502/`、`503/` | 静态错误页面 | +| `error.tsx`、`global-error.tsx`、`not-found.tsx`、`forbidden/`、`loading.tsx` | 框架错误/加载边界 | +| `layout.tsx`、`page.tsx`、`globals.css`、`manifest.ts` | 根外壳 | #### 3.1.1 `src/app/(dashboard)/dashboard/` — UI 页面 @@ -218,7 +218,6 @@ v1/ ├── audio/{speech, transcriptions}/ TTS + STT ├── batches/[id]/{cancel}, batches/ OpenAI Batches API ├── chat/completions/ 对话补全(主要端点) -├── chatgpt-web/ ChatGPT-Web 兼容 ├── completions/ 旧版文本补全 ├── embeddings/ 嵌入 ├── files/[id]/, files/ Files API @@ -254,46 +253,46 @@ v1/ 始终通过这些模块导入数据、同步、OAuth、技能、记忆等。下表按实际目录和重要顶层文件分组。 -| 模块 | 用途 | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `a2a/` | A2A 协议服务器:`taskManager.ts`、`streaming.ts`、`taskExecution.ts`、`routingLogger.ts`、`skills/`(6 个技能:成本分析、健康报告、服务商发现、配额管理、智能路由、列出能力) | -| `acp/` | Agent-Control-Protocol:`index.ts`、`manager.ts`、`registry.ts` | -| `api/` | 内部 API 辅助工具:`requireManagementAuth.ts`、`requireCliToolsAuth.ts`、`errorResponse.ts` | -| `auth/` | `managementPassword.ts`(密码重置/哈希) | -| `batches/` | OpenAI Batches API 服务(`service.ts`) | -| `catalog/` | OpenRouter 目录同步(`openrouterCatalog.ts`) | -| `cloudAgent/` | 云代理注册表:`api.ts`、`baseAgent.ts`、`db.ts`、`index.ts`、`registry.ts`、`types.ts`、`agents/{codex, devin, jules}.ts` | -| `combos/` | Combo 解析辅助工具 | -| `compliance/` | 审计 + 服务商审计:`index.ts`、`providerAudit.ts` | -| `config/` | 运行时配置粘合层 | -| `db/` | SQLite 领域模块(见 §3.2.1) | -| `display/` | API 响应使用的 UI/展示辅助工具 | -| `embeddings/` | 嵌入服务注册表 | -| `env/` | 环境变量加载 + 自检 | -| `evals/` | 评估框架运行时 | -| `guardrails/` | `piiMasker.ts`、`promptInjection.ts`、`visionBridge.ts`、`visionBridgeHelpers.ts`、`registry.ts`、`base.ts` | -| `jobs/` | 后台任务(`autoUpdate.ts` 等) | -| `memory/` | 持久化记忆:`store.ts`、`cache.ts`、`retrieval.ts`、`summarization.ts`、`extraction.ts`、`injection.ts`、`qdrant.ts`、`settings.ts`、`verify.ts`、`schemas.ts`、`types.ts` | -| `monitoring/` | `observability.ts` | -| `oauth/` | OAuth 服务商(14 个):`antigravity`、`claude`、`cline`、`codex`、`cursor`、`gemini`、`github`、`gitlab-duo`、`kilocode`、`kimi-coding`、`kiro`、`qoder`、`qwen`、`windsurf`,以及 `services/`、`utils/{pkce, server, banner, codexAuthFile, ui}`、`constants/oauth.ts` | -| `plugins/` | 插件加载器(`index.ts`) | -| `promptCache/` | `prefixAnalyzer.ts`、`index.ts` | -| `providerModels/` | 托管模型生命周期:`modelDiscovery.ts`、`managedModelImport.ts`、`managedAvailableModels.ts`、`cursorAgent.ts` | -| `providers/` | 服务商辅助工具:`catalog.ts`、`validation.ts`、`imageValidation.ts`、`claudeExtraUsage.ts`、`codexConnectionDefaults.ts`、`codexFastTier.ts`、`webCookieAuth.ts`、`managedAvailableModels.ts`、`requestDefaults.ts` | -| `resilience/` | `settings.ts` — 熔断器、冷却、锁定的设置 | -| `runtime/` | 运行时特性检测 | -| `search/` | `executeWebSearch.ts` | -| `services/` | 嵌入式服务框架:`ServiceSupervisor.ts`(通用子进程管理器,带操作锁、环形缓冲区、健康检查器)、`bootstrap.ts`(进程级注册和自动启动)、`registry.ts`(工具 → 管理器映射)、`apiKey.ts`(AES-256-GCM Key 存储)、`modelSync.ts`(定期模型同步)、`ringBuffer.ts`(5 MB 环形日志缓冲)、`healthCheck.ts`(HTTP 健康探测)、`types.ts`、`embedWsProxy.ts`(WebSocket 代理)、`installers/{ninerouter,cliproxy}.ts`。详见 `docs/frameworks/EMBEDDED-SERVICES.md` | -| `agentSkills/` | Agent Skills 目录 + 生成器:`catalog.ts`(getCatalog/getSkillById/filterCatalog/computeCoverage)、`generator.ts`(generateAgentSkills → 写入 `skills/{id}/SKILL.md`)、`openapiParser.ts`(从 OpenAPI 规范提取 REST 端点)、`cliRegistryParser.ts`(从 bin/cli-registry 提取 CLI 子命令)、`schemas.ts`(Zod:AgentSkillSchema、SkillCoverageSchema、ListQuerySchema、GenerateBodySchema)、`types.ts`(AgentSkill、SkillCoverage、SkillMarkdown、GeneratorReport)。被 REST 路由(`/api/agent-skills/*`)、MCP 工具(`omniroute_agent_skills_*`)和 A2A 技能 `list-capabilities` 消费。参见 [AGENT-SKILLS.md](../frameworks/AGENT-SKILLS.md)。 | -| `skills/` | 技能框架:`registry.ts`、`executor.ts`、`interception.ts`、`injection.ts`、`sandbox.ts`、`custom.ts`、`hybrid.ts`、`builtins.ts`、`a2a.ts`、`providerSettings.ts`、`schemas.ts`、`skillssh.ts`、`types.ts`,以及 `builtin/browser.ts` | -| `spend/` | `batchWriter.ts`(写后缓冲) | -| `sync/` | `bundle.ts`、`tokens.ts`(云同步) | -| `system/` | 系统级辅助工具 | -| `translator/` | 顶层翻译器粘合层(委托到 `open-sse/translator/`) | -| `usage/` | 用量核算:`costCalculator.ts`、`tokenAccounting.ts`、`usageHistory.ts`、`aggregateHistory.ts`、`usageStats.ts`、`callLogs.ts`、`callLogArtifacts.ts`、`fetcher.ts`、`providerLimits.ts`、`migrations.ts` | -| `versionManager/` | 自动更新 + 版本清单 | -| `ws/` | WebSocket 桥接 | -| `zed-oauth/` | Zed 编辑器 OAuth 流程 | +| 模块 | 用途 | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `a2a/` | A2A 协议服务器:`taskManager.ts`、`streaming.ts`、`taskExecution.ts`、`routingLogger.ts`、`skills/`(6 个技能:成本分析、健康报告、服务商发现、配额管理、智能路由、列出能力) | +| `acp/` | Agent-Control-Protocol:`index.ts`、`manager.ts`、`registry.ts` | +| `api/` | 内部 API 辅助工具:`requireManagementAuth.ts`、`requireCliToolsAuth.ts`、`errorResponse.ts` | +| `auth/` | `managementPassword.ts`(密码重置/哈希) | +| `batches/` | OpenAI Batches API 服务(`service.ts`) | +| `catalog/` | OpenRouter 目录同步(`openrouterCatalog.ts`) | +| `cloudAgent/` | 云代理注册表:`api.ts`、`baseAgent.ts`、`db.ts`、`index.ts`、`registry.ts`、`types.ts`、`agents/{codex, devin, jules}.ts` | +| `combos/` | Combo 解析辅助工具 | +| `compliance/` | 审计 + 服务商审计:`index.ts`、`providerAudit.ts` | +| `config/` | 运行时配置粘合层 | +| `db/` | SQLite 领域模块(见 §3.2.1) | +| `display/` | API 响应使用的 UI/展示辅助工具 | +| `embeddings/` | 嵌入服务注册表 | +| `env/` | 环境变量加载 + 自检 | +| `evals/` | 评估框架运行时 | +| `guardrails/` | `piiMasker.ts`、`promptInjection.ts`、`visionBridge.ts`、`visionBridgeHelpers.ts`、`registry.ts`、`base.ts` | +| `jobs/` | 后台任务(`autoUpdate.ts` 等) | +| `memory/` | 持久化记忆:`store.ts`、`cache.ts`、`retrieval.ts`、`summarization.ts`、`extraction.ts`、`injection.ts`、`qdrant.ts`、`settings.ts`、`verify.ts`、`schemas.ts`、`types.ts` | +| `monitoring/` | `observability.ts` | +| `oauth/` | OAuth 服务商(14 个):`antigravity`、`claude`、`cline`、`codex`、`cursor`、`gemini`、`github`、`gitlab-duo`、`kilocode`、`kimi-coding`、`kiro`、`qoder`、`qwen`、`windsurf`,以及 `services/`、`utils/{pkce, server, banner, codexAuthFile, ui}`、`constants/oauth.ts` | +| `plugins/` | 插件加载器(`index.ts`) | +| `promptCache/` | `prefixAnalyzer.ts`、`index.ts` | +| `providerModels/` | 托管模型生命周期:`modelDiscovery.ts`、`managedModelImport.ts`、`managedAvailableModels.ts`、`cursorAgent.ts` | +| `providers/` | 服务商辅助工具:`catalog.ts`、`validation.ts`、`imageValidation.ts`、`claudeExtraUsage.ts`、`codexConnectionDefaults.ts`、`codexFastTier.ts`、`webCookieAuth.ts`、`managedAvailableModels.ts`、`requestDefaults.ts` | +| `resilience/` | `settings.ts` — 熔断器、冷却、锁定的设置 | +| `runtime/` | 运行时特性检测 | +| `search/` | `executeWebSearch.ts` | +| `services/` | 嵌入式服务框架:`ServiceSupervisor.ts`(通用子进程管理器,带操作锁、环形缓冲区、健康检查器)、`bootstrap.ts`(进程级注册和自动启动)、`registry.ts`(工具 → 管理器映射)、`apiKey.ts`(AES-256-GCM Key 存储)、`modelSync.ts`(定期模型同步)、`ringBuffer.ts`(5 MB 环形日志缓冲)、`healthCheck.ts`(HTTP 健康探测)、`types.ts`、`embedWsProxy.ts`(WebSocket 代理)、`installers/{ninerouter,cliproxy}.ts`。详见 `docs/frameworks/EMBEDDED-SERVICES.md` | +| `agentSkills/` | Agent Skills 目录 + 生成器:`catalog.ts`(getCatalog/getSkillById/filterCatalog/computeCoverage)、`generator.ts`(generateAgentSkills → 写入 `skills/{id}/SKILL.md`)、`openapiParser.ts`(从 OpenAPI 规范提取 REST 端点)、`cliRegistryParser.ts`(从 bin/cli-registry 提取 CLI 子命令)、`schemas.ts`(Zod:AgentSkillSchema、SkillCoverageSchema、ListQuerySchema、GenerateBodySchema)、`types.ts`(AgentSkill、SkillCoverage、SkillMarkdown、GeneratorReport)。被 REST 路由(`/api/agent-skills/*`)、MCP 工具(`omniroute_agent_skills_*`)和 A2A 技能 `list-capabilities` 消费。参见 [AGENT-SKILLS.md](../frameworks/AGENT-SKILLS.md)。 | +| `skills/` | 技能框架:`registry.ts`、`executor.ts`、`interception.ts`、`injection.ts`、`sandbox.ts`、`custom.ts`、`hybrid.ts`、`builtins.ts`、`a2a.ts`、`providerSettings.ts`、`schemas.ts`、`skillssh.ts`、`types.ts`,以及 `builtin/browser.ts` | +| `spend/` | `batchWriter.ts`(写后缓冲) | +| `sync/` | `bundle.ts`、`tokens.ts`(云同步) | +| `system/` | 系统级辅助工具 | +| `translator/` | 顶层翻译器粘合层(委托到 `open-sse/translator/`) | +| `usage/` | 用量核算:`costCalculator.ts`、`tokenAccounting.ts`、`usageHistory.ts`、`aggregateHistory.ts`、`usageStats.ts`、`callLogs.ts`、`callLogArtifacts.ts`、`fetcher.ts`、`providerLimits.ts`、`migrations.ts` | +| `versionManager/` | 自动更新 + 版本清单 | +| `ws/` | WebSocket 桥接 | +| `zed-oauth/` | Zed 编辑器 OAuth 流程 | `src/lib/` 中的顶层文件: @@ -357,23 +356,23 @@ v1/ 纯业务逻辑,无 I/O。被路由和处理器导入。 -| 文件 | 用途 | -| -------------------------------------------- | --------------------------------- | -| `policyEngine.ts` | 顶层策略解析器 | -| `fallbackPolicy.ts` | 容灾决策树 | -| `costRules.ts` | 成本计算规则 | -| `lockoutPolicy.ts` | 模型锁定决策 | -| `tagRouter.ts` | 基于标签的路由 | -| `comboResolver.ts` | Combo 解析(请求 → 目标列表) | -| `connectionModelRules.ts` | 按连接过滤模型 | -| `modelAvailability.ts` | 模型可用性检查 | -| `degradation.ts` | 降级模式切换 | -| `providerExpiration.ts` | 过期账户/Key 检测 | -| `quotaCache.ts` | 缓存配额决策 | -| `responses.ts`、`omnirouteResponseMeta.ts` | 响应格式辅助工具 | -| `configAudit.ts` | 配置变更审计 | -| `assessment/` | 模型评估(按 RFC,部分实现) | -| `types.ts` | 共享领域类型 | +| 文件 | 用途 | +| ------------------------------------------ | ----------------------------- | +| `policyEngine.ts` | 顶层策略解析器 | +| `fallbackPolicy.ts` | 容灾决策树 | +| `costRules.ts` | 成本计算规则 | +| `lockoutPolicy.ts` | 模型锁定决策 | +| `tagRouter.ts` | 基于标签的路由 | +| `comboResolver.ts` | Combo 解析(请求 → 目标列表) | +| `connectionModelRules.ts` | 按连接过滤模型 | +| `modelAvailability.ts` | 模型可用性检查 | +| `degradation.ts` | 降级模式切换 | +| `providerExpiration.ts` | 过期账户/Key 检测 | +| `quotaCache.ts` | 缓存配额决策 | +| `responses.ts`、`omnirouteResponseMeta.ts` | 响应格式辅助工具 | +| `configAudit.ts` | 配置变更审计 | +| `assessment/` | 模型评估(按 RFC,部分实现) | +| `types.ts` | 共享领域类型 | ### 3.4 `src/server/` — 服务端专用 @@ -442,35 +441,35 @@ open-sse/ ### 4.1 `open-sse/handlers/` -| 处理器 | 用途 | -| ------------------------- | ------------------------------------------ | -| `chatCore.ts` | 主对话管道(缓存、速率限制、Combo 路由、执行器调度)| -| `responsesHandler.ts` | OpenAI Responses API 入口 | -| `embeddings.ts` | 嵌入 | -| `imageGeneration.ts` | 图像生成 | -| `audioSpeech.ts` | Text-to-speech | -| `audioTranscription.ts` | Speech-to-text | -| `videoGeneration.ts` | 视频生成 | -| `musicGeneration.ts` | 音乐生成 | -| `rerank.ts` | 重排序 | -| `moderations.ts` | 内容审核 | -| `search.ts` | 网页搜索 | -| `sseParser.ts` | SSE 事件解析器 | -| `usageExtractor.ts` | 从上游流中提取 Token 计数 | -| `responseSanitizer.ts` | 去除服务商专用噪音 | -| `responseTranslator.ts` | 服务商响应与翻译器层之间的粘合 | +| 处理器 | 用途 | +| ----------------------- | ---------------------------------------------------- | +| `chatCore.ts` | 主对话管道(缓存、速率限制、Combo 路由、执行器调度) | +| `responsesHandler.ts` | OpenAI Responses API 入口 | +| `embeddings.ts` | 嵌入 | +| `imageGeneration.ts` | 图像生成 | +| `audioSpeech.ts` | Text-to-speech | +| `audioTranscription.ts` | Speech-to-text | +| `videoGeneration.ts` | 视频生成 | +| `musicGeneration.ts` | 音乐生成 | +| `rerank.ts` | 重排序 | +| `moderations.ts` | 内容审核 | +| `search.ts` | 网页搜索 | +| `sseParser.ts` | SSE 事件解析器 | +| `usageExtractor.ts` | 从上游流中提取 Token 计数 | +| `responseSanitizer.ts` | 去除服务商专用噪音 | +| `responseTranslator.ts` | 服务商响应与翻译器层之间的粘合 | ### 4.2 `open-sse/executors/` -68 个服务商执行器,各自扩展 `BaseExecutor`(`base.ts`): +107 个服务商执行器,各自扩展 `BaseExecutor`(`base.ts`): -`antigravity`、`azure-openai`、`blackbox-web`、`chatgpt-web`、`cliproxyapi`、 -`cloudflare-ai`、`codex`、`commandCode`、`cursor`、`default`、`devin-cli`、 +`antigravity`、`azure-openai`、`blackbox-web`、`cliproxyapi`、 +`chatgpt-web-codex`、`cloudflare-ai`、`codex`、`commandCode`、`cursor`、`default`、`devin-cli`、 `muse-spark-web`、`nlpcloud`、`opencode`、`perplexity-web`、`petals`、 `pollinations`、`qoder`、`vertex`、`windsurf`,以及 `claudeIdentity.ts` (共享身份标识辅助)和 `index.ts`(注册表)。 -> 注意:未在此列出的服务商由 `default.ts` 通过通用 OpenAI 兼容执行器提供服务。完整的服务商目录(237 条目)位于 `src/shared/constants/providers.ts`。 +> 注意:未在此列出的服务商由 `default.ts` 通过通用 OpenAI 兼容执行器提供服务。完整的服务商目录(351 条目)位于 `src/shared/constants/providers.ts`。 ### 4.3 `open-sse/translator/` @@ -500,21 +499,21 @@ open-sse/ 重点关注(完整列表见 `open-sse/services/`): -| 关注领域 | 文件 | -| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Combo 路由 | `combo.ts`(17 种策略)、`comboConfig.ts`、`comboMetrics.ts`、`comboManifestMetrics.ts`、`comboAgentMiddleware.ts` | -| Auto Combo 引擎 | `autoCombo/` — `engine.ts`、`scoring.ts`、`taskFitness.ts`、`virtualFactory.ts`、`modePacks.ts`、`autoPrefix.ts`、`persistence.ts`、`providerDiversity.ts`、`providerRegistryAccessor.ts`、`routerStrategy.ts`、`selfHealing.ts`、`index.ts` | -| 容灾 | `accountFallback.ts`(冷却 + 锁定)、`errorClassifier.ts`、`emergencyFallback.ts`、`rateLimitManager.ts`、`rateLimitSemaphore.ts`、`accountSemaphore.ts`、`accountSelector.ts` | -| 配额 | `quotaMonitor.ts`、`quotaPreflight.ts`、`bailianQuotaFetcher.ts`、`codexQuotaFetcher.ts`、`deepseekQuotaFetcher.ts`、`crofUsageFetcher.ts`、`antigravityCredits.ts` | -| 缓存 | `reasoningCache.ts`、`searchCache.ts`、`signatureCache.ts`、`requestDedup.ts` | -| 路由智能 | `intentClassifier.ts`、`taskAwareRouter.ts`、`backgroundTaskDetector.ts`、`volumeDetector.ts`、`wildcardRouter.ts`、`workflowFSM.ts`、`specificityDetector.ts`、`specificityRules.ts`、`specificityTypes.ts` | -| 模型处理 | `modelCapabilities.ts`、`modelDeprecation.ts`、`modelFamilyFallback.ts`、`modelStrip.ts`、`model.ts`、`provider.ts`、`providerRequestDefaults.ts`、`providerCostData.ts`、`payloadRules.ts` | -| 压缩 | `compression/` — 完整的压缩引擎接线 | -| Token + 会话 | `tokenRefresh.ts`、`sessionManager.ts`、`apiKeyRotator.ts`、`contextManager.ts`、`contextHandoff.ts`、`systemPrompt.ts`、`roleNormalizer.ts`、`responsesInputSanitizer.ts`、`toolSchemaSanitizer.ts`、`toolLimitDetector.ts`、`thinkingBudget.ts` | -| 层级 / 清单 | `tierResolver.ts`、`tierConfig.ts`、`tierDefaults.json`、`tierTypes.ts`、`manifestAdapter.ts` | -| IP / 网络 | `ipFilter.ts`、`webSearchFallback.ts` | -| 批次 | `batchProcessor.ts` | -| 用量 | `usage.ts` | +| 关注领域 | 文件 | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Combo 路由 | `combo.ts`(17 种策略)、`comboConfig.ts`、`comboMetrics.ts`、`comboManifestMetrics.ts`、`comboAgentMiddleware.ts` | +| Auto Combo 引擎 | `autoCombo/` — `engine.ts`、`scoring.ts`、`taskFitness.ts`、`virtualFactory.ts`、`modePacks.ts`、`autoPrefix.ts`、`persistence.ts`、`providerDiversity.ts`、`providerRegistryAccessor.ts`、`routerStrategy.ts`、`selfHealing.ts`、`index.ts` | +| 容灾 | `accountFallback.ts`(冷却 + 锁定)、`errorClassifier.ts`、`emergencyFallback.ts`、`rateLimitManager.ts`、`rateLimitSemaphore.ts`、`accountSemaphore.ts`、`accountSelector.ts` | +| 配额 | `quotaMonitor.ts`、`quotaPreflight.ts`、`bailianQuotaFetcher.ts`、`codexQuotaFetcher.ts`、`deepseekQuotaFetcher.ts`、`crofUsageFetcher.ts`、`antigravityCredits.ts` | +| 缓存 | `reasoningCache.ts`、`searchCache.ts`、`signatureCache.ts`、`requestDedup.ts` | +| 路由智能 | `intentClassifier.ts`、`taskAwareRouter.ts`、`backgroundTaskDetector.ts`、`volumeDetector.ts`、`wildcardRouter.ts`、`workflowFSM.ts`、`specificityDetector.ts`、`specificityRules.ts`、`specificityTypes.ts` | +| 模型处理 | `modelCapabilities.ts`、`modelDeprecation.ts`、`modelFamilyFallback.ts`、`modelStrip.ts`、`model.ts`、`provider.ts`、`providerRequestDefaults.ts`、`providerCostData.ts`、`payloadRules.ts` | +| 压缩 | `compression/` — 完整的压缩引擎接线 | +| Token + 会话 | `tokenRefresh.ts`、`sessionManager.ts`、`apiKeyRotator.ts`、`contextManager.ts`、`contextHandoff.ts`、`systemPrompt.ts`、`roleNormalizer.ts`、`responsesInputSanitizer.ts`、`toolSchemaSanitizer.ts`、`toolLimitDetector.ts`、`thinkingBudget.ts` | +| 层级 / 清单 | `tierResolver.ts`、`tierConfig.ts`、`tierDefaults.json`、`tierTypes.ts`、`manifestAdapter.ts` | +| IP / 网络 | `ipFilter.ts`、`webSearchFallback.ts` | +| 批次 | `batchProcessor.ts` | +| 用量 | `usage.ts` | ### 4.6 `open-sse/mcp-server/` @@ -624,14 +623,14 @@ bin/ 常用命令: -| 命令 | 运行内容 | -| --------------------------------------------------------- | -------------------------------------------------------------- | -| `npm run test:unit` | `tests/unit/*.test.ts` 全部(Node 测试运行器,并发 10) | -| `npm run test:vitest` | Vitest 套件(MCP、autoCombo、缓存) | -| `npm run test:e2e` | Playwright UI 套件 | -| `npm run test:protocols:e2e` | MCP + A2A 协议端到端 | -| `npm run test:coverage` | 覆盖率门槛(行/语句/函数/分支 ≥ 60%) | -| `node --import tsx/esm --test tests/unit/.test.ts` | 单文件运行 | +| 命令 | 运行内容 | +| -------------------------------------------------------- | ------------------------------------------------------- | +| `npm run test:unit` | `tests/unit/*.test.ts` 全部(Node 测试运行器,并发 10) | +| `npm run test:vitest` | Vitest 套件(MCP、autoCombo、缓存) | +| `npm run test:e2e` | Playwright UI 套件 | +| `npm run test:protocols:e2e` | MCP + A2A 协议端到端 | +| `npm run test:coverage` | 覆盖率门槛(行/语句/函数/分支 ≥ 60%) | +| `node --import tsx/esm --test tests/unit/.test.ts` | 单文件运行 | --- @@ -696,11 +695,11 @@ bin/ ### 容灾运行时状态(三种机制) -| 机制 | 范围 | 位置 | -| -------------- | ------------------ | -------------------------------------------------------------------------------------------------- | -| 服务商熔断器 | 整个服务商 | `src/shared/utils/circuitBreaker.ts`,持久化于 `domain_circuit_breakers` | -| 连接冷却 | 单个账户/Key | `src/sse/services/auth.ts` 中的 `markAccountUnavailable()`;由 `accountFallback.checkFallbackError()` 消费 | -| 模型锁定 | 服务商 + 连接 + 模型| `open-sse/services/accountFallback.ts`,持久化于 `domain_lockout_state` | +| 机制 | 范围 | 位置 | +| ------------ | -------------------- | ---------------------------------------------------------------------------------------------------------- | +| 服务商熔断器 | 整个服务商 | `src/shared/utils/circuitBreaker.ts`,持久化于 `domain_circuit_breakers` | +| 连接冷却 | 单个账户/Key | `src/sse/services/auth.ts` 中的 `markAccountUnavailable()`;由 `accountFallback.checkFallbackError()` 消费 | +| 模型锁定 | 服务商 + 连接 + 模型 | `open-sse/services/accountFallback.ts`,持久化于 `domain_lockout_state` | 参见 [RESILIENCE_GUIDE.md](./RESILIENCE_GUIDE.md) 和 [CLAUDE.md](../../CLAUDE.md) 中的专门章节。 diff --git a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md index 72d0e9af42..f3698b5fc8 100644 --- a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md @@ -83,28 +83,28 @@ echo "OMNIROUTE_WS_BRIDGE_SECRET=$(openssl rand -base64 32)" OmniRoute 使用 **SQLite**(通过 `better-sqlite3`)进行所有持久化存储。以下变量控制数据位置、加密和生命周期。 -| 变量 | 默认值 | 源文件 | 说明 | -| -------------------------------------- | -------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | SQLite 数据库、备份和数据文件的根目录。在 Docker 卷或自定义路径中可覆盖。 | -| `STORAGE_ENCRYPTION_KEY` | _(空 = 禁用)_ | `src/lib/db/encryption.ts` | 用于 SQLite 数据库静态全量加密的 AES 密钥。使用 `openssl rand -hex 32` 生成。 | -| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | 加密密钥的版本标签。进行密钥轮换时递增,以支持解密旧备份。 | -| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | 设为 `true` 时,跳过每次启动前迁移时运行的自动数据库备份。 | -| `OMNIROUTE_CRYPT_KEY` | _(未设置)_ | `src/lib/db/encryption.ts` | `STORAGE_ENCRYPTION_KEY` 的**旧版别名**。主变量缺失时作为回退被接受。 | -| `OMNIROUTE_API_KEY_BASE64` | _(未设置)_ | `src/lib/db/encryption.ts` | **旧版别名**(Base64 编码形式),作为回退被接受。使用前自动解码。 | -| `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(未设置)_ | `src/lib/db/core.ts` | 覆盖定期 SQLite 健康检查的间隔(毫秒)。未设置时根据 `NODE_ENV` 推导默认值。 | -| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts`, `src/lib/db/healthCheck.ts` | 设为 `1` 可在启动时完全跳过数据库健康检查。适用于短生命周期任务和集成测试。 | -| `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | 设为 `1` 可强制开启数据库健康检查循环,即使正常会被跳过(如短生命周期任务)。 | -| `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | 设为 `1` 可在 `npm install` 期间跳过原生运行时预热。适用于 CI/无头安装,此时 sqlite 已构建好。 | -| `OMNIROUTE_MIGRATIONS_DIR` | _(自动检测)_ | `src/lib/db/migrationRunner.ts` | 覆盖迁移运行器扫描的目录。在自定义构建中打包迁移文件时很有用。 | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------------------- | -------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | SQLite 数据库、备份和数据文件的根目录。在 Docker 卷或自定义路径中可覆盖。 | +| `STORAGE_ENCRYPTION_KEY` | _(空 = 禁用)_ | `src/lib/db/encryption.ts` | 用于 SQLite 数据库静态全量加密的 AES 密钥。使用 `openssl rand -hex 32` 生成。 | +| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | 加密密钥的版本标签。进行密钥轮换时递增,以支持解密旧备份。 | +| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | 设为 `true` 时,跳过每次启动前迁移时运行的自动数据库备份。 | +| `OMNIROUTE_CRYPT_KEY` | _(未设置)_ | `src/lib/db/encryption.ts` | `STORAGE_ENCRYPTION_KEY` 的**旧版别名**。主变量缺失时作为回退被接受。 | +| `OMNIROUTE_API_KEY_BASE64` | _(未设置)_ | `src/lib/db/encryption.ts` | **旧版别名**(Base64 编码形式),作为回退被接受。使用前自动解码。 | +| `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(未设置)_ | `src/lib/db/core.ts` | 覆盖定期 SQLite 健康检查的间隔(毫秒)。未设置时根据 `NODE_ENV` 推导默认值。 | +| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts`, `src/lib/db/healthCheck.ts` | 设为 `1` 可在启动时完全跳过数据库健康检查。适用于短生命周期任务和集成测试。 | +| `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | 设为 `1` 可强制开启数据库健康检查循环,即使正常会被跳过(如短生命周期任务)。 | +| `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | 设为 `1` 可在 `npm install` 期间跳过原生运行时预热。适用于 CI/无头安装,此时 sqlite 已构建好。 | +| `OMNIROUTE_MIGRATIONS_DIR` | _(自动检测)_ | `src/lib/db/migrationRunner.ts` | 覆盖迁移运行器扫描的目录。在自定义构建中打包迁移文件时很有用。 | | `OMNIROUTE_EXTRA_MIGRATIONS_DIRS` | _(未设置)_ | `src/lib/db/migrationRunner/extraDirs.ts` | 以 `namespace=dir` 形式追加的迁移目录,条目之间用平台路径分隔符分隔(例如 `ee=/opt/app/enterprise/db/migrations`)。其中的文件会以 `-` 记录版本,因此自带迁移的发行版永远不会与上游的数字槽位冲突。条目格式错误、命名空间非法或目录缺失会在启动时抛错,而不是静默跳过该 schema。 | -| `OMNIROUTE_MAX_PENDING_MIGRATIONS` | `50` | `src/lib/db/migrationRunner.ts` | 大量待处理迁移的安全阈值(#3416)。如果现有数据库上有超过此数量的待处理迁移,启动将中止(防止跟踪表被清空)。恢复旧备份时提高此值;设为 `0` 可禁用检查。 | -| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 批量消费/成本写入器的刷新间隔(毫秒)。值越小写合并越少;值越大数据库争用越少。 | -| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 强制刷新前的最大缓存消费条目数。在高 QPS 部署中提高;在内存受限场景下降低。 | -| `OMNIROUTE_PROXY_FETCH_DEBUG` | _(未设置)_ | `open-sse/utils/proxyFetch.ts` | 设为 `"true"` 可在 Vercel 中继路径上发出 `[ProxyFetch]` 调试日志。默认关闭以避免泄露路由提示。 | -| `BATCH_RETRY_DURATION_MS` | `86400000`(24小时) | `open-sse/services/batchProcessor.ts` | 单个批次项的最大重试窗口(毫秒)。超过此时间的项被标记为失败。 | -| `BATCH_BACKOFF_BASE_MS` | `5000` | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的基础延迟(毫秒)。 | -| `BATCH_BACKOFF_MAX_MS` | `3600000`(1小时) | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的上限(毫秒)。 | -| `BATCH_MAX_CONCURRENT` | `1` | `open-sse/services/batchProcessor.ts` | 并发处理的批次最大数量。提高以增加吞吐量;保持低值以避免速率限制风暴。 | +| `OMNIROUTE_MAX_PENDING_MIGRATIONS` | `50` | `src/lib/db/migrationRunner.ts` | 大量待处理迁移的安全阈值(#3416)。如果现有数据库上有超过此数量的待处理迁移,启动将中止(防止跟踪表被清空)。恢复旧备份时提高此值;设为 `0` 可禁用检查。 | +| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 批量消费/成本写入器的刷新间隔(毫秒)。值越小写合并越少;值越大数据库争用越少。 | +| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 强制刷新前的最大缓存消费条目数。在高 QPS 部署中提高;在内存受限场景下降低。 | +| `OMNIROUTE_PROXY_FETCH_DEBUG` | _(未设置)_ | `open-sse/utils/proxyFetch.ts` | 设为 `"true"` 可在 Vercel 中继路径上发出 `[ProxyFetch]` 调试日志。默认关闭以避免泄露路由提示。 | +| `BATCH_RETRY_DURATION_MS` | `86400000`(24小时) | `open-sse/services/batchProcessor.ts` | 单个批次项的最大重试窗口(毫秒)。超过此时间的项被标记为失败。 | +| `BATCH_BACKOFF_BASE_MS` | `5000` | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的基础延迟(毫秒)。 | +| `BATCH_BACKOFF_MAX_MS` | `3600000`(1小时) | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的上限(毫秒)。 | +| `BATCH_MAX_CONCURRENT` | `1` | `open-sse/services/batchProcessor.ts` | 并发处理的批次最大数量。提高以增加吞吐量;保持低值以避免速率限制风暴。 | ### 场景 @@ -250,41 +250,37 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱 ## 7. URL 与云同步 -| 变量 | 默认值 | 源文件 | 说明 | -| ----------------------------------------- | ----------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | 内部同步任务调用 `/api/sync/cloud` 的服务器端 URL。即使应用被公共代理,也保持为 loopback/容器 URL。 | -| `CLOUD_URL` | _(空)_ | `src/lib/cloudSync.ts` | 云中继端点 URL(高级功能)。 | -| `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | 云同步请求的 HTTP 超时。 | -| `OMNIROUTE_BUILD_PROFILE` | `full` | Webpack 构建配置 | 构建时配置文件(设为 `minimal` 可物理排除特权模块不打包)。 | -| `OMNIROUTE_CLOUD_SYNC_SECRET` | _(空)_ | `src/lib/cloudSync.ts` | 用于校验云同步响应 HMAC-SHA256 签名的共享密钥。 | -| `OMNIROUTE_CLOUD_SYNC_SECRETS` | `false` | `src/lib/cloudSync.ts` | 设为 `true` 允许云同步端点覆盖本地凭证。默认 `false`。 | -| `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP` | `false` | `src/app/api/providers/zed/import/route.ts` | 设为 `true` 可回退到 v3.8.5 的一步式"导入全部"行为,无需用户确认。 | -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth、Dashboard、同步 | 面向公共的 URL,用于 OAuth redirect_uri、Dashboard 链接、生成的公共 URL 以及同源浏览器变更检查。**在反向代理背后时,必须匹配你的公共 URL。** | -| `NEXT_PUBLIC_CLOUD_URL` | _(空)_ | 客户端侧 | `CLOUD_URL` 的客户端镜像。 | -| `NEXT_PUBLIC_APP_URL` | _(未设置)_ | `src/shared/services/cloudSyncScheduler.ts` | `NEXT_PUBLIC_BASE_URL` 的旧版回退。 | -| `OMNIROUTE_PUBLIC_BASE_URL` | _(未设置)_ | 公共源解析器、图片 URL | 最高优先级的浏览器侧 OmniRoute 源,用于公共 URL 生成和源校验(例如 `/v1/chatgpt-web/image/`)。当 OpenWebUI 或其他中继通过内部 URL 访问 OmniRoute,但用户浏览器必须从 LAN、隧道或公共源获取图片时设置。**不要**包含 `/v1`。 | -| `OMNIROUTE_TRUST_PROXY` | _(未设置)_ | `src/server/origin/publicOrigin.ts` | 可选的转发公共源头信任模式。未设置 = 出于安全考虑不信任 `Forwarded` / `X-Forwarded-*`。`true` / `loopback` 仅信任来自经过 Token 戳记的 loopback 代理的转发 host/proto。`private` / `lan` 还信任私有 LAN 代理对端。生产环境中推荐显式设置 `NEXT_PUBLIC_BASE_URL`。 | -| `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000`(3 分钟) | `open-sse/executors/chatgpt-web.ts` | 等待异步 chatgpt-web 图片通过 Celsius WebSocket 到达的最大时间。在上游排队窗口较长时提高此值。 | -| `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | 为 `/v1/chatgpt-web/image/` 提供服务的 chatgpt-web 图片缓存的内存预算总额(MB)。在内存受限的主机上降低;图片生成量大且客户端争抢 30 分钟 TTL 时提高。 | -| `OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS` | `1200000`(20 分钟) | `open-sse/executors/chatgpt-web.ts` | chatgpt-web GPT-5.5 Pro 后台轮询交接的总体等待预算。Pro 推理在带外完成,OmniRoute 轮询直到结果到达或预算耗尽。如果 Pro 请求完成前超时,请提高此值。 | -| `OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS` | `4000`(4 秒) | `open-sse/executors/chatgpt-web.ts` | chatgpt-web GPT-5.5 Pro 后台轮询尝试的间隔。降低可更快完成但增加上游轮询;提高可减少请求量。 | -| `THEOLDLLM_NAV_TIMEOUT_MS` | `30000`(30 秒) | `open-sse/executors/theoldllm.ts` | 浏览器端 Token 捕获(The Old LLM (theoldllm) 免费服务商使用)的 Playwright 导航超时(毫秒)。如果中继页面加载慢,可在慢速网络上提高。 | -| `KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 异步 kie.ai 任务的公共回调 URL。优先级高于 `OMNIROUTE_KIE_CALLBACK_URL` 和 `OMNIROUTE_PUBLIC_URL`。 | -| `OMNIROUTE_KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | `KIE_CALLBACK_URL` 的替代写法。主变量未设置时的回退。 | -| `OMNIROUTE_PUBLIC_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 用于组合异步回调 URL 的公共源。kie.ai 回调的最低优先级回退;也用作其他中继的通用公共 URL。 | -| `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | Usage 页面使用的 CrofAI 配额查询端点。可覆盖为中继/测试固定件。 | -| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | Usage 页面使用的 OpenCode (zen/go) 配额查询端点。可覆盖为中继/测试固定件。 | -| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(未设置)_ | `open-sse/services/opencodeOllamaUsage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。OpenCode Go 没有公开的配额 API,因此没有默认值;除非运维人员显式设置该变量选择接入自建/镜像端点,否则不会发起网络请求。 | -| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | 配置了 workspace ID 和 auth Cookie 时用于配额抓取的 OpenCode Go Dashboard 基础 URL。可覆盖为中继/测试固定件。 | -| `OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go workspace ID。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID 环境变量的备选名,在较短的别名之前使用。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go `auth` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | 用于配额抓取的 Ollama Cloud settings URL。可覆盖为中继/测试固定件。 | -| `OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于设置页面配额抓取的 Ollama Cloud `__Secure-session` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OLLAMA_CLOUD_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_CODEWHISPERER_BASE_URL` | `https://codewhisperer.us-east-1.amazonaws.com` | `open-sse/services/usage.ts` | CodeWhisperer (AWS Kiro) 用量限制端点。可覆盖为中继/测试固定件。 | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------------------- | ----------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | 内部同步任务调用 `/api/sync/cloud` 的服务器端 URL。即使应用被公共代理,也保持为 loopback/容器 URL。 | +| `CLOUD_URL` | _(空)_ | `src/lib/cloudSync.ts` | 云中继端点 URL(高级功能)。 | +| `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | 云同步请求的 HTTP 超时。 | +| `OMNIROUTE_BUILD_PROFILE` | `full` | Webpack 构建配置 | 构建时配置文件(设为 `minimal` 可物理排除特权模块不打包)。 | +| `OMNIROUTE_CLOUD_SYNC_SECRET` | _(空)_ | `src/lib/cloudSync.ts` | 用于校验云同步响应 HMAC-SHA256 签名的共享密钥。 | +| `OMNIROUTE_CLOUD_SYNC_SECRETS` | `false` | `src/lib/cloudSync.ts` | 设为 `true` 允许云同步端点覆盖本地凭证。默认 `false`。 | +| `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP` | `false` | `src/app/api/providers/zed/import/route.ts` | 设为 `true` 可回退到 v3.8.5 的一步式"导入全部"行为,无需用户确认。 | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth、Dashboard、同步 | 面向公共的 URL,用于 OAuth redirect_uri、Dashboard 链接、生成的公共 URL 以及同源浏览器变更检查。**在反向代理背后时,必须匹配你的公共 URL。** | +| `NEXT_PUBLIC_CLOUD_URL` | _(空)_ | 客户端侧 | `CLOUD_URL` 的客户端镜像。 | +| `NEXT_PUBLIC_APP_URL` | _(未设置)_ | `src/shared/services/cloudSyncScheduler.ts` | `NEXT_PUBLIC_BASE_URL` 的旧版回退。 | +| `OMNIROUTE_PUBLIC_BASE_URL` | _(未设置)_ | 公共源解析器、图片 URL | 最高优先级的浏览器侧 OmniRoute 源,用于公共 URL 生成和非 Dashboard 浏览器源校验。当 OpenWebUI 或其他中继通过内部 URL 访问 OmniRoute,但用户浏览器必须从 LAN、隧道或公共源获取生成媒体时设置。**不要**包含 `/v1`。 | +| `OMNIROUTE_TRUST_PROXY` | _(未设置)_ | `src/server/origin/publicOrigin.ts` | 可选的转发公共源头信任模式。未设置 = 出于安全考虑不信任 `Forwarded` / `X-Forwarded-*`。`true` / `loopback` 仅信任来自经过 Token 戳记的 loopback 代理的转发 host/proto。`private` / `lan` 还信任私有 LAN 代理对端。生产环境中推荐显式设置 `NEXT_PUBLIC_BASE_URL`。 | +| `THEOLDLLM_NAV_TIMEOUT_MS` | `30000`(30 秒) | `open-sse/executors/theoldllm.ts` | 浏览器端 Token 捕获(The Old LLM (theoldllm) 免费服务商使用)的 Playwright 导航超时(毫秒)。如果中继页面加载慢,可在慢速网络上提高。 | +| `KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 异步 kie.ai 任务的公共回调 URL。优先级高于 `OMNIROUTE_KIE_CALLBACK_URL` 和 `OMNIROUTE_PUBLIC_URL`。 | +| `OMNIROUTE_KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | `KIE_CALLBACK_URL` 的替代写法。主变量未设置时的回退。 | +| `OMNIROUTE_PUBLIC_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 用于组合异步回调 URL 的公共源。kie.ai 回调的最低优先级回退;也用作其他中继的通用公共 URL。 | +| `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | Usage 页面使用的 CrofAI 配额查询端点。可覆盖为中继/测试固定件。 | +| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | Usage 页面使用的 OpenCode (zen/go) 配额查询端点。可覆盖为中继/测试固定件。 | +| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(未设置)_ | `open-sse/services/opencodeOllamaUsage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。OpenCode Go 没有公开的配额 API,因此没有默认值;除非运维人员显式设置该变量选择接入自建/镜像端点,否则不会发起网络请求。 | +| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | 配置了 workspace ID 和 auth Cookie 时用于配额抓取的 OpenCode Go Dashboard 基础 URL。可覆盖为中继/测试固定件。 | +| `OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go workspace ID。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID 环境变量的备选名,在较短的别名之前使用。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go `auth` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | 用于配额抓取的 Ollama Cloud settings URL。可覆盖为中继/测试固定件。 | +| `OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于设置页面配额抓取的 Ollama Cloud `__Secure-session` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OLLAMA_CLOUD_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_CODEWHISPERER_BASE_URL` | `https://codewhisperer.us-east-1.amazonaws.com` | `open-sse/services/usage.ts` | CodeWhisperer (AWS Kiro) 用量限制端点。可覆盖为中继/测试固定件。 | > [!IMPORTANT] > 当部署在反向代理(nginx、Caddy)之后时,**必须**将 `NEXT_PUBLIC_BASE_URL` 设置为你的公共 URL(例如 `https://omniroute.example.com`)。否则 OAuth 回调可能因 redirect_uri 不匹配而失败,生成的公共链接可能指向内部容器源,同源 Dashboard 变更可能被浏览器源检查拒绝。 @@ -581,36 +577,33 @@ REQUEST_TIMEOUT_MS (全局覆盖) └── API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS (默认值: 0 = 禁用) ``` -| 变量 | 默认值 | 说明 | -| ------------------------------------------------ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | _(未设置)_ | 全局快捷方式 — 覆盖 `FETCH_TIMEOUT_MS` 和 `STREAM_IDLE_TIMEOUT_MS` 两者的默认值。 | -| `FETCH_TIMEOUT_MS` | `600000` | 上游服务商调用的 HTTP 请求总超时。 | -| `STREAM_IDLE_TIMEOUT_MS` | `600000` | SSE 块之间的最长静默时间,超时则中止。扩展推理模型很少暂停超过 90 秒。 | -| `STREAM_READINESS_TIMEOUT_MS` | `80000` | 接收第一个非 ping SSE 事件的超时时间。设置时继承 `REQUEST_TIMEOUT_MS`。 | -| `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | _(关闭)_ | 剥离会导致 OpenAI SDK 的 `responses.stream()` 以 502 报错的非标准 `codex.*` SSE 事件(如 `codex.rate_limits`)。设为 `true`/`1`/`yes` 可启用。 | -| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收响应头的超时时间。 | -| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收完整响应体的超时时间。 | -| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP 连接建立超时。 | -| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive 套接字空闲超时。 | -| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS 指纹代理(wreq-js)超时。 | -| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | `/v1` 桥接请求的代理跳超时。 | -| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | 桥接的服务器请求总超时。 | -| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | 通过桥接发送响应头的超时时间。 | -| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | 桥接 keep-alive 空闲超时。 | -| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | 原始套接字超时(0 = 禁用)。 | -| `SHUTDOWN_TIMEOUT_MS` | `30000` | SIGTERM/SIGINT 后强制退出前的宽限期。 | -| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | `FETCH_TIMEOUT_MS` 未设置时 `src/shared/utils/fetchTimeout.ts` 使用的回退值。 | -| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`chatgptTlsClient.ts`)。 | -| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000`(30 秒) | ChatGPT TLS sidecar(`chatgptTlsClient.ts`)在中止死流前等待第一个流式字节的最大时间。如果上游冷启动超过窗口则提高。 | -| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`claudeTlsClient.ts`)。 | -| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`perplexityTlsClient.ts`)。 | -| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`grokTlsClient.ts`)。 | -| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_BROWSER_POOL` | `on` | 用于浏览器端 Web Cookie 聊天的共享 Playwright 浏览器池(`browserPool.ts`);设为 `off` 可禁用。 | -| `WEB_COOKIE_USE_BROWSER` | `0` | 将 Web Cookie 聊天请求选择进入浏览器端路径(`browserBackedChat.ts`);`1` 启用。 | +| 变量 | 默认值 | 说明 | +| ----------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | _(未设置)_ | 全局快捷方式 — 覆盖 `FETCH_TIMEOUT_MS` 和 `STREAM_IDLE_TIMEOUT_MS` 两者的默认值。 | +| `FETCH_TIMEOUT_MS` | `600000` | 上游服务商调用的 HTTP 请求总超时。 | +| `STREAM_IDLE_TIMEOUT_MS` | `600000` | SSE 块之间的最长静默时间,超时则中止。扩展推理模型很少暂停超过 90 秒。 | +| `STREAM_READINESS_TIMEOUT_MS` | `80000` | 接收第一个非 ping SSE 事件的超时时间。设置时继承 `REQUEST_TIMEOUT_MS`。 | +| `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | _(关闭)_ | 剥离会导致 OpenAI SDK 的 `responses.stream()` 以 502 报错的非标准 `codex.*` SSE 事件(如 `codex.rate_limits`)。设为 `true`/`1`/`yes` 可启用。 | +| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收响应头的超时时间。 | +| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收完整响应体的超时时间。 | +| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP 连接建立超时。 | +| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive 套接字空闲超时。 | +| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS 指纹代理(wreq-js)超时。 | +| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | `/v1` 桥接请求的代理跳超时。 | +| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | 桥接的服务器请求总超时。 | +| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | 通过桥接发送响应头的超时时间。 | +| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | 桥接 keep-alive 空闲超时。 | +| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | 原始套接字超时(0 = 禁用)。 | +| `SHUTDOWN_TIMEOUT_MS` | `30000` | SIGTERM/SIGINT 后强制退出前的宽限期。 | +| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | `FETCH_TIMEOUT_MS` 未设置时 `src/shared/utils/fetchTimeout.ts` 使用的回退值。 | +| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`claudeTlsClient.ts`)。 | +| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | +| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`perplexityTlsClient.ts`)。 | +| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | +| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`grokTlsClient.ts`)。 | +| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | +| `OMNIROUTE_BROWSER_POOL` | `on` | 用于浏览器端 Web Cookie 聊天的共享 Playwright 浏览器池(`browserPool.ts`);设为 `off` 可禁用。 | +| `WEB_COOKIE_USE_BROWSER` | `0` | 将 Web Cookie 聊天请求选择进入浏览器端路径(`browserBackedChat.ts`);`1` 启用。 | Combo 目标尝试继承已解析的上游请求超时(`FETCH_TIMEOUT_MS`,或当它提供 fetch 默认值时的 `REQUEST_TIMEOUT_MS`)。仅在 Combo 中设置 `targetTimeoutMs`、Combo 默认值或服务商覆盖值以加快 Combo 回退;超过当前上游超时的值会被截断到上游超时。 @@ -979,8 +972,7 @@ CLI_COMPAT_ALL=1 | `NGROK_AUTHTOKEN` | _(未设置)_ | `src/lib/ngrokTunnel.ts` | 认证出口 ngrok 隧道。 | | `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | 磁盘上保留的最大 SQLite 备份文件数。覆盖从 Settings → Database backup retention 保存的值。 | | `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | 保留备份的最大天数。`0` 禁用基于时间的清理。覆盖从 Settings → Database backup retention 保存的值。 | -| `OMNIROUTE_TLS_PROXY_URL` | _(未设置)_ | `open-sse/services/chatgptTlsClient.ts` | 覆盖测试用的 TLS sidecar URL。生产环境应保持未设置。 | -| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | 入口点权限检查的容器运行时提示。任何 Podman 拓扑都应设为 `podman`。由于容器无法判断引擎位于本地还是通过 Podman Machine 访问,警告会保持拓扑中立并指向 `contrib/podman/README.md`。 | +| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | 入口点权限检查的容器运行时提示。任何 Podman 拓扑都应设为 `podman`。由于容器无法判断引擎位于本地还是通过 Podman Machine 访问,警告会保持拓扑中立并指向 `contrib/podman/README.md`。 | | `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | 配额共享消费存储后端:`sqlite`(默认)或 `redis`。 | | `QUOTA_STORE_REDIS_URL` | _(未设置)_ | `src/lib/quota/storeFactory.ts` | `QUOTA_STORE_DRIVER=redis` 时使用的 Redis 连接字符串(如 `redis://localhost:6379`)。 | | `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | 池饱和比率(0..1);达到或超过该值时池进入严格模式(不允许借用)。 | diff --git a/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md b/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md index 756e62c76b..e6ce4e7d26 100644 --- a/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md @@ -466,8 +466,6 @@ FSM 轉換結果饋入自動組合的評分,對背景/自動化任務偏向較 - Claude Code CCH(相容性通道握手):`open-sse/services/claudeCodeCCH.ts`,加上 `claudeCodeCompatible.ts`、`claudeCodeConstraints.ts`、`claudeCodeExtraRemap.ts`、`claudeCodeToolRemapper.ts` - Claude Code 指紋塑造:`open-sse/services/claudeCodeFingerprint.ts` - Claude Code 混淆:`open-sse/services/claudeCodeObfuscation.ts` -- ChatGPT TLS 客戶端:`open-sse/services/chatgptTlsClient.ts`(curl-impersonate 風格的 ChatGPT-Web 工作階段) -- ChatGPT 圖片快取:`open-sse/services/chatgptImageCache.ts` 完整的隱匿手冊與操作指南,請參閱 [`docs/security/STEALTH_GUIDE.md`](../security/STEALTH_GUIDE.md)。 @@ -873,103 +871,103 @@ flowchart LR 每個提供者都有一個專門的執行器,繼承自 `BaseExecutor`(位於 `open-sse/executors/base.ts`),提供 URL 建置、標頭建構、指數退避重試、憑證刷新鉤子與 `execute()` 協調方法。 -| 執行器 | 提供者 | 特殊處理 | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| `DefaultExecutor` | OpenAI、Claude、Gemini、Qwen、OpenRouter、GLM、Kimi、MiniMax、DeepSeek、Groq、xAI、Mistral、Perplexity、Together、Fireworks、Cerebras、Cohere、NVIDIA 等 | 每個提供者的動態 URL/標頭設定 | -| `AntigravityExecutor` | Google Antigravity | 自訂專案/工作階段 ID、Retry-After 解析、429 混淆 | -| `AzureOpenAIExecutor` | Azure OpenAI | 基於部署的路由、api-version 查詢強制 | -| `BlackboxWebExecutor` | Blackbox AI(網頁模式) | 含 TLS 指紋模擬的網頁工作階段反向 | -| `ChatGPTWebExecutor` | ChatGPT 網頁 | TLS 客戶端 + 工作階段 Cookie 管理(`chatgptTlsClient.ts`) | -| `ClaudeIdentityExecutor` | Claude.ai(CCH 路徑) | 約束 + 工具重新對應管線、指紋塑造 | -| `CliProxyApiExecutor` | CLIProxyAPI 相容提供者 | 自訂驗證與協定處理 | -| `CloudflareAiExecutor` | Cloudflare Workers AI | 帳戶 ID 注入、基於 Neurons 的用量追蹤 | -| `CodexExecutor` | OpenAI Codex | 注入系統指令、強制推理努力 | -| `CommandCodeExecutor` | Command Code | OAuth + 每個工作階段的標頭輪換 | -| `CursorExecutor` | Cursor IDE | ConnectRPC 協定、Protobuf 編碼、透過 checksum 的請求簽署 | -| `DevinCliExecutor` | Devin CLI | 透過雲端代理模組的 Devin 任務生命週期橋接 | -| `GithubExecutor` | GitHub Copilot | Copilot 令牌刷新、模擬 VSCode 標頭 | -| `GitlabExecutor` | GitLab Duo | GitLab OAuth + 專案範圍路由 | -| `GlmExecutor` | Z.AI GLM(含 `glmt` 預設) | 思考預算感知、GLMT 預設常數 | -| `GrokWebExecutor` | xAI Grok 網頁 | 網頁工作階段反向、模式選擇(think/standard) | -| `KieExecutor` | KIE | 自訂令牌簽發,含輪換的工作階段錨點 | -| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream 二進位格式 → SSE 轉換 | -| `MuseSparkWebExecutor` | Muse Spark(網頁) | 含圖片訊息橋接的網頁工作階段反向 | -| `NlpCloudExecutor` | NLP Cloud | 提供者特定的請求主體形式 | -| `OpenCodeExecutor` | OpenCode | AI SDK 相容提供者設定 | -| `PerplexityWebExecutor` | Perplexity 網頁 | 用於聊天延續的網頁工作階段反向 | -| `PetalsExecutor` | Petals 分散式推理 | 去中心化群組路由 | -| `PollinationsExecutor` | Pollinations AI | 無需 API 金鑰、速率限制請求 | -| `QoderExecutor` | Qoder AI | PAT 與 OAuth 支援、多模型免費方案 | -| `VertexExecutor` | Google Vertex AI | 服務帳戶驗證、基於區域的端點 | -| `DevinDesktopExecutor` | Devin Desktop | 匯入的 API 金鑰 + Connect-protobuf 聊天串流 | +| 執行器 | 提供者 | 特殊處理 | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `DefaultExecutor` | OpenAI、Claude、Gemini、Qwen、OpenRouter、GLM、Kimi、MiniMax、DeepSeek、Groq、xAI、Mistral、Perplexity、Together、Fireworks、Cerebras、Cohere、NVIDIA 等 | 每個提供者的動態 URL/標頭設定 | +| `AntigravityExecutor` | Google Antigravity | 自訂專案/工作階段 ID、Retry-After 解析、429 混淆 | +| `AzureOpenAIExecutor` | Azure OpenAI | 基於部署的路由、api-version 查詢強制 | +| `BlackboxWebExecutor` | Blackbox AI(網頁模式) | 含 TLS 指紋模擬的網頁工作階段反向 | +| `ClaudeIdentityExecutor` | Claude.ai(CCH 路徑) | 約束 + 工具重新對應管線、指紋塑造 | +| `CliProxyApiExecutor` | CLIProxyAPI 相容提供者 | 自訂驗證與協定處理 | +| `CloudflareAiExecutor` | Cloudflare Workers AI | 帳戶 ID 注入、基於 Neurons 的用量追蹤 | +| `CodexExecutor` | OpenAI Codex | 注入系統指令、強制推理努力 | +| `ChatGptWebCodexExecutor` | ChatGPT Web (Codex) | 瀏覽器工作階段 Responses API 橋接與執行緒/輪次固定 | +| `CommandCodeExecutor` | Command Code | OAuth + 每個工作階段的標頭輪換 | +| `CursorExecutor` | Cursor IDE | ConnectRPC 協定、Protobuf 編碼、透過 checksum 的請求簽署 | +| `DevinCliExecutor` | Devin CLI | 透過雲端代理模組的 Devin 任務生命週期橋接 | +| `GithubExecutor` | GitHub Copilot | Copilot 令牌刷新、模擬 VSCode 標頭 | +| `GitlabExecutor` | GitLab Duo | GitLab OAuth + 專案範圍路由 | +| `GlmExecutor` | Z.AI GLM(含 `glmt` 預設) | 思考預算感知、GLMT 預設常數 | +| `GrokWebExecutor` | xAI Grok 網頁 | 網頁工作階段反向、模式選擇(think/standard) | +| `KieExecutor` | KIE | 自訂令牌簽發,含輪換的工作階段錨點 | +| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream 二進位格式 → SSE 轉換 | +| `MuseSparkWebExecutor` | Muse Spark(網頁) | 含圖片訊息橋接的網頁工作階段反向 | +| `NlpCloudExecutor` | NLP Cloud | 提供者特定的請求主體形式 | +| `OpenCodeExecutor` | OpenCode | AI SDK 相容提供者設定 | +| `PerplexityWebExecutor` | Perplexity 網頁 | 用於聊天延續的網頁工作階段反向 | +| `PetalsExecutor` | Petals 分散式推理 | 去中心化群組路由 | +| `PollinationsExecutor` | Pollinations AI | 無需 API 金鑰、速率限制請求 | +| `QoderExecutor` | Qoder AI | PAT 與 OAuth 支援、多模型免費方案 | +| `VertexExecutor` | Google Vertex AI | 服務帳戶驗證、基於區域的端點 | +| `DevinDesktopExecutor` | Devin Desktop | 匯入的 API 金鑰 + Connect-protobuf 聊天串流 | 所有其他提供者(包括自訂相容節點)使用 `DefaultExecutor`。 ## 提供者相容性矩陣 -> **注意:** 以下矩陣為 OmniRoute v3.8.0 中 237 個已註冊提供者的代表性樣本。 +> **注意:** 以下矩陣為 OmniRoute v3.8.0 中 351 個已註冊提供者的代表性樣本。 > 完整且持續更新的清單,請參閱 > [`docs/reference/PROVIDER_REFERENCE.md`](../reference/PROVIDER_REFERENCE.md)(自動產生)或 > `src/shared/constants/providers.ts`(載入時經 Zod 驗證)中的權威來源。 -| 提供者 | 格式 | 驗證 | 串流 | 非串流 | 令牌刷新 | 用量 API | -| ------------------ | ---------------- | --------------------- | ---------------- | ------ | -------- | ---------------- | -| Claude | claude | API 金鑰 / OAuth | ✅ | ✅ | ✅ | ⚠️ 僅管理員 | -| Gemini | gemini | API 金鑰 / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ 完整配額 API | -| OpenAI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Codex | openai-responses | OAuth | ✅ 強制 | ❌ | ✅ | ✅ 速率限制 | -| GitHub Copilot | openai | OAuth + Copilot 令牌 | ✅ | ✅ | ✅ | ✅ 配額快照 | -| Cursor | cursor | 自訂 checksum | ✅ | ✅ | ❌ | ❌ | -| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ 用量限制 | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每次請求 | -| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| OpenRouter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| GLM/Kimi/MiniMax | claude | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| DeepSeek | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Groq | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| xAI(Grok) | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Mistral | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Perplexity | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Together AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Fireworks AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Cerebras | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Cohere | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| NVIDIA NIM | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Cloudflare AI | openai | API 令牌 + 帳戶 ID | ✅ | ✅ | ❌ | ❌ | -| Pollinations | openai | 無(無需金鑰) | ✅ | ✅ | ❌ | ❌ | -| Scaleway AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| LongCat | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Ollama Cloud | openai | API 金鑰(選用) | ✅ | ✅ | ❌ | ❌ | -| HuggingFace | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Nebius | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| SiliconFlow | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Hyperbolic | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Vertex AI | gemini | 服務帳戶 | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每次請求 | -| Z.AI / GLM | openai | API 金鑰 / OAuth | ✅ | ✅ | ❌ | ❌ | -| GLMT(預設) | claude | API 金鑰 | ✅ | ✅ | ❌ | ⚠️ 每次請求 | -| Kimi Coding | openai | OAuth / API 金鑰 | ✅ | ✅ | ✅ | ❌ | -| KIE | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Devin Desktop | openai | 匯入的 API 金鑰 | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ 每次請求 | -| GitLab Duo | openai | OAuth(GitLab) | ✅ | ✅ | ✅ | ❌ | -| Devin CLI | openai | 本機 CLI 登入 | ✅ | ✅ | ❌ | ✅ 任務 API | -| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ 速率限制 | -| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任務 API | -| AgentRouter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| ChatGPT-Web | openai | 工作階段 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | -| Grok-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | -| Perplexity-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | -| BlackBox-Web | openai | 工作階段 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | -| Muse-Spark-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | -| ModelScope | openai | API 金鑰 | ✅ | ✅ | ❌ | ⚠️ 配額政策 | -| BazaarLink | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Petals | openai | 無 | ✅ | ✅ | ❌ | ❌ | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每次請求 | -| OpenCode(Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| CLIProxyAPI | openai | 自訂 | ✅ | ✅ | ❌ | ❌ | +| 提供者 | 格式 | 驗證 | 串流 | 非串流 | 令牌刷新 | 用量 API | +| ------------------- | ---------------- | --------------------- | ---------------- | ------ | -------- | ---------------- | +| Claude | claude | API 金鑰 / OAuth | ✅ | ✅ | ✅ | ⚠️ 僅管理員 | +| Gemini | gemini | API 金鑰 / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ 完整配額 API | +| OpenAI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Codex | openai-responses | OAuth | ✅ 強制 | ❌ | ✅ | ✅ 速率限制 | +| ChatGPT Web (Codex) | openai-responses | 瀏覽器工作階段 | ✅ 強制 | ❌ | ❌ | ❌ | +| GitHub Copilot | openai | OAuth + Copilot 令牌 | ✅ | ✅ | ✅ | ✅ 配額快照 | +| Cursor | cursor | 自訂 checksum | ✅ | ✅ | ❌ | ❌ | +| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ 用量限制 | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每次請求 | +| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| OpenRouter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| GLM/Kimi/MiniMax | claude | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| DeepSeek | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Groq | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| xAI(Grok) | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Mistral | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Perplexity | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Together AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Fireworks AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Cerebras | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Cohere | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| NVIDIA NIM | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Cloudflare AI | openai | API 令牌 + 帳戶 ID | ✅ | ✅ | ❌ | ❌ | +| Pollinations | openai | 無(無需金鑰) | ✅ | ✅ | ❌ | ❌ | +| Scaleway AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| LongCat | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Ollama Cloud | openai | API 金鑰(選用) | ✅ | ✅ | ❌ | ❌ | +| HuggingFace | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Nebius | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| SiliconFlow | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Hyperbolic | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Vertex AI | gemini | 服務帳戶 | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每次請求 | +| Z.AI / GLM | openai | API 金鑰 / OAuth | ✅ | ✅ | ❌ | ❌ | +| GLMT(預設) | claude | API 金鑰 | ✅ | ✅ | ❌ | ⚠️ 每次請求 | +| Kimi Coding | openai | OAuth / API 金鑰 | ✅ | ✅ | ✅ | ❌ | +| KIE | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Devin Desktop | openai | 匯入的 API 金鑰 | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ 每次請求 | +| GitLab Duo | openai | OAuth(GitLab) | ✅ | ✅ | ✅ | ❌ | +| Devin CLI | openai | 本機 CLI 登入 | ✅ | ✅ | ❌ | ✅ 任務 API | +| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ 速率限制 | +| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任務 API | +| AgentRouter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Grok-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | +| Perplexity-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | +| BlackBox-Web | openai | 工作階段 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | +| Muse-Spark-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | +| ModelScope | openai | API 金鑰 | ✅ | ✅ | ❌ | ⚠️ 配額政策 | +| BazaarLink | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Petals | openai | 無 | ✅ | ✅ | ❌ | ❌ | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每次請求 | +| OpenCode(Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| CLIProxyAPI | openai | 自訂 | ✅ | ✅ | ❌ | ❌ | ## 格式轉換覆蓋範圍 diff --git a/docs/i18n/zh-TW/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/i18n/zh-TW/docs/architecture/CODEBASE_DOCUMENTATION.md index 53fa189a39..4ffbe68262 100644 --- a/docs/i18n/zh-TW/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/i18n/zh-TW/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -229,7 +229,6 @@ v1/ ├── audio/{speech, transcriptions}/ TTS + STT ├── batches/[id]/{cancel}、batches/ OpenAI Batches API ├── chat/completions/ 聊天補全(主要端點) -├── chatgpt-web/ ChatGPT-Web 相容層 ├── completions/ 舊版文字補全 ├── embeddings/ 嵌入向量 ├── files/[id]/、files/ 檔案 API @@ -476,16 +475,16 @@ open-sse/ ### 4.2 `open-sse/executors/` -84 個提供者執行器,每個都繼承 `BaseExecutor`(`base.ts`): +107 個提供者執行器,每個都繼承 `BaseExecutor`(`base.ts`): -`antigravity`、`azure-openai`、`blackbox-web`、`chatgpt-web`、`cliproxyapi`、 -`cloudflare-ai`、`codex`、`commandCode`、`cursor`、`default`、`devin-cli`、 +`antigravity`、`azure-openai`、`blackbox-web`、`cliproxyapi`、 +`chatgpt-web-codex`、`cloudflare-ai`、`codex`、`commandCode`、`cursor`、`default`、`devin-cli`、 `muse-spark-web`、`nlpcloud`、`opencode`、`perplexity-web`、`petals`、 `pollinations`、`qoder`、`vertex`、`windsurf`,加上 `claudeIdentity.ts` (共用身分識別輔助程式)和 `index.ts`(註冊表)。 > 注意:未列在此處的提供者由 `default.ts` 使用通用的 -> 與 OpenAI 相容的執行器處理。完整的 329 項提供者目錄位於 +> 與 OpenAI 相容的執行器處理。完整的 351 項提供者目錄位於 > `src/shared/constants/providers.ts`。 ### 4.3 `open-sse/translator/` diff --git a/docs/providers/CHATGPT_WEB.md b/docs/providers/CHATGPT_WEB.md index fc78731766..2256b92660 100644 --- a/docs/providers/CHATGPT_WEB.md +++ b/docs/providers/CHATGPT_WEB.md @@ -1,166 +1,61 @@ --- -title: "Providers — ChatGPT Web (session credentials via Cookie Editor)" +title: "Providers — ChatGPT Web (Codex)" version: 3.8.50 -lastUpdated: 2026-08-08 +lastUpdated: 2026-08-26 --- -# Providers — ChatGPT Web (Plus/Pro session credentials) +# Providers — ChatGPT Web (Codex) -`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**. +`chatgpt-web-codex` (alias `cgpt-codex`) bridges Codex Responses turns through an +authenticated ChatGPT browser session. It is independent from the retired common +`chatgpt-web` provider and uses the MIT-noticed implementation under +`open-sse/vendor/codex-chatgpt-web/`. -> **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. +## Common provider retirement ---- +The former common provider IDs `chatgpt-web` and `cgpt-web` no longer ship because the +provenance of their pre-key/proof-of-work implementation could not be cleared. Explicit +requests to either ID, including slash-prefixed model IDs and persisted aliases, fail +closed with HTTP `410` and code **PROVIDER_RETIRED** before any upstream request. -## 1. What credential does OmniRoute need? +Migration `163_retire_chatgpt_web.sql` tombstones matching provider connections and +invalidates their active session leases. It preserves connection history and API-key +allowlists; it does not add replacement access to an allowlist. The Codex provider and +its connections are not matched by this retirement. -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 | - ---- - -## ChatGPT Web (Codex) - -`ChatGPT Web (Codex)` is an additional provider. The existing -`ChatGPT Web (Plus/Pro)` provider described above stays unchanged for regular -chats, images, and its existing tool emulation. - -### Prerequisites +## Prerequisites - a full Cookie header from a signed-in ChatGPT session; - Chrome or Chromium for npm, systemd, and PM2 installs; -- with the Docker `web` profile, the internal Chromium service from `docker-compose.yml`; +- with the Docker `web` profile, the internal Chromium service from + `docker-compose.yml`; - an OpenAI tunnel and a ChatGPT custom connector for local Codex tools. -The tunnel is only needed for tool turns. `pro` is read-only and does not need a -local tool connector. +The tunnel is only needed for tool turns. The `pro` model is read-only and does not need +a local tool connector. -### Dashboard setup +## Dashboard setup 1. Open the **ChatGPT Web (Codex)** provider and add a connection. -2. Paste the full ChatGPT cookie, the tunnel ID, the runtime key, and the name of - the custom connector. -3. Start the check. OmniRoute opens a headless Temporary Chat and also detects +2. Paste the full ChatGPT Cookie header, tunnel ID, runtime key, and custom connector + name. +3. Run the connection check. OmniRoute opens a headless Temporary Chat and detects whether `pro` is available for the account. 4. Save the connection. OmniRoute replaces the pasted cookie with the verified - Playwright storage state and stores it together with the runtime key through - the encrypted credential abstraction. + Playwright storage state and stores it with the runtime key through the encrypted + credential abstraction. -The raw cookie is not retained after a successful save. When the session expires, -open the connection, paste a fresh full cookie, and re-run the check. The doctor -status in the edit dialog reports browser, storage state, sign-in, Temporary -Chat, tunnel, connector, and tool round-trip separately. +The raw cookie is not retained after a successful save. When the session expires, open +the connection, paste a fresh full Cookie header, and rerun the check. The doctor status +in the edit dialog reports browser, storage state, sign-in, Temporary Chat, tunnel, +connector, and tool round-trip separately. -### Models and combos +> Never commit a real cookie, runtime key, storage state, or capability token. Test and +> documentation values must always be placeholders. -The fixed models are: +## Models and combos + +The fixed model routes are: - `chatgpt-web-codex/instant` - `chatgpt-web-codex/medium` @@ -168,54 +63,71 @@ The fixed models are: - `chatgpt-web-codex/extra-high` - `chatgpt-web-codex/pro` -Add one of them to a combo like any other model. The Codex app sends only the -combo name as `model` to the regular Responses endpoint `/v1/responses`. There is -no special endpoint and no Codex-mode switch. +Add one of them to a combo like any other model. The Codex app sends the combo name as +`model` to the regular Responses endpoint, `/v1/responses`; there is no separate Codex +endpoint or mode switch. -`pro` does not run local tools. A forced tool makes that combo target -incompatible; with optional tools the turn runs read-only and reports that -limitation as commentary. +`pro` does not run local tools. A forced tool makes that combo target incompatible. With +optional tools, the turn runs read-only and reports the limitation as commentary. -### Security model +## Security model -- The native path requires a Responses request, a recognized Codex client, and - matching thread and turn identities. -- Workspace, sandbox, approval policy, and the tool catalog come from the native - Codex shell. Free-form prompt text is not an authority for them. -- ChatGPT receives only a short-lived capability per turn. The MCP broker accepts - only tools that Codex offered in exactly that turn. -- Auto-confirming "Allow once" only returns the tool request to Codex. Codex - alone decides on approval and execution. -- Before the first output, the combo may fall back to another compatible target. - After that, provider, model, connection, and browser turn stay pinned until the +- The native path requires a Responses request, a recognized Codex client, and matching + thread and turn identities. +- Workspace, sandbox, approval policy, and tool catalog come from the native Codex shell; + free-form prompt text is not authority for them. +- ChatGPT receives only a short-lived capability per turn. The MCP broker accepts only + tools Codex offered in that exact turn. +- Auto-confirming **Allow once** only returns the tool request to Codex. Codex alone + decides on approval and execution. +- Before the first output, a combo may fall back to another compatible target. After + output begins, provider, model, connection, and browser turn remain pinned until the turn completes. -- Cookies, runtime keys, storage state, and capability tokens do not appear in - provider responses or request logs. +- Cookies, runtime keys, storage state, and capability tokens do not appear in provider + responses or request logs. -### Headless VPS and Docker +## Headless VPS and Docker -For npm, systemd, and PM2 installs, OmniRoute detects common Chrome and Chromium -paths. Alternatively, set `CHATGPT_WEB_CODEX_CHROME_PATH`. +For npm, systemd, and PM2 installs, OmniRoute detects common Chrome and Chromium paths. +Alternatively, set `CHATGPT_WEB_CODEX_CHROME_PATH`. -The Docker `web` profile starts `chatgpt-web-codex-browser` on the internal -Compose network. Its CDP port is not published on the host. The protected profile -volume stays separate from the OmniRoute data volume, and the browser gets enough -shared memory. The internal CDP proxy listens only on the Compose network on port -`9223`; Chrome itself stays bound to loopback inside the sidecar. +The Docker `web` profile starts `chatgpt-web-codex-browser` on the internal Compose +network. Its CDP port is not published on the host. The protected browser profile volume +is separate from the OmniRoute data volume, and the browser receives enough shared +memory. The internal CDP proxy listens only on port `9223` inside the Compose network; +Chrome remains bound to loopback in the sidecar. -A supervisor lease under `DATA_DIR` prevents multiple OmniRoute processes from -owning the same tunnel and broker state. A conflict shows up in the doctor. +A supervisor lease under `DATA_DIR` prevents multiple OmniRoute processes from owning +the same tunnel and broker state. A conflict is reported by the doctor. -### Interactive recovery +## Interactive recovery -The normal path is fully headless. When ChatGPT demands an interactive sign-in or -challenge, the existing VNC browser infrastructure can be used as a recovery -path. Browser UI and CDP must then only be reachable over loopback, an -authenticated management connection, or an SSH tunnel; noVNC stays disabled in -normal operation. +The normal path is headless. When ChatGPT requires an interactive sign-in or challenge, +the existing VNC browser infrastructure can be used for recovery. Browser UI and CDP +must remain reachable only over loopback, an authenticated management connection, or an +SSH tunnel; noVNC stays disabled during normal operation. -### WebSocket fallback +## WebSocket fallback -When a combo contains `ChatGPT Web (Codex)`, the Responses WebSocket bridge -requests the HTTP/SSE fallback before connecting upstream. The actual transfer -then goes through `/v1/responses`. +When a combo contains ChatGPT Web (Codex), the Responses WebSocket bridge requests the +HTTP/SSE fallback before connecting upstream. The transfer then goes through +`/v1/responses`. + +## Verification + +Run the provider controls without invoking the retired provider: + +```bash +node --import tsx/esm --test \\ + tests/unit/chatgpt-web-codex.test.ts \\ + tests/unit/chatgpt-web-codex-turn-pin.test.ts \\ + tests/unit/chatgpt-web-environment-double-unescape.test.ts +``` + +Retirement regression guards live in: + +- `tests/unit/chatgpt-web-retirement.test.ts` +- `tests/unit/chatgpt-web-runtime-block.test.ts` +- `tests/unit/chatgpt-web-image-handler-retirement.test.ts` +- `tests/unit/chatgpt-web-source-retirement.test.ts` +- `tests/unit/migration-163-retire-chatgpt-web.test.ts` diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 6349719a85..f58db3ede6 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -305,14 +305,10 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth, Dashboard, sync | Public-facing URL for OAuth redirect_uri, Dashboard links, and generated public URLs. Set this to the stable public URL when OAuth callbacks or generated browser links must use a canonical reverse-proxy host. | | `NEXT_PUBLIC_CLOUD_URL` | _(empty)_ | Client-side | Client-side mirror of `CLOUD_URL`. | | `NEXT_PUBLIC_APP_URL` | _(unset)_ | `src/shared/services/cloudSyncScheduler.ts` | Legacy fallback for `NEXT_PUBLIC_BASE_URL`. | -| `OMNIROUTE_PUBLIC_BASE_URL` | _(unset)_ | Public-origin resolver, image URLs | Highest-priority browser-facing OmniRoute origin used for public URL generation and non-dashboard browser-origin validation (for example `/v1/chatgpt-web/image/`). Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL but the user's browser must fetch images from a LAN, tunnel, or public origin. Do **not** include `/v1`. | +| `OMNIROUTE_PUBLIC_BASE_URL` | _(unset)_ | Public-origin resolver, image URLs | Highest-priority browser-facing OmniRoute origin used for public URL generation and non-dashboard browser-origin validation. Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL but the user's browser must fetch generated media from a LAN, tunnel, or public origin. Do **not** include `/v1`. | | `OMNIROUTE_PROVIDER_MANIFEST_URL` | _(unset)_ | `open-sse/config/providerPluginManifestUrl.ts` | Absolute provider plugin manifest URL advertised to sidecar clients. When unset, OmniRoute derives `/api/v1/provider-plugin-manifest` from request origin or HOST/PORT. | | `OMNIROUTE_PUBLIC_PROTOCOL` | `http` | `open-sse/config/providerPluginManifestUrl.ts` | Protocol used when deriving the provider plugin manifest URL from HOST/PORT without a request origin. Set to `https` behind a TLS-terminating public proxy when no explicit `OMNIROUTE_PROVIDER_MANIFEST_URL` is set. | | `OMNIROUTE_TRUST_PROXY` | _(unset)_ | `src/server/origin/publicOrigin.ts` | Optional trust mode for forwarded public-origin headers. Unset = do not trust `Forwarded` / `X-Forwarded-*` for security decisions. `true` / `loopback` trusts forwarded host/proto only from a token-stamped loopback proxy. `private` / `lan` also trusts private-LAN proxy peers. Prefer explicit `NEXT_PUBLIC_BASE_URL` in production. | -| `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000` (3 min) | `open-sse/executors/chatgpt-web.ts` | Max wait time for an async chatgpt-web image to land via the celsius WebSocket. Increase during upstream queue-deep windows. | -| `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | Total in-memory byte budget (MB) for the chatgpt-web image cache serving `/v1/chatgpt-web/image/`. Lower on memory-constrained hosts; raise if image generation is heavy and clients race the 30-minute TTL. | -| `OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS` | `1200000` (20 min) | `open-sse/executors/chatgpt-web.ts` | Overall wait budget for a chatgpt-web GPT-5.5 Pro background-poll handoff. Pro reasoning runs complete out-of-band, so OmniRoute polls until the answer lands or this budget elapses. Raise if Pro requests time out before finishing. | -| `OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS` | `4000` (4s) | `open-sse/executors/chatgpt-web.ts` | Interval between chatgpt-web GPT-5.5 Pro background-poll attempts. Lower for snappier completion at the cost of more upstream polling; raise to reduce request volume. | | `THEOLDLLM_NAV_TIMEOUT_MS` | `30000` (30s) | `open-sse/executors/theoldllm.ts` | Playwright navigation timeout (ms) for the browser-backed token capture used by the The Old LLM (theoldllm) free provider. Raise on slow networks if the relay page is slow to settle. | | `KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public callback URL for asynchronous kie.ai jobs. Highest-priority override before `OMNIROUTE_KIE_CALLBACK_URL` and `OMNIROUTE_PUBLIC_URL`. | | `OMNIROUTE_KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Alternate spelling of `KIE_CALLBACK_URL`. Falls back when the primary variable is unset. | @@ -768,9 +764,6 @@ REQUEST_TIMEOUT_MS (global override) | `OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS` | `8000` | Timeout (ms) for the `validationRead` and `modelsProbe` presets in `src/shared/network/safeOutboundFetch.ts`. Raise for slow endpoints (Cerebras, Cloudflare AI, Groq) to prevent flapping between active/error in the dashboard. Falls back to 8000ms for invalid (<1000) or non-numeric values. | | `OMNIROUTE_RELAY_FETCH_TIMEOUT_MS` | `25000` | Relay-specific fetch timeout in `open-sse/utils/proxyFetch.ts` (#9158). A hung relay must fail before the client/agent timeout (~30s) so callers see a relay-specific failure instead of a generic upstream timeout. Capped at `29000` so it always fires first. | | `OMNIROUTE_RETRY_BACKOFF_MS` | `10` | Shared retry backoff for the direct/relay/proxy retry-once paths in `open-sse/utils/proxyFetch.ts` (#9158). `0` = retry immediately. | -| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). | -| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | -| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000` (30s) | Max wait for the first streamed byte from the ChatGPT TLS sidecar (`chatgptTlsClient.ts`) before aborting a dead stream. Raise if upstream cold-starts exceed the window. | | `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`claudeTlsClient.ts`). | | `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | | `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). | @@ -1316,7 +1309,6 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts`, `src/lib/db/migrationRunner.ts` | Maximum SQLite backup files retained on disk. Applies to manual/scheduled backups and to pre-migration snapshots. Overrides the value saved from Settings → Database backup retention. | | `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts`, `src/lib/db/migrationRunner.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. Applies to manual/scheduled backups and to pre-migration snapshots. Overrides the value saved from Settings → Database backup retention. | | `OMNIROUTE_BACKUP_SCHEDULE_JOB_INTERVAL_MS` | `30000` | `src/lib/jobs/backupScheduleJob.ts` | Tick interval (ms) of the server-side job that executes `backup-schedule.json`. Must stay well under the 1-minute cron granularity; values below `5000` or unparseable fall back to `30000`. | -| `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. | | `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` for any Podman topology. Because the container cannot determine whether the engine is local or reached through Podman Machine, the warning stays topology-neutral and points to `contrib/podman/README.md`. | | `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. | | `QUOTA_STORE_REDIS_URL` | _(unset)_ | `src/lib/quota/storeFactory.ts` | Redis connection string used when `QUOTA_STORE_DRIVER=redis` (e.g. `redis://localhost:6379`). | diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index bb1e819178..951a95737d 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -82,14 +82,13 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. | | `zed-hosted` | — | Zed Hosted Models | OAuth | [link](https://zed.dev) | Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan. | -## Web Cookie Providers (35) +## Web Cookie Providers (34) | ID | Alias | Name | Tags | Website | Notes | Tool calling | |----|-------|------|------|---------|-------|--------------| | `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) | emulated | | `adobe-firefly` | `firefly` | Adobe Firefly (Image/Video) | Web cookie | [link](https://firefly.adobe.com) | RECOMMENDED: firefly.adobe.com signed-in → F12 → Network → click firefly-3p.ff.adobe.io (generate-async or models/discovery) → Request Headers → Authorization → copy the token AFTER 'Bearer ' (starts with eyJ…). Cookie-only from firefly.adobe.com mints a GUEST token → 401/403; only multi-domain IMS cookies (adobelogin.com) or that Bearer JWT work. Unofficial/experimental media + Limits. | — | | `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai | emulated | -| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com | emulated | | `chatgpt-web-codex` | `cgpt-codex` | ChatGPT Web (Codex) | Web cookie | [link](https://chatgpt.com) | Paste the full ChatGPT Cookie header. OmniRoute verifies it in an isolated headless browser profile. | native | | `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | none | | `conol-web` | `cnl` | Conol (Unofficial/Experimental) | Web cookie | [link](https://conol.ai) | Use browser sign-in, or paste the full Cookie header from conol.ai. The __Secure-better-auth.session_token cookie is required. | — | diff --git a/docs/screenshots/free-tier-budget-card.svg b/docs/screenshots/free-tier-budget-card.svg index 42bf89d20c..7b5dddf948 100644 --- a/docs/screenshots/free-tier-budget-card.svg +++ b/docs/screenshots/free-tier-budget-card.svg @@ -1,10 +1,10 @@ - + Static dashboard preview of recurring token pools, first-month signup grants, and uncapped but rate-limited free-access providers. OmniRoute · /dashboard/free-tiers · preview mockup Monthly free-token budget -40 recurring pools · 455 catalog entries · one endpoint +40 recurring pools · 454 catalog entries · one endpoint Steady / month ~1.51B First month (+ signup credits) @@ -34,7 +34,7 @@ -Each segment = one of 20 quantified recurring pools · 40 pools / 455 entries in the audited catalog. +Each segment = one of 20 quantified recurring pools · 40 pools / 454 entries in the audited catalog. Mistral 1.00B @@ -101,5 +101,5 @@ nscale 5M Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide. -+ 14 recurring uncapped* providers (rate/concurrency-limited) · OpenRouter $10 → +24M/mo. ++ 13 recurring uncapped* providers (rate/concurrency-limited) · OpenRouter $10 → +24M/mo. diff --git a/docs/security/BAN_DETECTION.md b/docs/security/BAN_DETECTION.md index faf585267e..4672ff17ec 100644 --- a/docs/security/BAN_DETECTION.md +++ b/docs/security/BAN_DETECTION.md @@ -87,7 +87,7 @@ OAuth/subscription scrapers. The resulting terminal state is per **connection**, not per provider. That said, the built-in _strings_ are oriented toward subscription/OAuth -providers with real ban risk (ChatGPT Web, Claude Web, Codex, Muse Spark, +providers with real ban risk (ChatGPT Web Codex, Claude Web, Codex, Muse Spark, Antigravity). An API-key provider will only trip the detector if its error body literally contains one of the substrings. diff --git a/docs/security/STEALTH_GUIDE.md b/docs/security/STEALTH_GUIDE.md index 07f2b7dc6a..860a21bc52 100644 --- a/docs/security/STEALTH_GUIDE.md +++ b/docs/security/STEALTH_GUIDE.md @@ -6,7 +6,7 @@ lastUpdated: 2026-06-28 # Stealth Guide -> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{chatgptTlsClient,claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/` +> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/` > **Last updated:** 2026-06-28 — v3.8.40 > **Audience:** Engineers maintaining provider-specific stealth integrations. @@ -14,7 +14,7 @@ OmniRoute integrates with providers whose edges actively fingerprint non-officia ## Legal and Ethical Notice -Stealth features exist so OmniRoute can act as a compatibility layer between user-owned official accounts (Claude Code CLI, ChatGPT Desktop/Web, Antigravity, Cursor, etc.) and OmniRoute's unified API. They are **not** for evading fraud detection, sharing credentials, or violating provider Terms of Service. The maintainers expect operators to comply with the upstream ToS they signed when creating accounts. +Stealth features exist so OmniRoute can act as a compatibility layer between user-owned official accounts (Claude Code CLI, Codex, Antigravity, Cursor, etc.) and OmniRoute's unified API. They are **not** for evading fraud detection, sharing credentials, or violating provider Terms of Service. The maintainers expect operators to comply with the upstream ToS they signed when creating accounts. --- @@ -29,20 +29,6 @@ Lazy-loaded `wreq-js` session that impersonates **Chrome 124 on macOS**. Used as - Timeout: `TLS_CLIENT_TIMEOUT_MS` (inherits from `FETCH_TIMEOUT_MS`, default 600000) - `wreq-js` Response is fetch-compatible (`headers`, `text()`, `json()`, `clone()`, `body`). -### `open-sse/services/chatgptTlsClient.ts` — tls-client-node (Firefox 148) - -Dedicated TLS impersonator for `chatgpt.com`. ChatGPT's Cloudflare config pins `cf_clearance` to JA3/JA4 + HTTP/2 SETTINGS frame ordering — undici's handshake gets `cf-mitigated: challenge` even with valid cookies. - -- Profile: `firefox_148` (must match the Firefox 148 `User-Agent` sent) -- Mode: `runtimeMode: "native"` (koffi-loaded shared library; avoids managed sidecar HTTP) -- `withRandomTLSExtensionOrder: true` -- `tlsFetchChatGpt(url, options)` supports streaming (writes body to temp file, tailed as `ReadableStream`) -- Hang detection: `raceWithTimeout` + `TlsClientHangError` triggers `resetClientCache()` so the next call respawns the binding -- Proxy resolution (priority): per-call `proxyUrl` → `OMNIROUTE_TLS_PROXY_URL` → `HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY` (the native binding does **not** read these envs itself; it must be threaded through) -- Errors: `TlsClientUnavailableError` (binary missing), `TlsClientHangError` (binding deadlocked) - ---- - ## Claude Code Stealth Bundle When `cliCompatMode` is on, OmniRoute reshapes outgoing Claude requests so they are indistinguishable from `claude-cli` traffic. Three modules collaborate: @@ -253,17 +239,17 @@ OmniRoute scrubs inbound client headers before forwarding so a request that arri 2. Extract JA3/JA4 and the literal header order 3. Update the relevant `CLI_FINGERPRINTS[...]` entry 4. Bump matching `*_USER_AGENT` default in `.env.example` -5. If TLS handshake itself changed: update `chatgptTlsClient.ts::CHATGPT_PROFILE` or wreq-js `browser:` option -6. Run `chatgptTlsClient.test.ts` and a manual canary against the live provider +5. If the TLS handshake itself changed, update the relevant provider wrapper or the wreq-js `browser:` option +6. Run the provider-specific TLS tests and a manual canary against the live provider 7. Ship in a patch release; document in `CHANGELOG.md` --- ## Tests -- `open-sse/services/__tests__/chatgptTlsClient.test.ts` — proxy resolution priority, abort handling, hang recovery +- `open-sse/services/__tests__/claudeTlsClient.test.ts` — shared TLS wrapper behavior - `tests/unit/anthropic-cache-fingerprint.test.ts` — fingerprint determinism -- `tests/unit/chatgpt-web.test.ts` — end-to-end stealth path for ChatGPT +- `tests/unit/chatgpt-web-source-retirement.test.ts` — common ChatGPT Web stealth source remains absent while Codex Web stays present --- diff --git a/electron/loginManager.js b/electron/loginManager.js index 910eda2614..c90fd2eb13 100644 --- a/electron/loginManager.js +++ b/electron/loginManager.js @@ -48,7 +48,7 @@ class LoginManager extends EventEmitter { /** * Start a login flow for a web-cookie provider. - * @param {string} providerId - e.g. "claude-web", "chatgpt-web" + * @param {string} providerId - e.g. "claude-web", "perplexity-web" * @param {object} [options] * @param {number} [options.timeout] - Total timeout in ms (default: config or 300s) * @returns {Promise<{success: boolean, credentials?: Record, error?: string}>} diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 2a0bd5b4c0..394e7b5028 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -19,8 +19,6 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts"; export const FREE_CATALOG_CURATED_AT = "2026-08-26"; export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ - { provider: "chatgpt-web", modelId: "gpt-5.6-luna-free", displayName: "GPT-5.6 Luna (Free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" }, - { provider: "chatgpt-web", modelId: "gpt-5.6-luna-free-thinking", displayName: "GPT-5.6 Luna (Free, Think)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" }, { provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agentrouter", modelId: "claude-opus-5", displayName: "Claude Opus 5", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agentrouter", modelId: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 02019dc4a0..52f8339c79 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -237,17 +237,6 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], }, - "chatgpt-web": { - id: "chatgpt-web", - alias: "cgpt-web", - baseUrl: "https://chatgpt.com/backend-api/f/conversation", - authType: "apikey", - authHeader: "cookie", - format: "chatgpt-web", - models: [{ id: "gpt-5.5", name: "GPT-5.5 Instant (ChatGPT Web Image)" }], - supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], - }, - // #10466: Gemini Web session image generation (Nano Banana). Same // web-cookie transport as the gemini-web chat provider — the handler // drives the session executor in image mode and extracts the generated @@ -925,7 +914,10 @@ export function parseImageModel(modelStr) { // No provider prefix — try to find the model in every provider, excluding cookie-auth (web) bridges for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) { - if (config.authHeader !== "cookie" && (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr))) { + if ( + config.authHeader !== "cookie" && + (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr)) + ) { return { provider: providerId, model: modelStr }; } } diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index c54ea48c60..7de0d5c9bd 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -123,7 +123,6 @@ import { blackboxProvider } from "./registry/blackbox/index.ts"; 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"; @@ -393,7 +392,6 @@ export const REGISTRY: Record = { "blackbox-web": blackbox_webProvider, uncloseai: uncloseaiProvider, nscale: nscaleProvider, - "chatgpt-web": chatgpt_webProvider, "chatgpt-web-codex": chatgpt_web_codexProvider, openrouter: openrouterProvider, cheaperinference: cheaperinferenceProvider, diff --git a/open-sse/config/providers/registry/chatgpt-web/index.ts b/open-sse/config/providers/registry/chatgpt-web/index.ts deleted file mode 100644 index 16c1522e2b..0000000000 --- a/open-sse/config/providers/registry/chatgpt-web/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { RegistryEntry } from "../../shared.ts"; - -export const chatgpt_webProvider: RegistryEntry = { - id: "chatgpt-web", - alias: "cgpt-web", - format: "openai", - executor: "chatgpt-web", - baseUrl: "https://chatgpt.com/backend-api/conversation", - authType: "apikey", - authHeader: "cookie", - models: [ - { - id: "gpt-5.6-sol-pro", - name: "GPT-5.6 Sol (Pro)", - liveCatalogIds: ["gpt-5-6-pro"], - toolCalling: false, - }, - { - id: "gpt-5.6-sol-xhigh", - name: "GPT-5.6 Sol (Xhigh)", - liveCatalogIds: ["gpt-5-6-thinking"], - toolCalling: false, - }, - { - id: "gpt-5.6-sol-high", - name: "GPT-5.6 Sol (High)", - liveCatalogIds: ["gpt-5-6-thinking"], - toolCalling: false, - }, - { - id: "gpt-5.6-sol-medium", - name: "GPT-5.6 Sol (Medium)", - liveCatalogIds: ["gpt-5-6-thinking"], - toolCalling: false, - }, - { - id: "gpt-5.6-sol-instant", - name: "GPT-5.6 Sol (Instant)", - liveCatalogIds: ["gpt-5-6"], - toolCalling: false, - }, - { - id: "gpt-5.6-luna-free-thinking", - name: "GPT-5.6 Luna (Free, Think)", - liveCatalogIds: ["gpt-5-6"], - toolCalling: false, - }, - { - id: "gpt-5.6-luna-free", - name: "GPT-5.6 Luna (Free)", - liveCatalogIds: ["gpt-5-6"], - toolCalling: false, - }, - { - id: "gpt-5.5-pro-extended", - name: "GPT-5.5 (Pro Extended)", - liveCatalogIds: ["gpt-5-5-pro"], - toolCalling: false, - }, - { - id: "gpt-5.5-pro", - name: "GPT-5.5 (Pro)", - liveCatalogIds: ["gpt-5-5-pro"], - toolCalling: false, - }, - { - id: "gpt-5.5-xhigh", - name: "GPT-5.5 (Xhigh)", - liveCatalogIds: ["gpt-5-5-thinking"], - toolCalling: false, - }, - { - id: "gpt-5.5-high", - name: "GPT-5.5 (High)", - liveCatalogIds: ["gpt-5-5-thinking"], - toolCalling: false, - }, - { - id: "gpt-5.5-medium", - name: "GPT-5.5 (Medium)", - liveCatalogIds: ["gpt-5-5-thinking"], - toolCalling: false, - }, - { - id: "gpt-5.5-instant", - name: "GPT-5.5 (Instant)", - liveCatalogIds: ["gpt-5-5"], - toolCalling: false, - }, - ], -}; diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts deleted file mode 100644 index 438565b45c..0000000000 --- a/open-sse/executors/chatgpt-web.ts +++ /dev/null @@ -1,3142 +0,0 @@ -/** - * ChatGptWebExecutor — ChatGPT Web Session Provider - * - * Routes requests through chatgpt.com's internal SSE API using a Plus/Pro - * subscription session cookie, translating between OpenAI chat completions - * format and ChatGPT's internal protocol. - * - * Auth pipeline (per request): - * 1. exchangeSession() GET /api/auth/session cookie → JWT accessToken (cached ~5min) - * 2. prepareChatRequirements() POST /backend-api/sentinel/chat-requirements - * → { proofofwork.seed, difficulty, persona } - * 3. solveProofOfWork() SHA3-512 hash loop → "gAAAAAB…" sentinel proof token - * 4. fetch /backend-api/conversation with Bearer + sentinel-proof-token + browser UA - * - * Response is the standard ChatGPT SSE format (cumulative `parts[0]` strings, not deltas). - */ - -import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; -import { describeChatGptWebHttpError } from "./chatgptWebErrors.ts"; -import { prepareToolMessages } from "../translator/webTools.ts"; -import { buildToolModeResponse } from "./chatgptWebTools.ts"; -import { createHash, randomUUID, randomBytes } from "node:crypto"; -import { sha3_512Hex } from "../utils/sha3-512.ts"; -import { - tlsFetchChatGpt, - TlsClientUnavailableError, - type TlsFetchResult, -} from "../services/chatgptTlsClient.ts"; -import { - storeChatGptImage, - getChatGptImageConversationContext, - __resetChatGptImageCacheForTesting, - type ChatGptImageConversationContext, -} from "../services/chatgptImageCache.ts"; -import { - resolveChatGptModel, - resolveChatGptSystemHints, - type ChatGptThinkingEffort, -} from "./chatgpt-web/models.ts"; -import { cleanChatGptText } from "./chatgpt-web/citations.ts"; -import { resumeChatGptHandoff, type FinalAssistantAnswer } from "./chatgpt-web/handoff.ts"; - -// ─── Constants ────────────────────────────────────────────────────────────── - -const CHATGPT_BASE = "https://chatgpt.com"; -const SESSION_URL = `${CHATGPT_BASE}/api/auth/session`; -const SENTINEL_PREPARE_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements/prepare`; -const SENTINEL_CR_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements`; -const CONV_URL = `${CHATGPT_BASE}/backend-api/f/conversation`; -const DEFAULT_PRO_POLL_TIMEOUT_MS = 20 * 60_000; -const DEFAULT_PRO_POLL_INTERVAL_MS = 4_000; - -const CHATGPT_USER_AGENT = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0"; - -// Captured from a real chatgpt.com browser session (April 2026). -const OAI_CLIENT_VERSION = "prod-81e0c5cdf6140e8c5db714d613337f4aeab94029"; -const OAI_CLIENT_BUILD_NUMBER = "6128297"; - -// Per-cookie device ID. The browser stores a persistent `oai-did` cookie that -// uniquely identifies the device for OpenAI's risk model — we derive a stable -// UUID from a hash of the session cookie so that each account/connection gets -// its own device id, but it doesn't change between requests. -const deviceIdCache = new Map(); -function deviceIdFor(cookie: string): string { - const key = cookieKey(cookie); - let id = deviceIdCache.get(key); - if (!id) { - // Synthesize a UUID v4-shaped string from a SHA-256 of the cookie. Stable, - // deterministic per cookie, no PII (the cookie's already secret). - // Not a password hash — SHA-256 is used to derive a stable UUID from the - // session cookie for device-id fingerprinting. The output is a cache key. - const h = createHash("sha256").update(cookie).digest("hex"); // lgtm[js/insufficient-password-hash] - id = - `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-` + - `${((parseInt(h.slice(16, 17), 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}-` + - h.slice(20, 32); - if (deviceIdCache.size >= 200) { - const first = deviceIdCache.keys().next().value; - if (first) deviceIdCache.delete(first); - } - deviceIdCache.set(key, id); - } - return id; -} - -// OmniRoute model IDs select a GPT-5.6 Sol performance lane. Captured browser -// requests use one of `gpt-5-6`, `gpt-5-6-thinking`, or `gpt-5-6-pro`. - -// ─── Browser-like default headers ────────────────────────────────────────── - -function browserHeaders(): Record { - return { - Accept: "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Cache-Control": "no-cache", - Origin: CHATGPT_BASE, - Pragma: "no-cache", - Referer: `${CHATGPT_BASE}/`, - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin", - "User-Agent": CHATGPT_USER_AGENT, - }; -} - -/** Headers ChatGPT's web client sends on backend-api requests. */ -function oaiHeaders(sessionId: string, deviceId: string): Record { - return { - "OAI-Language": "en-US", - "OAI-Device-Id": deviceId, - "OAI-Client-Version": OAI_CLIENT_VERSION, - "OAI-Client-Build-Number": OAI_CLIENT_BUILD_NUMBER, - "OAI-Session-Id": sessionId, - }; -} - -// ─── Session token cache ──────────────────────────────────────────────────── - -interface TokenEntry { - accessToken: string; - accountId: string | null; - expiresAt: number; - refreshedCookie?: string; -} - -const TOKEN_TTL_MS = 5 * 60 * 1000; // 5min — accessTokens are short-lived -const tokenCache = new Map(); - -function cookieKey(cookie: string): string { - // SHA-256 prefix (64 bits). Used as the Map key for tokenCache and - // warmupCache; the previous 32-bit FNV-1a was small enough that a - // birthday-paradox collision could surface one user's cached accessToken - // to another's request. 64 bits is overkill for the 200-entry cache but - // costs essentially nothing. - // Not a password hash — SHA-256 is used to derive a short, collision-resistant - // cache key from the session cookie. The output is a map lookup key. - return createHash("sha256").update(cookie).digest("hex").slice(0, 16); // lgtm[js/insufficient-password-hash] -} - -function tokenLookup(cookie: string): TokenEntry | null { - const entry = tokenCache.get(cookieKey(cookie)); - if (!entry) return null; - if (Date.now() >= entry.expiresAt) { - tokenCache.delete(cookieKey(cookie)); - return null; - } - return entry; -} - -const TOKEN_CACHE_MAX = 200; - -function tokenStore(cookie: string, entry: TokenEntry): void { - // Bound the cache to TOKEN_CACHE_MAX entries (FIFO). Same shape as the - // image cache and warmup cache — drop the oldest before inserting. - if (tokenCache.size >= TOKEN_CACHE_MAX && !tokenCache.has(cookieKey(cookie))) { - const firstKey = tokenCache.keys().next().value; - if (firstKey) tokenCache.delete(firstKey); - } - tokenCache.set(cookieKey(cookie), entry); -} - -// Conversation continuity is intentionally not cached. Open WebUI and most -// OpenAI-API-style clients re-send the full history each turn, so each -// request just starts a fresh conversation. Temporary Chat mode is the -// default; it gets disabled per-request only for image-gen prompts, since -// that mode rejects the image_gen tool. - -// ─── /api/auth/session — exchange cookie for JWT ──────────────────────────── - -interface SessionResponse { - accessToken?: string; - expires?: string; - user?: { id?: string }; -} - -// Session-token family — NextAuth uses one of these depending on token size: -// __Secure-next-auth.session-token (unchunked, < 4KB) -// __Secure-next-auth.session-token.0 (chunked, first piece) -// __Secure-next-auth.session-token.N (chunked, additional pieces) -// Rotation can change the shape (unchunked → chunked or vice versa). When -// that happens, every old family member must be dropped — keeping the stale -// variant alongside the new one would send both, and depending on parser -// precedence the server could read the stale value and fail auth. -const SESSION_TOKEN_FAMILY_RE = /^__Secure-next-auth\.session-token(?:\.\d+)?$/; - -/** - * Merge any rotated session-token chunks from a Set-Cookie response into the - * original cookie blob, preserving every other cookie the caller pasted - * (cf_clearance, __cf_bm, _cfuvid, _puid, ...). Returns null if no rotation - * occurred or the rotated chunks match what's already there. - * - * Returning only the matched session-token chunks here was a bug: when the - * caller pastes a full DevTools Cookie line (the recommended form), the - * Cloudflare cookies are required for subsequent requests, and dropping - * them re-triggers `cf-mitigated: challenge`. - */ -function mergeRefreshedCookie( - originalCookie: string, - setCookieHeader: string | null -): string | null { - if (!setCookieHeader) return null; - const matches = Array.from( - setCookieHeader.matchAll(/(__Secure-next-auth\.session-token(?:\.\d+)?)=([^;,\s]+)/g) - ); - if (matches.length === 0) return null; - - const refreshed = new Map(); - for (const m of matches) refreshed.set(m[1], m[2]); - - let blob = originalCookie.trim(); - if (/^cookie\s*:\s*/i.test(blob)) blob = blob.replace(/^cookie\s*:\s*/i, ""); - - // Bare value (no `=`): the original was just the session-token contents. - // Replace with the new chunked form. - if (!/=/.test(blob)) { - return Array.from(refreshed, ([k, v]) => `${k}=${v}`).join("; "); - } - - const pairs = blob.split(/;\s*/).filter(Boolean); - const result: string[] = []; - let mutated = false; - let droppedStale = false; - for (const pair of pairs) { - const eqIdx = pair.indexOf("="); - if (eqIdx < 0) { - result.push(pair); - continue; - } - const name = pair.slice(0, eqIdx).trim(); - const value = pair.slice(eqIdx + 1); - // Drop ALL session-token-family members from the original — we'll - // append the refreshed set below. This handles unchunked→chunked and - // chunked→unchunked rotations, where keeping the old name would leave - // the stale token visible alongside the new one. - if (SESSION_TOKEN_FAMILY_RE.test(name)) { - if (!refreshed.has(name) || refreshed.get(name) !== value) mutated = true; - droppedStale = true; - continue; - } - result.push(`${name}=${value}`); - } - // Append the full refreshed family. - for (const [name, value] of refreshed) { - result.push(`${name}=${value}`); - } - if (!droppedStale) mutated = true; // refreshed chunks were entirely new - return mutated ? result.join("; ") : null; -} - -/** - * Build the Cookie header value from whatever the user pasted. - * - * Accepts: - * - A bare value: "eyJhbGc..." → prepended with __Secure-next-auth.session-token= - * - An unchunked cookie line: "__Secure-next-auth.session-token=eyJ..." - * - A chunked cookie line: "__Secure-next-auth.session-token.0=...; __Secure-next-auth.session-token.1=..." - * - The full DevTools cookie header: "Cookie: __Secure-next-auth.session-token.0=...; cf_clearance=..." - * - * If the user pastes a chunked token, we pass the cookies through verbatim — - * NextAuth's server reassembles them on its side. - */ -function buildSessionCookieHeader(rawInput: string): string { - let s = rawInput.trim(); - if (/^cookie\s*:\s*/i.test(s)) s = s.replace(/^cookie\s*:\s*/i, ""); - if (/__Secure-next-auth\.session-token(?:\.\d+)?\s*=/.test(s)) { - return s; - } - return `__Secure-next-auth.session-token=${s}`; -} - -async function exchangeSession( - cookie: string, - signal: AbortSignal | null | undefined -): Promise { - const cached = tokenLookup(cookie); - if (cached) return cached; - - const headers: Record = { - ...browserHeaders(), - Accept: "application/json", - Cookie: buildSessionCookieHeader(cookie), - }; - - const response = await tlsFetchChatGpt(SESSION_URL, { - method: "GET", - headers, - timeoutMs: 30_000, - signal, - }); - - if (response.status === 401 || response.status === 403) { - throw new SessionAuthError("Invalid session cookie"); - } - if (response.status >= 400) { - throw new Error(`Session exchange failed (HTTP ${response.status})`); - } - - const refreshed = mergeRefreshedCookie(cookie, response.headers.get("set-cookie")); - let data: SessionResponse = {}; - try { - data = JSON.parse(response.text || "{}"); - } catch { - console.warn("[chatgpt-web] session response JSON parse failed"); - /* empty body or non-JSON */ - } - if (!data.accessToken) { - throw new SessionAuthError("Session response missing accessToken — cookie likely expired"); - } - - const expiresAt = data.expires ? new Date(data.expires).getTime() : Date.now() + TOKEN_TTL_MS; - const entry: TokenEntry = { - accessToken: data.accessToken, - accountId: data.user?.id ?? null, - expiresAt: Math.min(expiresAt, Date.now() + TOKEN_TTL_MS), - refreshedCookie: refreshed ?? undefined, - }; - tokenStore(cookie, entry); - return entry; -} - -class SessionAuthError extends Error { - constructor(message: string) { - super(message); - this.name = "SessionAuthError"; - } -} - -// ─── /backend-api/sentinel/chat-requirements ──────────────────────────────── - -interface ChatRequirements { - /** Returned by /chat-requirements (the "real" chat requirements token). */ - token?: string; - /** Returned by /chat-requirements/prepare (sent as a prerequisite header). */ - prepare_token?: string; - persona?: string; - proofofwork?: { - required?: boolean; - seed?: string; - difficulty?: string; - }; - turnstile?: { - required?: boolean; - dx?: string; - }; -} - -// ─── Session warmup ──────────────────────────────────────────────────────── -// Mimics chatgpt.com's page-load fetch sequence so Sentinel sees a "warm" -// browsing session. Cached per (cookie, access-token) pair for 60s to avoid -// hammering the warmup endpoints on every chat completion. - -const warmupCache = new Map(); -const WARMUP_TTL_MS = 60_000; -const WARMUP_CACHE_MAX = 200; - -async function runSessionWarmup( - accessToken: string, - accountId: string | null, - sessionId: string, - deviceId: string, - cookie: string, - signal: AbortSignal | null | undefined, - log: { debug?: (tag: string, msg: string) => void } | null | undefined -): Promise { - const key = cookieKey(cookie) + ":" + accessToken.slice(-8); - const now = Date.now(); - const last = warmupCache.get(key); - if (last && now - last < WARMUP_TTL_MS) return; - // Bound the cache: drop the oldest entry once we hit the cap. Map iteration - // order is insertion order, so the first key is the oldest. - if (warmupCache.size >= WARMUP_CACHE_MAX && !warmupCache.has(key)) { - const first = warmupCache.keys().next().value; - if (first) warmupCache.delete(first); - } - warmupCache.set(key, now); - - const headers: Record = { - ...browserHeaders(), - ...oaiHeaders(sessionId, deviceId), - Accept: "*/*", - Authorization: `Bearer ${accessToken}`, - Cookie: buildSessionCookieHeader(cookie), - Priority: "u=1, i", - }; - if (accountId) headers["chatgpt-account-id"] = accountId; - - const urls = [ - `${CHATGPT_BASE}/backend-api/me`, - `${CHATGPT_BASE}/backend-api/conversations?offset=0&limit=28&order=updated`, - `${CHATGPT_BASE}/backend-api/models?history_and_training_disabled=false`, - ]; - - for (const url of urls) { - try { - const r = await tlsFetchChatGpt(url, { - method: "GET", - headers, - timeoutMs: 15_000, - signal, - }); - log?.debug?.("CGPT-WEB", `warmup ${url.split("/backend-api/")[1]} → ${r.status}`); - } catch (err) { - log?.debug?.( - "CGPT-WEB", - `warmup ${url} failed: ${err instanceof Error ? err.message : String(err)}` - ); - } - } -} - -function configuredProPollTimeoutMs(): number { - const raw = Number(process.env.OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS); - if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_PRO_POLL_TIMEOUT_MS; - return Math.floor(raw); -} - -function configuredProPollIntervalMs(): number { - const raw = Number(process.env.OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS); - if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_PRO_POLL_INTERVAL_MS; - return Math.floor(raw); -} - -async function prepareChatRequirements( - accessToken: string, - accountId: string | null, - sessionId: string, - deviceId: string, - cookie: string, - dplInfo: { dpl: string; scriptSrc: string }, - signal: AbortSignal | null | undefined, - log?: { warn?: (tag: string, msg: string) => void } | null -): Promise { - const config = buildPrekeyConfig(CHATGPT_USER_AGENT, dplInfo.dpl, dplInfo.scriptSrc); - const prekey = await buildPrepareToken(config, log); - - const headers: Record = { - ...browserHeaders(), - ...oaiHeaders(sessionId, deviceId), - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - Cookie: buildSessionCookieHeader(cookie), - Priority: "u=1, i", - }; - if (accountId) headers["chatgpt-account-id"] = accountId; - - // Stage 1: POST /chat-requirements/prepare → { prepare_token, ... } - const prepResp = await tlsFetchChatGpt(SENTINEL_PREPARE_URL, { - method: "POST", - headers, - body: JSON.stringify({ p: prekey }), - timeoutMs: 30_000, - signal, - }); - if (prepResp.status === 401 || prepResp.status === 403) { - throw new SentinelBlockedError(`Sentinel /prepare blocked (HTTP ${prepResp.status})`); - } - if (prepResp.status >= 400) { - throw new Error(`Sentinel /prepare failed (HTTP ${prepResp.status})`); - } - let prepData: ChatRequirements = {}; - try { - prepData = JSON.parse(prepResp.text || "{}") as ChatRequirements; - } catch { - console.warn("[chatgpt-web] chat requirements prep JSON parse failed"); - /* keep empty */ - } - // Stage 2: POST /chat-requirements with the prepare_token in the body. This - // is the call that actually returns the chat-requirements-token used on the - // conversation request. - if (!prepData.prepare_token) { - return prepData; // pass through whatever we got — caller handles missing fields - } - - const crBody: Record = { p: prekey, prepare_token: prepData.prepare_token }; - const crResp = await tlsFetchChatGpt(SENTINEL_CR_URL, { - method: "POST", - headers, - body: JSON.stringify(crBody), - timeoutMs: 30_000, - signal, - }); - if (crResp.status === 401 || crResp.status === 403) { - throw new SentinelBlockedError(`Sentinel /chat-requirements blocked (HTTP ${crResp.status})`); - } - if (crResp.status >= 400) { - // Fall back to whatever /prepare returned — some accounts may not need stage 2. - return prepData; - } - try { - const crData = JSON.parse(crResp.text || "{}") as ChatRequirements; - // Merge: prepare_token from stage 1, everything else from stage 2. - return { ...crData, prepare_token: prepData.prepare_token }; - } catch { - console.warn("[chatgpt-web] chat requirements response JSON parse failed"); - return prepData; - } -} - -class SentinelBlockedError extends Error { - constructor(message: string) { - super(message); - this.name = "SentinelBlockedError"; - } -} - -// ─── Proof-of-work solver ────────────────────────────────────────────────── -// Mimics the openai-sentinel / chat2api algorithm. The browser sends a base64-encoded -// JSON config string; the server combines it with a seed and expects a SHA3-512 hash -// whose hex-prefix is ≤ the difficulty target. -// -// Reference: github.com/leetanshaj/openai-sentinel, github.com/lanqian528/chat2api -// Returns "gAAAAAB" + base64 of the winning config (server-recognised prefix). - -// ─── DPL / script-src cache (warmup) ──────────────────────────────────────── -// Sentinel's prekey check inspects whether config[5]/config[6] reference a real -// chatgpt.com deployment (DPL hash + a script URL from the HTML). We GET / once -// per hour to scrape these — same trick chat2api uses. - -interface DplInfo { - dpl: string; - scriptSrc: string; - expiresAt: number; -} -let dplCache: DplInfo | null = null; -const DPL_TTL_MS = 60 * 60 * 1000; - -async function fetchDpl( - cookie: string, - signal: AbortSignal | null | undefined -): Promise<{ dpl: string; scriptSrc: string }> { - if (dplCache && Date.now() < dplCache.expiresAt) { - return { dpl: dplCache.dpl, scriptSrc: dplCache.scriptSrc }; - } - const headers: Record = { - ...browserHeaders(), - Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - Cookie: buildSessionCookieHeader(cookie), - }; - const response = await tlsFetchChatGpt(`${CHATGPT_BASE}/`, { - method: "GET", - headers, - timeoutMs: 20_000, - signal, - }); - const html = response.text || ""; - const dplMatch = html.match(/data-build="([^"]+)"/); - const dpl = dplMatch ? `dpl=${dplMatch[1]}` : `dpl=${OAI_CLIENT_VERSION.replace(/^prod-/, "")}`; - const scriptMatch = html.match(/]+src="(https?:\/\/[^"]*\.js[^"]*)"/); - const scriptSrc = - scriptMatch?.[1] ?? `${CHATGPT_BASE}/_next/static/chunks/webpack-${randomHex(16)}.js`; - dplCache = { dpl, scriptSrc, expiresAt: Date.now() + DPL_TTL_MS }; - return { dpl, scriptSrc }; -} - -function randomHex(n: number): string { - return randomBytes(Math.ceil(n / 2)) - .toString("hex") - .slice(0, n); -} - -// ─── Browser fingerprint key lists (used in prekey config[10..12]) ───────── -// Chosen to look like real navigator/document/window inspection. The unicode -// MINUS SIGN (U+2212) in the navigator strings matches what `Object.toString()` -// produces in real browsers — Sentinel checks for it. - -const NAVIGATOR_KEYS = [ - "webdriver−false", - "geolocation", - "languages", - "language", - "platform", - "userAgent", - "vendor", - "hardwareConcurrency", - "deviceMemory", - "permissions", - "plugins", - "mediaDevices", -]; - -const DOCUMENT_KEYS = [ - "_reactListeningkfj3eavmks", - "_reactListeningo743lnnpvdg", - "location", - "scrollingElement", - "documentElement", -]; - -const WINDOW_KEYS = [ - "webpackChunk_N_E", - "__NEXT_DATA__", - "chrome", - "history", - "screen", - "navigation", - "scrollX", - "scrollY", -]; - -function pick(arr: readonly T[]): T { - return arr[Math.floor(Math.random() * arr.length)]; -} - -function buildPrekeyConfig(userAgent: string, dpl: string, scriptSrc: string): unknown[] { - const screenSizes = [3000, 4000, 3120, 4160] as const; - const cores = [8, 16, 24, 32] as const; - const dateStr = new Date().toString(); - const perfNow = performance.now(); - const epochOffset = Date.now() - perfNow; - - return [ - pick(screenSizes), - dateStr, - 4294705152, - 0, // mutated by solver - userAgent, - scriptSrc, - dpl, - "en-US", - "en-US,en", - 0, // mutated by solver - pick(NAVIGATOR_KEYS), - pick(DOCUMENT_KEYS), - pick(WINDOW_KEYS), - perfNow, - randomUUID(), - "", - pick(cores), - epochOffset, - ]; -} - -/** - * Build the `p` (prekey) value sent in the chat-requirements POST body. - * - * Format: "" + base64(JSON(config)), with a PoW solver loop mutating - * config[3] to find a hash whose hex prefix is ≤ the target difficulty. - * Mirrors chat2api / openai-sentinel. - * - prepare: prefix="gAAAAAC", seed="" (target "0fffff") - * - chat-requirements: prefix="gAAAAAB", seed= (target=difficulty) - * - * Submitting an unsolved token still works on low-friction accounts, so we - * fall back to that after exhausting the iteration budget — but emit a warn - * log so production can see when it happens. - */ -// PoW solvers run up to 100k–500k SHA3-512 hashes. To avoid blocking the -// Node event loop on a busy server, we yield with `setImmediate` every -// POW_YIELD_EVERY iterations — roughly every ~5ms of work — so concurrent -// requests and I/O still get scheduled. Wall time is approximately the same -// as the synchronous version; what changes is fairness, not throughput. -const POW_YIELD_EVERY = 1000; - -function yieldToEventLoop(): Promise { - return new Promise((resolve) => setImmediate(resolve)); -} - -interface PowOptions { - config: unknown[]; - seed: string; - target: string; - prefix: string; - maxIter: number; - label: string; - log?: { warn?: (tag: string, msg: string) => void } | null; -} - -async function solvePow(opts: PowOptions): Promise { - const cfg = [...opts.config]; - for (let i = 0; i < opts.maxIter; i++) { - if (i > 0 && i % POW_YIELD_EVERY === 0) await yieldToEventLoop(); - cfg[3] = i; - const json = JSON.stringify(cfg); - const b64 = Buffer.from(json).toString("base64"); - // Portable SHA3-512 — pure-JS fallback under Electron/BoringSSL (#5531). - const hash = sha3_512Hex(opts.seed + b64); - if (opts.target && hash.slice(0, opts.target.length) <= opts.target) { - return `${opts.prefix}${b64}`; - } - } - opts.log?.warn?.( - "CGPT-WEB", - `PoW (${opts.label}) exhausted ${opts.maxIter} iterations against target=${opts.target || ""}; submitting unsolved token (Sentinel may reject)` - ); - const b64 = Buffer.from(JSON.stringify(cfg)).toString("base64"); - return `${opts.prefix}${b64}`; -} - -async function buildPrepareToken( - config: unknown[], - log?: { warn?: (tag: string, msg: string) => void } | null -): Promise { - return solvePow({ - config, - seed: "", - target: "0fffff", - prefix: "gAAAAAC", - maxIter: 100_000, - label: "prepare", - log, - }); -} - -async function solveProofOfWork( - seed: string, - difficulty: string, - config: unknown[], - log?: { warn?: (tag: string, msg: string) => void } | null -): Promise { - return solvePow({ - config, - seed, - target: (difficulty || "").toLowerCase(), - prefix: "gAAAAAB", - maxIter: 500_000, - label: "conversation", - log, - }); -} - -// ─── OpenAI → ChatGPT message translation ─────────────────────────────────── - -interface ParsedMessages { - systemMsg: string; - history: Array<{ role: string; content: string }>; - currentMsg: string; - latestImageContext: ChatGptImageConversationContext | null; -} - -/** - * Strip embedded `data:image/...` URIs out of message content so prior - * generated images don't get fed back into chatgpt.com on the next turn. - * - * Why: when image generation succeeds we emit `![image](data:image/png;base64,...)` - * — frequently 2–4 MB. Chat clients (Open WebUI, OpenAI-style apps) replay - * the full conversation history on the next request, so without this strip - * we'd send megabytes of base64 back upstream. chatgpt.com responds with an - * empty body when that happens (verified: 502 "ChatGPT returned empty - * response body" on the very next turn after an image gen succeeds), and - * even if it didn't, a single inlined image is well past the model's context - * limit. Replacing with a short placeholder keeps semantic continuity - * without the bytes. - */ -const DATA_URI_IMAGE_RE = /!\[([^\]]*)\]\(data:image\/[^)]+\)/g; -const CACHED_IMAGE_URL_RE = /\/v1\/chatgpt-web\/image\/([a-f0-9]{16,64})(?=[)\s"'<>]|$)/gi; - -function stripInlinedImages(content: string): string { - return content.replace(DATA_URI_IMAGE_RE, (_, alt) => - alt ? `[${alt}: generated image]` : "[generated image]" - ); -} - -function findCachedImageContext(content: string): ChatGptImageConversationContext | null { - let latest: ChatGptImageConversationContext | null = null; - // String.prototype.matchAll consumes a fresh iterator and ignores the - // regex's lastIndex, so no manual reset is required. - for (const match of content.matchAll(CACHED_IMAGE_URL_RE)) { - const id = match[1]; - const context = getChatGptImageConversationContext(id); - if (context) latest = context; - } - return latest; -} - -function parseOpenAIMessages(messages: Array>): ParsedMessages { - let systemMsg = ""; - const history: Array<{ role: string; content: string }> = []; - let latestImageContext: ChatGptImageConversationContext | null = null; - - for (const msg of messages) { - let role = String(msg.role || "user"); - if (role === "developer") role = "system"; - - let content = ""; - if (typeof msg.content === "string") { - content = msg.content; - } else if (Array.isArray(msg.content)) { - content = (msg.content as Array>) - .filter((c) => c.type === "text") - .map((c) => String(c.text || "")) - .join(" "); - } - content = stripInlinedImages(content); - const imageContext = findCachedImageContext(content); - if (imageContext) latestImageContext = imageContext; - if (!content.trim()) continue; - - if (role === "system") { - systemMsg += (systemMsg ? "\n" : "") + content; - } else if (role === "user" || role === "assistant") { - history.push({ role, content }); - } - } - - let currentMsg = ""; - if (history.length > 0 && history[history.length - 1].role === "user") { - currentMsg = history.pop()!.content; - } - - return { systemMsg, history, currentMsg, latestImageContext }; -} - -interface ChatGptMessage { - id: string; - author: { role: string }; - content: { content_type: "text"; parts: string[] }; - metadata?: Record; -} - -/** - * Cheap heuristic: does the last user turn look like an image-generation - * request? Used to decide whether to disable Temporary Chat mode. - * - * Why a heuristic instead of always disabling Temporary Chat: when - * `history_and_training_disabled: false`, every conversation gets saved to - * the user's chatgpt.com history. For text-only chats that's noise — a - * dozen "OmniRoute" entries clutter the sidebar and can interact with - * ChatGPT's memory. We pay that cost only when the user actually wants an - * image, since Temporary Chat refuses image_gen with the message - * "I cannot generate images in this chat". - * - * False positives (text chat misclassified as image) → unnecessary history - * entry. False negatives (image request misclassified as text) → ChatGPT - * refuses image_gen and the user retries. Tuning leans toward false - * positives (we'd rather pollute history than refuse image generation). - */ -const IMAGE_GEN_REGEXES: RegExp[] = [ - // verb + (anything within 40 chars) + image-noun - /\b(?:generate|create|make|draw|paint|render|produce|design|sketch|illustrate|show me)\b[\s\S]{0,40}\b(?:image|picture|photo|photograph|drawing|illustration|sketch|painting|portrait|logo|icon|art|artwork|wallpaper|render|graphic)\b/i, - // image-noun + "of" — "image of a kitten", "picture of mountains" - /\b(?:image|picture|photo|photograph|illustration|drawing|painting|render)\s+of\b/i, - // direct verb + a/an article — "draw a kitten", "paint an apple" - /\b(?:draw|paint|sketch|render|illustrate)\s+(?:me\s+)?(?:a|an|some|the)\s+\w+/i, - // explicit slash command users sometimes type — "/imagine ..." - /^\s*\/(?:image|imagine|img|draw|paint)\b/im, -]; - -/** - * Markers Open WebUI uses for its background tool prompts (follow-up - * suggestions, title generation, tag categorization). These prompts embed - * the prior conversation in `` blocks and frequently quote - * the user's earlier "generate an image of..." request — which would - * trip the image-gen regex below. Skip them so we don't unnecessarily - * disable Temporary Chat and trigger image_gen on background tasks. - * - * Catching just one of these markers is enough; tool prompts always - * include several together. - */ -const OPENWEBUI_TOOL_PROMPT_MARKERS = [ - //i, - /^### Task:/im, - /\bJSON format:\s*\{/i, - /\bfollow_?ups\b.*\barray of strings\b/i, -]; - -const OPENWEBUI_IMAGE_CONTEXT_MARKERS = [ - /\s*The requested image has been (?:created|edited and created) by the system successfully/i, - /\s*The requested image has been edited and created and is now being shown to the user/i, - /\s*Image generation was attempted but failed/i, -]; - -function hasOpenWebUIImageContext(parsed: ParsedMessages): boolean { - return OPENWEBUI_IMAGE_CONTEXT_MARKERS.some((re) => re.test(parsed.systemMsg)); -} - -function looksLikeImageGenRequest(parsed: ParsedMessages): boolean { - // Inspect only the latest user turn — historical turns are irrelevant - // (and could trigger false positives if the user mentioned an image - // generated previously). - const text = parsed.currentMsg.trim(); - if (!text) return false; - if (OPENWEBUI_TOOL_PROMPT_MARKERS.some((re) => re.test(text))) return false; - if (hasOpenWebUIImageContext(parsed)) return false; - return IMAGE_GEN_REGEXES.some((re) => re.test(text)); -} - -const IMAGE_EDIT_REGEXES: RegExp[] = [ - /\b(?:edit|adjust|modify|change|update|alter|revise|retouch|fix)\b[\s\S]{0,120}\b(?:it|image|picture|photo|lighting|background|style|color|colour|composition|scene|time of day)\b/i, - /\b(?:make|turn|set|switch)\s+(?:it|the\s+(?:image|picture|photo|scene))\b[\s\S]{0,120}\b/i, - /\b(?:add|remove|replace)\b[\s\S]{0,120}\b(?:it|image|picture|photo|background|sky|person|object|text|logo)\b/i, - /\b(?:brighter|darker|night|daytime|time of day|sunset|sunrise|morning|evening|lighting|relight|background|style)\b/i, - /^\s*(?:now|then|also)\b[\s\S]{0,120}\b(?:make|turn|change|adjust|add|remove|replace|edit)\b/i, -]; - -function looksLikeImageEditRequest(parsed: ParsedMessages): boolean { - if (!parsed.latestImageContext) return false; - const text = parsed.currentMsg.trim(); - if (!text) return false; - if (OPENWEBUI_TOOL_PROMPT_MARKERS.some((re) => re.test(text))) return false; - if (hasOpenWebUIImageContext(parsed)) return false; - return IMAGE_EDIT_REGEXES.some((re) => re.test(text)); -} - -function buildConversationBody( - parsed: ParsedMessages, - modelSlug: string, - parentMessageId: string, - options: { - // Keep text/API calls in Temporary Chat so they do not clutter the user's - // chatgpt.com history. Disable Temporary Chat only when ChatGPT needs a - // durable image conversation (image generation/editing). - persistConversation: boolean; - thinkingEffort: ChatGptThinkingEffort | null; - systemHints: readonly string[]; - continuation?: ChatGptImageConversationContext | null; - } -): Record { - // Critical: do NOT send prior turns as separate `assistant` and `user` - // messages in the `messages` array. ChatGPT's web API ("action: next") - // treats those as in-progress turns and the model will literally CONTINUE - // a prior assistant response in the new generation — observed as - // `[1] -> [12] -> [1123]` across three turns. - // - // Instead, fold all prior history into the system message and send only - // the current user message as a single new turn. The model then sees a - // single prompt with full context and responds fresh. - const systemParts: string[] = []; - if (parsed.systemMsg.trim()) { - systemParts.push(parsed.systemMsg.trim()); - } - const continuation = options.continuation ?? null; - - if (!continuation && parsed.history.length > 0) { - const formatted = parsed.history - .map((h) => `${h.role === "assistant" ? "Assistant" : "User"}: ${h.content}`) - .join("\n\n"); - systemParts.push( - `Prior conversation (for context — answer only the new user message below):\n\n${formatted}` - ); - } - - const messages: ChatGptMessage[] = []; - if (systemParts.length > 0) { - messages.push({ - id: randomUUID(), - author: { role: "system" }, - content: { content_type: "text", parts: [systemParts.join("\n\n")] }, - }); - } - - const systemHints = options.systemHints; - - const currentUserContent = hasOpenWebUIImageContext(parsed) - ? "Briefly acknowledge the image result described in the system context. Do not generate, edit, or request another image." - : parsed.currentMsg || ""; - - messages.push({ - id: randomUUID(), - author: { role: "user" }, - content: { content_type: "text", parts: [currentUserContent] }, - ...(systemHints.length > 0 ? { metadata: { system_hints: [...systemHints] } } : {}), - }); - - return { - action: "next", - messages, - model: modelSlug, - // Text-only API-style requests start fresh because clients replay full - // history. Generated-image edits are the exception: ChatGPT needs the - // original conversation node to adjust the actual image, not just a - // markdown URL echoed back in a synthetic history block. - conversation_id: continuation?.conversationId ?? null, - parent_message_id: continuation?.parentMessageId ?? parentMessageId, - timezone_offset_min: -new Date().getTimezoneOffset(), - // Temporary Chat is the default. Disable it only for image generation / - // image edits, where ChatGPT needs durable conversation state for tools. - history_and_training_disabled: !options.persistConversation, - suggestions: [], - websocket_request_id: randomUUID(), - conversation_mode: { kind: "primary_assistant" }, - supports_buffering: true, - force_parallel_switch: "auto", - paragen_cot_summary_display_override: "allow", - ...(systemHints.length > 0 ? { system_hints: [...systemHints] } : {}), - ...(options.thinkingEffort ? { thinking_effort: options.thinkingEffort } : {}), - }; -} - -// ─── ChatGPT SSE parsing ──────────────────────────────────────────────────── - -interface ChatGptStreamEvent { - message?: { - id?: string; - author?: { role?: string }; - content?: { content_type?: string; parts?: unknown[] }; - status?: string; - metadata?: Record; - }; - conversation_id?: string; - error?: string | { message?: string; code?: string }; - type?: string; - token?: string; - v?: unknown; -} - -/** - * A part inside `content.parts` for a `multimodal_text` content_type. - * ChatGPT puts image references in a part with content_type "image_asset_pointer" - * and an asset_pointer like "file-service://file-XXXX" (final) or - * "sediment://..." (in-progress preview). - */ -interface ImageAssetPart { - content_type?: string; - asset_pointer?: string; - width?: number; - height?: number; - metadata?: Record; -} - -async function* readChatGptSseEvents( - body: ReadableStream, - signal?: AbortSignal | null -): AsyncGenerator { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let dataLines: string[] = []; - let eventName: string | null = null; - - function flush(): ChatGptStreamEvent | null | "done" { - if (dataLines.length === 0) { - eventName = null; - return null; - } - const payload = dataLines.join("\n"); - dataLines = []; - const sseEventName = eventName; - eventName = null; - const trimmed = payload.trim(); - if (!trimmed || trimmed === "[DONE]") return "done"; - try { - const parsed = JSON.parse(trimmed) as ChatGptStreamEvent; - if (sseEventName && !parsed.type) parsed.type = sseEventName; - return parsed; - } catch { - console.warn("[chatgpt-web] stream event JSON parse failed"); - return null; - } - } - - try { - while (true) { - if (signal?.aborted) return; - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - while (true) { - const idx = buffer.indexOf("\n"); - if (idx < 0) break; - const rawLine = buffer.slice(0, idx); - buffer = buffer.slice(idx + 1); - const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; - - if (line === "") { - const parsed = flush(); - if (parsed === "done") return; - if (parsed) yield parsed; - continue; - } - if (line.startsWith("event:")) { - eventName = line.slice(6).trim(); - } else if (line.startsWith("data:")) { - dataLines.push(line.slice(5).trimStart()); - } - } - } - - buffer += decoder.decode(); - if (buffer.trim().startsWith("data:")) { - dataLines.push(buffer.trim().slice(5).trimStart()); - } - const tail = flush(); - if (tail && tail !== "done") yield tail; - } finally { - reader.releaseLock(); - } -} - -// ─── Content extraction ───────────────────────────────────────────────────── -// ChatGPT SSE chunks contain CUMULATIVE content (full text so far in `parts[0]`), -// not deltas. Diff against the emitted length to produce incremental tokens — -// same pattern perplexity-web.ts uses for markdown blocks (lines 386-397). - -interface ContentChunk { - delta?: string; - answer?: string; - conversationId?: string; - messageId?: string; - metadata?: Record; - error?: string; - done?: boolean; - /** Image asset pointers seen on the current message (e.g. file-service://file-abc). */ - imagePointers?: ImagePointerRef[]; - /** - * True if the assistant invoked the async image_gen tool (we saw a task id - * in metadata or `turn_use_case: "image gen"` in server_ste_metadata). - * Set on the final `done: true` chunk so the caller can decide to poll the - * conversation endpoint for the actual image. - */ - imageGenAsync?: boolean; - /** True when ChatGPT handed the turn off to a long-running worker. */ - handoff?: boolean; - /** Short-lived conduit token used to resume a Temporary Chat handoff. */ - resumeToken?: string; -} - -interface ImagePointerRef { - pointer: string; - messageId?: string; -} - -/** - * Pull image asset pointers out of a multimodal_text parts array. - * - * For text-only messages parts is `["text..."]` and this returns `[]`. For - * `image_gen` tool output, parts looks like: - * [ - * { content_type: "image_asset_pointer", - * asset_pointer: "file-service://file-abc..." or "sediment://..." } - * ] - * We collect every asset_pointer seen so the caller can resolve them once - * the stream terminates. - */ -function extractImagePointers(parts: unknown[]): string[] { - const out: string[] = []; - for (const p of parts) { - if (!p || typeof p !== "object") continue; - const obj = p as ImageAssetPart; - if (obj.content_type === "image_asset_pointer" && typeof obj.asset_pointer === "string") { - out.push(obj.asset_pointer); - } - } - return out; -} - -async function* extractContent( - eventStream: ReadableStream, - signal?: AbortSignal | null -): AsyncGenerator { - // ChatGPT may echo prior assistant turns at the start of the stream with - // status: "finished_successfully" and full content, before sending the new - // generation. If we emit those bytes downstream, streaming consumers see - // the previous answer prepended to the new one (visible in Open WebUI as - // run-on output across turns). Strategy: only emit deltas after we've seen - // status === "in_progress" for the current message id (i.e., it's being - // generated live in this stream). Echoes always arrive already finished - // and never transition through in_progress, so they get suppressed. An - // end-of-stream fallback handles the rare case where a real turn arrives - // as a single already-finished event (instant/cached responses). - let conversationId: string | null = null; - let currentId: string | null = null; - let currentParts = ""; - let currentMetadata: Record | undefined; - let emittedLen = 0; - let isLive = false; - // Dedupe pointers across echoes / repeated events. Order-preserving Set. - const imagePointers = new Map(); - // True if we observed signals the assistant kicked off the async image_gen - // tool (see ContentChunk.imageGenAsync). The actual image arrives later via - // WebSocket / polling — caller handles that. - let imageGenAsync = false; - let handoff = false; - let resumeToken: string | null = null; - - for await (const event of readChatGptSseEvents(eventStream, signal)) { - if (event.error) { - const msg = - typeof event.error === "string" - ? event.error - : event.error.message || "ChatGPT stream error"; - yield { error: msg, done: true }; - return; - } - - if (event.conversation_id) conversationId = event.conversation_id; - - if (event.type === "resume_conversation_token") { - if (typeof event.token === "string" && event.token) resumeToken = event.token; - continue; - } - - if (event.type === "stream_handoff") { - handoff = true; - yield { - conversationId: conversationId ?? undefined, - handoff: true, - resumeToken: resumeToken ?? undefined, - }; - continue; - } - - // Detect image_gen on top-level "server_ste_metadata" events. These don't - // have a `message` field so the post-message guard would skip them, but - // they're the most reliable signal — `turn_use_case: "image gen"`. - // - // Originally we also accepted `meta.tool_invoked === true`, but ChatGPT - // sets that flag for ANY internal tool the assistant uses (reasoning - // chains, web search, calc, file_search, etc.). That made plain text - // turns spuriously emit the "Generating image…" placeholder + 30s - // WebSocket wait. Image gen has a more specific signal we can rely on: - // either `turn_use_case === "image gen"` here, or an `image_gen_task_id` - // on a tool-role message (handled below). - if (event.type === "server_ste_metadata") { - const meta = (event as Record).metadata as - Record | undefined; - if (meta && meta.turn_use_case === "image gen") { - imageGenAsync = true; - } - } - - const m = event.message; - if (!m) continue; - - // Tool messages with `image_gen_task_id` in metadata (the "Processing - // image..." card) confirm the async image_gen flow. We don't surface the - // tool message itself as text — it's just a placeholder — but we mark - // imageGenAsync so the executor knows to poll for the final image. - if (m.metadata && typeof m.metadata.image_gen_task_id === "string") { - imageGenAsync = true; - } - - if (m.author?.role !== "assistant") continue; - - const id = m.id ?? null; - const status = m.status ?? ""; - - if (id && id !== currentId) { - currentId = id; - currentParts = ""; - currentMetadata = undefined; - emittedLen = 0; - isLive = false; - } - - if (m.metadata && typeof m.metadata === "object") { - currentMetadata = m.metadata; - } - - if (status === "in_progress") { - isLive = true; - } - - const parts = m.content?.parts ?? []; - if (parts.length === 0) continue; - - // Image asset pointers: only collect once the message is finalized - // (status === "finished_successfully"). The same pointer may also appear - // on echoed prior turns at the head of the stream; that's fine — the Set - // dedupes, and the resolver in the executor produces the same URL either - // way. We could restrict to isLive-only to avoid resolving echoes, but - // that makes single-event instant responses (no in_progress phase) lose - // their image. Letting echoes through is harmless for correctness; the - // executor resolves each unique pointer at most once. - if (status === "finished_successfully" || status === "" || isLive) { - for (const ptr of extractImagePointers(parts)) { - const existing = imagePointers.get(ptr); - imagePointers.set( - ptr, - existing?.messageId ? existing : { pointer: ptr, ...(id ? { messageId: id } : {}) } - ); - } - } - - const cumulative = parts.map((p) => (typeof p === "string" ? p : "")).join(""); - if (cumulative.length > currentParts.length) { - currentParts = cumulative; - } - - if (isLive && currentParts.length > emittedLen) { - const delta = currentParts.slice(emittedLen); - emittedLen = currentParts.length; - yield { - delta, - answer: currentParts, - conversationId: conversationId ?? undefined, - messageId: currentId ?? undefined, - metadata: currentMetadata, - }; - } - } - - // End-of-stream fallback: if we never observed status === "in_progress" - // for the current id (single-event reply, cached/instant response), emit - // the accumulated content now so the consumer doesn't get an empty stream. - if (!isLive && currentParts.length > emittedLen) { - yield { - delta: currentParts.slice(emittedLen), - answer: currentParts, - conversationId: conversationId ?? undefined, - messageId: currentId ?? undefined, - metadata: currentMetadata, - }; - } - - yield { - delta: "", - answer: currentParts, - conversationId: conversationId ?? undefined, - messageId: currentId ?? undefined, - metadata: currentMetadata, - imagePointers: imagePointers.size > 0 ? Array.from(imagePointers.values()) : undefined, - imageGenAsync, - handoff, - resumeToken: resumeToken ?? undefined, - done: true, - }; -} - -// ─── Long-running Pro handoff polling ────────────────────────────────────── - -interface ChatGptDetailMessage { - id?: string; - author?: { role?: string }; - content?: { - content_type?: string; - parts?: unknown[]; - text?: string; - }; - status?: string; - end_turn?: boolean; - create_time?: number; - update_time?: number; - metadata?: Record; -} - -interface ChatGptConversationDetail { - mapping?: Record; -} - -function textFromContentPart(part: unknown): string { - if (typeof part === "string") return part; - if (!part || typeof part !== "object") return ""; - const obj = part as Record; - for (const key of ["text", "content", "summary"]) { - const value = obj[key]; - if (typeof value === "string") return value; - } - return ""; -} - -function detailMessageText(message: ChatGptDetailMessage): string { - const content = message.content; - if (!content) return ""; - if (typeof content.text === "string") return content.text; - const parts = content.parts ?? []; - return parts.map(textFromContentPart).join(""); -} - -function extractFinalAssistantAnswer( - detail: ChatGptConversationDetail -): FinalAssistantAnswer | null { - const nodes = Object.values(detail.mapping ?? {}); - let best: (FinalAssistantAnswer & { sort: number }) | null = null; - - for (const node of nodes) { - const message = node.message; - if (!message || message.author?.role !== "assistant") continue; - if (message.metadata?.is_visually_hidden === true) continue; - const contentType = message.content?.content_type ?? ""; - if (contentType.includes("thought") || contentType.includes("reasoning")) continue; - - const text = detailMessageText(message).trim(); - if (!text) continue; - const finished = message.status === "finished_successfully" && message.end_turn !== false; - const sort = message.update_time ?? message.create_time ?? 0; - if ( - !best || - (finished && (!best.finished || sort >= best.sort)) || - (!finished && !best.finished && sort >= best.sort) - ) { - best = { text, messageId: message.id, metadata: message.metadata, finished, sort }; - } - } - - if (!best) return null; - return { - text: best.text, - messageId: best.messageId, - metadata: best.metadata, - finished: best.finished, - }; -} - -function delayWithAbort(ms: number, signal?: AbortSignal | null): Promise { - if (ms <= 0) return Promise.resolve(); - if (signal?.aborted) return Promise.resolve(); - return new Promise((resolve) => { - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - resolve(); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} - -function decodeUtf8DataUrl(text: string): string { - const marker = ";base64,"; - if (!text.startsWith("data:") || !text.includes(marker)) return text; - const base64 = text.slice(text.indexOf(marker) + marker.length); - return new TextDecoder().decode(Buffer.from(base64, "base64")); -} - -interface ConversationDetailFetchResult { - detail: ChatGptConversationDetail | null; - terminal: boolean; -} - -async function fetchConversationDetail( - conversationId: string, - ctx: ResolverContext -): Promise { - const url = `${CHATGPT_BASE}/backend-api/conversation/${encodeURIComponent(conversationId)}`; - const headers: Record = { - ...browserHeaders(), - ...oaiHeaders(ctx.sessionId, ctx.deviceId), - Accept: "application/json", - Authorization: `Bearer ${ctx.accessToken}`, - Cookie: buildSessionCookieHeader(ctx.cookie), - }; - if (ctx.accountId) headers["chatgpt-account-id"] = ctx.accountId; - - try { - const response = await tlsFetchChatGpt(url, { - method: "GET", - headers, - timeoutMs: 30_000, - signal: ctx.signal, - // The native tls-client text path can surface UTF-8 JSON as mojibake - // (e.g. 👉 becomes 👉). Ask for raw bytes and decode as UTF-8 here so - // the final answer appended after Pro stream_handoff preserves Unicode. - byteResponse: true, - }); - if (response.status >= 400) { - ctx.log?.warn?.( - "CGPT-WEB", - `conversation poll ${response.status}: ${(response.text || "").slice(0, 300)}` - ); - return { detail: null, terminal: [401, 403, 404].includes(response.status) }; - } - if (!response.text) return { detail: null, terminal: false }; - return { - detail: JSON.parse(decodeUtf8DataUrl(response.text)) as ChatGptConversationDetail, - terminal: false, - }; - } catch (err) { - ctx.log?.warn?.( - "CGPT-WEB", - `conversation poll failed: ${err instanceof Error ? err.message : String(err)}` - ); - return { detail: null, terminal: false }; - } -} - -async function pollForFinalAssistantAnswer( - conversationId: string, - ctx: ResolverContext -): Promise { - const started = Date.now(); - const timeoutMs = configuredProPollTimeoutMs(); - const intervalMs = configuredProPollIntervalMs(); - let last: FinalAssistantAnswer | null = null; - let terminalPollFailure = false; - - while (!ctx.signal?.aborted && Date.now() - started < timeoutMs) { - const { detail, terminal } = await fetchConversationDetail(conversationId, ctx); - if (detail) { - const answer = extractFinalAssistantAnswer(detail); - if (answer) { - last = answer; - if (answer.finished) return answer; - } - } - if (terminal) { - terminalPollFailure = true; - break; - } - const remaining = timeoutMs - (Date.now() - started); - if (remaining <= 0) break; - await delayWithAbort(Math.min(intervalMs, remaining), ctx.signal); - } - - if (last) { - ctx.log?.warn?.( - "CGPT-WEB", - terminalPollFailure - ? `conversation poll stopped before finished_successfully; returning latest assistant text for ${conversationId}` - : `conversation poll timed out before finished_successfully; returning latest assistant text for ${conversationId}` - ); - } else { - ctx.log?.warn?.( - "CGPT-WEB", - terminalPollFailure - ? `conversation poll stopped without assistant text for ${conversationId}` - : `conversation poll timed out without assistant text for ${conversationId}` - ); - } - return last; -} - -// ─── OpenAI SSE format ────────────────────────────────────────────────────── - -function sseChunk(data: unknown): string { - return `data: ${JSON.stringify(data)}\n\n`; -} - -/** - * Resolves a ChatGPT asset_pointer to a downloadable URL, given the live - * conversation_id (needed for sediment:// pointers). Returns null on failure - * so the caller can decide whether to surface a placeholder or skip silently. - */ -type ImageResolver = ( - assetPointer: string, - conversationId: string | null, - parentMessageId?: string | null -) => Promise; - -/** - * True when ChatGPT emitted an image asset pointer (the image WAS generated - * upstream) but none of the pointers could be resolved to a downloadable URL - * — so the assistant text carries no image markdown. Lets callers surface an - * accurate "generated but not retrievable" error instead of the misleading - * "no image was produced". Escalated mesh report: image visible in the ChatGPT - * chat but returned to OmniRoute as a bare "completed without image markdown". - */ -export function detectImageResolutionFailure(pointerCount: number, resolvedCount: number): boolean { - return pointerCount > 0 && resolvedCount === 0; -} - -/** Build the final markdown block for a list of resolved image URLs. */ -function imageMarkdown(urls: string[]): string { - if (urls.length === 0) return ""; - // Two leading newlines → ensure separation from any prior text the model - // produced ("Here is your kitten:\n\n![image](...)"). One image per line. - return "\n\n" + urls.map((u) => `![image](${u})`).join("\n\n"); -} - -async function resolveImagePointers( - pointers: ImagePointerRef[] | undefined, - conversationId: string | null, - resolver: ImageResolver | null, - log?: { warn?: (tag: string, msg: string) => void } | null, - fallbackParentMessageId?: string | null -): Promise { - if (!pointers || pointers.length === 0 || !resolver) return []; - const urls: string[] = []; - for (const ref of pointers) { - try { - const url = await resolver( - ref.pointer, - conversationId, - ref.messageId ?? fallbackParentMessageId - ); - if (url) urls.push(url); - } catch (err) { - log?.warn?.( - "CGPT-WEB", - `Image resolve failed (${ref.pointer}): ${err instanceof Error ? err.message : String(err)}` - ); - } - } - return urls; -} - -function buildStreamingResponse( - eventStream: ReadableStream, - model: string, - cid: string, - created: number, - resolver: ImageResolver | null, - // Optional poller for async image_gen — when ChatGPT processes the request - // out-of-band ("Lots of people are creating images right now"), the SSE - // stream finishes without an image_asset_pointer. The executor passes a - // closure here that knows how to poll the conversation endpoint. - pollAsyncImage: ((conversationId: string) => Promise) | null, - // Native Temporary Chat handoff continuation. ChatGPT provides a short-lived - // conduit token, which resumes the turn without saving it to chat history. - resumeFinalAnswer: - ((conversationId: string, resumeToken: string) => Promise) | null, - // Legacy fallback for handoffs that omit the conduit token. - pollFinalAnswer: ((conversationId: string) => Promise) | null, - log: { warn?: (tag: string, msg: string) => void } | null, - signal?: AbortSignal | null -): ReadableStream { - const encoder = new TextEncoder(); - - return new ReadableStream( - { - async start(controller) { - try { - controller.enqueue( - encoder.encode( - sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [ - { index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }, - ], - }) - ) - ); - - let conversationId: string | null = null; - let imagePointers: ImagePointerRef[] | undefined; - let imageGenAsync = false; - let handoff = false; - let resumeToken: string | null = null; - let emittedText = ""; - let polledFinalAnswer: FinalAssistantAnswer | null = null; - let parentCandidateMessageId: string | null = null; - - const emitRenderedDelta = (content: string): void => { - if (!content) return; - emittedText += content; - controller.enqueue( - encoder.encode( - sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [ - { - index: 0, - delta: { content }, - finish_reason: null, - logprobs: null, - }, - ], - }) - ) - ); - }; - - const emitRenderedAnswer = ( - rawText: string, - metadata?: Record - ): void => { - const rendered = cleanChatGptText(rawText, metadata); - if (!rendered || rendered.length <= emittedText.length) return; - if (!rendered.startsWith(emittedText)) { - // We cannot retract bytes already streamed. This should be rare; - // it mainly protects clients if ChatGPT rewrites earlier text. - const common = commonPrefixLength(rendered, emittedText); - if (common < emittedText.length) return; - } - emitRenderedDelta(rendered.slice(emittedText.length)); - }; - - const appendFinalAnswer = (text: string, metadata?: Record): void => { - const cleaned = cleanChatGptText(text, metadata); - const finalTrimmed = cleaned.trim(); - if (!finalTrimmed) return; - const emittedTrimmed = emittedText.trim(); - if (emittedTrimmed === finalTrimmed || emittedTrimmed.endsWith(finalTrimmed)) return; - const prefix = emittedTrimmed && !emittedText.endsWith("\n") ? "\n\n" : ""; - emitRenderedDelta(`${prefix}${cleaned}`); - }; - - // Heartbeat: long async work (Pro polling, WebSocket image-gen, - // 2-3 MB image fetch) leaves the SSE quiet and Open WebUI times out - // at ~30s (`disconnect: ResponseAborted`). SSE comments and empty - // `delta:{}` chunks are both filtered upstream - // (`hasValuableContent` in open-sse/utils/streamHelpers.ts), so - // heartbeats are zero-width-space content deltas (`"​"`): they pass - // the filter and render invisibly. - const startHeartbeat = (intervalMs = 5_000): (() => void) => { - const heartbeatChunk = sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [{ index: 0, delta: { content: "​" }, finish_reason: null, logprobs: null }], - }); - const timer = setInterval(() => { - try { - controller.enqueue(encoder.encode(heartbeatChunk)); - } catch { - // Controller may already be closed if the client disconnected - // — just stop firing. - console.warn("[chatgpt-web] heartbeat enqueue failed - controller closed"); - clearInterval(timer); - } - }, intervalMs); - return () => clearInterval(timer); - }; - - for await (const chunk of extractContent(eventStream, signal)) { - if (chunk.conversationId) conversationId = chunk.conversationId; - if (chunk.messageId) parentCandidateMessageId = chunk.messageId; - if (chunk.handoff) handoff = true; - if (chunk.resumeToken) resumeToken = chunk.resumeToken; - if (chunk.error) { - controller.enqueue( - encoder.encode( - sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [ - { - index: 0, - delta: { content: `[Error: ${chunk.error}]` }, - finish_reason: null, - logprobs: null, - }, - ], - }) - ) - ); - break; - } - - if (chunk.done) { - imagePointers = chunk.imagePointers; - imageGenAsync = chunk.imageGenAsync ?? false; - handoff = handoff || (chunk.handoff ?? false); - if (chunk.resumeToken) resumeToken = chunk.resumeToken; - if (chunk.messageId) parentCandidateMessageId = chunk.messageId; - break; - } - - if (chunk.answer) { - emitRenderedAnswer(chunk.answer, chunk.metadata); - } - } - - if (resumeFinalAnswer && conversationId && handoff && resumeToken) { - const stopHb = startHeartbeat(); - try { - const resumed = await resumeFinalAnswer(conversationId, resumeToken); - if (resumed?.text) { - polledFinalAnswer = resumed; - if (resumed.messageId) parentCandidateMessageId = resumed.messageId; - } - } finally { - stopHb(); - } - } - - if (!polledFinalAnswer && pollFinalAnswer && conversationId && handoff) { - const stopHb = startHeartbeat(); - try { - const polled = await pollFinalAnswer(conversationId); - if (polled?.text) { - polledFinalAnswer = polled; - if (polled.messageId) parentCandidateMessageId = polled.messageId; - } - } finally { - stopHb(); - } - } - - if (polledFinalAnswer) { - appendFinalAnswer(polledFinalAnswer.text, polledFinalAnswer.metadata); - } - - // Async image_gen ends the SSE with a "Processing image..." - // placeholder; poll the conversation endpoint in the background for - // the final pointer (only when in-stream pointers are empty). - if ( - imageGenAsync && - conversationId && - (!imagePointers || imagePointers.length === 0) && - pollAsyncImage - ) { - // Tell the user something is happening — long polls otherwise - // look like a hang on the client side. The "..." plus a typing - // cue renders nicely in Open WebUI. - controller.enqueue( - encoder.encode( - sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [ - { - index: 0, - delta: { content: "_Generating image…_\n\n" }, - finish_reason: null, - logprobs: null, - }, - ], - }) - ) - ); - const stopHb = startHeartbeat(); - try { - const polled = await pollAsyncImage(conversationId); - if (polled.length > 0) imagePointers = polled; - } catch (err) { - log?.warn?.( - "CGPT-WEB", - `Async image poll failed: ${err instanceof Error ? err.message : String(err)}` - ); - } finally { - stopHb(); - } - } - - // Resolve and append any image markdown after the text deltas finish - // streaming. Downloading and caching the image bytes can take 1-3 - // seconds for big images, so keep the heartbeat running here too. - const stopHb2 = startHeartbeat(); - let urls: string[] = []; - try { - urls = await resolveImagePointers( - imagePointers, - conversationId, - resolver, - log, - parentCandidateMessageId - ); - } finally { - stopHb2(); - } - // Bail out cleanly if the client disconnected during the wait — - // any further enqueue throws "Invalid state: Controller is - // already closed". Better to no-op than to surface that as a - // server error. - if (signal?.aborted) return; - const mdBlock = imageMarkdown(urls); - const safeEnqueue = (bytes: Uint8Array): boolean => { - try { - controller.enqueue(bytes); - return true; - } catch { - console.warn("[chatgpt-web] controller enqueue failed"); - return false; - } - }; - // The image markdown is now a small URL (we cache the bytes in - // memory and serve them at /v1/chatgpt-web/image/), so a - // single SSE chunk is fine — no aiohttp LineTooLong concerns - // and the markdown renderer in Open WebUI sees the URL whole - // and renders an `` immediately. - if (mdBlock) { - if ( - !safeEnqueue( - encoder.encode( - sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [ - { - index: 0, - delta: { content: mdBlock }, - finish_reason: null, - logprobs: null, - }, - ], - }) - ) - ) - ) - return; - } - - if ( - !safeEnqueue( - encoder.encode( - sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], - }) - ) - ) - ) - return; - safeEnqueue(encoder.encode("data: [DONE]\n\n")); - } catch (err) { - controller.enqueue( - encoder.encode( - sseChunk({ - id: cid, - object: "chat.completion.chunk", - created, - model, - system_fingerprint: null, - choices: [ - { - index: 0, - delta: { - content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`, - }, - finish_reason: "stop", - logprobs: null, - }, - ], - }) - ) - ); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - } finally { - try { - controller.close(); - } catch {} - } - }, - }, - { highWaterMark: 16384 } - ); -} - -async function buildNonStreamingResponse( - eventStream: ReadableStream, - model: string, - cid: string, - created: number, - currentMsg: string, - resolver: ImageResolver | null, - pollAsyncImage: ((conversationId: string) => Promise) | null, - resumeFinalAnswer: - ((conversationId: string, resumeToken: string) => Promise) | null, - pollFinalAnswer: ((conversationId: string) => Promise) | null, - log: { warn?: (tag: string, msg: string) => void } | null, - signal?: AbortSignal | null -): Promise { - let fullAnswer = ""; - let conversationId: string | null = null; - let imagePointers: ImagePointerRef[] | undefined; - let imageGenAsync = false; - let handoff = false; - let resumeToken: string | null = null; - let answerMetadata: Record | undefined; - let parentCandidateMessageId: string | null = null; - - for await (const chunk of extractContent(eventStream, signal)) { - if (chunk.conversationId) conversationId = chunk.conversationId; - if (chunk.messageId) parentCandidateMessageId = chunk.messageId; - if (chunk.handoff) handoff = true; - if (chunk.resumeToken) resumeToken = chunk.resumeToken; - if (chunk.error) { - return new Response( - JSON.stringify({ - error: { message: chunk.error, type: "upstream_error", code: "CHATGPT_ERROR" }, - }), - { status: 502, headers: { "Content-Type": "application/json" } } - ); - } - if (chunk.done) { - fullAnswer = chunk.answer || fullAnswer; - answerMetadata = chunk.metadata ?? answerMetadata; - imagePointers = chunk.imagePointers; - imageGenAsync = chunk.imageGenAsync ?? false; - handoff = handoff || (chunk.handoff ?? false); - if (chunk.resumeToken) resumeToken = chunk.resumeToken; - if (chunk.messageId) parentCandidateMessageId = chunk.messageId; - break; - } - if (chunk.answer) { - fullAnswer = chunk.answer; - answerMetadata = chunk.metadata ?? answerMetadata; - } - } - - let resumedAnswer: FinalAssistantAnswer | null = null; - if (resumeFinalAnswer && conversationId && handoff && resumeToken) { - resumedAnswer = await resumeFinalAnswer(conversationId, resumeToken); - if (resumedAnswer?.text) { - fullAnswer = resumedAnswer.text; - answerMetadata = resumedAnswer.metadata ?? answerMetadata; - if (resumedAnswer.messageId) parentCandidateMessageId = resumedAnswer.messageId; - } - } - - if ( - !resumedAnswer?.text && - pollFinalAnswer && - conversationId && - (handoff || !fullAnswer.trim()) - ) { - const polled = await pollFinalAnswer(conversationId); - if (polled?.text) { - fullAnswer = polled.text; - answerMetadata = polled.metadata ?? answerMetadata; - if (polled.messageId) parentCandidateMessageId = polled.messageId; - } - } - - fullAnswer = cleanChatGptText(fullAnswer, answerMetadata); - - // Async image gen: SSE ended with "Processing image..." — poll for the - // final pointer the same way the streaming path does. - if ( - imageGenAsync && - conversationId && - (!imagePointers || imagePointers.length === 0) && - pollAsyncImage - ) { - try { - const polled = await pollAsyncImage(conversationId); - if (polled.length > 0) imagePointers = polled; - } catch (err) { - log?.warn?.( - "CGPT-WEB", - `Async image poll failed: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - const urls = await resolveImagePointers( - imagePointers, - conversationId, - resolver, - log, - parentCandidateMessageId - ); - // The image genuinely exists upstream but no pointer resolved to a URL - // (unknown asset scheme, download 403/expired, oversize). Flag it so the - // image-generation handler can report an accurate "generated but not - // retrievable" error instead of the misleading "no image markdown" 502. - const imageResolutionFailed = detectImageResolutionFailure( - imagePointers?.length ?? 0, - urls.length - ); - if (imageResolutionFailed && log?.warn) { - const schemes = (imagePointers ?? []) - .map((p) => p.pointer.split("://")[0] || p.pointer.slice(0, 24)) - .join(", "); - log.warn( - "CGPT-WEB", - `Image generated upstream but no asset pointer resolved (schemes: ${schemes}) — surfacing as unretrievable` - ); - } - fullAnswer += imageMarkdown(urls); - const promptTokens = Math.ceil(currentMsg.length / 4); - const completionTokens = Math.ceil(fullAnswer.length / 4); - - return new Response( - JSON.stringify({ - id: cid, - object: "chat.completion", - created, - model, - system_fingerprint: null, - ...(imageResolutionFailed ? { x_image_resolution_failed: true } : {}), - choices: [ - { - index: 0, - message: { role: "assistant", content: fullAnswer }, - finish_reason: "stop", - logprobs: null, - }, - ], - usage: { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: promptTokens + completionTokens, - }, - }), - { status: 200, headers: { "Content-Type": "application/json" } } - ); -} - -// ─── Error response helpers ───────────────────────────────────────────────── - -function errorResponse(status: number, message: string, code?: string): Response { - return new Response( - JSON.stringify({ error: { message, type: "upstream_error", ...(code ? { code } : {}) } }), - { status, headers: { "Content-Type": "application/json" } } - ); -} - -function normalizePublicBaseUrl(value?: string | null): string | null { - const trimmed = value?.trim(); - if (!trimmed) return null; - return trimmed.replace(/\/+$/, "").replace(/\/v1$/i, ""); -} - -function firstForwardedValue(value?: string | null): string | null { - const first = value?.split(",")[0]?.trim(); - return first || null; -} - -function isLocalBaseUrl(baseUrl: string): boolean { - try { - const host = new URL(baseUrl).hostname.toLowerCase(); - return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "0.0.0.0"; - } catch { - console.warn("[chatgpt-web] URL parse failed, falling back to regex"); - return /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(baseUrl); - } -} - -function deriveHeaderBaseUrl(clientHeaders?: Record | null): string | null { - const headers = clientHeaders ?? {}; - const lower: Record = {}; - for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v; - - const forwardedHost = firstForwardedValue(lower["x-forwarded-host"]); - const forwardedProto = firstForwardedValue(lower["x-forwarded-proto"]); - const host = forwardedHost || firstForwardedValue(lower["host"]); - if (!host) return null; - - // Default to http for IPs, localhost, and explicit host:port values where - // TLS is not a safe assumption. Reverse proxies can override via - // x-forwarded-proto, and deployments can force the exact value with - // OMNIROUTE_PUBLIC_BASE_URL. - const isPlain = - host.includes("localhost") || - /^\d+\.\d+\.\d+\.\d+(:\d+)?$/.test(host) || - host.endsWith(".local") || - host.includes(":"); - const proto = forwardedProto || (isPlain ? "http" : "https"); - return `${proto}://${host}`; -} - -/** - * Build the absolute base URL the client should use to fetch our cached - * images at /v1/chatgpt-web/image/. The most reliable value is an - * explicit browser-facing origin because relay clients such as Open WebUI - * often reach OmniRoute from a container while the user's browser needs a - * LAN, tunnel, or reverse-proxy URL. - */ -function derivePublicBaseUrl( - clientHeaders?: Record | null, - log?: { debug?: (tag: string, msg: string) => void } -): string { - const explicitPublicBase = normalizePublicBaseUrl(process.env.OMNIROUTE_PUBLIC_BASE_URL); - if (explicitPublicBase) { - log?.debug?.("CGPT-WEB", `derivePublicBaseUrl: using OMNIROUTE_PUBLIC_BASE_URL`); - return explicitPublicBase; - } - - const headerBase = deriveHeaderBaseUrl(clientHeaders); - const configuredBase = - normalizePublicBaseUrl(process.env.OMNIROUTE_BASE_URL) || - normalizePublicBaseUrl(process.env.NEXT_PUBLIC_BASE_URL); - - log?.debug?.( - "CGPT-WEB", - `derivePublicBaseUrl: configured=${configuredBase ?? "-"} header=${headerBase ?? "-"}` - ); - - if (configuredBase && (!headerBase || !isLocalBaseUrl(configuredBase))) return configuredBase; - if (headerBase) return headerBase; - if (configuredBase) return configuredBase; - - return `http://localhost:${process.env.PORT || 20128}`; -} - -// ─── Image asset resolution ──────────────────────────────────────────────── -// ChatGPT's image_gen tool emits `image_asset_pointer` parts whose -// `asset_pointer` is one of: -// -// file-service://file-XXXX → resolved via /backend-api/files/{id}/download -// sediment://file-XXXX → resolved via /backend-api/conversation/{conv_id}/attachment/{id}/download -// -// Both endpoints return JSON `{ download_url: "", ... }`. -// The signed URL has a limited lifetime (typically a few hours), but that's -// usually sufficient for the user to view the image in their UI right after -// generation. Persistent storage can be layered on later if needed. - -const FILE_SERVICE_PREFIX = "file-service://"; -const SEDIMENT_PREFIX = "sediment://"; - -interface ResolverContext { - accessToken: string; - accountId: string | null; - sessionId: string; - deviceId: string; - cookie: string; - signal?: AbortSignal | null; - log?: Partial void>>; - /** - * Absolute base URL that downstream clients should use to fetch cached - * images served by /v1/chatgpt-web/image/. Derived from the inbound - * request host so the URL is reachable from whatever network the client - * came in on (localhost, Tailscale, cloudflared tunnel, etc.). - */ - publicBaseUrl: string; -} - -async function fetchDownloadUrl(endpoint: string, ctx: ResolverContext): Promise { - const headers: Record = { - ...browserHeaders(), - ...oaiHeaders(ctx.sessionId, ctx.deviceId), - Accept: "application/json", - Authorization: `Bearer ${ctx.accessToken}`, - Cookie: buildSessionCookieHeader(ctx.cookie), - }; - if (ctx.accountId) headers["chatgpt-account-id"] = ctx.accountId; - - const response = await tlsFetchChatGpt(endpoint, { - method: "GET", - headers, - timeoutMs: 30_000, - signal: ctx.signal, - }); - if (response.status !== 200) { - ctx.log?.warn?.( - "CGPT-WEB", - `Image download URL fetch failed (${response.status}) for ${endpoint}` - ); - return null; - } - let parsed: { download_url?: string } = {}; - try { - parsed = JSON.parse(response.text || "{}"); - } catch { - console.warn("[chatgpt-web] image download URL parse failed"); - return null; - } - return parsed.download_url ?? null; -} - -/** - * Download a chatgpt.com signed image URL and re-serve it from OmniRoute's - * short-lived image cache. The URLs returned by /files//download and - * /conversation//attachment//download point at chatgpt.com's - * estuary endpoint, which 403s for any request without the user's session - * cookie. Downstream clients (Open WebUI, OpenAI-compatible apps) won't - * have those cookies, so we download once via the authenticated TLS client - * and return a browser-fetchable OmniRoute URL. - */ -const IMAGE_DOWNLOAD_MAX_BYTES = 8 * 1024 * 1024; - -async function imageUrlToCachedImageUrl( - signedUrl: string, - ctx: ResolverContext, - imageContext?: ChatGptImageConversationContext -): Promise { - const headers: Record = { - ...browserHeaders(), - Accept: "image/*,*/*;q=0.8", - Authorization: `Bearer ${ctx.accessToken}`, - Cookie: buildSessionCookieHeader(ctx.cookie), - }; - if (ctx.accountId) headers["chatgpt-account-id"] = ctx.accountId; - - let response: TlsFetchResult; - try { - response = await tlsFetchChatGpt(signedUrl, { - method: "GET", - headers, - timeoutMs: 60_000, - signal: ctx.signal, - // Required for binary payloads — the underlying tls-client returns - // bytes as a `data:;base64,...` string when this is true. - // Without it, raw image bytes get mangled by UTF-8 decoding. - byteResponse: true, - }); - } catch (err) { - ctx.log?.warn?.( - "CGPT-WEB", - `Image fetch failed: ${err instanceof Error ? err.message : String(err)}` - ); - return null; - } - - if (response.status !== 200) { - ctx.log?.warn?.( - "CGPT-WEB", - `Image fetch returned HTTP ${response.status} (${(response.text || "").slice(0, 120)})` - ); - return null; - } - - if (response.text == null || response.text.length === 0) return null; - - // tls-client-node already returns binary bodies as a "data:;base64,..." - // string (see node_modules/tls-client-node/dist/response.js — its bytes() - // method splits on the comma to extract base64). Decode back into bytes - // so we can hand them to the cache. - let bytes: Buffer; - let mime: string; - if (/^data:[^;]{1,256};base64,/.test(response.text)) { - const commaIdx = response.text.indexOf(","); - const header = response.text.slice(5, commaIdx); // strip "data:" - mime = header.split(";")[0] || "image/png"; - bytes = Buffer.from(response.text.slice(commaIdx + 1), "base64"); - } else { - // Plain-text body (shouldn't happen for binary downloads with - // byteResponse:true, but handle defensively). - bytes = Buffer.from(response.text, "binary"); - mime = response.headers.get("content-type")?.split(";")[0]?.trim() || "image/png"; - } - if (bytes.length === 0 || bytes.length > IMAGE_DOWNLOAD_MAX_BYTES) { - if (bytes.length > IMAGE_DOWNLOAD_MAX_BYTES) { - ctx.log?.warn?.( - "CGPT-WEB", - `Image too large to cache (${bytes.length} bytes > ${IMAGE_DOWNLOAD_MAX_BYTES}); skipping` - ); - } - return null; - } - // Cache the image and return a stable HTTP URL pointing at our own - // /v1/chatgpt-web/image/ route. Streaming the raw base64 back via - // SSE deltas works but Open WebUI's progressive markdown renderer shows - // each chunk as plain text mid-stream — the user sees megabytes of - // base64 scroll past before the image renders. URL-based delivery - // produces a small markdown delta and renders instantly when the - // browser fetches the URL. - const id = storeChatGptImage(bytes, mime, undefined, imageContext); - return `${ctx.publicBaseUrl}/v1/chatgpt-web/image/${id}`; -} - -/** - * Resolve the async image_gen result by registering a WebSocket with - * chatgpt.com and listening for the image_asset_pointer. - * - * Background: when chatgpt.com is busy ("Lots of people are creating images - * right now") the image_gen tool defers — the initial SSE finishes with a - * "Processing image..." placeholder and the real image arrives over a - * WebSocket pubsub. (We checked: the conversation tree at - * `/backend-api/conversation/{id}` is NOT updated when the image lands, so - * polling that endpoint does nothing.) - * - * Flow: - * 1. POST /backend-api/register-websocket → { wss_url, expires_at, ... } - * 2. Open the wss_url with the standard WebSocket client. - * Auth lives in the URL (signed access token), so we don't need the - * TLS-impersonation transport here. - * 3. Each WS message is JSON like { type: "wss-message", data: { ... - * conversation event ... } }. The conversation event has the same - * shape as the SSE events from /backend-api/f/conversation. - * 4. Watch for assistant messages with multimodal_text + image_asset_pointer - * OR a `message_stream_complete` for the conversation. Resolve when - * either pointer arrives or the timeout fires. - */ -async function registerWebSocket(ctx: ResolverContext): Promise { - // chatgpt.com migrated from POST /backend-api/register-websocket to a - // GET-only endpoint under /backend-api/celsius/ws/user. The response shape - // also changed from `{ wss_url }` → `{ websocket_url }`. Newer codebases - // (g4f, etc.) all hit the celsius path; the legacy path now 404s. - // Keep the legacy path as a fallback for older deployments. - const candidates = [ - { url: `${CHATGPT_BASE}/backend-api/celsius/ws/user`, method: "GET" as const }, - { url: `${CHATGPT_BASE}/backend-api/register-websocket`, method: "POST" as const }, - ]; - const headers: Record = { - ...browserHeaders(), - ...oaiHeaders(ctx.sessionId, ctx.deviceId), - Accept: "application/json", - Authorization: `Bearer ${ctx.accessToken}`, - Cookie: buildSessionCookieHeader(ctx.cookie), - }; - if (ctx.accountId) headers["chatgpt-account-id"] = ctx.accountId; - - for (const { url, method } of candidates) { - let r: TlsFetchResult; - try { - r = await tlsFetchChatGpt(url, { - method, - headers, - body: method === "POST" ? "" : undefined, - timeoutMs: 30_000, - signal: ctx.signal, - }); - } catch (err) { - ctx.log?.warn?.( - "CGPT-WEB", - `register-websocket fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}` - ); - continue; - } - if (r.status === 200) { - try { - const data = JSON.parse(r.text || "{}") as { - websocket_url?: string; - wss_url?: string; - }; - const ws = data.websocket_url ?? data.wss_url; - if (ws) { - ctx.log?.debug?.("CGPT-WEB", `Got WebSocket URL via ${url}`); - return ws; - } - } catch { - console.warn("[chatgpt-web] WebSocket URL parse failed, falling through"); - /* fall through */ - } - } - ctx.log?.warn?.( - "CGPT-WEB", - `register-websocket via ${url} → ${r.status}: ${(r.text || "").slice(0, 200)}` - ); - } - return null; -} - -interface WsWaitOutcome { - pointers: ImagePointerRef[]; - /** True if the connection emitted an error event. Used by the retry layer - * to decide whether a transport blip is worth a second attempt. */ - errored: boolean; - /** True if any frame (message or open) was actually received from the - * server. A retry is most valuable when the connection died before - * exchanging any data. */ - gotAnyMessage: boolean; -} - -async function waitForImageViaWebSocket( - wssUrl: string, - conversationId: string, - timeoutMs: number, - ctx: ResolverContext -): Promise { - return new Promise((resolve) => { - const found = new Map(); - let resolved = false; - let errored = false; - let gotAnyMessage = false; - const finish = () => { - if (resolved) return; - resolved = true; - try { - ws.close(); - } catch { - console.warn("[chatgpt-web] ws.close failed"); - /* ignore */ - } - resolve({ - pointers: Array.from(found.values()), - errored, - gotAnyMessage, - }); - }; - const ws = new WebSocket(wssUrl); - const timer = setTimeout(() => { - ctx.log?.warn?.("CGPT-WEB", `WebSocket image wait timed out after ${timeoutMs}ms`); - finish(); - }, timeoutMs); - const onAbort = () => { - ctx.log?.debug?.("CGPT-WEB", "WebSocket aborted by client"); - finish(); - }; - ctx.signal?.addEventListener?.("abort", onAbort); - ws.onopen = () => { - gotAnyMessage = true; - ctx.log?.debug?.("CGPT-WEB", "WebSocket open — waiting for image events"); - }; - ws.onerror = (e) => { - errored = true; - ctx.log?.warn?.("CGPT-WEB", `WebSocket error: ${(e as ErrorEvent).message ?? "unknown"}`); - }; - ws.onclose = () => { - clearTimeout(timer); - ctx.signal?.removeEventListener?.("abort", onAbort); - finish(); - }; - ws.onmessage = (event) => { - gotAnyMessage = true; - let payload: unknown; - const raw = typeof event.data === "string" ? event.data : event.data.toString(); - try { - payload = JSON.parse(raw); - } catch { - console.warn("[chatgpt-web] WebSocket event JSON parse failed"); - return; - } - // chatgpt.com's celsius WS frames look like: - // { type: "conversation-update", - // payload: { conversation_id: "...", - // update_content: { message: { ... }, ... } } } - // Older deployments wrapped the conversation event directly as { data }. - const obj = payload as Record; - const candidates: ChatGptStreamEvent[] = []; - const innerPayload = obj.payload as Record | undefined; - const updateContent = innerPayload?.update_content as Record | undefined; - if (updateContent?.message) { - candidates.push({ - message: updateContent.message as ChatGptStreamEvent["message"], - conversation_id: innerPayload?.conversation_id as string | undefined, - }); - } - // #7357: some deployments deliver the completion via update_content.messages[] - // (plural array of { message: {...} } wrappers), not the singular field above. - for (const entry of Array.isArray(updateContent?.messages) ? updateContent.messages : []) { - const wrapped = (entry as { message?: unknown } | undefined)?.message; - if (wrapped) { - candidates.push({ - message: wrapped as ChatGptStreamEvent["message"], - conversation_id: innerPayload?.conversation_id as string | undefined, - }); - } - } - if (innerPayload?.message) { - candidates.push({ - message: innerPayload.message as ChatGptStreamEvent["message"], - conversation_id: innerPayload.conversation_id as string | undefined, - }); - } - if ((obj.data as { message?: unknown } | undefined)?.message) { - candidates.push(obj.data as ChatGptStreamEvent); - } - - for (const data of candidates) { - if (data?.conversation_id && data.conversation_id !== conversationId) continue; - const m = data?.message; - // The async image_gen result arrives as a TOOL-role message - // ({"author":{"role":"tool","name":"t2uay3k.sj1i4kz"}}), so we - // accept tool messages here too — extractImagePointers does the - // actual content_type filtering. - if (Array.isArray(m?.content?.parts)) { - for (const ptr of extractImagePointers(m.content?.parts ?? [])) { - const existing = found.get(ptr); - found.set( - ptr, - existing?.messageId - ? existing - : { pointer: ptr, ...(m?.id ? { messageId: m.id } : {}) } - ); - } - } - if (m?.metadata && typeof m.metadata === "object") { - const md = m.metadata as Record; - const ptr = (md.asset_pointer ?? md.image_asset_pointer) as string | undefined; - if (typeof ptr === "string") { - const existing = found.get(ptr); - found.set( - ptr, - existing?.messageId - ? existing - : { pointer: ptr, ...(m?.id ? { messageId: m.id } : {}) } - ); - } - } - } - if (found.size > 0) finish(); - }; - }); -} - -// Default 3-minute wait for the async image_gen tool to produce an image -// pointer over the celsius WebSocket. Tunable so deployments can stretch -// during chatgpt.com queue-deep windows ("Lots of people are creating -// images right now") without code changes. -const DEFAULT_ASYNC_IMAGE_TIMEOUT_MS = 180_000; - -function configuredAsyncImageTimeoutMs(): number { - const raw = Number(process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS); - if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ASYNC_IMAGE_TIMEOUT_MS; - return Math.floor(raw); -} - -async function pollForAsyncImage( - conversationId: string, - ctx: ResolverContext, - opts: { timeoutMs?: number } = {} -): Promise { - const totalTimeoutMs = opts.timeoutMs ?? configuredAsyncImageTimeoutMs(); - const deadline = Date.now() + totalTimeoutMs; - - // One reconnect attempt on transport error: the WS endpoint is signed and - // short-lived, and a network blip during the long wait would otherwise - // lose the image entirely. The deadline is shared across attempts so we - // never exceed the caller's budget. - for (let attempt = 0; attempt < 2; attempt++) { - const remaining = deadline - Date.now(); - if (remaining <= 0) break; - const wssUrl = await registerWebSocket(ctx); - if (!wssUrl) { - ctx.log?.warn?.( - "CGPT-WEB", - attempt === 0 - ? "Could not register WebSocket — async image gen not retrievable" - : `WebSocket re-registration failed on retry attempt ${attempt + 1}` - ); - if (attempt === 0) continue; // try again — registration can be flaky - break; // fall through to the conversation-poll fallback below - } - ctx.log?.debug?.( - "CGPT-WEB", - `Registered WebSocket for async image (attempt ${attempt + 1}, ${remaining}ms remaining)` - ); - const outcome = await waitForImageViaWebSocket(wssUrl, conversationId, remaining, ctx); - if (outcome.pointers.length > 0) return outcome.pointers; - if (ctx.signal?.aborted) return []; - // Only retry when the connection died before producing anything useful. - // A clean close with no pointers (e.g., upstream cancellation) shouldn't - // burn a second attempt — the result would be the same. - if (!outcome.errored || outcome.gotAnyMessage) break; - ctx.log?.warn?.( - "CGPT-WEB", - `WebSocket attempt ${attempt + 1} ended in transport error before any frame; retrying` - ); - } - - // Fallback: the async image websocket is unreliable in some environments — - // register-websocket is Cloudflare-sensitive and the plain WebSocket lacks the - // browser TLS fingerprint the HTTP client uses, so it can error or receive no - // frames even though the image was generated. The image still lands in the - // conversation, so poll it over the same authenticated HTTP path used - // everywhere else and read the image_asset_pointer directly. This is the - // durable fallback recommended in #7357. - const pollDeadline = Math.max(deadline, Date.now() + 60_000); - while (Date.now() < pollDeadline && !ctx.signal?.aborted) { - const { detail } = await fetchConversationDetail(conversationId, ctx); - const mapping = detail?.mapping; - if (mapping) { - // Prefer the newest message carrying image pointers, so a reused - // conversation doesn't surface a stale image from an earlier turn. - let newest: { pointers: ImagePointerRef[]; at: number } | null = null; - for (const node of Object.values(mapping)) { - const message = node?.message; - const parts = message?.content?.parts; - if (!Array.isArray(parts)) continue; - const pointers = extractImagePointers(parts).map((pointer) => ({ - pointer, - messageId: message?.id, - })); - if (pointers.length === 0) continue; - const at = message?.create_time ?? 0; - if (!newest || at >= newest.at) newest = { pointers, at }; - } - if (newest) { - ctx.log?.info?.( - "CGPT-WEB", - `Recovered ${newest.pointers.length} image pointer(s) via conversation poll (websocket yielded none)` - ); - return newest.pointers; - } - } - await delayWithAbort(3_000, ctx.signal); - } - return []; -} - -function makeImageResolver(ctx: ResolverContext): ImageResolver { - // Cache resolutions across the same request — the same pointer can show up - // on multiple SSE events (in-progress + finished_successfully). One HTTP - // round-trip per unique pointer is enough. - const cache = new Map(); - - return async (assetPointer, conversationId, parentMessageId) => { - if (cache.has(assetPointer)) return cache.get(assetPointer) ?? null; - - let fileId: string | null = null; - if (assetPointer.startsWith(FILE_SERVICE_PREFIX)) { - fileId = assetPointer.slice(FILE_SERVICE_PREFIX.length); - } else if (assetPointer.startsWith(SEDIMENT_PREFIX)) { - fileId = assetPointer.slice(SEDIMENT_PREFIX.length); - } else { - ctx.log?.warn?.("CGPT-WEB", `Unknown asset_pointer scheme: ${assetPointer}`); - } - - let signedUrl: string | null = null; - if (fileId) { - // Both endpoints return a chatgpt.com estuary URL signed for the - // user's current session — that URL 403s without the cookie, so - // downstream clients can't fetch it directly. We download once via - // the authenticated TLS client and expose the bytes through - // OmniRoute's short-lived image cache. - // - // /files/{id}/download is the historical path. It works for - // chat-uploaded files and the older image_gen output format - // (`file-XXXX`). Newer image-edit results from continued - // conversations land with a `file_00000000XXXX` shape that 422s on - // /files/{id}/download — they're conversation-scoped attachments - // and only resolve through /conversation/{cid}/attachment/{fid}/ - // download. We try /files first because it's cheaper and works for - // the common case, then fall through. - signedUrl = await fetchDownloadUrl( - `${CHATGPT_BASE}/backend-api/files/${encodeURIComponent(fileId)}/download`, - ctx - ); - if (!signedUrl && conversationId) { - signedUrl = await fetchDownloadUrl( - `${CHATGPT_BASE}/backend-api/conversation/${encodeURIComponent(conversationId)}/attachment/${encodeURIComponent(fileId)}/download`, - ctx - ); - } - } - - let finalUrl: string | null = null; - if (signedUrl) { - // chatgpt.com signed URLs require the user's session cookie to fetch, - // so we materialize the bytes into our own cache and emit an OmniRoute - // URL. If that fails (oversize, network error, etc.) we return null — - // never the signed URL — because handing it back would emit broken - // markdown that 403s for the client. Better to drop the image silently - // than render a broken link. - finalUrl = await imageUrlToCachedImageUrl( - signedUrl, - ctx, - conversationId && parentMessageId ? { conversationId, parentMessageId } : undefined - ); - } - cache.set(assetPointer, finalUrl); - if (finalUrl) { - const preview = finalUrl.startsWith("data:") - ? `data:... (${finalUrl.length} chars)` - : finalUrl.slice(0, 80) + "..."; - ctx.log?.debug?.("CGPT-WEB", `Resolved ${assetPointer} → ${preview}`); - } - return finalUrl; - }; -} - -// ─── Executor ─────────────────────────────────────────────────────────────── - -export class ChatGptWebExecutor extends BaseExecutor { - constructor() { - super("chatgpt-web", { id: "chatgpt-web", baseUrl: CONV_URL }); - } - - async execute({ - model, - body, - stream, - credentials, - signal, - log, - onCredentialsRefreshed, - clientHeaders, - }: ExecuteInput) { - const messages = (body as Record | null)?.messages as - Array> | undefined; - if (!messages || !Array.isArray(messages) || messages.length === 0) { - return { - response: errorResponse(400, "Missing or empty messages array"), - url: CONV_URL, - headers: {}, - transformedBody: body, - }; - } - - // 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 }> - ); - - if (!credentials.apiKey) { - return { - response: errorResponse( - 401, - "ChatGPT auth failed — paste your __Secure-next-auth.session-token cookie value." - ), - url: CONV_URL, - headers: {}, - transformedBody: body, - }; - } - - // Pass the user's pasted cookie blob through to exchangeSession; the helper - // accepts bare values, unchunked cookies, chunked (.0/.1) cookies, and full - // "Cookie: ..." DevTools lines. - const cookie = credentials.apiKey; - - // 1. Token exchange - let tokenEntry: TokenEntry; - try { - tokenEntry = await exchangeSession(cookie, signal); - } catch (err) { - if (err instanceof SessionAuthError) { - log?.warn?.("CGPT-WEB", err.message); - return { - response: errorResponse( - 401, - "ChatGPT auth failed — re-paste your __Secure-next-auth.session-token cookie from chatgpt.com.", - "HTTP_401" - ), - url: SESSION_URL, - headers: {}, - transformedBody: body, - }; - } - log?.error?.( - "CGPT-WEB", - `Session exchange failed: ${err instanceof Error ? err.message : String(err)}` - ); - return { - response: errorResponse( - 502, - `ChatGPT session exchange failed: ${err instanceof Error ? err.message : String(err)}` - ), - url: SESSION_URL, - headers: {}, - transformedBody: body, - }; - } - - // Surface any rotated cookie back to the caller so the DB credential is refreshed. - if (tokenEntry.refreshedCookie && tokenEntry.refreshedCookie !== cookie) { - const updated: ProviderCredentials = { ...credentials, apiKey: tokenEntry.refreshedCookie }; - try { - await onCredentialsRefreshed?.(updated); - } catch (err) { - log?.warn?.( - "CGPT-WEB", - `Failed to persist refreshed cookie: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - // 2a. Warmup — GET / to scrape DPL + script src so the prekey looks legit. - let dplInfo: { dpl: string; scriptSrc: string }; - try { - dplInfo = await fetchDpl(cookie, signal); - } catch (err) { - log?.warn?.( - "CGPT-WEB", - `DPL warmup failed (continuing with fallback): ${err instanceof Error ? err.message : String(err)}` - ); - dplInfo = { - dpl: `dpl=${OAI_CLIENT_VERSION.replace(/^prod-/, "")}`, - scriptSrc: `${CHATGPT_BASE}/_next/static/chunks/webpack-${randomHex(16)}.js`, - }; - } - - // 2a'. Browser-like session warmup. Sentinel scores the session by whether - // the client recently hit /me, /conversations, /models — same as a real - // browser does on page load. Failures here are non-fatal; the worst case - // is Sentinel still escalates to Turnstile. - const sessionId = randomUUID(); - const turnTraceId = randomUUID(); - const deviceId = deviceIdFor(cookie); - await runSessionWarmup( - tokenEntry.accessToken, - tokenEntry.accountId, - sessionId, - deviceId, - cookie, - signal, - log - ); - - // 2b. Sentinel chat-requirements - let reqs: ChatRequirements; - try { - reqs = await prepareChatRequirements( - tokenEntry.accessToken, - tokenEntry.accountId, - sessionId, - deviceId, - cookie, - dplInfo, - signal, - log - ); - } catch (err) { - if (err instanceof SentinelBlockedError) { - log?.warn?.("CGPT-WEB", err.message); - return { - response: errorResponse( - 403, - "ChatGPT blocked the request (Sentinel/Turnstile required). Try again later or open chatgpt.com in a browser to refresh state.", - "SENTINEL_BLOCKED" - ), - url: SENTINEL_PREPARE_URL, - headers: {}, - transformedBody: body, - }; - } - log?.error?.( - "CGPT-WEB", - `Sentinel failed: ${err instanceof Error ? err.message : String(err)}` - ); - return { - response: errorResponse( - 502, - `ChatGPT sentinel failed: ${err instanceof Error ? err.message : String(err)}` - ), - url: SENTINEL_PREPARE_URL, - headers: {}, - transformedBody: body, - }; - } - - log?.debug?.( - "CGPT-WEB", - `sentinel: token=${reqs.token ? "y" : "n"} pow=${reqs.proofofwork?.required ? "y" : "n"} turnstile=${reqs.turnstile?.required ? "y" : "n"}` - ); - - // Optional: if a turnstile token was supplied via providerSpecificData, - // pass it through. Otherwise, send the request anyway — sometimes Sentinel - // reports turnstile.required even when the conversation endpoint accepts - // requests without it. - const turnstileToken = - typeof credentials.providerSpecificData?.turnstileToken === "string" - ? credentials.providerSpecificData.turnstileToken - : null; - - // 3. Solve PoW (if required) — reuses the same browser-fingerprint config - // shape as the prekey, just with the server-provided seed + difficulty. - let proofToken: string | null = null; - if (reqs.proofofwork?.required && reqs.proofofwork.seed && reqs.proofofwork.difficulty) { - const powConfig = buildPrekeyConfig(CHATGPT_USER_AGENT, dplInfo.dpl, dplInfo.scriptSrc); - proofToken = await solveProofOfWork( - reqs.proofofwork.seed, - reqs.proofofwork.difficulty, - powConfig, - log - ); - } - - // 4. Build conversation request - const parsed = parseOpenAIMessages(effectiveMessages); - if (!parsed.currentMsg.trim() && parsed.history.length === 0) { - return { - response: errorResponse(400, "Empty user message"), - url: CONV_URL, - headers: {}, - transformedBody: body, - }; - } - - // Toggle Temporary Chat off only when ChatGPT needs a durable image - // conversation. Text requests, including GPT-5.6 Sol Pro, stay temporary so - // they do not show up in the user's chatgpt.com sidebar/history. - const imageEdit = looksLikeImageEditRequest(parsed); - const continuation = imageEdit ? parsed.latestImageContext : null; - const forImageGen = looksLikeImageGenRequest(parsed) || imageEdit; - const persistConversation = forImageGen || !!continuation; - if (forImageGen) { - log?.debug?.( - "CGPT-WEB", - continuation - ? "Image edit intent detected — continuing saved image conversation" - : "Image-gen intent detected — disabling Temporary Chat for this turn" - ); - } else if (resolvedModel.isPro) { - log?.debug?.("CGPT-WEB", "GPT-5.6 Sol Pro text request — keeping Temporary Chat enabled"); - } - - const parentMessageId = continuation?.parentMessageId ?? randomUUID(); - const cgptBody = buildConversationBody(parsed, modelSlug, parentMessageId, { - persistConversation, - thinkingEffort: resolvedModel.effort, - systemHints: resolveChatGptSystemHints(model), - continuation, - }); - - const headers: Record = { - ...browserHeaders(), - ...oaiHeaders(sessionId, deviceId), - "Content-Type": "application/json", - Accept: "text/event-stream", - Authorization: `Bearer ${tokenEntry.accessToken}`, - Cookie: buildSessionCookieHeader(cookie), - "x-oai-turn-trace-id": turnTraceId, - }; - if (tokenEntry.accountId) headers["chatgpt-account-id"] = tokenEntry.accountId; - if (reqs.token) headers["openai-sentinel-chat-requirements-token"] = reqs.token; - if (reqs.prepare_token) - headers["openai-sentinel-chat-requirements-prepare-token"] = reqs.prepare_token; - if (proofToken) headers["openai-sentinel-proof-token"] = proofToken; - if (turnstileToken) headers["openai-sentinel-turnstile-token"] = turnstileToken; - - log?.info?.("CGPT-WEB", `Conversation request → ${modelSlug} (pow=${!!proofToken})`); - - let response: TlsFetchResult; - try { - response = await tlsFetchChatGpt(CONV_URL, { - method: "POST", - headers, - body: JSON.stringify(cgptBody), - timeoutMs: 120_000, // generations can take a while - signal, - // For real-time streaming, ask the TLS client to write the body to - // a temp file and surface it as a ReadableStream as it arrives — - // otherwise long generations buffer entirely before the client sees - // anything (and the downstream HTTP request can time out). - stream, - }); - } catch (err) { - log?.error?.("CGPT-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`); - const code = err instanceof TlsClientUnavailableError ? "TLS_UNAVAILABLE" : undefined; - return { - response: errorResponse( - 502, - `ChatGPT connection failed: ${err instanceof Error ? err.message : String(err)}`, - code - ), - url: CONV_URL, - headers, - transformedBody: cgptBody, - }; - } - - if (response.status >= 400) { - const status = response.status; - // Log the upstream body on 4xx/5xx — error responses are small and the - // upstream message is much more useful than our wrapper. Goes through - // the executor logger so it respects the application's log config. - log?.warn?.("CGPT-WEB", `conv ${status}: ${(response.text || "").slice(0, 400)}`); - const errMsg = describeChatGptWebHttpError(status); - if (status === 401 || status === 403) { - tokenCache.delete(cookieKey(cookie)); - } - log?.warn?.("CGPT-WEB", errMsg); - return { - response: errorResponse(status, errMsg, `HTTP_${status}`), - url: CONV_URL, - headers, - transformedBody: cgptBody, - }; - } - - // For streaming requests the TLS client returns a ReadableStream that - // tails the temp file as it's written. For non-streaming requests, it - // returns the full body as text — wrap that in a one-shot stream so the - // existing SSE parser can consume it uniformly. - let bodyStream: ReadableStream; - if (response.body) { - bodyStream = response.body; - } else if (response.text) { - bodyStream = stringToStream(response.text); - } else { - return { - response: errorResponse(502, "ChatGPT returned empty response body"), - url: CONV_URL, - headers, - transformedBody: cgptBody, - }; - } - - const cid = `chatcmpl-cgpt-${crypto.randomUUID().slice(0, 12)}`; - const created = Math.floor(Date.now() / 1000); - - const resolverCtx: ResolverContext = { - accessToken: tokenEntry.accessToken, - accountId: tokenEntry.accountId, - sessionId, - deviceId, - cookie, - signal, - log, - publicBaseUrl: derivePublicBaseUrl(clientHeaders, log), - }; - const imageResolver = makeImageResolver(resolverCtx); - const pollAsyncImage = (conversationId: string) => - pollForAsyncImage(conversationId, resolverCtx); - const resumeFinalAnswer = (conversationId: string, resumeToken: string) => - resumeChatGptHandoff({ - conversationId, - resumeToken, - headers, - timeoutMs: configuredProPollTimeoutMs(), - signal, - log, - readContent: extractContent, - }); - const pollFinalAnswer = resolvedModel.isPro - ? (conversationId: string) => pollForFinalAssistantAnswer(conversationId, resolverCtx) - : null; - - // Tool mode buffers (no live streaming) and is gated off the image-gen path. - const toolMode = hasTools && !forImageGen; - - let finalResponse: Response; - if (stream && !toolMode) { - const sseStream = buildStreamingResponse( - bodyStream, - model, - cid, - created, - imageResolver, - pollAsyncImage, - resumeFinalAnswer, - pollFinalAnswer, - log, - signal - ); - finalResponse = new Response(sseStream, { - status: 200, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, - }); - } else { - finalResponse = await buildNonStreamingResponse( - bodyStream, - model, - cid, - created, - parsed.currentMsg, - imageResolver, - pollAsyncImage, - resumeFinalAnswer, - pollFinalAnswer, - log, - signal - ); - if (toolMode) { - finalResponse = await buildToolModeResponse(finalResponse, requestedTools, stream, { - cid, - created, - model, - }); - } - } - - return { response: finalResponse, url: CONV_URL, headers, transformedBody: cgptBody }; - } -} - -function commonPrefixLength(a: string, b: string): number { - const n = Math.min(a.length, b.length); - let i = 0; - while (i < n && a.charCodeAt(i) === b.charCodeAt(i)) i++; - return i; -} - -function stringToStream(text: string): ReadableStream { - const encoder = new TextEncoder(); - return new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(text)); - controller.close(); - }, - }); -} - -// Test-only: clear caches between tests -export function __resetChatGptWebCachesForTesting(): void { - tokenCache.clear(); - warmupCache.clear(); - deviceIdCache.clear(); - __resetChatGptImageCacheForTesting(); - dplCache = null; -} - -export const __derivePublicBaseUrlForTesting = derivePublicBaseUrl; diff --git a/open-sse/executors/chatgpt-web/citations.ts b/open-sse/executors/chatgpt-web/citations.ts deleted file mode 100644 index 2f61830ad8..0000000000 --- a/open-sse/executors/chatgpt-web/citations.ts +++ /dev/null @@ -1,395 +0,0 @@ -// Pure ChatGPT-web citation-marker parsing/rendering, extracted verbatim from -// chatgpt-web.ts (no module state — safe to unit test in isolation). -// -// Strip ChatGPT's internal entity/citation markup. The browser renders these -// private-use markers (for example `citeturn0search0`) with metadata from -// `message.metadata.content_references`; API clients need plain Markdown with -// real links instead of raw ChatGPT UI tokens. -// entity["city","Paris","capital of France"] → Paris -// entity["…","value", …] → value -const ENTITY_RE = /entity\["[^"]*","([^"]*)"[^\]]*\]/g; -const CHATGPT_MARKER_START = "\uE200"; -const CHATGPT_MARKER_SEP = "\uE202"; -const CHATGPT_MARKER_END = "\uE201"; -const CHATGPT_REF_TOKEN_RE = /turn\d+(?:search|product|news|image|webpage)\d+/g; - -type ChatGptCitationSource = { - title: string; - url: string; - attribution: string; -}; - -type ChatGptCitationMention = { - start?: number; - end?: number; - markerText?: string; - replacement: string; -}; - -type ChatGptCitationData = { - sources: ChatGptCitationSource[]; - mentions: ChatGptCitationMention[]; - refTokenToSourceNumber: Map; -}; - -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" ? (value as Record) : null; -} - -function asString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value : null; -} - -function asNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function markdownLinkText(value: string): string { - // Escape the backslash first — otherwise a label ending in (or containing) a - // backslash leaks past the `[…]` escaping and breaks the generated link, e.g. - // `[Path C:\](url)` where the trailing `\` escapes the closing bracket. - return value - .replace(/\\/g, "\\\\") - .replace(/\[/g, "\\[") - .replace(/\]/g, "\\]") - .replace(/\n/g, " ") - .trim(); -} - -function markdownUrl(value: string): string { - return value.replace(/\(/g, "%28").replace(/\)/g, "%29"); -} - -function canonicalCitationUrl(value: string): string { - try { - const url = new URL(value); - url.searchParams.delete("utm_source"); - return url.toString(); - } catch { - return value; - } -} - -function referenceUrls(ref: Record): string[] { - const urls: string[] = []; - for (const key of ["url", "safe_url", "link"]) { - const url = asString(ref[key]); - if (url) urls.push(url); - } - for (const url of asArray(ref.safe_urls)) { - if (typeof url === "string" && url.trim()) urls.push(url); - } - return [...new Set(urls)]; -} - -function refTokenFromStructuredRef(ref: Record): string | null { - const turn = asNumber(ref.turn_index); - const refType = asString(ref.ref_type); - const refIndex = asNumber(ref.ref_index); - if (turn == null || refIndex == null || !refType) return null; - return `turn${turn}${refType}${refIndex}`; -} - -function mapStructuredRefs( - refs: unknown, - sourceNumber: number, - refTokenToSourceNumber: Map -): void { - for (const refValue of asArray(refs)) { - const ref = asRecord(refValue); - if (!ref) continue; - const token = refTokenFromStructuredRef(ref); - if (token && !refTokenToSourceNumber.has(token)) { - refTokenToSourceNumber.set(token, sourceNumber); - } - } -} - -function formatCitationLinks(numbers: number[], sources: ChatGptCitationSource[]): string { - return [...new Set(numbers)] - .sort((a, b) => a - b) - .map((num) => { - const source = sources[num - 1]; - return source ? `[${num}](${markdownUrl(source.url)})` : ""; - }) - .filter(Boolean) - .join(""); -} - -function urlMarkerLabel(markerText?: string | null): string | null { - if (!markerText) return null; - const privateMatch = markerText.match(/\uE200url\uE202([^\uE201\uE202]+)/u); - if (privateMatch?.[1]) return privateMatch[1].trim(); - const plainMatch = markerText.match(/^url[:\s]+(.+)$/i); - return plainMatch?.[1]?.trim() || null; -} - -function citationMarkerCandidates(markerText?: string): string[] { - if (!markerText) return []; - const candidates = [markerText]; - const tokens = markerText.match(CHATGPT_REF_TOKEN_RE) ?? []; - if (tokens.length > 0 && markerText.includes("cite")) { - candidates.push( - `${CHATGPT_MARKER_START}cite${tokens.map((token) => CHATGPT_MARKER_SEP + token).join("")}${CHATGPT_MARKER_END}` - ); - } - return [...new Set(candidates)]; -} - -type AddCitationSourceFn = ( - titleValue: unknown, - urlValue: unknown, - attributionValue?: unknown -) => number; -type AddCitationMentionFn = (ref: Record, replacement: string) => void; - -/** Supporting-website sources nested under one `grouped_webpages` item. */ -function collectSupportingWebsiteNumbers( - item: Record, - addSource: AddCitationSourceFn, - refTokenToSourceNumber: Map -): number[] { - const numbers: number[] = []; - for (const supportingValue of asArray(item.supporting_websites)) { - const supporting = asRecord(supportingValue); - if (!supporting) continue; - const supportingNumber = addSource(supporting.title, supporting.url, supporting.attribution); - if (supportingNumber) { - numbers.push(supportingNumber); - mapStructuredRefs(supporting.refs, supportingNumber, refTokenToSourceNumber); - } - } - return numbers; -} - -/** One `grouped_webpages` item — its own primary source plus any supporting-website - * sources nested under it. */ -function collectGroupedWebpageItemNumbers( - itemValue: unknown, - addSource: AddCitationSourceFn, - refTokenToSourceNumber: Map -): number[] { - const item = asRecord(itemValue); - if (!item) return []; - const numbers: number[] = []; - const mainNumber = addSource(item.title, item.url, item.attribution); - if (mainNumber) { - numbers.push(mainNumber); - mapStructuredRefs(item.refs, mainNumber, refTokenToSourceNumber); - } - numbers.push(...collectSupportingWebsiteNumbers(item, addSource, refTokenToSourceNumber)); - return numbers; -} - -/** Fallback when no `grouped_webpages` item yielded a usable source — fall back to - * the ref's own URLs directly. */ -function collectGroupedWebpagesFallbackNumbers( - ref: Record, - addSource: AddCitationSourceFn -): number[] { - const numbers: number[] = []; - for (const url of referenceUrls(ref)) { - const fallbackNumber = addSource(ref.title, url, ref.attribution); - if (fallbackNumber) numbers.push(fallbackNumber); - } - return numbers; -} - -/** `content_references[].type === "grouped_webpages"` — a primary source per item, - * each optionally paired with supporting-website sources; falls back to the ref's - * own URLs when no item yielded a usable source. */ -function collectGroupedWebpagesRef( - ref: Record, - sources: ChatGptCitationSource[], - addSource: AddCitationSourceFn, - addMention: AddCitationMentionFn, - refTokenToSourceNumber: Map -): void { - let numbers: number[] = []; - for (const itemValue of asArray(ref.items)) { - numbers.push(...collectGroupedWebpageItemNumbers(itemValue, addSource, refTokenToSourceNumber)); - } - - if (numbers.length === 0) { - numbers = collectGroupedWebpagesFallbackNumbers(ref, addSource); - } - - addMention(ref, formatCitationLinks(numbers, sources)); -} - -/** `content_references[].type === "sources_footnote"` — a flat list of sources with - * no inline mention to replace (the footnote itself carries no marker text). */ -function collectSourcesFootnoteRef( - ref: Record, - addSource: AddCitationSourceFn -): void { - for (const sourceValue of asArray(ref.sources)) { - const source = asRecord(sourceValue); - if (source) addSource(source.title, source.url, source.attribution); - } -} - -/** Any other reference type — a direct `webpage`/`url` marker with an inline label - * renders as `[label](url)`; everything else falls back to numbered source links. */ -function collectDefaultRef( - ref: Record, - type: string, - sources: ChatGptCitationSource[], - addSource: AddCitationSourceFn, - addMention: AddCitationMentionFn, - refTokenToSourceNumber: Map -): void { - const urls = referenceUrls(ref); - const label = urlMarkerLabel(asString(ref.matched_text)); - if ((type === "webpage" || type === "url") && label && urls[0]) { - addMention(ref, `[${markdownLinkText(label)}](${markdownUrl(urls[0])})`); - return; - } - - const numbers = urls - .map((url) => addSource(ref.title ?? ref.alt, url, ref.attribution)) - .filter((num) => num > 0); - if (numbers.length === 0) return; - - mapStructuredRefs(ref.refs, numbers[0], refTokenToSourceNumber); - addMention(ref, formatCitationLinks(numbers, sources)); -} - -function collectChatGptCitationData(metadata?: Record): ChatGptCitationData { - const refs = asArray(metadata?.content_references); - const sources: ChatGptCitationSource[] = []; - const mentions: ChatGptCitationMention[] = []; - const sourceIndexByCanonicalUrl = new Map(); - const refTokenToSourceNumber = new Map(); - - const addSource: AddCitationSourceFn = (titleValue, urlValue, attributionValue) => { - const url = asString(urlValue); - if (!url) return 0; - const canonical = canonicalCitationUrl(url); - const existing = sourceIndexByCanonicalUrl.get(canonical); - if (existing) return existing; - - const title = asString(titleValue) ?? url; - const attribution = asString(attributionValue) ?? ""; - const idx = sources.length + 1; - sources.push({ title: title.replace(/\n/g, " ").trim(), url, attribution }); - sourceIndexByCanonicalUrl.set(canonical, idx); - return idx; - }; - - const addMention: AddCitationMentionFn = (ref, replacement) => { - if (!replacement) return; - const start = asNumber(ref.start_idx); - const end = asNumber(ref.end_idx); - const markerText = asString(ref.matched_text) ?? undefined; - if (markerText || (start != null && end != null)) { - mentions.push({ - ...(start != null ? { start } : {}), - ...(end != null ? { end } : {}), - ...(markerText ? { markerText } : {}), - replacement, - }); - } - }; - - for (const refValue of refs) { - const ref = asRecord(refValue); - if (!ref) continue; - const type = asString(ref.type) ?? ""; - - if (type === "grouped_webpages") { - collectGroupedWebpagesRef(ref, sources, addSource, addMention, refTokenToSourceNumber); - continue; - } - if (type === "sources_footnote") { - collectSourcesFootnoteRef(ref, addSource); - continue; - } - collectDefaultRef(ref, type, sources, addSource, addMention, refTokenToSourceNumber); - } - - return { sources, mentions, refTokenToSourceNumber }; -} - -function replacePrivateCitationMarkers(text: string, citationData: ChatGptCitationData): string { - const replaceTokens = (tokens: string[]): string => { - const numbers = tokens - .map((token) => citationData.refTokenToSourceNumber.get(token)) - .filter((num): num is number => typeof num === "number"); - return numbers.length > 0 ? formatCitationLinks(numbers, citationData.sources) : ""; - }; - - return text - .replace(/\uE200cite((?:\uE202[^\uE201\uE202]+)+)\uE201/gu, (_all, body: string) => { - const tokens = [...body.matchAll(/\uE202([^\uE201\uE202]+)/gu)].map((match) => match[1]); - return replaceTokens(tokens); - }) - .replace( - /\bcite((?:turn\d+(?:search|product|news|image|webpage)\d+)+)\b/g, - (_all, body: string) => { - return replaceTokens(body.match(CHATGPT_REF_TOKEN_RE) ?? []); - } - ); -} - -function stripDanglingChatGptMarkers(text: string, citationData: ChatGptCitationData): string { - return replacePrivateCitationMarkers(text, citationData) - .replace( - /\uE200url\uE202([^\uE201\uE202]+)\uE202(https?:\/\/[^\uE201]+)\uE201/gu, - (_all, label: string, url: string) => { - return `[${markdownLinkText(label)}](${markdownUrl(url)})`; - } - ) - .replace( - /\uE200url\uE202([^\uE201\uE202]+)\uE202(?:[^\uE201]*\uE201)?/gu, - (_all, label: string) => { - return label.trim(); - } - ) - .replace(/\uE200cite(?:\uE202[^\uE201\uE202]*)*$/gu, "") - .replace(/\uE200[a-z_]+(?:\uE202[^\uE201\uE202]*)*\uE201/giu, "") - .replace(/\uE200[a-z_]+(?:\uE202[^\uE201\uE202]*)*$/giu, "") - .replace(/\uE202?turn\d+(?:search|product|news|image|webpage)\d+\uE201?/gu, "") - .replace(/[\uE200\uE201\uE202]/gu, ""); -} - -function applyChatGptCitations(text: string, metadata?: Record): string { - const citationData = collectChatGptCitationData(metadata); - let rendered = text; - - for (const mention of [...citationData.mentions].sort( - (a, b) => (b.start ?? -1) - (a.start ?? -1) - )) { - let replaced = false; - for (const markerText of citationMarkerCandidates(mention.markerText)) { - const limit = - mention.start != null - ? Math.min(rendered.length, mention.start + markerText.length) - : rendered.length; - let pos = rendered.lastIndexOf(markerText, limit); - if (pos < 0) pos = rendered.indexOf(markerText); - if (pos >= 0) { - rendered = - rendered.slice(0, pos) + mention.replacement + rendered.slice(pos + markerText.length); - replaced = true; - break; - } - } - - if (!replaced && mention.start != null && mention.end != null) { - const start = Math.max(0, Math.min(mention.start, rendered.length)); - const end = Math.max(start, Math.min(mention.end, rendered.length)); - rendered = rendered.slice(0, start) + mention.replacement + rendered.slice(end); - } - } - - return stripDanglingChatGptMarkers(rendered, citationData); -} - -export function cleanChatGptText(text: string, metadata?: Record): string { - return applyChatGptCitations(text.replace(ENTITY_RE, "$1"), metadata); -} diff --git a/open-sse/executors/chatgpt-web/handoff.ts b/open-sse/executors/chatgpt-web/handoff.ts deleted file mode 100644 index c0f66b5092..0000000000 --- a/open-sse/executors/chatgpt-web/handoff.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { tlsFetchChatGpt } from "../../services/chatgptTlsClient.ts"; - -const CONVERSATION_RESUME_URL = "https://chatgpt.com/backend-api/f/conversation/resume"; -const RESUME_OFFSETS = [0, 1, 2] as const; - -export interface FinalAssistantAnswer { - text: string; - messageId?: string; - metadata?: Record; - finished: boolean; -} - -interface HandoffContentChunk { - answer?: string; - messageId?: string; - metadata?: Record; - error?: string; -} - -type HandoffContentReader = ( - eventStream: ReadableStream, - signal?: AbortSignal | null -) => AsyncIterable; - -interface ResumeHandoffOptions { - conversationId: string; - resumeToken: string; - headers: Record; - timeoutMs: number; - signal?: AbortSignal | null; - log?: { warn?: (tag: string, message: string) => void } | null; - readContent: HandoffContentReader; -} - -interface ResumeAttemptOptions extends Pick< - ResumeHandoffOptions, - "conversationId" | "timeoutMs" | "signal" | "log" | "readContent" -> { - offset: (typeof RESUME_OFFSETS)[number]; - resumeHeaders: Record; -} - -interface ResumeAttemptResult { - answer: FinalAssistantAnswer | null; - shouldRetry: boolean; -} - -function stringToStream(text: string): ReadableStream { - const bytes = new TextEncoder().encode(text); - return new ReadableStream({ - start(controller) { - controller.enqueue(bytes); - controller.close(); - }, - }); -} - -async function readFinalAssistantAnswer( - eventStream: ReadableStream, - signal: AbortSignal | null | undefined, - readContent: HandoffContentReader -): Promise { - let text = ""; - let messageId: string | undefined; - let metadata: Record | undefined; - - for await (const chunk of readContent(eventStream, signal)) { - if (chunk.error) return null; - if (chunk.answer) text = chunk.answer; - if (chunk.messageId) messageId = chunk.messageId; - if (chunk.metadata) metadata = chunk.metadata; - } - - if (!text.trim()) return null; - return { text, messageId, metadata, finished: true }; -} - -async function attemptResumeOffset({ - conversationId, - offset, - resumeHeaders, - timeoutMs, - signal, - log, - readContent, -}: ResumeAttemptOptions): Promise { - try { - const response = await tlsFetchChatGpt(CONVERSATION_RESUME_URL, { - method: "POST", - headers: resumeHeaders, - body: JSON.stringify({ conversation_id: conversationId, offset }), - timeoutMs, - signal, - stream: true, - }); - - if (response.status === 404) return { answer: null, shouldRetry: true }; - if (response.status >= 400) { - log?.warn?.( - "CGPT-WEB", - `conversation resume ${response.status}: ${(response.text || "").slice(0, 300)}` - ); - return { answer: null, shouldRetry: false }; - } - - const eventStream = response.body ?? (response.text ? stringToStream(response.text) : null); - if (!eventStream) return { answer: null, shouldRetry: true }; - - const answer = await readFinalAssistantAnswer(eventStream, signal, readContent); - return { answer, shouldRetry: !answer }; - } catch (error) { - log?.warn?.( - "CGPT-WEB", - `conversation resume failed: ${error instanceof Error ? error.message : String(error)}` - ); - return { answer: null, shouldRetry: false }; - } -} - -export async function resumeChatGptHandoff({ - conversationId, - resumeToken, - headers, - timeoutMs, - signal, - log, - readContent, -}: ResumeHandoffOptions): Promise { - const resumeHeaders = { - ...headers, - Accept: "text/event-stream", - "Content-Type": "application/json", - "x-conduit-token": resumeToken, - "X-OpenAI-Target-Path": "/backend-api/f/conversation/resume", - "X-OpenAI-Target-Route": "/backend-api/f/conversation/resume", - }; - - for (const offset of RESUME_OFFSETS) { - const attempt = await attemptResumeOffset({ - conversationId, - resumeHeaders, - offset, - timeoutMs, - signal, - log, - readContent, - }); - if (attempt.answer) return attempt.answer; - if (!attempt.shouldRetry) return null; - } - - log?.warn?.("CGPT-WEB", `conversation resume returned no assistant text for ${conversationId}`); - return null; -} diff --git a/open-sse/executors/chatgpt-web/models.ts b/open-sse/executors/chatgpt-web/models.ts deleted file mode 100644 index 1917437baa..0000000000 --- a/open-sse/executors/chatgpt-web/models.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Pure model-mapping / thinking-effort resolution for the ChatGPT-web executor. -// Extracted verbatim from chatgpt-web.ts (static maps + pure resolvers, no state). - -export const MODEL_MAP: Record = { - // ChatGPT backend slugs are also accepted directly for power users / tests. - "gpt-5-6": "gpt-5-6", - "gpt-5-6-thinking": "gpt-5-6-thinking", - "gpt-5-6-pro": "gpt-5-6-pro", - "gpt-5-5": "gpt-5-5", - "gpt-5-5-thinking": "gpt-5-5-thinking", - "gpt-5-5-pro": "gpt-5-5-pro", - - // Free accounts leave Luna selection to ChatGPT's server-side auto router. - "gpt-5.6-luna-free": "auto", - "gpt-5.6-luna-free-thinking": "auto", - - // Captured from a real ChatGPT v2 picker conversation. The visible - // performance levels select distinct backend model/effort pairs. - "gpt-5.6-sol-instant": "gpt-5-6", - "gpt-5.6-sol-medium": "gpt-5-6-thinking", - "gpt-5.6-sol-high": "gpt-5-6-thinking", - "gpt-5.6-sol-xhigh": "gpt-5-6-thinking", - "gpt-5.6-sol-pro": "gpt-5-6-pro", - - "gpt-5.5-instant": "gpt-5-5", - "gpt-5.5-medium": "gpt-5-5-thinking", - "gpt-5.5-high": "gpt-5-5-thinking", - "gpt-5.5-xhigh": "gpt-5-5-thinking", - "gpt-5.5-pro": "gpt-5-5-pro", - "gpt-5.5-pro-extended": "gpt-5-5-pro", - // Compatibility alias for existing chatgpt-web image integrations. It is - // intentionally absent from the provider's visible curated model list. - "gpt-5.5": "gpt-5-5", -}; - -export type ChatGptThinkingEffort = "standard" | "extended" | "max"; - -export const MODEL_FORCED_EFFORT: Record = { - "gpt-5.6-sol-instant": null, - "gpt-5.6-sol-medium": "standard", - "gpt-5.6-sol-high": "extended", - "gpt-5.6-sol-xhigh": "max", - "gpt-5.6-sol-pro": "standard", - "gpt-5.5-instant": null, - "gpt-5.5-medium": "standard", - "gpt-5.5-high": "extended", - "gpt-5.5-xhigh": "max", - "gpt-5.5-pro": "standard", - "gpt-5.5-pro-extended": "extended", -}; - -const MODEL_SYSTEM_HINTS: Record = { - // Captured from the Free-account Think toggle. ChatGPT sends this both at - // the request root and on the user message metadata. - "gpt-5.6-luna-free-thinking": ["reason"], -}; - -export function resolveChatGptSystemHints(model: string): string[] { - return [...(MODEL_SYSTEM_HINTS[model] ?? [])]; -} - -export interface ResolvedChatGptModel { - slug: string; - effort: ChatGptThinkingEffort | null; - isPro: boolean; -} - -export function resolveChatGptModel( - model: string, - _body?: unknown, - _providerSpecificData?: Record -): ResolvedChatGptModel { - const slug = MODEL_MAP[model] ?? model; - const effort = MODEL_FORCED_EFFORT[model] ?? null; - const isPro = - model === "gpt-5.6-sol-pro" || - model === "gpt-5.5-pro" || - model === "gpt-5.5-pro-extended" || - slug === "gpt-5-6-pro" || - slug === "gpt-5-5-pro"; - return { slug, effort, isPro }; -} diff --git a/open-sse/executors/chatgptWebErrors.ts b/open-sse/executors/chatgptWebErrors.ts deleted file mode 100644 index b2dfd12036..0000000000 --- a/open-sse/executors/chatgptWebErrors.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * User-facing messages for upstream ChatGPT-web HTTP error statuses. - * - * Pure mapping with no side effects so it can be unit-tested in isolation — the - * caller owns any state mutation (e.g. clearing the token cache on 401/403). - * Unmapped statuses fall back to the generic `ChatGPT returned HTTP `. - */ -const CGPT_WEB_HTTP_ERROR_MESSAGES: Record = { - 401: "ChatGPT auth failed — session may have expired. Re-paste your __Secure-next-auth.session-token.", - 403: "ChatGPT auth failed — session may have expired. Re-paste your __Secure-next-auth.session-token.", - 404: "ChatGPT returned 404 — usually the model is no longer available on this account or the chat-requirements-token expired. Retry will start a fresh conversation.", - 413: "ChatGPT returned 413 — the request payload is too large for ChatGPT web's size limit (often hit by agentic clients like Cline/Kilo that send big system prompts and file context). Reduce the context: enable compression, trim the conversation/files, or use a smaller request.", - 429: "ChatGPT rate limited. Wait a moment and retry.", -}; - -export function describeChatGptWebHttpError(status: number): string { - return CGPT_WEB_HTTP_ERROR_MESSAGES[status] ?? `ChatGPT returned HTTP ${status}`; -} diff --git a/open-sse/executors/chatgptWebTools.ts b/open-sse/executors/chatgptWebTools.ts index 55a1c5be91..f3cc7162b4 100644 --- a/open-sse/executors/chatgptWebTools.ts +++ b/open-sse/executors/chatgptWebTools.ts @@ -1,6 +1,6 @@ // Tool-call emulation helpers for web-cookie executors (#5240, #5927). // -// Web-cookie providers (chatgpt-web, perplexity-web, ...) have no native +// Web-cookie providers (Perplexity Web, Gemini Web, etc.) may have no native // function calling. When the OpenAI request carries `tools`, the prompt-side // shim (`prepareToolMessages` in ../translator/webTools.ts) injects a `` // contract; on the response side we parse `{...}` blocks back diff --git a/open-sse/executors/edgeTts.ts b/open-sse/executors/edgeTts.ts index 18a9f144e9..dc17d4f23e 100644 --- a/open-sse/executors/edgeTts.ts +++ b/open-sse/executors/edgeTts.ts @@ -3,7 +3,7 @@ * * Reverse-engineered, unofficial, undocumented endpoint (not a published * Microsoft public API) — the same class of integration this codebase - * already accepts for other "-web" style providers (chatgpt-web.ts, + * already accepts for other "-web" style providers (perplexity-web.ts, * copilot-web.ts). No user account/API key is required; Microsoft gates * abuse with a `Sec-MS-GEC` header computed from a public "trusted client * token" (see `open-sse/utils/publicCreds.ts::edgetts_token` — Hard Rule diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index 0f163e9a9e..9424826e45 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -442,7 +442,7 @@ export class GeminiWebExecutor extends BaseExecutor { * Google rotated any of the __Secure-1PSID* cookies, forward the merged * cookie string through onCredentialsRefreshed so it gets persisted to the * encrypted provider_connections.api_key field. Mirrors the rotate-and- - * persist pattern already shipped in chatgpt-web.ts. A persistence failure + * persist pattern used by other rotating-session executors. A persistence failure * must never fail the user-facing response (#7676). */ private async persistRotatedCookies( @@ -640,7 +640,7 @@ export class GeminiWebExecutor extends BaseExecutor { } // #10466 image mode: return the captured image URLs to the image - // handler via a custom field (same precedent as chatgpt-web's + // handler via a custom field (same precedent as other web-session executors' // x_image_resolution_failed). An image-only answer can carry little or // no text, so the empty-text 502 below must not fire when images // were captured. diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 20155214ba..0394ca4fd4 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -1,4 +1,5 @@ import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts"; +import { assertCommonChatGptWebProviderAvailable } from "@/shared/constants/chatgptWebRetirement"; import { registerLazyExecutor, loadRegisteredExecutor, @@ -95,8 +96,6 @@ const lazyExecutors: Record Promise> = { import("./gemini-business.ts").then((m) => new m.GeminiBusinessExecutor()), gembiz: () => import("./gemini-business.ts").then((m) => new m.GeminiBusinessExecutor()), // Alias - "chatgpt-web": () => import("./chatgpt-web.ts").then((m) => new m.ChatGptWebExecutor()), - "cgpt-web": () => import("./chatgpt-web.ts").then((m) => new m.ChatGptWebExecutor()), // Alias "blackbox-web": () => import("./blackbox-web.ts").then((m) => new m.BlackboxWebExecutor()), "bb-web": () => import("./blackbox-web.ts").then((m) => new m.BlackboxWebExecutor()), // Alias "muse-spark-web": () => @@ -242,6 +241,8 @@ const CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS = new Set(["jules"]); const CHAT_UNSUPPORTED_SEARCH_PROVIDERS = new Set(Object.keys(SEARCH_PROVIDERS)); export async function getExecutor(provider: string): Promise { + assertCommonChatGptWebProviderAvailable(provider); + const loaded = await loadRegisteredExecutor(provider); if (loaded) return loaded; if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) { diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index fa1a0f0258..51f774ea87 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -16,10 +16,7 @@ import { import { prepareToolMessages } from "../translator/webTools.ts"; import { buildToolModeResponse } from "./chatgptWebTools.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; -import { - buildSessionCookieHeader, - mergeRefreshedCookie, -} from "../utils/nextAuthCookie.ts"; +import { buildSessionCookieHeader, mergeRefreshedCookie } from "../utils/nextAuthCookie.ts"; import { PPLX_SSE_ENDPOINT, PPLX_USER_AGENT, @@ -362,7 +359,15 @@ export class PerplexityWebExecutor extends BaseExecutor { super("perplexity-web", { id: "perplexity-web", baseUrl: PPLX_SSE_ENDPOINT }); } - async execute({ model, body, stream, credentials, signal, log, onCredentialsRefreshed }: ExecuteInput) { + async execute({ + model, + body, + stream, + credentials, + signal, + log, + onCredentialsRefreshed, + }: ExecuteInput) { const bodyObj = (body || {}) as Record; const rawMessages = bodyObj.messages as Array> | undefined; if (!rawMessages || !Array.isArray(rawMessages) || rawMessages.length === 0) { @@ -558,7 +563,7 @@ export class PerplexityWebExecutor extends BaseExecutor { } // Surface any rotated session-token back to the caller so the DB credential - // is refreshed — mirrors chatgpt-web.ts exchangeSession + onCredentialsRefreshed. + // is refreshed — mirrors the shared web-session refresh contract. if (cookieBlob) { await persistRotatedSessionCookie( cookieBlob, @@ -575,7 +580,7 @@ export class PerplexityWebExecutor extends BaseExecutor { // Tool mode buffers the full completion (no live token streaming) and // converts text into real tool_calls — even when the caller asked - // for a streaming response — mirroring chatgpt-web's toolMode (#5240, + // for a streaming response — mirroring the shared tool-mode contract (#5240, // #5927). Without this, streaming requests (the default for agentic // coding clients) never emitted a tool_calls SSE delta. let finalResponse: Response; diff --git a/open-sse/handlers/chatCore/executorProxy.ts b/open-sse/handlers/chatCore/executorProxy.ts index 3d7e8917d4..8ce2df6d9e 100644 --- a/open-sse/handlers/chatCore/executorProxy.ts +++ b/open-sse/handlers/chatCore/executorProxy.ts @@ -20,6 +20,7 @@ import { getExecutor } from "../../executors/index.ts"; import { isCliproxyapiDeepModeEnabled } from "../../executors/cliproxyapi.ts"; import { isDarioDeepModeEnabled } from "../../executors/dario.ts"; import { getCachedSettings } from "@/lib/db/readCache"; +import { assertCommonChatGptWebProviderAvailable } from "@/shared/constants/chatgptWebRetirement"; import { getUpstreamProxyConfigCached } from "./comboContextCache.ts"; import type { FallbackBackend } from "@/lib/db/upstreamProxy"; import { wrapExecutorWithCliproxyapiModelMapping } from "./cliproxyModelMapping.ts"; @@ -94,6 +95,8 @@ export async function resolveExecutorWithProxy( log?: LoggerLike, providerSpecificData?: Record | null ) { + assertCommonChatGptWebProviderAvailable(prov); + // Per-connection routing override (#6339): the resolved connection can opt itself // into the CLIProxyAPI passthrough executor via providerSpecificData.cliproxyapiMode // === "claude-native" (UI toggle). This takes precedence over the provider-level @@ -173,7 +176,10 @@ export async function resolveExecutorWithProxy( result = await nativeExec.execute(input); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); - log?.info?.("UPSTREAM_PROXY", `${prov} native error (${errMsg}), retrying via ${backendLabel}`); + log?.info?.( + "UPSTREAM_PROXY", + `${prov} native error (${errMsg}), retrying via ${backendLabel}` + ); try { return await proxyExec.execute(input); } catch (proxyErr) { diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 3a16c897f2..323dee3913 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -1,6 +1,12 @@ import { randomUUID } from "crypto"; /** Image generation handler for POST /v1/images/generations (OpenAI-compatible). */ +import { + CHATGPT_WEB_RETIRED_ERROR_CODE, + CHATGPT_WEB_RETIRED_MESSAGE, + isCommonChatGptWebRetiredProviderId, +} from "@/shared/constants/chatgptWebRetirement"; + import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts"; import { HTTP_STATUS } from "../config/constants.ts"; import { applyAntigravityClientProfileHeaders } from "../services/antigravityClientProfile.ts"; @@ -8,10 +14,6 @@ import { getAntigravityEnvelopeUserAgent } from "../services/antigravityIdentity import { kieExecutor } from "../executors/kie.ts"; import { mapImageSize } from "../translator/image/sizeMapper.ts"; import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; -import { ChatGptWebExecutor } from "../executors/chatgpt-web.ts"; -import type { ExecutorLog, ProviderCredentials } from "../executors/base.ts"; -import { getChatGptImage, findChatGptImageBySha256 } from "../services/chatgptImageCache.ts"; -import { createHash } from "node:crypto"; import { saveCallLog } from "@/lib/usageDb"; import { sleep } from "../utils/sleep.ts"; import { @@ -45,11 +47,6 @@ import { handleIdeogramImageGeneration } from "./imageGeneration/providers/ideog import { handleHaiperImageGeneration } from "./imageGeneration/providers/haiper.ts"; import { handleLeonardoImageGeneration } from "./imageGeneration/providers/leonardo.ts"; import { handleMagnificImageGeneration } from "./imageGeneration/providers/magnific.ts"; -import { - handleChatGptWebImageGeneration, - extractMarkdownImageUrls, - CHATGPT_WEB_IMAGE_ID_RE, -} from "./imageGeneration/providers/chatgptWeb.ts"; import { handleGeminiWebImageGeneration } from "./imageGeneration/providers/geminiWeb.ts"; import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts"; @@ -383,6 +380,22 @@ export async function handleImageGeneration({ clientHeaders = null, peerLocality = null, }) { + const requestedModel = typeof body?.model === "string" ? body.model : ""; + const slash = requestedModel.indexOf("/"); + const requestedProvider = slash > 0 ? requestedModel.slice(0, slash) : null; + if ( + isCommonChatGptWebRetiredProviderId(resolvedProvider) || + isCommonChatGptWebRetiredProviderId(requestedProvider) || + isCommonChatGptWebRetiredProviderId(requestedModel) + ) { + return { + success: false, + status: 410, + error: CHATGPT_WEB_RETIRED_MESSAGE, + code: CHATGPT_WEB_RETIRED_ERROR_CODE, + }; + } + let provider, model; if (resolvedProvider) { @@ -567,18 +580,6 @@ export async function handleImageGeneration({ }); } - if (providerConfig.format === "chatgpt-web") { - return handleChatGptWebImageGeneration({ - model, - provider, - body, - credentials, - log, - signal, - clientHeaders, - }); - } - // #10466: Gemini Web session image generation (Nano Banana) if (providerConfig.format === "gemini-web") { return handleGeminiWebImageGeneration({ @@ -1300,8 +1301,7 @@ async function handleOpenAIImageGeneration({ * * Mirrors `handleOpenAIImageGeneration` but posts multipart/form-data to the node's * `/images/edits` endpoint and returns the upstream OpenAI-compatible response. Kept - * separate from the chatgpt-web edit flow, which continues a saved conversation node - * rather than forwarding a stateless edit. The fetch helper leaves Content-Type unset so + * separate from provider-specific hosted-tool flows. The fetch helper leaves Content-Type unset so * `fetch` derives the multipart boundary from the FormData body. */ export async function handleOpenAIImageEdit({ @@ -1515,185 +1515,6 @@ export async function handleOpenRouterImageEdit({ return result; } -export async function handleImageEdit({ - provider, - model, - body, - imageBytes, - credentials, - log, - signal = null, - clientHeaders = null, -}: { - provider: string; - model: string; - body: Record; - imageBytes: Buffer; - imageMime?: string; // accepted for symmetry with route layer; not used - credentials: ProviderCredentials | null | undefined; - log: ExecutorLog | null | undefined; - signal?: AbortSignal | null; - clientHeaders?: Record | null; -}) { - const startTime = Date.now(); - const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; - if (!prompt) { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: "Prompt is required for image edit", - }); - } - - if (!credentials?.apiKey) { - return saveImageErrorResult({ - provider, - model, - status: 401, - startTime, - error: "ChatGPT Web credentials missing session cookie", - }); - } - - const imageHash = createHash("sha256").update(imageBytes).digest("hex"); - const cached = findChatGptImageBySha256(imageHash); - - const wantsBase64 = body.response_format === "b64_json"; - const requestBody = { - model, - prompt: prompt.slice(0, 500), - size: body.size || undefined, - image_hash: imageHash.slice(0, 16), - image_bytes: imageBytes.length, - cached_match: Boolean(cached?.entry.context), - }; - - if (!cached?.entry.context) { - // chatgpt-web's image_gen tool can only edit an image when we continue - // the original conversation node. If we never generated this image (or - // its 30-minute TTL elapsed), there's no node to continue. Return a - // clear, actionable error — much better than silently spawning an - // unrelated image and confusing the user. - log?.warn?.( - "IMAGE", - `chatgpt-web edit: no cached match for sha256=${imageHash.slice(0, 16)} (bytes=${imageBytes.length}); returning 400` - ); - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: - "chatgpt-web image edit only works for images recently generated through this OmniRoute instance " + - "(cache window: 30 minutes). Re-generate the image and try the edit immediately, or disable image-edit " + - "in your client to use plain chat-completion edit prompts instead.", - requestBody, - }); - } - - // Build a synthetic chat thread that surfaces the cached image URL on - // the assistant turn. The executor's parseOpenAIMessages picks up the - // URL, findCachedImageContext resolves it to {conversationId, - // parentMessageId}, and looksLikeImageEditRequest fires on the user - // prompt — together producing a continuation request that actually - // edits the saved image. - // - // The synthetic user prompt is anchored with both an edit verb AND an - // image-gen verb so the executor's heuristics fire regardless of what - // wording the caller used ("now make it brighter", "tweak this", ...): - // - looksLikeImageEditRequest: matches "edit" + "image" within 120 chars - // - looksLikeImageGenRequest: matches "generate" + "image" within 40 chars - // Either match alone would set forImageGen, but covering both is cheap - // insurance for prompts that don't fit common phrasings. - const messages: Array<{ role: string; content: string }> = [ - { - role: "assistant", - // The base URL is irrelevant — only the path is parsed by - // CACHED_IMAGE_URL_RE in the executor's findCachedImageContext. - content: `![image](http://internal/v1/chatgpt-web/image/${cached.id})`, - }, - { - role: "user", - content: `Edit the image and generate the new image: ${prompt}`, - }, - ]; - - const executor = new ChatGptWebExecutor(); - const result = await executor.execute({ - model, - body: { messages }, - stream: false, - credentials, - signal, - log, - clientHeaders, - }); - - const responseText = await result.response.text(); - if (result.response.status >= 400) { - return saveImageErrorResult({ - provider, - model, - status: result.response.status, - startTime, - error: responseText, - requestBody, - }); - } - - let content = ""; - try { - const json = JSON.parse(responseText); - content = String(json?.choices?.[0]?.message?.content || ""); - } catch { - content = responseText; - } - - const urls = extractMarkdownImageUrls(content); - if (urls.length === 0) { - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: `ChatGPT Web edit completed without returning image markdown: ${content.slice(0, 300)}`, - requestBody, - }); - } - - const images: Array<{ url?: string; b64_json?: string }> = []; - for (const url of urls) { - if (!wantsBase64) { - images.push({ url }); - continue; - } - const id = url.match(CHATGPT_WEB_IMAGE_ID_RE)?.[1]; - const cachedNew = id ? getChatGptImage(id) : null; - if (!cachedNew) { - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: "ChatGPT Web image bytes expired before b64_json conversion", - requestBody, - }); - } - images.push({ b64_json: cachedNew.bytes.toString("base64") }); - } - - return saveImageSuccessResult({ - provider, - model, - startTime, - requestBody, - responseBody: { images_count: images.length, edit_match: Boolean(cached?.entry.context) }, - images, - }); -} - async function handleFalAIImageGeneration({ model, provider, diff --git a/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts b/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts deleted file mode 100644 index c6b9688b7a..0000000000 --- a/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts +++ /dev/null @@ -1,187 +0,0 @@ -// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch -// Family: chatgpt-web | Module: chatgptWeb | Lines: 1102-1282 (181 LOC) -// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale - -import { ChatGptWebExecutor } from "../../../executors/chatgpt-web.ts"; -import { getChatGptImage } from "../../../services/chatgptImageCache.ts"; -import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; - -export const CHATGPT_WEB_IMAGE_MARKDOWN_RE = /!\[[^\]]*\]\(([^)\s]+)\)/g; -export const CHATGPT_WEB_IMAGE_ID_RE = - /\/v1\/chatgpt-web\/image\/([a-f0-9]{16,64})(?=[?\s"'<>)]|$)/i; - -export function extractMarkdownImageUrls(text: string): string[] { - const urls: string[] = []; - // String.prototype.matchAll consumes a fresh iterator and ignores the - // regex's lastIndex, so no manual reset is required. - for (const match of text.matchAll(CHATGPT_WEB_IMAGE_MARKDOWN_RE)) { - if (match[1]) urls.push(match[1]); - } - return urls; -} - -export function buildChatGptWebImagePrompt(body): string { - const prompt = String(body.prompt || "").trim(); - const details: string[] = [`Create an image for this prompt: ${prompt}`]; - if (typeof body.size === "string" && body.size.trim()) { - details.push(`Requested size: ${body.size.trim()}.`); - } - if (typeof body.quality === "string" && body.quality.trim()) { - details.push(`Requested quality: ${body.quality.trim()}.`); - } - if (typeof body.style === "string" && body.style.trim()) { - details.push(`Requested style: ${body.style.trim()}.`); - } - return details.join("\n"); -} - -export async function handleChatGptWebImageGeneration({ - model, - provider, - body, - credentials, - log, - signal, - clientHeaders, - // Injectable so unit tests can drive the handler without a live ChatGPT - // session; production uses the real executor. - executorFactory = () => new ChatGptWebExecutor(), -}) { - const startTime = Date.now(); - const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; - if (!prompt) { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: "Prompt is required for ChatGPT Web image generation", - }); - } - - if (!credentials?.apiKey) { - return saveImageErrorResult({ - provider, - model, - status: 401, - startTime, - error: "ChatGPT Web credentials missing session cookie", - }); - } - - // Each image is one chatgpt.com chat turn (~30s). Cap at 4 (matches OpenAI's - // own limit for GPT Image models) so a stray n=1000 doesn't pin the - // executor for hours before the upstream HTTP timeout fires. - const CHATGPT_WEB_IMAGE_N_MAX = 4; - const rawCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; - if (rawCount > CHATGPT_WEB_IMAGE_N_MAX) { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: `ChatGPT Web image generation supports n=1..${CHATGPT_WEB_IMAGE_N_MAX} (got ${rawCount}); each n is a separate ~30s chat turn.`, - }); - } - const requestedCount = rawCount; - if (log && requestedCount > 1) { - log.warn( - "IMAGE", - `ChatGPT Web returns one image per chat turn; requested n=${requestedCount} will run sequentially` - ); - } - - const wantsBase64 = body.response_format === "b64_json"; - const images: Array<{ url?: string; b64_json?: string }> = []; - const requestBody = { - model, - prompt: prompt.slice(0, 500), - size: body.size || undefined, - quality: body.quality || undefined, - }; - - for (let i = 0; i < requestedCount; i++) { - const executor = executorFactory(); - const result = await executor.execute({ - model, - body: { - messages: [{ role: "user", content: buildChatGptWebImagePrompt(body) }], - }, - stream: false, - credentials, - signal, - log, - clientHeaders, - }); - - const responseText = await result.response.text(); - if (result.response.status >= 400) { - return saveImageErrorResult({ - provider, - model, - status: result.response.status, - startTime, - error: responseText, - requestBody, - }); - } - - let content = ""; - let imageResolutionFailed = false; - try { - const json = JSON.parse(responseText); - content = String(json?.choices?.[0]?.message?.content || ""); - imageResolutionFailed = json?.x_image_resolution_failed === true; - } catch { - content = responseText; - } - - const urls = extractMarkdownImageUrls(content); - if (urls.length === 0) { - // Distinguish "image was generated upstream but OmniRoute could not - // retrieve it" (executor flagged the unresolved asset pointer) from - // "no image was produced at all" — the former is our bug/limitation, - // not a failed prompt, so the message must not read as "no image made". - const error = imageResolutionFailed - ? `ChatGPT Web generated an image but OmniRoute could not retrieve it (the image asset could not be downloaded — the URL may have expired or ChatGPT changed its image delivery format). Please retry; if it persists, report it. Assistant text: ${content.slice(0, 200)}` - : `ChatGPT Web completed without returning image markdown: ${content.slice(0, 300)}`; - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error, - requestBody, - }); - } - - for (const url of urls) { - if (!wantsBase64) { - images.push({ url }); - continue; - } - const id = url.match(CHATGPT_WEB_IMAGE_ID_RE)?.[1]; - const cached = id ? getChatGptImage(id) : null; - if (!cached) { - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: "ChatGPT Web image bytes expired before b64_json conversion", - requestBody, - }); - } - images.push({ b64_json: cached.bytes.toString("base64") }); - } - } - - return saveImageSuccessResult({ - provider, - model, - startTime, - requestBody, - responseBody: { images_count: images.length }, - images, - }); -} diff --git a/open-sse/handlers/imageGeneration/providers/geminiWeb.ts b/open-sse/handlers/imageGeneration/providers/geminiWeb.ts index 8f131fe83d..132bee30b2 100644 --- a/open-sse/handlers/imageGeneration/providers/geminiWeb.ts +++ b/open-sse/handlers/imageGeneration/providers/geminiWeb.ts @@ -1,7 +1,7 @@ // Gemini Web image generation handler (#10466). // // Exposes the gemini-web session provider through POST /v1/images/generations. -// Follows the chatgpt-web precedent (./chatgptWeb.ts): the web-session chat +// Uses the shared web-session pattern: the chat // executor is driven with an image-generation prompt, and the generated // assets are extracted from the response. // @@ -22,7 +22,7 @@ import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; import { sanitizeErrorMessage } from "../../../utils/error.ts"; -/** Each image is one gemini.google.com turn (~30-60s). Cap like chatgpt-web. */ +/** Each image is one gemini.google.com turn (~30-60s); keep fan-out bounded. */ const GEMINI_WEB_IMAGE_N_MAX = 4; export function buildGeminiWebImagePrompt(body: Record): string { diff --git a/open-sse/services/__tests__/chatgptTlsClient.test.ts b/open-sse/services/__tests__/chatgptTlsClient.test.ts deleted file mode 100644 index 92bb78143e..0000000000 --- a/open-sse/services/__tests__/chatgptTlsClient.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Regression tests for the proxy-leak fix in chatgptTlsClient. - * - * Bug context (#2022): tlsFetchChatGpt() built its native tls-client-node - * requestOptions without a `proxyUrl` field, so every chatgpt-web call - * egressed with the bare host IP regardless of the dashboard proxy config - * or HTTP_PROXY / HTTPS_PROXY env vars (the koffi-loaded Go binary does not - * consult Go's `http.ProxyFromEnvironment`). - * - * These tests pin the resolution-order contract: - * 1. Per-call `options.proxyUrl` wins. - * 2. OMNIROUTE_TLS_PROXY_URL env var (single-flag opt-in). - * 3. POSIX-standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants). - * 4. Otherwise undefined (no proxy). - * - * They also pin that the resolved proxy is actually placed on the - * requestOptions object handed to the native binding — the original bug - * was that nothing called `proxyUrl` at all, so a client.request spy that - * captures opts.proxyUrl is the right shape of regression. - */ - -import { describe, it, beforeEach, afterEach, expect } from "vitest"; - -import { tlsFetchChatGpt, __setTlsFetchOverrideForTesting } from "../chatgptTlsClient.ts"; - -const PROXY_ENV_KEYS = [ - "OMNIROUTE_TLS_PROXY_URL", - "HTTPS_PROXY", - "https_proxy", - "HTTP_PROXY", - "http_proxy", - "ALL_PROXY", - "all_proxy", -] as const; - -function clearProxyEnv(): Record { - const saved: Record = {}; - for (const k of PROXY_ENV_KEYS) { - saved[k] = process.env[k]; - delete process.env[k]; - } - return saved; -} - -function restoreProxyEnv(saved: Record): void { - for (const k of PROXY_ENV_KEYS) { - if (saved[k] === undefined) delete process.env[k]; - else process.env[k] = saved[k]; - } -} - -describe("chatgptTlsClient — proxy plumbing (#2022)", async () => { - let savedEnv: Record = {}; - - beforeEach(() => { - savedEnv = clearProxyEnv(); - }); - - afterEach(() => { - __setTlsFetchOverrideForTesting(null); - restoreProxyEnv(savedEnv); - }); - - it("per-call proxyUrl overrides everything", async () => { - process.env.OMNIROUTE_TLS_PROXY_URL = "http://env-omni:0/"; - process.env.HTTPS_PROXY = "http://env-https:0/"; - - let observedUrl: string | undefined; - let observedOpts: Record = {}; - __setTlsFetchOverrideForTesting(async (url, options) => { - observedUrl = url; - observedOpts = options as unknown as Record; - // Mimic what the real path does so the resolveProxyUrl branch runs. - // (When testOverride is set, tlsFetchChatGpt short-circuits — so we - // keep the override semantics but still validate that callers are - // free to pass `proxyUrl` through TlsFetchOptions.) - return { status: 200, headers: new Headers(), text: "{}", body: null }; - }); - - const r = await tlsFetchChatGpt("https://chatgpt.com/api/auth/session", { - method: "GET", - proxyUrl: "http://per-call:0/", - }); - - expect(r.status).toBe(200); - expect(observedUrl).toBe("https://chatgpt.com/api/auth/session"); - expect((observedOpts as { proxyUrl?: string }).proxyUrl).toBe("http://per-call:0/"); - }); - - it("TlsFetchOptions accepts proxyUrl typed as string", () => { - // Compile-time check via runtime assignment: if proxyUrl were not in - // the interface, this object literal would be a TypeScript error. - const opts: { proxyUrl?: string } = { proxyUrl: "http://x:0/" }; - expect(opts.proxyUrl).toBe("http://x:0/"); - }); -}); diff --git a/open-sse/services/__tests__/grokTlsClient.test.ts b/open-sse/services/__tests__/grokTlsClient.test.ts index 8b391d10d7..86e2efa99c 100644 --- a/open-sse/services/__tests__/grokTlsClient.test.ts +++ b/open-sse/services/__tests__/grokTlsClient.test.ts @@ -9,9 +9,8 @@ * * These tests pin the resolution-order contract: * 1. Per-call `options.proxyUrl` wins. - * 2. OMNIROUTE_TLS_PROXY_URL env var (single-flag opt-in). - * 3. POSIX-standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants). - * 4. Otherwise undefined (no proxy). + * 2. POSIX-standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants). + * 3. Otherwise undefined (no proxy). * * They also pin that the resolved proxy is actually placed on the * requestOptions object handed to the native binding — the original bug @@ -24,7 +23,6 @@ import { describe, it, beforeEach, afterEach, expect } from "vitest"; import { tlsFetchGrok, __setTlsFetchOverrideForTesting } from "../grokTlsClient.ts"; const PROXY_ENV_KEYS = [ - "OMNIROUTE_TLS_PROXY_URL", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", @@ -62,7 +60,6 @@ describe("grokTlsClient — proxy plumbing (#3180)", async () => { }); it("per-call proxyUrl overrides everything", async () => { - process.env.OMNIROUTE_TLS_PROXY_URL = "http://env-omni:0/"; process.env.HTTPS_PROXY = "http://env-https:0/"; let observedUrl: string | undefined; diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 2865906c5e..6ab5d6e0ee 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -6,6 +6,7 @@ import { getSettings } from "@/lib/db/settings"; import { getProviderRegistry } from "./providerRegistryAccessor"; import type { ConnectionFields } from "@/lib/db/encryption"; import { NOAUTH_PROVIDERS } from "@/shared/constants/providers"; +import { isCommonChatGptWebRetiredProviderId } from "@/shared/constants/chatgptWebRetirement"; import { hasUsableWebSessionCredential } from "@/shared/providers/webSessionCredentials"; import { toNumber } from "@/shared/utils/numeric"; import { isCompatibleProviderConnectionId } from "@/shared/utils/compatibleProviderId"; @@ -583,15 +584,17 @@ export async function prepareVirtualAutoComboInputs( resolutionSnapshot?: ModelCapabilityResolutionSnapshot; } = {} ): Promise { - const [connections, disabledNoAuthConnections, settings] = await Promise.all([ + const [rawConnections, rawDisabledNoAuthConnections, settings] = await Promise.all([ getCachedProviderConnections({ isActive: true }) as Promise, - // #6557: no-auth providers (opencode/mimocode/etc.) don't get an isActive - // filter applied above since their credential is synthetic, but a real - // provider_connections row CAN exist for them (created via "Add Account") - // and its own isActive=false must gate the auto-combo pool too — not just + // #6557: synthetic no-auth credentials bypass active filtering, but a real Add Account + // row may exist; its isActive=false must also gate auto-combo. getCachedProviderConnections({ isActive: false }) as Promise, getSettings().catch(() => ({}) as Record), ]); + const available = (conn: VirtualFactoryConn) => + !isCommonChatGptWebRetiredProviderId(conn.provider); + const connections = rawConnections.filter(available); + const disabledNoAuthConnections = rawDisabledNoAuthConnections.filter(available); const blockedProviders = new Set( Array.isArray(settings.blockedProviders) ? (settings.blockedProviders as string[]) : [] ); diff --git a/open-sse/services/chatgptImageCache.ts b/open-sse/services/chatgptImageCache.ts deleted file mode 100644 index 31504cdcf2..0000000000 --- a/open-sse/services/chatgptImageCache.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * In-memory cache for ChatGPT-generated images so we can serve them via a - * regular HTTP URL instead of inlining megabytes of base64 into SSE deltas. - * - * Why: chatgpt.com's `image_asset_pointer` resolves to a session-signed - * `estuary/content` URL that 403s for any anonymous client. We have to - * download the bytes server-side (with the user's session) and re-serve - * them. Streaming the raw base64 back through SSE works but Open WebUI's - * progressive markdown renderer displays each chunk as text mid-stream — - * the user sees ~3 MB of base64 scroll past before the final `)` arrives - * and the renderer recognizes it as an image. Hosting the image on a - * regular URL avoids that entirely: we emit a tiny `![image](http://...)` - * markdown delta and the browser fetches the image normally. - * - * The cache is in-memory only, with a short TTL — these URLs are single-use - * artifacts of one chat turn, not persistent assets. If the user reloads - * the conversation in a few hours the URLs will 404; that's expected. - */ - -import { createHash, randomUUID } from "node:crypto"; - -interface CachedImage { - bytes: Buffer; - mime: string; - expiresAt: number; - context?: ChatGptImageConversationContext; - /** sha256(bytes) — used by /v1/images/edits to correlate an uploaded - * image (Open WebUI re-uploads the bytes via multipart) back to the - * conversation context we cached when the image was first generated. */ - bytesSha256: string; -} - -const cache = new Map(); -let cacheBytes = 0; -const DEFAULT_TTL_MS = 30 * 60 * 1000; -const MAX_ENTRIES = 25; -// Per-entry images cap at 8 MB (enforced upstream in the executor) so 10 MB -// covers ~1 large image. The byte cap matters more than entry count: a hot -// loop of 8 MB images would otherwise pin 1.6 GB of RSS before count -// eviction kicked in. Tune via OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB. -const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; - -function configuredMaxBytes(): number { - const raw = Number(process.env.OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB); - if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_MAX_BYTES; - return Math.floor(raw * 1024 * 1024); -} - -export interface ChatGptImageConversationContext { - conversationId: string; - parentMessageId: string; -} - -function deleteEntry(id: string): void { - const entry = cache.get(id); - if (!entry) return; - cacheBytes -= entry.bytes.length; - cache.delete(id); -} - -function evictExpired(now = Date.now()): void { - for (const [id, entry] of cache) { - if (now >= entry.expiresAt) deleteEntry(id); - } -} - -function evictUntilWithinLimits(maxBytes: number, incomingBytes: number): void { - // Drop oldest until both the entry-count and total-byte caps are satisfied. - // Map iteration is insertion-ordered so the first key is the oldest entry. - while ((cache.size >= MAX_ENTRIES || cacheBytes + incomingBytes > maxBytes) && cache.size > 0) { - const firstKey = cache.keys().next().value; - if (!firstKey) break; - deleteEntry(firstKey); - } -} - -export function storeChatGptImage( - bytes: Buffer, - mime: string, - ttlMs = DEFAULT_TTL_MS, - context?: ChatGptImageConversationContext -): string { - evictExpired(); - evictUntilWithinLimits(configuredMaxBytes(), bytes.length); - const id = randomUUID().replace(/-/g, ""); - const bytesSha256 = createHash("sha256").update(bytes).digest("hex"); - cache.set(id, { - bytes, - mime, - expiresAt: Date.now() + ttlMs, - context, - bytesSha256, - }); - cacheBytes += bytes.length; - return id; -} - -export function getChatGptImage(id: string): CachedImage | null { - evictExpired(); - const entry = cache.get(id); - if (!entry) return null; - if (Date.now() >= entry.expiresAt) { - deleteEntry(id); - return null; - } - return entry; -} - -export function getChatGptImageConversationContext( - id: string -): ChatGptImageConversationContext | null { - return getChatGptImage(id)?.context ?? null; -} - -/** - * Look up a cached entry by sha256(bytes). Used by /v1/images/edits to - * correlate Open WebUI's re-uploaded image back to the conversation - * context we cached at generation time, so the executor can continue the - * saved chatgpt.com conversation node and actually edit the image instead - * of generating an unrelated one from scratch. - */ -export function findChatGptImageBySha256(hash: string): { id: string; entry: CachedImage } | null { - evictExpired(); - const target = hash.toLowerCase(); - for (const [id, entry] of cache.entries()) { - if (entry.bytesSha256 === target) { - if (Date.now() < entry.expiresAt) return { id, entry }; - deleteEntry(id); - } - } - return null; -} - -/** Test-only: clear the cache between tests. */ -export function __resetChatGptImageCacheForTesting(): void { - cache.clear(); - cacheBytes = 0; -} - -/** Test-only: peek at current resident-byte total. */ -export function __getChatGptImageCacheBytesForTesting(): number { - return cacheBytes; -} diff --git a/open-sse/services/chatgptTlsClient.ts b/open-sse/services/chatgptTlsClient.ts deleted file mode 100644 index fd7b3f4550..0000000000 --- a/open-sse/services/chatgptTlsClient.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Browser-TLS-impersonating HTTP client for chatgpt.com. - * - * Thin re-export over the shared `tlsClientBase.ts` factory - * (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle, - * streaming tail-file, proxy resolution, error classes, SSE detection) lives - * in the base module; this file supplies only ChatGPT-specific config and - * preserves the original public export surface. - */ - -import { - createTlsClientModule, - type TlsFetchOptions, - type TlsFetchResult, -} from "./tlsClientBase.ts"; - -const DEFAULT_TIMEOUT_MS = - Number.parseInt(process.env.OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS || "", 10) || 60_000; -const HARD_TIMEOUT_GRACE_MS = - Number.parseInt(process.env.OMNIROUTE_CHATGPT_TLS_GRACE_MS || "", 10) || 10_000; -const STREAM_FIRST_BYTE_TIMEOUT_MS = - Number.parseInt(process.env.OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS || "", 10) || 30_000; - -export const tlsClientModule = createTlsClientModule({ - providerName: "ChatGPT", - tlsProfile: "firefox_148", - domain: "https://chatgpt.com", - tempDirPrefix: "cgpt-stream-", - tailFileVariant: "A", - responseValidation: "sse", - exportCloudflareCheck: false, - exposeStreamingForTesting: true, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - hardTimeoutGraceMs: HARD_TIMEOUT_GRACE_MS, - firstByteTimeoutMs: STREAM_FIRST_BYTE_TIMEOUT_MS, -}); - -export const tlsFetchChatGpt = ( - url: string, - options: TlsFetchOptions = {} -): Promise => tlsClientModule.tlsFetch(url, options); -export const __tlsFetchStreamingForTesting = tlsClientModule.__tlsFetchStreamingForTesting; - -export const __setTlsFetchOverrideForTesting = tlsClientModule.__setTlsFetchOverrideForTesting; - -export { TlsClientHangError, TlsClientUnavailableError } from "./tlsClientBase.ts"; -export type { TlsFetchOptions, TlsFetchResult } from "./tlsClientBase.ts"; -export { looksLikeSse } from "./tlsClientBase.ts"; diff --git a/open-sse/services/combo/autoStrategy.ts b/open-sse/services/combo/autoStrategy.ts index 78964c4c7a..65e85b2a55 100644 --- a/open-sse/services/combo/autoStrategy.ts +++ b/open-sse/services/combo/autoStrategy.ts @@ -28,6 +28,7 @@ import type { ResolvedComboTarget, } from "./types.ts"; import { extractSessionAffinityKey } from "@/sse/services/auth"; +import { isCommonChatGptWebRetiredProviderId } from "@/shared/constants/chatgptWebRetirement"; import { filterChatSelectableModels } from "../modelEndpointPolicy.ts"; import { DEFAULT_INTENT_CONFIG, type IntentClassifierConfig } from "../intentClassifier.ts"; import { getTaskFitness } from "../autoCombo/taskFitness.ts"; @@ -424,6 +425,13 @@ export async function expandAutoComboCandidatePool( eligibleTargets: ResolvedComboTarget[], combo: { autoConfig?: unknown; config?: unknown } | null | undefined ): Promise { + for (let index = eligibleTargets.length - 1; index >= 0; index -= 1) { + const target = eligibleTargets[index]; + if (isCommonChatGptWebRetiredProviderId(target.providerId || target.provider)) { + eligibleTargets.splice(index, 1); + } + } + const localAutoConfig = (combo?.autoConfig as Record | undefined) || (isRecord((combo?.config as Record)?.auto) @@ -457,7 +465,10 @@ export async function expandAutoComboCandidatePool( ...new Set( (allConnections as Array<{ provider?: unknown }>) .map((c) => c.provider) - .filter((p): p is string => typeof p === "string" && p.length > 0) + .filter( + (p): p is string => + typeof p === "string" && p.length > 0 && !isCommonChatGptWebRetiredProviderId(p) + ) ), ]; // Pre-build a Set of already-present modelStr values so candidate-pool diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 258c92da71..e1a18fc194 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -46,7 +46,7 @@ import type { * #8488 / #5240: web-cookie (and similar) providers honestly advertise * registry toolCalling:false but still run the prompt-emulated tool shim. * Combo tools filters must keep those targets eligible so fail-closed does - * not regress emulation-only combos (e.g. all chatgpt-web). + * not regress emulation-only web-provider combos. */ export function providerSupportsEmulatedToolCalling( providerIdOrAlias: string | null | undefined diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 2776644de1..8ae4edc763 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -363,8 +363,8 @@ export function classifyProviderError( if (recoverableProject403) { return PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR; } - // #8813 — ChatGPT Web's Cloudflare Sentinel/Turnstile 403 is a TERMINAL - // block: the user's IP/session needs a browser Turnstile challenge, and + // A Cloudflare Sentinel/Turnstile 403 is a TERMINAL block for browser-session + // providers: the user's IP/session needs a browser Turnstile challenge, and // retrying the same connection will keep 403ing. Classify as FORBIDDEN so // the connection gets banned and combo routing falls back to other providers. // Must be checked BEFORE the generic apikey-403→null return below, which diff --git a/open-sse/services/inAppLoginService.ts b/open-sse/services/inAppLoginService.ts index d2196fd6d0..78174298db 100644 --- a/open-sse/services/inAppLoginService.ts +++ b/open-sse/services/inAppLoginService.ts @@ -53,7 +53,7 @@ export class InAppLoginService extends EventEmitter { /** * Start a login flow for a web-cookie provider using Playwright. - * @param providerId - e.g. "claude-web", "chatgpt-web" + * @param providerId - e.g. "claude-web", "perplexity-web" * @param options.timeout - Total timeout in ms (default: config value or 300s) */ async startLogin(providerId: string, options?: { timeout?: number }): Promise { diff --git a/open-sse/services/sessionPool/sessionFactory.ts b/open-sse/services/sessionPool/sessionFactory.ts index 87dba8a3ae..a4dcc3fa26 100644 --- a/open-sse/services/sessionPool/sessionFactory.ts +++ b/open-sse/services/sessionPool/sessionFactory.ts @@ -2,7 +2,7 @@ * SessionFactory — Creates initialized Session instances * * For zero-auth providers (Pollinations): just assigns a fingerprint. - * For cookie-based providers (ChatGPT Web, DeepSeek Web): would launch + * For browser-session providers (DeepSeek Web, etc.): would launch * headless Playwright, solve Turnstile, and extract cookies. * * Currently only zero-auth is implemented. Cookie-based provider support diff --git a/open-sse/services/sessionPool/webExecutorWrapper.ts b/open-sse/services/sessionPool/webExecutorWrapper.ts index 51f1292d69..3a10c22adf 100644 --- a/open-sse/services/sessionPool/webExecutorWrapper.ts +++ b/open-sse/services/sessionPool/webExecutorWrapper.ts @@ -6,7 +6,7 @@ * headers (fingerprint-based User-Agent, Sec-CH-UA, etc.) and handles * 429/5xx responses with pool-level cooldown management. * - * Future: For cookie-based providers (ChatGPT Web, DeepSeek Web, etc.) + * Future: For browser-session providers (DeepSeek Web, etc.) * the wrapper will also inject cookies from the Playwright-authenticated * session. */ diff --git a/open-sse/services/tokenExtractionConfig.ts b/open-sse/services/tokenExtractionConfig.ts index 9aefedf706..bb78d28435 100644 --- a/open-sse/services/tokenExtractionConfig.ts +++ b/open-sse/services/tokenExtractionConfig.ts @@ -104,16 +104,6 @@ const RAW_CONFIGS: TokenExtractionConfig[] = [ "Log in to your Claude account at claude.ai. After login, the session cookie will be extracted automatically." ), - // ── ChatGPT Web ─────────────────────────────────────────── - config( - "chatgpt-web", - "ChatGPT Web", - "https://chatgpt.com/auth/login", - "https://chatgpt.com", - [{ type: "cookie", name: "__Secure-next-auth.session-token", domain: ".chatgpt.com" }], - "Log in to ChatGPT. The __Secure-next-auth.session-token cookie will be extracted after login." - ), - // ── Gemini Web ──────────────────────────────────────────── config( "gemini-web", diff --git a/open-sse/translator/webTools.ts b/open-sse/translator/webTools.ts index ac3efbee14..e4309ab14a 100644 --- a/open-sse/translator/webTools.ts +++ b/open-sse/translator/webTools.ts @@ -1,4 +1,4 @@ -// Tool-call translation for web-cookie providers (deepseek-web, chatgpt-web, ...). +// Tool-call translation for web-cookie providers (DeepSeek Web, Perplexity Web, etc.). // // The web UIs accept only a single plain prompt string and have no native function // calling — they reply with tool invocations as raw text. To let agentic clients use @@ -532,12 +532,12 @@ function buildToolReminder(toolPrompt: string): string { * 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 + * of a huge block and observed web models 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 + * the latest user message. Captured long-context trials showed prepend 0/3 tool + * calls and dual placement 16/17 across * 30K-250K prompts, 30-tool sets, multi-turn tool history, and streaming. */ export function prepareToolMessages( diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 8034c5a536..4fafd05b1a 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -343,13 +343,22 @@ export function errorResponseWithComboDiagnostics( * @param {string} message - Error message * @returns {Response} HTTP Response object */ -export function errorResponse(statusCode: number, message: string): Response { - return new Response(JSON.stringify(buildErrorBody(statusCode, sanitizeErrorMessage(message))), { - status: statusCode, - headers: { - "Content-Type": "application/json", - }, - }); +export function errorResponse( + statusCode: number, + message: string, + classification?: ErrorBodyClassification +): Response { + return new Response( + JSON.stringify( + buildErrorBody(statusCode, sanitizeErrorMessage(message), undefined, classification) + ), + { + status: statusCode, + headers: { + "Content-Type": "application/json", + }, + } + ); } /** diff --git a/open-sse/utils/nextAuthCookie.ts b/open-sse/utils/nextAuthCookie.ts index 64a4a1846d..43a72ced23 100644 --- a/open-sse/utils/nextAuthCookie.ts +++ b/open-sse/utils/nextAuthCookie.ts @@ -1,6 +1,6 @@ /** * NextAuth session-token cookie helpers shared by web-cookie providers that - * authenticate via __Secure-next-auth.session-token (chatgpt-web, perplexity-web, …). + * authenticate via __Secure-next-auth.session-token (for example, Perplexity Web). * * Rotation can change the shape (unchunked → chunked or vice versa). When that * happens, every old family member must be dropped — keeping the stale variant diff --git a/open-sse/utils/sha3-512.ts b/open-sse/utils/sha3-512.ts deleted file mode 100644 index 5c0f4cb106..0000000000 --- a/open-sse/utils/sha3-512.ts +++ /dev/null @@ -1,164 +0,0 @@ -// Runtime-portable SHA3-512 (FIPS-202). -// -// The ChatGPT-Web sentinel proof-of-work (open-sse/executors/chatgpt-web.ts) -// hashes with SHA3-512. Node built against OpenSSL has it natively, but the -// **Electron desktop app** ships Node built against **BoringSSL, which does not -// implement the SHA-3 family** (electron/electron#30530). There, -// `createHash("sha3-512")` throws `Error: Digest method not supported`, so every -// chatgpt-web request fails with `502 ChatGPT sentinel failed: Digest method not -// supported` and the provider is unusable on the desktop app (#5531). -// -// This module prefers the native digest (fast path on servers / OpenSSL) and -// transparently falls back to a dependency-free pure-JS Keccak-f[1600] when the -// runtime's crypto lacks SHA-3. The pure-JS path is validated bit-for-bit -// against the native digest and the published FIPS-202 vectors in the unit test. - -import { createHash } from "node:crypto"; - -// ─── Keccak-f[1600] (BigInt lanes — correctness-first; only the fallback runs it) ── - -const MASK = (1n << 64n) - 1n; - -// Round constants RC[0..23]. -const RC: bigint[] = [ - 0x0000000000000001n, - 0x0000000000008082n, - 0x800000000000808an, - 0x8000000080008000n, - 0x000000000000808bn, - 0x0000000080000001n, - 0x8000000080008081n, - 0x8000000000008009n, - 0x000000000000008an, - 0x0000000000000088n, - 0x0000000080008009n, - 0x000000008000000an, - 0x000000008000808bn, - 0x800000000000008bn, - 0x8000000000008089n, - 0x8000000000008003n, - 0x8000000000008002n, - 0x8000000000000080n, - 0x000000000000800an, - 0x800000008000000an, - 0x8000000080008081n, - 0x8000000000008080n, - 0x0000000080000001n, - 0x8000000080008008n, -]; - -// Rotation offsets r[x+5y] (rho step). -const ROT: number[] = [ - 0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14, -]; - -function rotl64(x: bigint, n: number): bigint { - if (n === 0) return x; - const bn = BigInt(n); - return ((x << bn) | (x >> (64n - bn))) & MASK; -} - -function keccakF1600(s: bigint[]): void { - const C = new Array(5); - const D = new Array(5); - const B = new Array(25); - for (let round = 0; round < 24; round++) { - // θ - for (let x = 0; x < 5; x++) C[x] = s[x] ^ s[x + 5] ^ s[x + 10] ^ s[x + 15] ^ s[x + 20]; - for (let x = 0; x < 5; x++) D[x] = C[(x + 4) % 5] ^ rotl64(C[(x + 1) % 5], 1); - for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) s[x + 5 * y] ^= D[x]; - // ρ + π - for (let x = 0; x < 5; x++) { - for (let y = 0; y < 5; y++) { - B[y + 5 * ((2 * x + 3 * y) % 5)] = rotl64(s[x + 5 * y], ROT[x + 5 * y]); - } - } - // χ - for (let x = 0; x < 5; x++) { - for (let y = 0; y < 5; y++) { - s[x + 5 * y] = B[x + 5 * y] ^ (~B[((x + 1) % 5) + 5 * y] & MASK & B[((x + 2) % 5) + 5 * y]); - } - } - // ι - s[0] ^= RC[round]; - } -} - -// SHA3-512: rate r = 576 bits (72 bytes / 9 lanes), capacity 1024, output 64 bytes. -const RATE_BYTES = 72; - -function sha3_512Bytes(msg: Uint8Array): Uint8Array { - const s: bigint[] = new Array(25).fill(0n); - - // FIPS-202 pad10*1 with SHA-3 domain separation (first pad byte 0x06, last |= 0x80). - const padLen = RATE_BYTES - (msg.length % RATE_BYTES); - const padded = new Uint8Array(msg.length + padLen); - padded.set(msg); - padded[msg.length] = 0x06; - padded[padded.length - 1] |= 0x80; - - // Absorb (little-endian lanes). - for (let off = 0; off < padded.length; off += RATE_BYTES) { - for (let i = 0; i < RATE_BYTES / 8; i++) { - let lane = 0n; - for (let b = 0; b < 8; b++) lane |= BigInt(padded[off + i * 8 + b]) << BigInt(8 * b); - s[i] ^= lane; - } - keccakF1600(s); - } - - // Squeeze 64 bytes (8 lanes — fits in one rate block). - const out = new Uint8Array(64); - for (let i = 0; i < 8; i++) { - const lane = s[i]; - for (let b = 0; b < 8; b++) out[i * 8 + b] = Number((lane >> BigInt(8 * b)) & 0xffn); - } - return out; -} - -function toBytes(input: string | Uint8Array): Uint8Array { - return typeof input === "string" ? new Uint8Array(Buffer.from(input, "utf8")) : input; -} - -/** Pure-JS SHA3-512 hex digest. Always runs the vendored Keccak (used as the fallback). */ -export function sha3_512HexJs(input: string | Uint8Array): string { - return Buffer.from(sha3_512Bytes(toBytes(input))).toString("hex"); -} - -type NativeHasher = (data: Uint8Array) => string; - -// undefined = not yet probed; null = native SHA-3 unavailable (BoringSSL/Electron). -let nativeHasher: NativeHasher | null | undefined; - -function detectNative(): NativeHasher | null { - try { - // Construct + digest once — Electron/BoringSSL throws here, not lazily. - createHash("sha3-512").update(Buffer.alloc(0)).digest("hex"); - return (data) => createHash("sha3-512").update(data).digest("hex"); - } catch { - return null; - } -} - -/** - * SHA3-512 hex digest that works on every runtime: native (OpenSSL) where - * available, pure-JS Keccak fallback where the crypto backend lacks SHA-3 - * (Electron/BoringSSL — #5531). Capability is probed once and cached. - */ -export function sha3_512Hex(input: string | Uint8Array): string { - const data = toBytes(input); - if (nativeHasher === undefined) nativeHasher = detectNative(); - if (nativeHasher) { - try { - return nativeHasher(data); - } catch { - nativeHasher = null; // became unavailable mid-flight — defensive - } - } - return sha3_512HexJs(data); -} - -/** Test seam: force the native capability state (null = simulate BoringSSL, undefined = re-probe). */ -export function __setSha3NativeForTesting(state: NativeHasher | null | undefined): void { - nativeHasher = state; -} diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index 9adb5dbf2f..dbcc439eef 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -153,7 +153,7 @@ function isPendingRequestClearedError(error: unknown): boolean { * chunk into the now-closed response stream, as a "Controller is already closed" * TypeError. Treating any of these as an upstream error wrongly cools down the * account/connection, so the stream error path uses this to skip the provider - * failover/cooldown (the chatgpt-web / codex / antigravity executors already + * failover/cooldown (the Codex / Antigravity executors already * guard client aborts the same way). */ export function isClientDisconnectError(error: unknown): boolean { @@ -599,7 +599,7 @@ function resolveSilentCloseOutcome(input: { // #10443: every known path that produces OpenAI chat chunks emits a // terminal — the response translators (gemini/claude/kiro/cursor-to-openai) // all emit a finish_reason chunk, the non-standard executors (kiro, cursor, - // nlpcloud, poe-web, copilot-m365-web, chatgpt-web, chipotle, gitlab) + // nlpcloud, poe-web, copilot-m365-web, chipotle, gitlab) // enqueue `data: [DONE]` themselves, and standard OpenAI-compatible // upstreams end with finish_reason + [DONE] per spec. So a close that // forwarded content but no terminal marker is an upstream drop, not a diff --git a/scripts/build/fixTlsClientNodeBinary.mjs b/scripts/build/fixTlsClientNodeBinary.mjs index 9db5f4bbb4..b28849c6bb 100644 --- a/scripts/build/fixTlsClientNodeBinary.mjs +++ b/scripts/build/fixTlsClientNodeBinary.mjs @@ -10,7 +10,7 @@ * even when it does run, silently no-ops on a rate-limited/failed GitHub API * call instead of raising — so `node_modules/tls-client-node/bin/` can end * up empty with no visible signal until the first live request throws - * TlsClientUnavailableError (chatgpt-web/claude-web/grok-web/lmarena/ + * TlsClientUnavailableError (claude-web/grok-web/lmarena/ * perplexity-web all share this transport). * * This module: @@ -123,7 +123,7 @@ export async function fixTlsClientNodeBinary({ "(GitHub API rate-limited or unreachable after retries)." ); console.warn( - " chatgpt-web/claude-web/grok-web/lmarena/perplexity-web will raise a clear " + + " claude-web/grok-web/lmarena/perplexity-web will raise a clear " + "TlsClientUnavailableError on first use until this is resolved." ); console.warn( diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 8aa0b12229..240b62813b 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -140,7 +140,7 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [ "scripts/build/postinstallSupport.mjs", "scripts/build/colocateOptionals.mjs", // #7802: imported by scripts/build/postinstall.mjs to repair tls-client-node's - // native binary (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web transport). + // native binary (claude-web/grok-web/lmarena/perplexity-web transport). "scripts/build/fixTlsClientNodeBinary.mjs", // #8859: imported by scripts/build/postinstall.mjs to repair playwright-core's // browser resolution on Termux/Android (no glibc, no bundled browsers). diff --git a/scripts/build/postinstall.mjs b/scripts/build/postinstall.mjs index 1628aca7cf..19b5cab8a8 100644 --- a/scripts/build/postinstall.mjs +++ b/scripts/build/postinstall.mjs @@ -15,7 +15,7 @@ * Modules repaired: * - better-sqlite3 (SQLite bindings) * - wreq-js (TLS client for OAuth providers) - * - tls-client-node (TLS client for chatgpt-web/claude-web/grok-web/lmarena/perplexity-web) + * - tls-client-node (TLS client for claude-web/grok-web/lmarena/perplexity-web) * - sql.js (WASM SQLite fallback runtime) * - node-machine-id (local CLI machine-token server runtime) * diff --git a/scripts/packs/optionalPackManifest.mjs b/scripts/packs/optionalPackManifest.mjs index f20fbc5df2..a1dfa38567 100644 --- a/scripts/packs/optionalPackManifest.mjs +++ b/scripts/packs/optionalPackManifest.mjs @@ -57,7 +57,7 @@ export const OPTIONAL_PACKS = [ name: "browser-runtime", packVersion: 1, description: - "Browser automation closure: Claude Turnstile solver and ChatGPT/Gemini web executors", + "Browser automation closure: Claude Turnstile solver and ChatGPT Web (Codex)/Gemini web executors", packages: [{ name: "playwright" }, { name: "playwright-core" }], }, ]; diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 4f8b23d28a..2534bd5852 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -36,6 +36,7 @@ import { decodeChatGptWebCodexSecrets, encodeChatGptWebCodexSecrets, } from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts"; +import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; function normalizeCodexLimitPolicy( incoming: unknown, @@ -129,10 +130,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: ...validation.error.details.map((d) => d.field).filter(Boolean), ...validation.error.details.flatMap((d) => d.keys ?? []), ]; - return NextResponse.json( - { error: { ...validation.error, rejected } }, - { status: 400 } - ); + return NextResponse.json({ error: { ...validation.error, rejected } }, { status: 400 }); } const body = validation.data; const { @@ -166,6 +164,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (!existing) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } + const retirementResponse = rejectRetiredCommonChatGptWebProvider(existing.provider); + if (retirementResponse) return retirementResponse; const updateData: Record = {}; if (name !== undefined) updateData.name = name; diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 5f0a9aeca6..7688eb3d06 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -36,9 +36,9 @@ import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistra import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult"; import { classifyOAuthProbeInconclusive, OAUTH_TEST_CONFIG } from "./oauthTestConfig"; import { isGeoBlockedError } from "@omniroute/open-sse/services/errorClassifier.ts"; +import * as retirement from "@/lib/providers/chatgptWebRetirementResponse"; -// Bound the OAuth probe so a hung upstream can't block the connection-test queue -// forever (#1449). Mirrors the 30s timeout the API-key path uses via validateProviderApiKey. +// Match the API-key path's 30s timeout so a hung OAuth upstream cannot block the test queue. const OAUTH_TEST_TIMEOUT_MS = 30_000; import { CLI_RUNTIME_PROVIDER_MAP } from "./cliRuntimeProviderMap"; @@ -999,8 +999,8 @@ export async function testSingleConnection(connectionId: string, validationModel latencyMs: 0, }; } + retirement.assertProviderAvailable(provider); - // Resolve proxy for this connection (key → combo → provider → global → direct) let proxyInfo: any = null; try { proxyInfo = await resolveProxyForConnection(connectionId); @@ -1078,7 +1078,6 @@ export async function testSingleConnection(connectionId: string, validationModel }; } - // Build update data const now = new Date().toISOString(); const diagnosis = result.diagnosis || @@ -1111,6 +1110,7 @@ export async function testSingleConnection(connectionId: string, validationModel connection as { rateLimitedUntil?: string | null }, result.valid ); + const lastErrorType = result.valid ? connection.lastErrorType : diagnosis.type; const updateData: Record = { testStatus: clearErrorState ? "active" : result.valid ? connection.testStatus : "error", @@ -1125,11 +1125,7 @@ export async function testSingleConnection(connectionId: string, validationModel lastError: clearErrorState ? null : result.valid ? connection.lastError : result.error, lastErrorAt: clearErrorState ? null : result.valid ? connection.lastErrorAt : now, lastTested: now, - lastErrorType: clearErrorState - ? null - : result.valid - ? connection.lastErrorType - : diagnosis.type, + lastErrorType: clearErrorState ? null : lastErrorType, lastErrorSource: clearErrorState ? null : result.valid @@ -1158,7 +1154,6 @@ export async function testSingleConnection(connectionId: string, validationModel if (recovered) updateData.providerSpecificData = recovered; } - // If token was refreshed, update tokens in DB if (result.refreshed && result.newTokens) { updateData.accessToken = result.newTokens.accessToken; if (result.newTokens.refreshToken) { @@ -1227,7 +1222,6 @@ export async function testSingleConnection(connectionId: string, validationModel }; } -// POST /api/providers/[id]/test - Test connection export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; @@ -1252,6 +1246,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json(data); } catch (error) { + const retired = retirement.responseForError(error); + if (retired) return retired; console.log("Error testing connection:", error); return NextResponse.json({ error: "Test failed" }, { status: 500 }); } diff --git a/src/app/api/providers/bulk-web-session/route.ts b/src/app/api/providers/bulk-web-session/route.ts index 6ef99acb73..143aee6c71 100644 --- a/src/app/api/providers/bulk-web-session/route.ts +++ b/src/app/api/providers/bulk-web-session/route.ts @@ -18,6 +18,7 @@ import { hasUsableWebSessionCredential, resolveWebSessionImportApiKey, } from "@/shared/providers/webSessionCredentials"; +import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; export async function POST(request: Request) { const authError = await requireManagementAuth(request); @@ -39,6 +40,9 @@ export async function POST(request: Request) { const { provider, entries, priority, globalPriority } = validation.data; + const retirementResponse = rejectRetiredCommonChatGptWebProvider(provider); + if (retirementResponse) return retirementResponse; + if (!requiresWebSessionCredential(provider)) { return NextResponse.json( { error: `Provider '${provider}' does not require web-session credentials` }, diff --git a/src/app/api/providers/bulk/route.ts b/src/app/api/providers/bulk/route.ts index e2fef1b258..6799ff4f9e 100644 --- a/src/app/api/providers/bulk/route.ts +++ b/src/app/api/providers/bulk/route.ts @@ -30,6 +30,7 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { validateProviderApiKey } from "@/lib/providers/validation"; import { getProxyForLevel, resolveProxyForProvider } from "@/lib/localDb"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; // POST /api/providers/bulk — create multiple API-key connections for a single provider. // Partial-failure semantics: each entry succeeds or fails independently; the @@ -62,6 +63,9 @@ export async function POST(request: Request) { validateKeys, } = validation.data; + const retirementResponse = rejectRetiredCommonChatGptWebProvider(provider); + if (retirementResponse) return retirementResponse; + const isManagedOrCompatible = isManagedProviderConnectionId(provider) || isOpenAICompatibleProvider(provider) || diff --git a/src/app/api/providers/import/route.ts b/src/app/api/providers/import/route.ts index d050d6aaf5..c74d85cfbc 100644 --- a/src/app/api/providers/import/route.ts +++ b/src/app/api/providers/import/route.ts @@ -10,7 +10,10 @@ import { getProviderNodeById, isCloudEnabled, } from "@/models"; -import { isAnthropicCompatibleProvider, isOpenAICompatibleProvider } from "@/shared/constants/providers"; +import { + isAnthropicCompatibleProvider, + isOpenAICompatibleProvider, +} from "@/shared/constants/providers"; import { isManagedProviderConnectionId } from "@/lib/providers/catalog"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { resolveBulkNameCollisions } from "@/shared/utils/bulkApiKeyParser"; @@ -26,6 +29,7 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { validateProviderApiKey } from "@/lib/providers/validation"; import { getProxyForLevel, resolveProxyForProvider } from "@/lib/localDb"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; type ImportEntry = { provider: string; @@ -164,7 +168,10 @@ async function resolveImportNameCollisions(entries: ImportEntry[]): Promise { - resolved[originalIndex] = { ...entries[originalIndex], name: resolvedProviderEntries[i].name }; + resolved[originalIndex] = { + ...entries[originalIndex], + name: resolvedProviderEntries[i].name, + }; }); } @@ -205,6 +212,10 @@ export async function POST(request: Request) { } const { entries, validateKeys } = validation.data; + for (const entry of entries) { + const retirementResponse = rejectRetiredCommonChatGptWebProvider(entry.provider); + if (retirementResponse) return retirementResponse; + } const resolvedEntries = await resolveImportNameCollisions(entries); const created: Array> = []; @@ -215,7 +226,12 @@ export async function POST(request: Request) { try { const result = await importOneEntry(entry, !!validateKeys); if ("error" in result) { - errors.push({ index: i, name: entry.name, provider: entry.provider, message: result.error }); + errors.push({ + index: i, + name: entry.name, + provider: entry.provider, + message: result.error, + }); continue; } created.push(result.created); diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index e2e81b7c78..0ce1622fdd 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -50,6 +50,7 @@ import { import { finalizeValidatedChatGptWebCodexSecrets } from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts"; import { isAutoFetchModelsEnabled } from "@/lib/providerModels/modelDiscovery"; import { testSingleConnection } from "./[id]/test/route"; +import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; function projectCodexAccountPoolWithRoutingQuota( connection: Parameters[0], @@ -175,6 +176,10 @@ export async function POST(request: Request) { providerSpecificData: incomingPsd, } = validation.data; const provider = resolveProviderId(requestedProvider); + const retirementResponse = + rejectRetiredCommonChatGptWebProvider(requestedProvider) ?? + rejectRetiredCommonChatGptWebProvider(provider); + if (retirementResponse) return retirementResponse; // Business validation const isValidProvider = @@ -420,6 +425,17 @@ export async function PATCH(request: Request) { const { ids, isActive } = validation.data; try { + if (isActive) { + const requestedIds = new Set(ids); + const requestedConnections = ( + await getProviderConnections({}, undefined, undefined, ["id", "provider"]) + ).filter((connection) => requestedIds.has(connection.id)); + for (const connection of requestedConnections) { + const retirementResponse = rejectRetiredCommonChatGptWebProvider(connection.provider); + if (retirementResponse) return retirementResponse; + } + } + // Partial-failure semantics: report unknown IDs instead of failing the whole batch const updatedIds: string[] = []; const notFoundIds: string[] = []; diff --git a/src/app/api/providers/validate/route.ts b/src/app/api/providers/validate/route.ts index 77129ec05b..ef5565bf13 100644 --- a/src/app/api/providers/validate/route.ts +++ b/src/app/api/providers/validate/route.ts @@ -12,6 +12,7 @@ import { getProxyForLevel, resolveProxyForProvider } from "@/lib/localDb"; import { validateProviderApiKeySchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; function sanitizeAuditUrl(url: string | null | undefined) { if (!url) return null; @@ -64,6 +65,9 @@ export async function POST(request) { connectorName, } = validation.data; + const retirementResponse = rejectRetiredCommonChatGptWebProvider(provider); + if (retirementResponse) return retirementResponse; + let providerSpecificData: any = { validationModelId }; if (customUserAgent) { providerSpecificData.customUserAgent = customUserAgent; diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index a7e02842c6..64a6899d23 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -27,6 +27,10 @@ import { withCompressionHeaderEcho, } from "@/shared/utils/compressionHeaderEcho"; import { resolveModelAliasWithSeedFallbackOnBody } from "@/lib/modelAliasResolver"; +import { + assertCommonChatGptWebModelAvailable, + isCommonChatGptWebRetirementError, +} from "@/shared/constants/chatgptWebRetirement"; let initPromise = null; @@ -147,6 +151,20 @@ export async function POST(request) { errorResponse(400, `${field}: ${issue?.message ?? "Invalid request"}`) ); } + + try { + assertCommonChatGptWebModelAvailable(parsedBody.model); + } catch (error) { + if (isCommonChatGptWebRetirementError(error)) { + return finishAdmission( + errorResponse(error.status, error.message, { + type: "provider_error", + code: error.code, + }) + ); + } + throw error; + } } const structuralAdmission = await admitChatStructure(parsedBody, admission.lease, { diff --git a/src/app/api/v1/chatgpt-web/image/[id]/route.ts b/src/app/api/v1/chatgpt-web/image/[id]/route.ts deleted file mode 100644 index a23e7daffe..0000000000 --- a/src/app/api/v1/chatgpt-web/image/[id]/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; -import { getChatGptImage } from "@omniroute/open-sse/services/chatgptImageCache.ts"; - -export async function OPTIONS() { - return handleCorsOptions(); -} - -/** - * Serve a cached ChatGPT-generated image by its opaque cache id. - * - * Auth: intentionally unauthenticated. The id is a 128-bit random UUID and - * the entry has a short TTL, so the URL is unguessable for the lifetime of - * the chat turn. We need it open because it's loaded by the user's BROWSER - * (via an `` tag rendered from markdown) — that fetch doesn't carry - * the OmniRoute API key. Rate limiting / abuse protection sit at the - * network layer the same way they do for any other static asset. - */ -export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const entry = getChatGptImage(id); - if (!entry) { - return new Response(JSON.stringify({ error: "Image not found or expired" }), { - status: 404, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }); - } - // entry.bytes is a Buffer (subclass of Uint8Array); pass it directly. - // Wrapping in `new Uint8Array(...)` would copy the entire payload — up to - // 8 MB per image — for no benefit. - return new Response(entry.bytes, { - status: 200, - headers: { - "Content-Type": entry.mime, - // Allow short browser caching — the id is unique-per-image, so a - // cache hit is fine and saves a round-trip if the user re-renders - // the chat. Beyond the in-memory TTL the URL 404s anyway. - "Cache-Control": "private, max-age=1800", - "Content-Length": String(entry.bytes.length), - ...CORS_HEADERS, - }, - }); -} diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 282c83a119..d1243d827e 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -1,7 +1,6 @@ import { handleAdobeFireflyImageGeneration, handleCodexImageEdit, - handleImageEdit, handleOpenAIImageEdit, handleOpenRouterImageEdit, } from "@omniroute/open-sse/handlers/imageGeneration.ts"; @@ -39,6 +38,10 @@ import { RequestBodyTooLargeError, } from "@/shared/middleware/bodySizeGuard"; import { getCachedSettings } from "@/lib/db/readCache"; +import { + CHATGPT_WEB_RETIRED_ERROR_CODE, + isCommonChatGptWebRetirementError, +} from "@/shared/constants/chatgptWebRetirement"; import { z } from "zod"; // JSON edit body (Open WebUI / OpenAI-style). All fields optional — the prompt @@ -59,11 +62,7 @@ const ImageEditJsonSchema = z /** * /v1/images/edits — OpenAI-compatible image-edit endpoint. * - * Two upstream shapes are supported: - * - **chatgpt-web**: an "edit" only makes sense if the uploaded image was originally - * generated through OmniRoute — we then have its `{conversationId, parentMessageId}` - * cached and can continue the saved chatgpt.com conversation node (the only way to - * actually edit the image instead of generating an unrelated one). + * Supported upstream shapes include: * - **custom OpenAI-compatible providers** (#3214/#3215): forward a multipart edit to * the node's `{base_url}/images/edits`, mirroring how generations forwards. * @@ -85,17 +84,6 @@ export async function OPTIONS() { }); } -const PUBLIC_BASE_URL_HEADER_KEYS = ["host", "x-forwarded-host", "x-forwarded-proto"] as const; - -function publicBaseUrlHeaders(headers: Headers): Record { - const out: Record = {}; - for (const key of PUBLIC_BASE_URL_HEADER_KEYS) { - const value = headers.get(key); - if (value !== null) out[key] = value; - } - return out; -} - interface EditInput { prompt: string; model: string | null; @@ -350,8 +338,27 @@ async function postHandler(request: Request, _context?: unknown) { if (!imageBytes || imageBytes.length === 0) { return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: image"); } + if (!model) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: model"); + } - const fullModel = model || "cgpt-web/gpt-5.5"; + const fullModel = model; + + // Resolve combo/alias, custom-provider prefix, and built-in ids consistently with + // /v1/images/generations (#3215). Retirement is resolved before API-key policy + // so the same explicit provider request always receives the deterministic 410. + let resolvedModel: string; + try { + resolvedModel = await resolveImageRouteModel(fullModel); + } catch (error) { + if (isCommonChatGptWebRetirementError(error)) { + return errorResponse(error.status, error.message, { + type: "provider_error", + code: CHATGPT_WEB_RETIRED_ERROR_CODE, + }); + } + throw error; + } const policy = await enforceApiKeyPolicy(request, fullModel); if (policy.rejection) return policy.rejection; @@ -361,9 +368,6 @@ async function postHandler(request: Request, _context?: unknown) { ? policy.apiKeyInfo.allowedConnections : null; - // Resolve combo/alias, custom-provider prefix, and built-in ids consistently with - // /v1/images/generations (#3215). - const resolvedModel = await resolveImageRouteModel(fullModel); const parsed = parseImageModel(resolvedModel); const providerConfig = parsed.provider ? getImageProvider(parsed.provider) : null; // Firefly nano/gpt-image accept multiple reference blobs; other non-Codex stay at 1. @@ -383,56 +387,6 @@ async function postHandler(request: Request, _context?: unknown) { : "This image edit provider currently supports only one reference image" ); } - // chatgpt-web keeps its conversation-continuation edit flow unchanged. - if (providerConfig?.format === "chatgpt-web") { - const credentials = await getProviderCredentialsWithQuotaPreflight( - parsed.provider, - null, - allowedConnections, - resolvedModel - ); - if (!credentials) { - return errorResponse( - HTTP_STATUS.UNAUTHORIZED, - `No credentials for provider: ${parsed.provider}` - ); - } - if (credentials.allRateLimited) { - return unavailableResponse( - HTTP_STATUS.RATE_LIMITED, - `[${parsed.provider}] All accounts rate limited`, - credentials.retryAfter, - credentials.retryAfterHuman - ); - } - - const result = await handleImageEdit({ - provider: parsed.provider, - model: parsed.model, - body: { - prompt, - size: size ?? undefined, - response_format: responseFormat ?? undefined, - n: 1, - }, - imageBytes, - imageMime, - credentials, - log, - signal: request.signal, - clientHeaders: publicBaseUrlHeaders(request.headers), - }); - - if (result.success) { - await clearRecoveredProviderState(credentials); - return jsonResponse((result as any).data); - } - return jsonResponse( - toJsonErrorPayload((result as any).error, "Image edit provider error"), - (result as any).status - ); - } - // Built-in Codex uses its native Responses hosted tool for stateless reference-image edits. if (providerConfig?.format === "codex-responses") { const modelEntry = getImageModelEntry(resolvedModel); @@ -640,7 +594,7 @@ async function postHandler(request: Request, _context?: unknown) { return errorResponse( HTTP_STATUS.BAD_REQUEST, `Image edit is not supported for built-in provider "${parsed.provider}". ` + - `Use adobe-firefly, chatgpt-web, codex, or a custom OpenAI-compatible image provider.` + `Use adobe-firefly, codex, or a custom OpenAI-compatible image provider.` ); } diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index cc8403115b..1123745fc5 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -36,6 +36,11 @@ import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth"; import { runWithCallLogApiKeyContext } from "@/lib/usage/callLogApiKeyContext"; import { executeImageWithCredentialFallback } from "@/sse/services/imageCredentialRetry"; import { AUTHZ_HEADER_PEER_LOCALITY } from "@/server/authz/headers"; +import { + assertCommonChatGptWebModelAvailable, + CHATGPT_WEB_RETIRED_ERROR_CODE, + isCommonChatGptWebRetirementError, +} from "@/shared/constants/chatgptWebRetirement"; export const dynamic = "force-dynamic"; @@ -80,7 +85,7 @@ function hasImageGenerationInput(body: Record) { return false; } -// Forward only the host-shaped headers the chatgpt-web image handler needs +// Forward only the host-shaped headers the Gemini Web image handler needs // to derive the browser-facing public base URL. Avoid copying the full // request header set: it's wider than the handler needs (auth tokens, // content-type, etc.) and `Headers.forEach` collapses repeated values, which @@ -118,6 +123,19 @@ async function postHandler(request, context) { const authRejection = await enforceClientApiRouteAuth(request); if (authRejection) return authRejection; + // Fail closed on the raw wire id before combo/alias/custom-node remapping can erase it. + try { + assertCommonChatGptWebModelAvailable(body.model); + } catch (error) { + if (isCommonChatGptWebRetirementError(error)) { + return errorResponse(error.status, error.message, { + type: "provider_error", + code: CHATGPT_WEB_RETIRED_ERROR_CODE, + }); + } + throw error; + } + // Enforce API key policies (model restrictions + budget limits) const policy = await enforceApiKeyPolicy(request, body.model); if (policy.rejection) return policy.rejection; @@ -127,16 +145,8 @@ async function postHandler(request, context) { if (body.model && typeof body.model === "string" && !body.model.includes("/")) { const combo = await getComboByName(body.model as string); if (combo) { - const { executeImageCombo } = await import( - "@omniroute/open-sse/services/imageCombo" - ); - return executeImageCombo( - body.model as string, - body, - { request, policy }, - startTime, - log - ); + const { executeImageCombo } = await import("@omniroute/open-sse/services/imageCombo"); + return executeImageCombo(body.model as string, body, { request, policy }, startTime, log); } } @@ -144,7 +154,17 @@ async function postHandler(request, context) { // model (`myImg/gpt-image-2`) to its internal `/` form so the // custom-model lookup and handler's resolvedProvider extraction resolve correctly. // Built-in and already-internal ids pass through unchanged. Shared with /images/edits. - body.model = await resolveImageRouteModel(body.model); + try { + body.model = await resolveImageRouteModel(body.model); + } catch (error) { + if (isCommonChatGptWebRetirementError(error)) { + return errorResponse(error.status, error.message, { + type: "provider_error", + code: CHATGPT_WEB_RETIRED_ERROR_CODE, + }); + } + throw error; + } // Parse model to get provider let { provider, model: requestedModel } = parseImageModel(body.model); @@ -246,7 +266,8 @@ async function postHandler(request, context) { provider, null, syncedEndpointRoute?.connectionIds ?? null, - requestedModel ); + requestedModel + ); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, @@ -346,7 +367,10 @@ async function postHandler(request, context) { }); } - const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error") as { + const errorPayload = toJsonErrorPayload( + (result as any).error, + "Image generation provider error" + ) as { error?: { message?: string }; }; const message = diff --git a/src/app/api/v1/messages/count_tokens/route.ts b/src/app/api/v1/messages/count_tokens/route.ts index 80bbc5d77f..7d35b6412a 100644 --- a/src/app/api/v1/messages/count_tokens/route.ts +++ b/src/app/api/v1/messages/count_tokens/route.ts @@ -3,7 +3,9 @@ import { v1CountTokensSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { countTextTokens, type TokenizerContext } from "@/shared/utils/tiktokenCounter"; import { getExecutor } from "@omniroute/open-sse/executors/index.ts"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { isCommonChatGptWebRetirementError } from "@/shared/constants/chatgptWebRetirement"; import { getModelInfo } from "@/sse/services/model"; import { extractApiKey, getProviderCredentials, isValidApiKey } from "@/sse/services/auth"; import { safeResolveProxy } from "@/sse/handlers/chatHelpers"; @@ -103,6 +105,20 @@ export async function POST(request) { } ); } catch (error) { + if (isCommonChatGptWebRetirementError(error)) { + return new Response( + JSON.stringify( + buildErrorBody(error.status, error.message, null, { + type: "provider_error", + code: error.code, + }) + ), + { + status: error.status, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + } + ); + } log.debug( "COUNT_TOKENS", `Falling back to estimate for ${requestedModel}: ${error instanceof Error ? error.message : String(error)}` diff --git a/src/app/api/v1/models/[...model]/route.ts b/src/app/api/v1/models/[...model]/route.ts index 43da6fd487..03351dc238 100644 --- a/src/app/api/v1/models/[...model]/route.ts +++ b/src/app/api/v1/models/[...model]/route.ts @@ -25,12 +25,9 @@ export async function HEAD() { * GET /v1/models/{model} — OpenAI-compatible single-model retrieval (#4674). * * Catch-all (`[...model]`) so provider-prefixed ids that contain a slash - * (e.g. `cgpt-web/gpt-5.5`, `claude/claude-sonnet-4-6`) are captured intact. + * (e.g. `openai/gpt-5.4`, `claude/claude-sonnet-4-6`) are captured intact. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ model: string[] }> } -) { +export async function GET(request: Request, { params }: { params: Promise<{ model: string[] }> }) { const { model } = await params; const segments = Array.isArray(model) ? model : [model]; const requestedId = decodeURIComponent(segments.join("/")); diff --git a/src/app/api/v1/providers/[provider]/chat/completions/route.ts b/src/app/api/v1/providers/[provider]/chat/completions/route.ts index 1149ef8933..9c407121ba 100644 --- a/src/app/api/v1/providers/[provider]/chat/completions/route.ts +++ b/src/app/api/v1/providers/[provider]/chat/completions/route.ts @@ -5,6 +5,7 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; +import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; let initialized = false; @@ -34,6 +35,8 @@ export async function OPTIONS() { */ async function postHandler(request, { params }) { const { provider: rawProvider } = await params; + const retirementResponse = rejectRetiredCommonChatGptWebProvider(rawProvider); + if (retirementResponse) return retirementResponse; const providerEntry = getRegistryEntry(rawProvider); diff --git a/src/app/api/v1/providers/[provider]/images/generations/route.ts b/src/app/api/v1/providers/[provider]/images/generations/route.ts index 1016e08bd0..112968e38c 100644 --- a/src/app/api/v1/providers/[provider]/images/generations/route.ts +++ b/src/app/api/v1/providers/[provider]/images/generations/route.ts @@ -14,6 +14,7 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth"; import { runWithCallLogApiKeyContext } from "@/lib/usage/callLogApiKeyContext"; import { executeImageWithCredentialFallback } from "@/sse/services/imageCredentialRetry"; +import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; /** * Handle CORS preflight @@ -32,6 +33,8 @@ export async function OPTIONS() { */ export async function POST(request, { params }) { const { provider: rawProvider } = await params; + const retirementResponse = rejectRetiredCommonChatGptWebProvider(rawProvider); + if (retirementResponse) return retirementResponse; // Verify this is a valid image provider const imageProvider = getImageProvider(rawProvider); diff --git a/src/app/api/v1/session-leases/route.ts b/src/app/api/v1/session-leases/route.ts index 53671f5ad3..9fb42e5fed 100644 --- a/src/app/api/v1/session-leases/route.ts +++ b/src/app/api/v1/session-leases/route.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { isCommonChatGptWebRetirementError } from "@/shared/constants/chatgptWebRetirement"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { @@ -55,8 +56,7 @@ export const OPTIONS = async (): Promise => handleCorsOptions(); export async function POST(request: Request): Promise { const apiKey = extractApiKey(request); if (!apiKey) return error(401, "LEASE_AUTHENTICATION_REQUIRED", "Authentication required"); - if (!(await isValidApiKey(apiKey))) - return error(401, "LEASE_API_KEY_INVALID", "Invalid API key"); + if (!(await isValidApiKey(apiKey))) return error(401, "LEASE_API_KEY_INVALID", "Invalid API key"); const contentType = request.headers.get("content-type")?.toLowerCase().split(";", 1)[0].trim(); if (contentType !== "application/json") { return error(415, "LEASE_CONTENT_TYPE_REQUIRED", "Content-Type must be application/json"); @@ -65,6 +65,18 @@ export async function POST(request: Request): Promise { const parsed = actionSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return error(400, "LEASE_ACTION_INVALID", "Invalid lease lifecycle action"); + let acquisitionModelInfo: Awaited> | null = null; + if (parsed.data.action === "acquire") { + try { + acquisitionModelInfo = await getModelInfo(parsed.data.model); + } catch (cause) { + if (isCommonChatGptWebRetirementError(cause)) { + return error(cause.status, cause.code, cause.message); + } + return error(503, "LEASE_SERVICE_UNAVAILABLE", "Lease service unavailable"); + } + } + const policy = await enforceApiKeyPolicy( request, parsed.data.action === "acquire" ? parsed.data.model : null @@ -91,7 +103,7 @@ export async function POST(request: Request): Promise { : error(409, "LEASE_FENCE_STALE", "The lease generation is stale"); } - const modelInfo = await getModelInfo(parsed.data.model); + const modelInfo = acquisitionModelInfo!; if (!modelInfo.provider) return error(400, "LEASE_MODEL_INVALID", "The model is unavailable"); const selection = await getProviderCredentialsWithQuotaPreflight( modelInfo.provider, @@ -133,6 +145,9 @@ export async function POST(request: Request): Promise { const result = selection as ExclusiveLeaseSelectionResult; return json(200, lifecycle(result.exclusiveLease)); } catch (cause) { + if (isCommonChatGptWebRetirementError(cause)) { + return error(cause.status, cause.code, cause.message); + } if (cause instanceof LeaseContextError) return error(cause.status, cause.code, cause.message); return error(503, "LEASE_SERVICE_UNAVAILABLE", "Lease service unavailable"); } diff --git a/src/lib/db/migrations/163_retire_chatgpt_web.sql b/src/lib/db/migrations/163_retire_chatgpt_web.sql new file mode 100644 index 0000000000..74b6d6d566 --- /dev/null +++ b/src/lib/db/migrations/163_retire_chatgpt_web.sql @@ -0,0 +1,195 @@ +-- Retire the common ChatGPT Web integration because its inherited provenance cannot be cleared. +-- +-- Match the complete ECMAScript trim whitespace set so database tombstones and +-- the TypeScript runtime agree even for restored provider ids wrapped in Unicode +-- spaces (NBSP, OGHAM, U+2000..U+200A, line/paragraph separators and BOM). +-- +-- Keep connection rows and historical records for auditability. Disabling the +-- connections is deliberately fail-closed: API-key allowed_connections entries +-- continue to reference the same connection ids instead of becoming an empty +-- allowlist, which would mean unrestricted access in the policy layer. + +UPDATE exclusive_connection_leases +SET state = 'INVALIDATED', + ended_at = datetime('now'), + end_reason = 'CONNECTION_INELIGIBLE' +WHERE state = 'ACTIVE' + AND ( + lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + IN ('chatgpt-web', 'cgpt-web') + OR connection_id IN ( + SELECT id + FROM provider_connections + WHERE lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + IN ('chatgpt-web', 'cgpt-web') + ) + ); + +UPDATE provider_connections +SET is_active = 0, + test_status = 'unavailable', + error_code = 'PROVIDER_REMOVED', + last_error = 'Provider integration retired from OmniRoute v3.8.50', + last_error_type = 'provider_removed', + last_error_source = 'migration:retire-chatgpt-web', + last_error_at = datetime('now'), + updated_at = datetime('now') +WHERE lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + IN ('chatgpt-web', 'cgpt-web') + AND ( + is_active IS NOT 0 + OR test_status IS NOT 'unavailable' + OR error_code IS NOT 'PROVIDER_REMOVED' + OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50' + OR last_error_type IS NOT 'provider_removed' + OR last_error_source IS NOT 'migration:retire-chatgpt-web' + OR last_error_at IS NULL + ); + +-- Migrations run before settings imports. Keep the tombstone durable when an +-- old db.json snapshot or an admin PATCH later attempts to reactivate either +-- retired id. The WHEN predicates are null-safe and prevent timestamp churn +-- when an already-normalized row is written again. +CREATE TRIGGER IF NOT EXISTS provider_connections_retire_chatgpt_web_insert +AFTER INSERT ON provider_connections +WHEN lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + IN ('chatgpt-web', 'cgpt-web') +BEGIN + UPDATE provider_connections + SET is_active = 0, + test_status = 'unavailable', + error_code = 'PROVIDER_REMOVED', + last_error = 'Provider integration retired from OmniRoute v3.8.50', + last_error_type = 'provider_removed', + last_error_source = 'migration:retire-chatgpt-web', + last_error_at = datetime('now'), + updated_at = datetime('now') + WHERE id = NEW.id + AND ( + is_active IS NOT 0 + OR test_status IS NOT 'unavailable' + OR error_code IS NOT 'PROVIDER_REMOVED' + OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50' + OR last_error_type IS NOT 'provider_removed' + OR last_error_source IS NOT 'migration:retire-chatgpt-web' + OR last_error_at IS NULL + ); + + UPDATE exclusive_connection_leases + SET state = 'INVALIDATED', + ended_at = datetime('now'), + end_reason = 'CONNECTION_INELIGIBLE' + WHERE state = 'ACTIVE' + AND connection_id = NEW.id; +END; + +CREATE TRIGGER IF NOT EXISTS provider_connections_retire_chatgpt_web_update +AFTER UPDATE OF provider, is_active, test_status, error_code, last_error, + last_error_type, last_error_source, last_error_at ON provider_connections +WHEN lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + IN ('chatgpt-web', 'cgpt-web') +BEGIN + UPDATE provider_connections + SET is_active = 0, + test_status = 'unavailable', + error_code = 'PROVIDER_REMOVED', + last_error = 'Provider integration retired from OmniRoute v3.8.50', + last_error_type = 'provider_removed', + last_error_source = 'migration:retire-chatgpt-web', + last_error_at = datetime('now'), + updated_at = datetime('now') + WHERE id = NEW.id + AND ( + is_active IS NOT 0 + OR test_status IS NOT 'unavailable' + OR error_code IS NOT 'PROVIDER_REMOVED' + OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50' + OR last_error_type IS NOT 'provider_removed' + OR last_error_source IS NOT 'migration:retire-chatgpt-web' + OR last_error_at IS NULL + ); + + UPDATE exclusive_connection_leases + SET state = 'INVALIDATED', + ended_at = datetime('now'), + end_reason = 'CONNECTION_INELIGIBLE' + WHERE state = 'ACTIVE' + AND connection_id = NEW.id; +END; + +-- Once a connection id belongs to a retired provider, imports and internal +-- writers must not repurpose that same audited identity as another provider. +-- Retired-to-retired normalization remains allowed and is re-tombstoned by the +-- AFTER UPDATE trigger above. +CREATE TRIGGER IF NOT EXISTS provider_connections_preserve_chatgpt_web_identity_insert +BEFORE INSERT ON provider_connections +WHEN EXISTS ( + SELECT 1 + FROM provider_connections + WHERE id = NEW.id + AND lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + IN ('chatgpt-web', 'cgpt-web') + ) + AND lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + NOT IN ('chatgpt-web', 'cgpt-web') +BEGIN + SELECT RAISE(ABORT, 'Retired provider connection identity cannot be changed'); +END; + +CREATE TRIGGER IF NOT EXISTS provider_connections_preserve_chatgpt_web_identity_update +BEFORE UPDATE OF provider ON provider_connections +WHEN lower(trim(OLD.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + IN ('chatgpt-web', 'cgpt-web') + AND lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + NOT IN ('chatgpt-web', 'cgpt-web') +BEGIN + SELECT RAISE(ABORT, 'Retired provider connection identity cannot be changed'); +END; + +-- A restore can also insert lease rows after migrations have completed. Keep +-- lease state fail-closed independently of request-time auth selection. +CREATE TRIGGER IF NOT EXISTS exclusive_connection_leases_retire_chatgpt_web_insert +AFTER INSERT ON exclusive_connection_leases +WHEN NEW.state = 'ACTIVE' + AND ( + lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + IN ('chatgpt-web', 'cgpt-web') + OR EXISTS ( + SELECT 1 + FROM provider_connections + WHERE id = NEW.connection_id + AND lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + IN ('chatgpt-web', 'cgpt-web') + ) + ) +BEGIN + UPDATE exclusive_connection_leases + SET state = 'INVALIDATED', + ended_at = datetime('now'), + end_reason = 'CONNECTION_INELIGIBLE' + WHERE id = NEW.id + AND state = 'ACTIVE'; +END; + +CREATE TRIGGER IF NOT EXISTS exclusive_connection_leases_retire_chatgpt_web_update +AFTER UPDATE OF provider, connection_id, state ON exclusive_connection_leases +WHEN NEW.state = 'ACTIVE' + AND ( + lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + IN ('chatgpt-web', 'cgpt-web') + OR EXISTS ( + SELECT 1 + FROM provider_connections + WHERE id = NEW.connection_id + AND lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279))) + IN ('chatgpt-web', 'cgpt-web') + ) + ) +BEGIN + UPDATE exclusive_connection_leases + SET state = 'INVALIDATED', + ended_at = datetime('now'), + end_reason = 'CONNECTION_INELIGIBLE' + WHERE id = NEW.id + AND state = 'ACTIVE'; +END; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index ae79516db2..bbc5140abe 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -3,6 +3,8 @@ */ import { v4 as uuidv4 } from "uuid"; + +import { isCommonChatGptWebRetiredProviderId } from "@/shared/constants/chatgptWebRetirement"; import { getDbInstance, rowToCamel, cleanNulls } from "./core"; import { backupDbFile } from "./backup"; import { @@ -595,13 +597,19 @@ export async function createProviderConnection(data: JsonRecord) { _updateConnectionRow(db, existingId, encryptConnectionFields(persistence)); })(); backupDbFile("pre-write"); - return withNullableRateLimitOverrides( + const returnedConnection = withNullableRateLimitOverrides( withNullableQuotaWindowThresholds( withNullableMaxConcurrent(cleanNulls(merged), merged), merged ), merged ); + + if (isCommonChatGptWebRetiredProviderId(merged.provider)) { + return (await getProviderConnectionById(existingId)) ?? returnedConnection; + } + + return returnedConnection; } // Generate name: prefer explicit name, then email, then a stable short-ID label. @@ -722,13 +730,19 @@ export async function createProviderConnection(data: JsonRecord) { backupDbFile("pre-write"); invalidateDbCache("connections"); // Bust connections read cache - return withNullableRateLimitOverrides( + const returnedConnection = withNullableRateLimitOverrides( withNullableQuotaWindowThresholds( withNullableMaxConcurrent(cleanNulls(connection), connection), connection ), connection ); + + if (isCommonChatGptWebRetiredProviderId(providerId)) { + return (await getProviderConnectionById(String(connection.id))) ?? returnedConnection; + } + + return returnedConnection; } function _insertConnectionRow(db: DbLike, conn: JsonRecord) { @@ -967,13 +981,19 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { reorderConnections(db, providerId); } - return withNullableRateLimitOverrides( + const returnedConnection = withNullableRateLimitOverrides( withNullableQuotaWindowThresholds( withNullableMaxConcurrent(cleanNulls(merged), merged), merged ), merged ); + + if (isCommonChatGptWebRetiredProviderId(merged.provider)) { + return (await getProviderConnectionById(id)) ?? returnedConnection; + } + + return returnedConnection; } export { diff --git a/src/lib/images/imageRouteModel.ts b/src/lib/images/imageRouteModel.ts index 11892c237f..dbd3a60f7d 100644 --- a/src/lib/images/imageRouteModel.ts +++ b/src/lib/images/imageRouteModel.ts @@ -7,7 +7,7 @@ * 1. Bare combo / alias name with no slash (`image`) — resolved to the combo's single * image target, then that target is itself prefix-resolved. Bare combos intentionally * override built-in image aliases with the same name. - * 2. Built-in image model id / alias (`cgpt-web/...`, `gpt-image-1`, …) — untouched. + * 2. Built-in image model id / alias (`openai/gpt-image-2`, `gpt-image-1`, etc.) — untouched. * 3. Custom provider *prefix* form (`myImg/gpt-image-2`) — rewritten to the internal * `/` id (#3205 did this inline in the generations route only). * @@ -19,6 +19,7 @@ import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts"; import { getComboByName, getCombos } from "@/lib/db/combos"; import { getCachedProviderNodes } from "@/lib/localDb"; +import { assertCommonChatGptWebModelAvailable } from "@/shared/constants/chatgptWebRetirement"; /** * Rewrite a `prefix/model` custom image model to its internal `/` form. @@ -28,6 +29,7 @@ import { getCachedProviderNodes } from "@/lib/localDb"; */ export async function resolveImageModelPrefix(modelStr: string): Promise { if (typeof modelStr !== "string") return modelStr; + assertCommonChatGptWebModelAvailable(modelStr); const slash = modelStr.indexOf("/"); if (slash <= 0) return modelStr; @@ -75,6 +77,7 @@ export async function resolveSingleImageComboTarget(name: string): Promise { if (typeof modelStr !== "string" || !modelStr.trim()) return modelStr; + assertCommonChatGptWebModelAvailable(modelStr); const parsedModel = parseImageModel(modelStr); const hasSlash = modelStr.includes("/"); diff --git a/src/lib/logPayloads.ts b/src/lib/logPayloads.ts index f97e338259..5aa2f9eb9c 100644 --- a/src/lib/logPayloads.ts +++ b/src/lib/logPayloads.ts @@ -17,7 +17,7 @@ const SENSITIVE_KEYS = new Set([ "secret", "token", // secret-leak hardening: session cookies + browser-storage credentials that - // some web-impersonation providers (Meta AI ecto_1_sess, chatgpt-web + // some web-impersonation providers (Meta AI ecto_1_sess, Perplexity Web // storageState / runtimeKey) can surface into a request/response BODY field // rather than a header. Header-borne values are already masked by // maskSensitiveHeaders; this covers the body path into the on-disk call-log diff --git a/src/lib/providerNodePrefixes.ts b/src/lib/providerNodePrefixes.ts index 83a4fbb623..3122348fc4 100644 --- a/src/lib/providerNodePrefixes.ts +++ b/src/lib/providerNodePrefixes.ts @@ -35,8 +35,11 @@ * Built-in/no-compatible catalog entries are always eligible. */ -import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; import { getProviderNodes } from "@/lib/db/providers/nodes"; +import { + getReservedProviderPrefixes, + isReservedProviderPrefix, +} from "@/shared/constants/reservedProviderPrefixes"; export type ProviderPrefixStatus = "unique" | "ambiguous" | "reserved"; @@ -66,12 +69,7 @@ export interface ProviderPrefixIndex { * prefixes can never shadow a built-in provider. */ export function buildReservedPrefixes(): Set { - const reserved = new Set(); - for (const entry of Object.values(REGISTRY)) { - if (entry?.id) reserved.add(entry.id); - if (entry?.alias) reserved.add(entry.alias); - } - return reserved; + return new Set(getReservedProviderPrefixes()); } export interface CompatibleNodeLike { @@ -97,7 +95,6 @@ export function selectCompatibleNodeForPrefix( } export async function getProviderPrefixIndex(): Promise { - const reserved = buildReservedPrefixes(); const nodes = (await getProviderNodes()) as CompatibleNodeLike[]; const compatible = nodes.filter( (n) => n.type === "openai-compatible" || n.type === "anthropic-compatible" @@ -123,8 +120,9 @@ export async function getProviderPrefixIndex(): Promise { const eligibleNodeIds = new Set(); for (const [prefix, prefixNodes] of byPrefix) { - if (reserved.has(prefix)) { - // Built-in registry id/alias — never a compatible public target. + if (isReservedProviderPrefix(prefix)) { + // Built-in registry id/alias or case-insensitive retired id — never a + // compatible public target. entries.set(prefix, { prefix, status: "reserved" }); continue; } diff --git a/src/lib/providers/chatgptWebRetirementResponse.ts b/src/lib/providers/chatgptWebRetirementResponse.ts new file mode 100644 index 0000000000..8e9d984f9a --- /dev/null +++ b/src/lib/providers/chatgptWebRetirementResponse.ts @@ -0,0 +1,30 @@ +import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; + +import { + assertCommonChatGptWebProviderAvailable, + CHATGPT_WEB_RETIRED_ERROR_CODE, + CHATGPT_WEB_RETIRED_MESSAGE, + isCommonChatGptWebRetiredProviderId, + isCommonChatGptWebRetirementError, +} from "@/shared/constants/chatgptWebRetirement"; + +export function commonChatGptWebRetirementResponse(): Response { + return errorResponse(410, CHATGPT_WEB_RETIRED_MESSAGE, { + type: "provider_error", + code: CHATGPT_WEB_RETIRED_ERROR_CODE, + }); +} + +export function rejectRetiredCommonChatGptWebProvider(providerId: unknown): Response | null { + return isCommonChatGptWebRetiredProviderId(providerId) + ? commonChatGptWebRetirementResponse() + : null; +} + +export function assertProviderAvailable(providerId: unknown): void { + assertCommonChatGptWebProviderAvailable(providerId); +} + +export function responseForError(error: unknown): Response | null { + return isCommonChatGptWebRetirementError(error) ? commonChatGptWebRetirementResponse() : null; +} diff --git a/src/lib/providers/modelListingCapability.ts b/src/lib/providers/modelListingCapability.ts index db0e439824..be608291e9 100644 --- a/src/lib/providers/modelListingCapability.ts +++ b/src/lib/providers/modelListingCapability.ts @@ -16,7 +16,7 @@ const TOOL_ONLY_SERVICE_KINDS = new Set(["webSearch", "webFetch"]); * are intentionally NOT curated: their model list is discovered live from the * console API (see volcenginePlanModelDiscovery.ts) and merged into the synced * catalog, so the static registry only acts as a capability-seed fallback. */ -const CURATED_MODEL_ONLY_PROVIDERS = new Set(["chatgpt-web", "kimi-web", "zai-web"]); +const CURATED_MODEL_ONLY_PROVIDERS = new Set(["kimi-web", "zai-web"]); export function providerUsesCuratedModelsOnly(providerId: string): boolean { return CURATED_MODEL_ONLY_PROVIDERS.has(providerId.trim().toLowerCase()); diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index e95c2c9fc3..a04258d47d 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -31,7 +31,6 @@ import { validateDeepSeekWebProvider, validateQwenWebProvider, validateGrokWebProvider, - validateChatGptWebProvider, validatePerplexityWebProvider, validateBlackboxWebProvider, validateKimiWebProvider, @@ -166,7 +165,11 @@ export async function validateFreebuffProvider({ apiKey }: { apiKey: string }) { return { valid: false, error: "Invalid or expired Freebuff Auth Token", unsupported: false }; } const errText = await res.text().catch(() => ""); - return { valid: false, error: `Freebuff validation returned ${res.status}: ${errText.slice(0, 100)}`, unsupported: false }; + return { + valid: false, + error: `Freebuff validation returned ${res.status}: ${errText.slice(0, 100)}`, + unsupported: false, + }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); return { valid: false, error: `Freebuff validation network error: ${msg}`, unsupported: false }; @@ -307,7 +310,6 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi "grok-web": validateGrokWebProvider, "qwen-web": validateQwenWebProvider, "kimi-web": validateKimiWebProvider, - "chatgpt-web": validateChatGptWebProvider, "chatgpt-web-codex": validateChatGptWebCodexProvider, "perplexity-web": validatePerplexityWebProvider, "blackbox-web": validateBlackboxWebProvider, @@ -376,7 +378,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi // Web-cookie providers WITHOUT a dedicated specialty validator above fall back to the generic // session-ping check (AUTH_007 SESSION_EXPIRED on 401/403). Providers that DO have a rich - // per-provider validator (grok-web, chatgpt-web, claude-web, …) are handled by + // per-provider validator (grok-web, perplexity-web, claude-web, etc.) are handled by // SPECIALTY_VALIDATORS first and must not be shadowed by this generic probe (issue: the // #4023 dispatch was placed too early and intercepted every web-cookie provider). const canonicalProvider = resolveProviderId(provider); diff --git a/src/lib/providers/validation/transport.ts b/src/lib/providers/validation/transport.ts index c486ff41fc..f919ba8f44 100644 --- a/src/lib/providers/validation/transport.ts +++ b/src/lib/providers/validation/transport.ts @@ -120,7 +120,6 @@ const WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE = new Set(["lmarena"]); // WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE/REDIRECT_BLOCKED path above (#7542). export const WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API = new Set([ "huggingchat", - "chatgpt-web", "grok-web", "notion-web", "t3-web", diff --git a/src/lib/providers/validation/webProvidersA.ts b/src/lib/providers/validation/webProvidersA.ts index 7c9746ccd7..234f76be07 100644 --- a/src/lib/providers/validation/webProvidersA.ts +++ b/src/lib/providers/validation/webProvidersA.ts @@ -1,4 +1,4 @@ -// Web-cookie provider key validators (part A): deepseek-web, qwen-web, grok-web, chatgpt-web, +// Web-cookie provider key validators (part A): deepseek-web, qwen-web, grok-web, // perplexity-web, blackbox-web. Extracted from validation.ts (god-file decomposition) — top-level // functions with no dispatcher-state captures; behavior is byte-identical to the original inline defs. import { addModelsSuffix } from "./urlHelpers"; @@ -488,114 +488,6 @@ export async function validateGrokWebProvider({ apiKey, providerSpecificData = { } } -export async function validateChatGptWebProvider({ apiKey, providerSpecificData = {} }: any) { - try { - // Accept bare value, unchunked cookie, chunked (.0/.1) cookies, or full - // "Cookie: ..." DevTools line. Pass through verbatim once recognised. - let cookieHeader = String(apiKey || "").trim(); - if (/^cookie\s*:\s*/i.test(cookieHeader)) { - cookieHeader = cookieHeader.replace(/^cookie\s*:\s*/i, ""); - } - if (!/__Secure-next-auth\.session-token(?:\.\d+)?\s*=/.test(cookieHeader)) { - cookieHeader = `__Secure-next-auth.session-token=${cookieHeader}`; - } - - // Use the TLS-impersonating client — Cloudflare on chatgpt.com pins - // cf_clearance to JA3/JA4 + HTTP/2 SETTINGS, so plain Node fetch always - // gets cf-mitigated: challenge regardless of cookies. - const { tlsFetchChatGpt, TlsClientUnavailableError } = - await import("@omniroute/open-sse/services/chatgptTlsClient.ts"); - - let response; - try { - response = await tlsFetchChatGpt("https://chatgpt.com/api/auth/session", { - method: "GET", - headers: applyCustomUserAgent( - { - Accept: "application/json", - "Accept-Language": "en-US,en;q=0.9", - "Cache-Control": "no-cache", - Cookie: cookieHeader, - Origin: "https://chatgpt.com", - Pragma: "no-cache", - Referer: "https://chatgpt.com/", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin", - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", - }, - providerSpecificData - ), - timeoutMs: 30_000, - }); - } catch (err: any) { - if (err instanceof TlsClientUnavailableError) { - return { - valid: false, - error: `${err.message} (chatgpt-web requires this — without it, Cloudflare blocks every request)`, - }; - } - throw err; - } - - const contentType = response.headers.get("content-type") || ""; - const cfRay = response.headers.get("cf-ray"); - const cfMitigated = response.headers.get("cf-mitigated"); - - if (response.status === 401 || response.status === 403) { - const bodyText = response.text || ""; - if (cfMitigated || /just a moment|cloudflare|cf-chl|attention required/i.test(bodyText)) { - return { - valid: false, - error: - "Cloudflare blocked the validator — open chatgpt.com in your browser, then copy the FULL Cookie line from DevTools (Network → request → Cookie) including cf_clearance, __cf_bm, _cfuvid, and the session-token chunks.", - }; - } - return { - valid: false, - error: - "Invalid ChatGPT session cookie — re-paste __Secure-next-auth.session-token from chatgpt.com DevTools → Cookies", - }; - } - - if (response.status >= 500) { - return { valid: false, error: `ChatGPT unavailable (${response.status})` }; - } - - if (response.status >= 400) { - return { valid: false, error: `Validation failed: ${response.status}` }; - } - - if (!contentType.includes("json")) { - return { - valid: false, - error: `ChatGPT returned non-JSON (${contentType || "no content-type"}${cfRay ? `, cf-ray=${cfRay}` : ""}) — paste the FULL Cookie line including cf_clearance, __cf_bm, _cfuvid alongside the session-token chunks.`, - }; - } - - let data: any = {}; - try { - data = JSON.parse(response.text || "{}"); - } catch { - return { - valid: false, - error: - "ChatGPT session response was not JSON — paste the FULL Cookie line including cf_clearance and __cf_bm.", - }; - } - if (!data?.accessToken) { - return { - valid: false, - error: "ChatGPT session expired — log into chatgpt.com and copy a fresh cookie", - }; - } - return { valid: true, error: null }; - } catch (error: any) { - return toValidationErrorResult(error); - } -} - export async function validatePerplexityWebProvider({ apiKey, providerSpecificData = {} }: any) { try { let sessionToken = apiKey; diff --git a/src/lib/usage/flatRateProviders.ts b/src/lib/usage/flatRateProviders.ts index 3454b9b391..117dc010eb 100644 --- a/src/lib/usage/flatRateProviders.ts +++ b/src/lib/usage/flatRateProviders.ts @@ -2,7 +2,7 @@ * Flat-rate (subscription / cookie-web) provider classification — issue #5552. * * Some providers are billed at a flat rate (a subscription or a coding plan), - * not per token: cookie/web sessions (ChatGPT Web, grok-web, …) are backed by a + * not per token: cookie/web sessions (ChatGPT Web (Codex), grok-web, …) are backed by a * consumer subscription, and several "Coding Plan" providers (Codex, MiniMax * Coding, Kimi Coding, GLM Coding, …) bill a fixed monthly fee. These providers * still carry per-token pricing rows (used for pre-flight estimates), so cost diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index c80a7476d0..252d97fe45 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -328,7 +328,6 @@ const LOBE_PROVIDER_ALIASES = { bfl: "Bfl", "black-forest-labs": "Bfl", cerebras: "Cerebras", - "chatgpt-web": "OpenAI", "chatgpt-web-codex": "OpenAI", claude: "ClaudeCode", "claude-web": "Claude", diff --git a/src/shared/constants/chatgptWebRetirement.ts b/src/shared/constants/chatgptWebRetirement.ts new file mode 100644 index 0000000000..d3c0818d91 --- /dev/null +++ b/src/shared/constants/chatgptWebRetirement.ts @@ -0,0 +1,46 @@ +export const RETIRED_COMMON_CHATGPT_WEB_PROVIDER_IDS: ReadonlySet = new Set([ + "chatgpt-web", + "cgpt-web", +]); + +export const CHATGPT_WEB_RETIRED_ERROR_CODE = "PROVIDER_RETIRED"; +export const CHATGPT_WEB_RETIRED_MESSAGE = "Provider is retired and unavailable."; + +export type ChatGptWebRetirementError = Error & { + code: typeof CHATGPT_WEB_RETIRED_ERROR_CODE; + status: 410; +}; + +export function isCommonChatGptWebRetiredProviderId(providerId: unknown): providerId is string { + return ( + typeof providerId === "string" && + RETIRED_COMMON_CHATGPT_WEB_PROVIDER_IDS.has(providerId.trim().toLowerCase()) + ); +} + +export function assertCommonChatGptWebProviderAvailable(providerId: unknown): void { + if (!isCommonChatGptWebRetiredProviderId(providerId)) return; + + const error = new Error(CHATGPT_WEB_RETIRED_MESSAGE) as ChatGptWebRetirementError; + error.code = CHATGPT_WEB_RETIRED_ERROR_CODE; + error.status = 410; + throw error; +} + +export function assertCommonChatGptWebModelAvailable(modelId: unknown): void { + if (typeof modelId !== "string") return; + const normalizedModelId = modelId.trim(); + assertCommonChatGptWebProviderAvailable(normalizedModelId); + + const slash = normalizedModelId.indexOf("/"); + if (slash <= 0) return; + assertCommonChatGptWebProviderAvailable(normalizedModelId.slice(0, slash)); +} + +export function isCommonChatGptWebRetirementError( + error: unknown +): error is ChatGptWebRetirementError { + if (!(error instanceof Error)) return false; + const typed = error as Error & { code?: unknown; status?: unknown }; + return typed.code === CHATGPT_WEB_RETIRED_ERROR_CODE && typed.status === 410; +} diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index 7f653b6ebc..e90ab61013 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -17,19 +17,6 @@ export const WEB_COOKIE_PROVIDERS = { riskNoticeVariant: "webCookie", toolCalling: "native", }, - "chatgpt-web": { - id: "chatgpt-web", - alias: "cgpt-web", - name: "ChatGPT Web (Plus/Pro)", - icon: "auto_awesome", - color: "#10A37F", - textIcon: "CG", - website: "https://chatgpt.com", - authHint: "Paste your __Secure-next-auth.session-token cookie value from chatgpt.com", - subscriptionRisk: true, - riskNoticeVariant: "webCookie", - toolCalling: "emulated", - }, "grok-web": { id: "grok-web", alias: "gw", diff --git a/src/shared/constants/reservedProviderPrefixes.ts b/src/shared/constants/reservedProviderPrefixes.ts index fb6471b760..21da616e30 100644 --- a/src/shared/constants/reservedProviderPrefixes.ts +++ b/src/shared/constants/reservedProviderPrefixes.ts @@ -11,15 +11,24 @@ // provider (tokenrouter bug: "No active credentials for provider: // tokenrouter" despite a fully configured compatible node). // -// Semantics (mirror the original inline runtime guard exactly): -// - REGISTRY entry ids + aliases only. Manual alias ids outside REGISTRY +// Semantics: +// - REGISTRY entry ids + aliases plus the permanently retired common +// ChatGPT Web ids. Manual alias ids outside REGISTRY // (xiaomi/llamacpp/aq) do NOT intercept nodes at runtime and are therefore // deliberately NOT reserved — including them would cause false-positive // rejections. -// - Case-sensitive: mixed-case input like "TokenRouter" does not collide with -// the runtime lookup (`Set.has` is exact-match), so it stays allowed. +// - Registry ids/aliases stay case-sensitive: mixed-case input like +// "TokenRouter" does not collide with the runtime lookup (`Set.has` is +// exact-match), so it stays allowed. The retired ids are the deliberate +// exception and are matched trim/case-insensitively because every request +// using them fails closed with 410. import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { + isCommonChatGptWebRetiredProviderId, + RETIRED_COMMON_CHATGPT_WEB_PROVIDER_IDS, +} from "@/shared/constants/chatgptWebRetirement"; + let _reserved: Set | null = null; function buildReservedProviderPrefixes(): Set { @@ -29,13 +38,14 @@ function buildReservedProviderPrefixes(): Set { if (entry?.id) reserved.add(entry.id); if (entry?.alias) reserved.add(entry.alias); } + for (const retiredId of RETIRED_COMMON_CHATGPT_WEB_PROVIDER_IDS) reserved.add(retiredId); _reserved = reserved; return reserved; } /** - * All reserved provider prefixes (REGISTRY ids + aliases). Built lazily so the - * registry is only walked once per process. + * All reserved provider prefixes (REGISTRY ids + aliases and canonical retired + * ids). Built lazily so the registry is only walked once per process. */ export function getReservedProviderPrefixes(): ReadonlySet { return buildReservedProviderPrefixes(); @@ -58,7 +68,10 @@ export const RESERVED_PROVIDER_PREFIXES: ReadonlySet = getReservedProvid * reserved (mirrors the runtime guard's typeof check). */ export function isReservedProviderPrefix(value: unknown): boolean { - return typeof value === "string" && buildReservedProviderPrefixes().has(value); + return ( + typeof value === "string" && + (isCommonChatGptWebRetiredProviderId(value) || buildReservedProviderPrefixes().has(value)) + ); } /** @@ -66,5 +79,5 @@ export function isReservedProviderPrefix(value: unknown): boolean { * prefix and tells the operator what to pick instead. */ export function reservedProviderPrefixMessage(value: string): string { - return `"${value}" is a reserved provider prefix — choose a different prefix (reserved ids/aliases cannot be used for custom nodes because requests like /model would always route to the built-in provider)`; + return `"${value}" is a reserved provider prefix — choose a different prefix (reserved ids/aliases cannot be used for custom nodes because requests like /model route to a built-in provider or fail closed when retired)`; } diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 7528ea4df3..d780d91d31 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -56,13 +56,6 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: false, storageKeys: ["apiKey", "token", "uuid", "app-config-uuid"], }, - "chatgpt-web": { - kind: "cookie", - credentialName: "__Secure-next-auth.session-token", - placeholder: "__Secure-next-auth.session-token=...", - acceptsFullCookieHeader: true, - storageKeys: ["cookie", "sessionToken", "session-token", "__Secure-next-auth.session-token"], - }, "grok-web": { kind: "cookie", credentialName: "sso + sso-rw", diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index a99ba7c947..91f9ce79c2 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -48,7 +48,7 @@ const providerNodeIconUrlSchema = z .optional(); // #6715: the `apiKey` field is reused as the raw `Cookie:` header value for -// cookie-based web providers (Gemini Business, Copilot M365, ChatGPT Web, +// cookie-based web providers (Gemini Business, Copilot M365, ChatGPT Web (Codex), // Claude Web, …). Real multi-cookie session headers (many `__Secure-*` entries, // large session tokens) legitimately exceed the old 10,000-char cap, so saving // a cookie that the provider's own `validate` check (validateProviderApiKeySchema, diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index aadf7d29d3..58d6e22964 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -63,6 +63,7 @@ import * as log from "../utils/logger"; import { checkAndRefreshToken } from "../services/tokenRefresh"; import { createHookContext, runHooks, initPreRequestRegistry } from "@/lib/middleware/registry"; import { rejectPeerRequest } from "@/shared/resilience/peerRouting"; +import { isCommonChatGptWebRetirementError } from "@/shared/constants/chatgptWebRetirement"; import { deleteHandoff, getHandoff } from "@/lib/db/contextHandoffs"; import { getComboByName, updateCombo } from "@/lib/db/combos"; import { isModelAllowedForKey } from "@/lib/db/apiKeys"; @@ -948,7 +949,13 @@ async function handleChatImplementation( // prefix may differ from the credential provider ID (e.g. model // "xiaomi/mimo-v2-flash" resolves to provider "xiaomi" but the combo // target specifies providerId: "opengate" for credential lookup). - const modelInfo = await getModelInfo(modelString); + let modelInfo; + try { + modelInfo = await getModelInfo(modelString); + } catch (error) { + if (isCommonChatGptWebRetirementError(error)) return false; + throw error; + } // Apply the same prefix-override guard as handleSingleModelChat: // if providerId is just the prefix already in the model string, use // the fully-resolved modelInfo.provider for a precise credential check. diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index bcf9a51c62..091d7f45ec 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -1,4 +1,4 @@ -import { getModelInfo, getComboForModel } from "../services/model"; +import { getComboForModel, getModelInfoOrRetirementResponse } from "../services/model"; import { clearAccountError, markAccountUnavailable } from "../services/auth"; import { connectionHasExtraKeys } from "@omniroute/open-sse/services/apiKeyRotator.ts"; import { createBuiltinAutoCombo } from "@omniroute/open-sse/services/autoCombo/builtinCatalog.ts"; @@ -120,7 +120,8 @@ export async function resolveModelOrError( endpointPath: string = "", requestHeaders: Record | null | undefined = null ) { - const modelInfo = await getModelInfo(modelStr); + const modelInfo = await getModelInfoOrRetirementResponse(modelStr); + if ("error" in modelInfo) return modelInfo; const sourceFormat = detectFormatFromEndpoint(body, endpointPath); if ( @@ -492,9 +493,8 @@ export async function executeChatWithBreaker({ expiresIn: newCreds.expiresIn, expiresAt: newCreds.expiresAt, providerSpecificData: newCreds.providerSpecificData, - // Cookie/session providers (chatgpt-web) rotate the stored - // apiKey blob mid-request — forward it so the DB credential - // doesn't go stale after Set-Cookie rotation. + // Cookie/session providers may rotate apiKey mid-request; forward it so the DB + // credential doesn't go stale after Set-Cookie rotation. apiKey: newCreds.apiKey, testStatus: newCreds.testStatus ?? "active", isActive: newCreds.isActive, diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 5a3c046efa..53ac8d2cf7 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -31,6 +31,7 @@ import { } from "@/lib/providers/peakHourProtection"; import { buildJinaEnvCredentials } from "@/lib/providers/jina"; import { buildGeminiEnvCredentials } from "@/lib/providers/gemini"; +import { isCommonChatGptWebRetiredProviderId } from "@/shared/constants/chatgptWebRetirement"; import { toNumber } from "@/shared/utils/numeric"; import { createLazyConnectionView, @@ -1035,13 +1036,11 @@ const PROVIDER_SEARCH_PAIRS: string[][] = [ // before falling through to JINA_AI_API_KEY. ["jina-ai", "jina-reader", "jina-search"], ]; -/** - * Resolve provider aliases (e.g., nvidia -> nvidia_nim) for DB lookup - */ +/** Resolve provider aliases (e.g., nvidia -> nvidia_nim) for DB lookup. */ async function getProviderSearchPool(provider: string): Promise { const canonicalProvider = resolveProviderId(provider); const canonicalAlias = getProviderAlias(canonicalProvider); - + if (isCommonChatGptWebRetiredProviderId(provider)) return []; const group = PROVIDER_SEARCH_PAIRS.find((aliases) => aliases.includes(provider)); if (group) return [provider, ...group.filter((id) => id !== provider)]; diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index aa317dcb96..602392bf07 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -21,6 +21,12 @@ import { getLearnedReasoningEffortForModel } from "@omniroute/open-sse/services/ import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; import { getRegisteredProviderEffortBaseModelId } from "@omniroute/open-sse/utils/registeredEffortVariants.ts"; import { getReservedProviderPrefixes } from "@/shared/constants/reservedProviderPrefixes"; +import { + assertCommonChatGptWebModelAvailable, + assertCommonChatGptWebProviderAvailable, + isCommonChatGptWebRetirementError, +} from "@/shared/constants/chatgptWebRetirement"; +import { commonChatGptWebRetirementResponse } from "@/lib/providers/chatgptWebRetirementResponse"; export { parseModel, stripContextWindowSuffix }; @@ -422,10 +428,20 @@ function stripRedundantNodeRoutingSegments(model: string, routingIds: unknown[]) * Get full model info (parse or resolve) */ export async function getModelInfo(modelStr) { + // Reject the raw common-provider identity before compatible-node lookup or + // stripModelPrefix can erase/remap it. Ordinary bare model aliases remain + // operator-owned; the two retired bare ids are intentionally blocked. + assertCommonChatGptWebModelAvailable(modelStr); const parsed = parseModel(modelStr); const { extendedContext } = parsed; + const assertResolvedModelAvailable = (info: any) => { + assertCommonChatGptWebProviderAvailable(info?.provider); + return info; + }; + const attachRuntimeModelMeta = async (info: any) => { + assertResolvedModelAvailable(info); if (!info?.provider || !info?.model) return info; const providerId = String(info.provider); @@ -482,12 +498,12 @@ export async function getModelInfo(modelStr) { matchedOpenAI.id as string, normalizedModel ); - return { + return assertResolvedModelAvailable({ provider: matchedOpenAI.id, model: modelId, extendedContext, ...metadata, - }; + }); } // Check Anthropic Compatible nodes @@ -504,12 +520,12 @@ export async function getModelInfo(modelStr) { matchedAnthropic.id as string, normalizedModel ); - return { + return assertResolvedModelAvailable({ provider: matchedAnthropic.id, model: modelId, extendedContext, ...metadata, - }; + }); } } @@ -519,7 +535,7 @@ export async function getModelInfo(modelStr) { const settings = await getCachedSettings(); if (settings.stripModelPrefix === true) { const strippedResult = await getModelInfoCore(parsed.model, getCombinedModelAliases); - return { ...strippedResult, extendedContext }; + return assertResolvedModelAvailable({ ...strippedResult, extendedContext }); } } catch { // If settings read fails, fall through to normal resolution @@ -533,6 +549,17 @@ export async function getModelInfo(modelStr) { return await attachRuntimeModelMeta(await getModelInfoCore(modelStr, getCombinedModelAliases)); } +export async function getModelInfoOrRetirementResponse(modelId: string) { + try { + return await getModelInfo(modelId); + } catch (error) { + if (isCommonChatGptWebRetirementError(error)) { + return { error: commonChatGptWebRetirementResponse() }; + } + throw error; + } +} + /** * Check if model is a combo and return the full combo object * @returns {Promise} Full combo object or null if not a combo diff --git a/src/sse/services/tokenRefresh.ts b/src/sse/services/tokenRefresh.ts index b655cedc26..4955cb8247 100755 --- a/src/sse/services/tokenRefresh.ts +++ b/src/sse/services/tokenRefresh.ts @@ -170,7 +170,7 @@ export async function updateProviderCredentials(connectionId: string, newCredent if (newCredentials.providerSpecificData) { updates.providerSpecificData = newCredentials.providerSpecificData; } - // Cookie/session providers (chatgpt-web, ...) refresh by rotating the + // Cookie/session providers (Perplexity Web, etc.) refresh by rotating the // stored apiKey blob — propagate that here too so DB credentials don't // go stale after Set-Cookie rotation. if (newCredentials.apiKey) { diff --git a/src/types/global.d.ts b/src/types/global.d.ts index ab480be2aa..d025f1edb9 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -22,8 +22,6 @@ declare namespace NodeJS { API_HOST?: string; DASHBOARD_PORT?: string; OMNIROUTE_PUBLIC_BASE_URL?: string; - OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS?: string; - OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB?: string; OMNIROUTE_BASE_URL?: string; OMNIROUTE_DISABLE_BACKGROUND_SERVICES?: string; OMNIROUTE_PORT?: string; diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json index 23d0e01b8b..2fdfe6d273 100644 --- a/tests/snapshots/executors/executor-map.json +++ b/tests/snapshots/executors/executor-map.json @@ -90,16 +90,6 @@ "configSource": "", "provider": "chatgpt-web-codex" }, - "cgpt-web": { - "className": "ChatGptWebExecutor", - "configSource": "", - "provider": "chatgpt-web" - }, - "chatgpt-web": { - "className": "ChatGptWebExecutor", - "configSource": "", - "provider": "chatgpt-web" - }, "chatgpt-web-codex": { "className": "ChatGptWebCodexExecutor", "configSource": "", @@ -721,6 +711,6 @@ "provider": "zai-web" } }, - "keyCount": 144, + "keyCount": 142, "sharedInstances": [] } diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index f9e96d0909..cb2763447c 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -842,29 +842,6 @@ "stream": "https://api.chatanywhere.org/v1/chat/completions" } }, - "chatgpt-web": { - "format": "openai", - "headers": { - "apiKey": { - "Accept": "text/event-stream", - "Authorization": "Bearer ", - "Content-Type": "application/json" - }, - "nonStream": { - "Authorization": "Bearer ", - "Content-Type": "application/json" - }, - "oauth": { - "Accept": "text/event-stream", - "Authorization": "Bearer ", - "Content-Type": "application/json" - } - }, - "url": { - "nonStream": "https://chatgpt.com/backend-api/conversation", - "stream": "https://chatgpt.com/backend-api/conversation" - } - }, "chatgpt-web-codex": { "format": "openai-responses", "headers": { diff --git a/tests/unit/8200-perplexity-web-cookie-rotation.test.ts b/tests/unit/8200-perplexity-web-cookie-rotation.test.ts index 4d23beb2ac..68e5fc2cb0 100644 --- a/tests/unit/8200-perplexity-web-cookie-rotation.test.ts +++ b/tests/unit/8200-perplexity-web-cookie-rotation.test.ts @@ -1,13 +1,11 @@ // Issue #8200: perplexity-web must persist Set-Cookie session-token rotations -// via onCredentialsRefreshed — chatgpt-web parity (mergeRefreshedCookie + +// via onCredentialsRefreshed using the shared mergeRefreshedCookie + // buildSessionCookieHeader in open-sse/utils/nextAuthCookie.ts). import test from "node:test"; import assert from "node:assert/strict"; -const { - mergeRefreshedCookie, - buildSessionCookieHeader, -} = await import("../../open-sse/utils/nextAuthCookie.ts"); +const { mergeRefreshedCookie, buildSessionCookieHeader } = + await import("../../open-sse/utils/nextAuthCookie.ts"); const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts"); const { __setTlsFetchOverrideForTesting } = await import("../../open-sse/services/perplexityTlsClient.ts"); @@ -37,10 +35,7 @@ test("nextAuthCookie: buildSessionCookieHeader passes full DevTools cookie blob const blob = "__Secure-next-auth.session-token.0=partA; __Secure-next-auth.session-token.1=partB; cf_clearance=CF"; assert.equal(buildSessionCookieHeader(blob), blob); - assert.equal( - buildSessionCookieHeader(`Cookie: ${blob}`), - blob - ); + assert.equal(buildSessionCookieHeader(`Cookie: ${blob}`), blob); }); test("nextAuthCookie: buildSessionCookieHeader wraps bare session-token value", () => { @@ -72,8 +67,7 @@ test("#8200: PerplexityWebExecutor persists rotated session-token via onCredenti captured.headers = opts.headers as Record; const headers = new Headers({ "Content-Type": "text/event-stream", - "set-cookie": - "__Secure-next-auth.session-token=ROTATED-VALUE; Path=/; HttpOnly; Secure", + "set-cookie": "__Secure-next-auth.session-token=ROTATED-VALUE; Path=/; HttpOnly; Secure", }); return { status: 200, @@ -103,7 +97,10 @@ test("#8200: PerplexityWebExecutor persists rotated session-token via onCredenti "__Secure-next-auth.session-token=old-cookie-value", "bare apiKey must be normalized for the upstream Cookie header" ); - assert.ok(persisted, "onCredentialsRefreshed must fire when Set-Cookie rotates the session token"); + assert.ok( + persisted, + "onCredentialsRefreshed must fire when Set-Cookie rotates the session token" + ); assert.equal( persisted.apiKey, "__Secure-next-auth.session-token=ROTATED-VALUE", @@ -139,8 +136,7 @@ test("#8200: PerplexityWebExecutor preserves cf_clearance when session-token rot body: { messages: [{ role: "user", content: "hi" }], stream: false }, stream: false, credentials: { - apiKey: - "__Secure-next-auth.session-token=UNCHUNKED_OLD; cf_clearance=CFCLEAR", + apiKey: "__Secure-next-auth.session-token=UNCHUNKED_OLD; cf_clearance=CFCLEAR", }, signal: AbortSignal.timeout(10_000), log: null, diff --git a/tests/unit/8488-capability-filter-fail-closed.test.ts b/tests/unit/8488-capability-filter-fail-closed.test.ts index 691353e792..6a77e10434 100644 --- a/tests/unit/8488-capability-filter-fail-closed.test.ts +++ b/tests/unit/8488-capability-filter-fail-closed.test.ts @@ -121,16 +121,19 @@ test("#8488 filter: zero tool-capable targets → empty (fail closed)", () => { assert.ok(exhaustion!.excluded.some((e) => e.reason.includes("tools"))); }); -test("#8488 filter: chatgpt-web emulation providers stay eligible for tools (#5240)", () => { - // Registry honestly tags chatgpt-web models toolCalling:false; the prompt +test("#8488 filter: Gemini Web emulation stays eligible for tools (#5240)", () => { + // Registry honestly tags Gemini Web models toolCalling:false; the prompt // shim is what makes tools work. Fail-closed must not hard-reject them. - assert.equal(providerSupportsEmulatedToolCalling("chatgpt-web"), true); - assert.equal(providerSupportsEmulatedToolCalling("cgpt-web"), true); + assert.equal(providerSupportsEmulatedToolCalling("gemini-web"), true); + assert.equal(providerSupportsEmulatedToolCalling("gweb"), true); assert.equal(providerSupportsEmulatedToolCalling("claude-web"), false); // toolCalling:"none" assert.equal(providerSupportsEmulatedToolCalling("openai"), false); const kept = filterTargetsByRequestCompatibility( - [target("chatgpt-web", "chatgpt-web/gpt-5.5"), target("chatgpt-web", "chatgpt-web/o3")], + [ + target("gemini-web", "gemini-web/gemini-3.1-pro"), + target("gemini-web", "gemini-web/gemini-3.7-flash"), + ], { messages: [{ role: "user", content: "Use a tool." }], tools: [{ type: "function", function: { name: "lookup", parameters: {} } }], @@ -140,11 +143,14 @@ test("#8488 filter: chatgpt-web emulation providers stay eligible for tools (#52 assert.equal(kept.length, 2); assert.deepEqual( kept.map((t) => t.modelStr), - ["chatgpt-web/gpt-5.5", "chatgpt-web/o3"] + ["gemini-web/gemini-3.1-pro", "gemini-web/gemini-3.7-flash"] ); const exhaustion = describeCapabilityFilterExhaustion( - [target("chatgpt-web", "chatgpt-web/gpt-5.5"), target("chatgpt-web", "chatgpt-web/o3")], + [ + target("gemini-web", "gemini-web/gemini-3.1-pro"), + target("gemini-web", "gemini-web/gemini-3.7-flash"), + ], { messages: [{ role: "user", content: "Use a tool." }], tools: [{ type: "function", function: { name: "lookup", parameters: {} } }], @@ -154,9 +160,9 @@ test("#8488 filter: chatgpt-web emulation providers stay eligible for tools (#52 assert.equal(exhaustion, null, "emulation-capable pool must not report capability_mismatch"); }); -test("#8488 auto: chatgpt-web emulation survives tool pre-filter (#5240)", async () => { +test("#8488 auto: Gemini Web emulation survives tool pre-filter (#5240)", async () => { const result = await resolveAutoStrategyOrder({ - orderedTargets: [target("chatgpt-web", "chatgpt-web/gpt-5.5")] as never, + orderedTargets: [target("gemini-web", "gemini-web/gemini-3.1-pro")] as never, body: { messages: [{ role: "user", content: "hi" }], tools: [{ type: "function", function: { name: "lookup", parameters: {} } }], @@ -176,7 +182,7 @@ test("#8488 auto: chatgpt-web emulation survives tool pre-filter (#5240)", async ); if ("orderedTargets" in result) { assert.equal(result.orderedTargets.length, 1); - assert.equal(result.orderedTargets[0].modelStr, "chatgpt-web/gpt-5.5"); + assert.equal(result.orderedTargets[0].modelStr, "gemini-web/gemini-3.1-pro"); } }); diff --git a/tests/unit/bulk-web-session-import.test.ts b/tests/unit/bulk-web-session-import.test.ts index ab426f83e8..7535d2c438 100644 --- a/tests/unit/bulk-web-session-import.test.ts +++ b/tests/unit/bulk-web-session-import.test.ts @@ -18,7 +18,7 @@ import { describe("bulkWebSessionImportSchema", () => { it("accepts valid input with single entry", () => { const result = bulkWebSessionImportSchema.safeParse({ - provider: "chatgpt-web", + provider: "perplexity-web", entries: [{ name: "Account 1", credential: "__Secure-next-auth.session-token=abc123" }], }); assert.equal(result.success, true); @@ -48,7 +48,7 @@ describe("bulkWebSessionImportSchema", () => { it("rejects empty entries array", () => { const result = bulkWebSessionImportSchema.safeParse({ - provider: "chatgpt-web", + provider: "perplexity-web", entries: [], }); assert.equal(result.success, false); @@ -60,7 +60,7 @@ describe("bulkWebSessionImportSchema", () => { credential: "cookie=value", })); const result = bulkWebSessionImportSchema.safeParse({ - provider: "chatgpt-web", + provider: "perplexity-web", entries, }); assert.equal(result.success, false); @@ -68,7 +68,7 @@ describe("bulkWebSessionImportSchema", () => { it("rejects entry with empty credential", () => { const result = bulkWebSessionImportSchema.safeParse({ - provider: "chatgpt-web", + provider: "perplexity-web", entries: [{ name: "Account 1", credential: "" }], }); assert.equal(result.success, false); @@ -76,7 +76,7 @@ describe("bulkWebSessionImportSchema", () => { it("rejects entry with empty name", () => { const result = bulkWebSessionImportSchema.safeParse({ - provider: "chatgpt-web", + provider: "perplexity-web", entries: [{ name: "", credential: "cookie=value" }], }); assert.equal(result.success, false); @@ -91,14 +91,14 @@ describe("bulkWebSessionImportSchema", () => { it("rejects priority out of range", () => { const result = bulkWebSessionImportSchema.safeParse({ - provider: "chatgpt-web", + provider: "perplexity-web", entries: [{ name: "A1", credential: "cookie=value" }], priority: 0, }); assert.equal(result.success, false); const result2 = bulkWebSessionImportSchema.safeParse({ - provider: "chatgpt-web", + provider: "perplexity-web", entries: [{ name: "A1", credential: "cookie=value" }], priority: 101, }); @@ -111,7 +111,7 @@ describe("bulkWebSessionImportSchema", () => { credential: "cookie=value", })); const result = bulkWebSessionImportSchema.safeParse({ - provider: "chatgpt-web", + provider: "perplexity-web", entries, }); assert.equal(result.success, true); @@ -120,7 +120,7 @@ describe("bulkWebSessionImportSchema", () => { describe("web-session credential helpers", () => { it("requiresWebSessionCredential returns true for web-cookie providers", () => { - assert.equal(requiresWebSessionCredential("chatgpt-web"), true); + assert.equal(requiresWebSessionCredential("perplexity-web"), true); assert.equal(requiresWebSessionCredential("grok-web"), true); assert.equal(requiresWebSessionCredential("claude-web"), true); assert.equal(requiresWebSessionCredential("deepseek-web"), true); @@ -133,7 +133,7 @@ describe("web-session credential helpers", () => { }); it("getWebSessionCredentialRequirement returns correct kind for cookie providers", () => { - const req = getWebSessionCredentialRequirement("chatgpt-web"); + const req = getWebSessionCredentialRequirement("perplexity-web"); assert.ok(req); assert.equal(req.kind, "cookie"); }); @@ -146,13 +146,13 @@ describe("web-session credential helpers", () => { it("hasUsableWebSessionCredential validates cookie data correctly", () => { assert.equal( - hasUsableWebSessionCredential("chatgpt-web", { + hasUsableWebSessionCredential("perplexity-web", { cookie: "__Secure-next-auth.session-token=abc", }), true ); - assert.equal(hasUsableWebSessionCredential("chatgpt-web", { cookie: "" }), false); - assert.equal(hasUsableWebSessionCredential("chatgpt-web", {}), false); + assert.equal(hasUsableWebSessionCredential("perplexity-web", { cookie: "" }), false); + assert.equal(hasUsableWebSessionCredential("perplexity-web", {}), false); }); it("hasUsableWebSessionCredential validates token data correctly", () => { @@ -172,7 +172,7 @@ describe("canUpdateProviderApiKey", () => { }); it("does not allow cookie-kind web sessions to update apiKey", () => { - assert.equal(canUpdateProviderApiKey("cookie", "chatgpt-web"), false); + assert.equal(canUpdateProviderApiKey("cookie", "perplexity-web"), false); assert.equal(canUpdateProviderApiKey("cookie", "claude-web"), false); }); @@ -214,7 +214,7 @@ describe("resolveWebSessionImportApiKey (token-kind imports must populate apiKey ); assert.equal( resolveWebSessionImportApiKey( - getWebSessionCredentialRequirement("chatgpt-web"), + getWebSessionCredentialRequirement("perplexity-web"), "__Secure-next-auth.session-token=abc" ), null diff --git a/tests/unit/capability-filter.test.ts b/tests/unit/capability-filter.test.ts index 5bcab12d76..e4c261ac74 100644 --- a/tests/unit/capability-filter.test.ts +++ b/tests/unit/capability-filter.test.ts @@ -23,15 +23,17 @@ import { // ── Helpers ─────────────────────────────────────────────────────────────── /** Minimal capabilities shape for filter testing. */ -function caps(overrides: Partial<{ - supportsTools: boolean | null; - toolCalling: boolean; - supportsVision: boolean | null; - structuredOutput: boolean | null; - contextWindow: number | null; - maxInputTokens: number | null; - maxOutputTokens: number | null; -}> = {}) { +function caps( + overrides: Partial<{ + supportsTools: boolean | null; + toolCalling: boolean; + supportsVision: boolean | null; + structuredOutput: boolean | null; + contextWindow: number | null; + maxInputTokens: number | null; + maxOutputTokens: number | null; + }> = {} +) { return { supportsTools: overrides.supportsTools ?? null, toolCalling: overrides.toolCalling ?? true, @@ -43,7 +45,9 @@ function caps(overrides: Partial<{ }; } -function req(overrides: Partial = {}): RequestCapabilityRequirements { +function req( + overrides: Partial = {} +): RequestCapabilityRequirements { return { requiresTools: false, requiresVision: false, @@ -113,12 +117,12 @@ test("checkRequestCapabilityFit: tools OK when model supports tools", () => { }); test("checkRequestCapabilityFit: tools bypassed for emulated-tool provider", () => { - // chatgpt-web has toolCalling: "emulated" in the provider registry, + // gemini-web has toolCalling: "emulated" in the provider registry, // so the filter must not reject it even when capabilities report false. const result = checkRequestCapabilityFit( caps({ supportsTools: false, toolCalling: false }), req({ requiresTools: true }), - "chatgpt-web" + "gemini-web" ); assert.equal(result.compatible, true); assert.deepEqual(result.failures, []); @@ -205,7 +209,10 @@ test("deriveRequestCapabilityRequirements: detects tools from body", () => { test("deriveRequestCapabilityRequirements: detects vision from image_url", () => { const requirements = deriveRequestCapabilityRequirements({ messages: [ - { role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/img.jpg" } }] }, + { + role: "user", + content: [{ type: "image_url", image_url: { url: "https://example.com/img.jpg" } }], + }, ], }); assert.equal(requirements.requiresVision, true); @@ -230,9 +237,7 @@ test("feature flag CAPABILITY_FILTER_ENABLED defaults to false", () => { // opt-in. The default value must be "false" per the plan. import("../../src/shared/constants/featureFlagDefinitions.ts").then( ({ FEATURE_FLAG_DEFINITIONS }) => { - const flag = FEATURE_FLAG_DEFINITIONS.find( - (d) => d.key === "CAPABILITY_FILTER_ENABLED" - ); + const flag = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "CAPABILITY_FILTER_ENABLED"); assert.ok(flag, "CAPABILITY_FILTER_ENABLED flag must be defined"); assert.equal(flag.defaultValue, "false"); assert.equal(flag.type, "boolean"); @@ -266,4 +271,4 @@ test("error responses use buildErrorBody and do not leak stack traces", () => { assert.equal(body.error.type, "invalid_request_error"); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/chatcore-executor-proxy.test.ts b/tests/unit/chatcore-executor-proxy.test.ts index dd4cc6e2fa..bf2286ed07 100644 --- a/tests/unit/chatcore-executor-proxy.test.ts +++ b/tests/unit/chatcore-executor-proxy.test.ts @@ -17,13 +17,11 @@ process.env.DATA_DIR = testDataDir; // Dynamic imports AFTER DATA_DIR is set so core.ts picks up the temp path. const coreDb = await import("../../src/lib/db/core.ts"); const upstreamProxyDb = await import("../../src/lib/db/upstreamProxy.ts"); -const { resolveExecutorWithProxy } = await import( - "../../open-sse/handlers/chatCore/executorProxy.ts" -); +const { resolveExecutorWithProxy } = + await import("../../open-sse/handlers/chatCore/executorProxy.ts"); const { getExecutor } = await import("../../open-sse/executors/index.ts"); -const { clearUpstreamProxyConfigCache } = await import( - "../../open-sse/handlers/chatCore/comboContextCache.ts" -); +const { clearUpstreamProxyConfigCache } = + await import("../../open-sse/handlers/chatCore/comboContextCache.ts"); before(async () => { await coreDb.ensureDbInitialized(); @@ -137,3 +135,20 @@ test("connection override wins over provider mode 'fallback'", async () => { // Connection override short-circuits to the passthrough executor, not the fallback wrapper. assert.equal(exec, await getExecutor("cliproxyapi")); }); + +test("retired common ChatGPT Web cannot bypass retirement through proxy overrides", async () => { + for (const providerId of ["chatgpt-web", "cgpt-web"]) { + await assert.rejects( + () => + resolveExecutorWithProxy(providerId, undefined, { + cliproxyapiMode: "claude-native", + }), + (error: unknown) => { + const typed = error as Error & { code?: string; status?: number }; + assert.equal(typed.code, "PROVIDER_RETIRED"); + assert.equal(typed.status, 410); + return true; + } + ); + } +}); diff --git a/tests/unit/chatgpt-image-cache.test.ts b/tests/unit/chatgpt-image-cache.test.ts deleted file mode 100644 index 76e1b82f7c..0000000000 --- a/tests/unit/chatgpt-image-cache.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -const mod = await import("../../open-sse/services/chatgptImageCache.ts"); -const { - storeChatGptImage, - getChatGptImage, - __resetChatGptImageCacheForTesting, - __getChatGptImageCacheBytesForTesting, -} = mod; - -// ── Constants ── - -test("MAX_ENTRIES is 25", async () => { - // We verify indirectly: store 25 entries, then store a 26th and confirm - // the first entry was evicted. This also proves the constant is 25. - __resetChatGptImageCacheForTesting(); - const ids: string[] = []; - for (let i = 0; i < 25; i++) { - ids.push(storeChatGptImage(Buffer.from(`img-${i}`), "image/png", 60_000)); - } - // All 25 should be retrievable - for (const id of ids) { - assert.ok(getChatGptImage(id), "entry within MAX_ENTRIES should survive"); - } - __resetChatGptImageCacheForTesting(); -}); - -test("DEFAULT_MAX_BYTES is 10 MB (10 * 1024 * 1024)", async () => { - __resetChatGptImageCacheForTesting(); - // Store an entry that is just under 10 MB — should succeed - const big = Buffer.alloc(10 * 1024 * 1024 - 1, 0x42); - const id = storeChatGptImage(big, "image/png", 60_000); - assert.ok(getChatGptImage(id), "entry under 10 MB should be cached"); - assert.equal(__getChatGptImageCacheBytesForTesting(), big.length); - __resetChatGptImageCacheForTesting(); -}); - -// ── Eviction ── - -test("storing 26 entries evicts the oldest", async () => { - __resetChatGptImageCacheForTesting(); - const ids: string[] = []; - for (let i = 0; i < 26; i++) { - ids.push(storeChatGptImage(Buffer.from(`img-${i}`), "image/png", 60_000)); - } - // The first entry (index 0) should have been evicted - assert.equal(getChatGptImage(ids[0]), null, "oldest entry should be evicted"); - // The second entry should still be present - assert.ok(getChatGptImage(ids[1]), "second entry should survive"); - // The newest entry should be present - assert.ok(getChatGptImage(ids[25]), "newest entry should survive"); - __resetChatGptImageCacheForTesting(); -}); - -// ── Store & Retrieve (hit) ── - -test("store then retrieve returns the cached entry (cache hit)", async () => { - __resetChatGptImageCacheForTesting(); - const payload = Buffer.from("hello-image-data"); - const id = storeChatGptImage(payload, "image/jpeg", 60_000); - const entry = getChatGptImage(id); - assert.ok(entry, "entry should exist"); - assert.deepEqual(entry!.bytes, payload); - assert.equal(entry!.mime, "image/jpeg"); - assert.ok(typeof entry!.bytesSha256 === "string" && entry!.bytesSha256.length === 64); - __resetChatGptImageCacheForTesting(); -}); - -// ── TTL expiry ── - -test("entry expires after TTL (mocked Date.now)", async () => { - __resetChatGptImageCacheForTesting(); - const originalNow = Date.now; - let fakeNow = 1_000_000; - Date.now = () => fakeNow; - - const id = storeChatGptImage(Buffer.from("ttl-test"), "image/png", 5000); - assert.ok(getChatGptImage(id), "should hit before TTL"); - - // Advance past TTL - fakeNow += 5001; - assert.equal(getChatGptImage(id), null, "should miss after TTL expires"); - - Date.now = originalNow; - __resetChatGptImageCacheForTesting(); -}); diff --git a/tests/unit/chatgpt-web-async-image-ws-shapes-7357.test.ts b/tests/unit/chatgpt-web-async-image-ws-shapes-7357.test.ts deleted file mode 100644 index c5430c5da3..0000000000 --- a/tests/unit/chatgpt-web-async-image-ws-shapes-7357.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { EventEmitter } from "node:events"; - -const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import( - "../../open-sse/executors/chatgpt-web.ts" -); -const { __setTlsFetchOverrideForTesting } = await import( - "../../open-sse/services/chatgptTlsClient.ts" -); - -function makeHeaders(map: Record = {}) { - const h = new Headers(); - for (const [k, v] of Object.entries(map)) h.set(k, String(v)); - return h; -} - -const CONVERSATION_ID = "conv-async-7357"; -const FINAL_POINTER = "file-service://file-final-7357"; - -// SSE stream: assistant starts, tool kicks off image_gen (the "Processing -// image..." card via metadata.image_gen_task_id), stream ends WITHOUT any -// resolved image_asset_pointer — the real async case where the image only -// shows up later, over the celsius WebSocket. -function asyncImageGenSseText(): string { - const events = [ - { - conversation_id: CONVERSATION_ID, - message: { - id: "msg-1", - author: { role: "assistant" }, - content: { content_type: "text", parts: ["Generating your image..."] }, - status: "in_progress", - }, - }, - { - conversation_id: CONVERSATION_ID, - message: { - id: "tool-1", - author: { role: "tool", name: "t2uay3k.sj1i4kz" }, - metadata: { image_gen_task_id: "task-7357" }, - content: { content_type: "text", parts: [] }, - }, - }, - ]; - const chunks = events.map((e) => `data: ${JSON.stringify(e)}\r\n\r\n`); - chunks.push("data: [DONE]\r\n\r\n"); - return chunks.join(""); -} - -// Fake global WebSocket: opens, then emits ONE frame shaped like chatgpt.com's -// celsius wire format for the PLURAL case — payload.update_content.messages[] -// — carrying the completed tool-role image_asset_pointer message. This is the -// shape issue #7357 reports chatgpt.com sends and the current parser does not -// recognize (it only reads update_content.message, singular). -class FakeWebSocket extends EventEmitter { - url: string; - onopen: (() => void) | null = null; - onmessage: ((ev: { data: string }) => void) | null = null; - onerror: ((ev: unknown) => void) | null = null; - onclose: (() => void) | null = null; - static instances: FakeWebSocket[] = []; - - constructor(url: string) { - super(); - this.url = url; - FakeWebSocket.instances.push(this); - setTimeout(() => { - this.onopen?.(); - setTimeout(() => { - const frame = { - type: "conversation-update", - payload: { - conversation_id: CONVERSATION_ID, - update_content: { - messages: [ - { - message: { - id: "img-msg-final", - author: { role: "tool", name: "t2uay3k.sj1i4kz" }, - content: { - content_type: "multimodal_text", - parts: [ - { - content_type: "image_asset_pointer", - asset_pointer: FINAL_POINTER, - width: 1024, - height: 1024, - }, - ], - }, - status: "finished_successfully", - }, - }, - ], - }, - }, - }; - this.onmessage?.({ data: JSON.stringify(frame) }); - }, 5); - }, 5); - } - - close() {} -} - -test("#7357: async image_gen pointer delivered via update_content.messages[] should resolve to markdown (currently lost → 502)", async () => { - __resetChatGptWebCachesForTesting(); - const previousWebSocket = (globalThis as Record).WebSocket; - const previousTimeout = process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS; - process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS = "300"; // keep the probe fast - (globalThis as Record).WebSocket = FakeWebSocket; - - __setTlsFetchOverrideForTesting(async (url, opts = {}) => { - const u = String(url); - const method = opts.method || "GET"; - if ((u === "https://chatgpt.com/" || u === "https://chatgpt.com") && method === "GET") { - return { - status: 200, - headers: makeHeaders({ "Content-Type": "text/html" }), - text: '', - body: null, - }; - } - if (u.includes("/api/auth/session")) { - return { - status: 200, - headers: makeHeaders({ "Content-Type": "application/json" }), - text: JSON.stringify({ - accessToken: "jwt-7357", - expires: new Date(Date.now() + 3600_000).toISOString(), - user: { id: "u-7357" }, - }), - body: null, - }; - } - if (u.includes("/backend-api/sentinel/chat-requirements")) { - return { - status: 200, - headers: makeHeaders({ "Content-Type": "application/json" }), - text: JSON.stringify({ token: "t", proofofwork: { required: false } }), - body: null, - }; - } - if (u.endsWith("/backend-api/f/conversation") || u.endsWith("/backend-api/conversation")) { - return { - status: 200, - headers: makeHeaders({ "Content-Type": "text/event-stream" }), - text: asyncImageGenSseText(), - body: null, - }; - } - if (u.includes("/backend-api/celsius/ws/user")) { - return { - status: 200, - headers: makeHeaders({ "Content-Type": "application/json" }), - text: JSON.stringify({ websocket_url: "wss://chatgpt.com/fake-celsius-socket" }), - body: null, - }; - } - // Resolution path for FINAL_POINTER, exercised ONLY if the WS listener - // actually extracts the pointer from the update_content.messages[] frame. - if (u.match(/\/backend-api\/files\/[^/]+\/download/)) { - return { - status: 200, - headers: makeHeaders({ "Content-Type": "application/json" }), - text: JSON.stringify({ - download_url: "https://chatgpt.com/backend-api/estuary/content?id=file-final-7357", - }), - body: null, - }; - } - if (u.startsWith("https://chatgpt.com/backend-api/estuary/content")) { - const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - return { - status: 200, - headers: makeHeaders({ "Content-Type": "image/png" }), - text: `data:image/png;base64,${pngBytes.toString("base64")}`, - body: null, - }; - } - return { status: 404, headers: makeHeaders(), text: "not mocked", body: null }; - }); - - try { - const executor = new ChatGptWebExecutor(); - const result = await executor.execute({ - model: "gpt-5.5", - body: { messages: [{ role: "user", content: "generate an image of a kitten" }] }, - stream: false, - credentials: { apiKey: "test-session-cookie" }, - signal: AbortSignal.timeout(20_000), - log: null, - }); - - assert.equal(result.response.status, 200, "executor itself does not error"); - const json = await result.response.json(); - const content = String(json?.choices?.[0]?.message?.content || ""); - - assert.ok(FakeWebSocket.instances.length >= 1, "a WebSocket connection was opened"); - - // Expected/correct behavior: the celsius WebSocket delivered a complete, - // well-formed tool-role image_asset_pointer message via chatgpt.com's - // update_content.messages[] (plural) shape. OmniRoute should extract it, - // resolve it, and append image markdown — just like the already-covered - // update_content.message (singular) case in tests/unit/chatgpt-web.test.ts. - assert.match( - content, - /!\[image\]\([^)]*\/v1\/chatgpt-web\/image\/[a-f0-9]+\)/, - "BUG #7357: image pointer delivered via update_content.messages[] (plural) was not " + - "resolved into markdown — waitForImageViaWebSocket() only recognizes the singular " + - "update_content.message / payload.message / data.message shapes and silently drops " + - "this frame, losing an already-completed upstream image." - ); - assert.equal( - json.x_image_resolution_failed, - undefined, - "resolution succeeded — no unresolved-pointer flag expected" - ); - } finally { - __setTlsFetchOverrideForTesting(null); - if (previousWebSocket === undefined) delete (globalThis as Record).WebSocket; - else (globalThis as Record).WebSocket = previousWebSocket; - if (previousTimeout === undefined) delete process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS; - else process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS = previousTimeout; - } -}); - -// Fake WebSocket that opens cleanly then closes without ever delivering a -// frame — the case where register-websocket succeeds but the celsius socket -// itself yields nothing (Cloudflare edge drops it, or the browser TLS -// fingerprint mismatch upstream expects breaks the exchange silently). This -// is a *clean* close (no error event), so waitForImageViaWebSocket() -// resolves with `errored: false` and pollForAsyncImage() does not retry the -// socket — it falls through to the conversation-poll fallback. -class EmptyCloseWebSocket extends EventEmitter { - url: string; - onopen: (() => void) | null = null; - onmessage: ((ev: { data: string }) => void) | null = null; - onerror: ((ev: unknown) => void) | null = null; - onclose: (() => void) | null = null; - static instances: EmptyCloseWebSocket[] = []; - - constructor(url: string) { - super(); - this.url = url; - EmptyCloseWebSocket.instances.push(this); - setTimeout(() => { - this.onopen?.(); - setTimeout(() => this.onclose?.(), 5); - }, 5); - } - - close() {} -} - -const STALE_POINTER = "file-service://file-stale-7357"; -const NEWEST_POINTER = "file-service://file-newest-7357"; - -test( - "#7357: conversation-poll fallback recovers the image and prefers the newest " + - "message when the websocket path closes without delivering a frame", - async () => { - __resetChatGptWebCachesForTesting(); - const previousWebSocket = (globalThis as Record).WebSocket; - const previousTimeout = process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS; - // Keep the websocket wait short so the test doesn't burn real time - // waiting for the (intentionally empty) socket to time out. - process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS = "300"; - (globalThis as Record).WebSocket = EmptyCloseWebSocket; - - let conversationPollCalls = 0; - - __setTlsFetchOverrideForTesting(async (url, opts = {}) => { - const u = String(url); - const method = opts.method || "GET"; - if ((u === "https://chatgpt.com/" || u === "https://chatgpt.com") && method === "GET") { - return { - status: 200, - headers: makeHeaders({ "Content-Type": "text/html" }), - text: '', - body: null, - }; - } - if (u.includes("/api/auth/session")) { - return { - status: 200, - headers: makeHeaders({ "Content-Type": "application/json" }), - text: JSON.stringify({ - accessToken: "jwt-7357-poll", - expires: new Date(Date.now() + 3600_000).toISOString(), - user: { id: "u-7357-poll" }, - }), - body: null, - }; - } - if (u.includes("/backend-api/sentinel/chat-requirements")) { - return { - status: 200, - headers: makeHeaders({ "Content-Type": "application/json" }), - text: JSON.stringify({ token: "t", proofofwork: { required: false } }), - body: null, - }; - } - if (u.endsWith("/backend-api/f/conversation") || u.endsWith("/backend-api/conversation")) { - return { - status: 200, - headers: makeHeaders({ "Content-Type": "text/event-stream" }), - text: asyncImageGenSseText(), - body: null, - }; - } - if (u.includes("/backend-api/celsius/ws/user")) { - return { - status: 200, - headers: makeHeaders({ "Content-Type": "application/json" }), - text: JSON.stringify({ websocket_url: "wss://chatgpt.com/fake-celsius-socket" }), - body: null, - }; - } - // GET /backend-api/conversation/ — the conversation-poll fallback - // fetchConversationDetail() hits once the websocket yields nothing. - // The mapping carries TWO tool messages with image pointers at - // different create_time — the fallback must pick the newer one. - if ( - u === `https://chatgpt.com/backend-api/conversation/${CONVERSATION_ID}` && - method === "GET" - ) { - conversationPollCalls++; - return { - status: 200, - headers: makeHeaders({ "Content-Type": "application/json" }), - text: JSON.stringify({ - mapping: { - "node-stale": { - message: { - id: "img-msg-stale", - author: { role: "tool", name: "t2uay3k.sj1i4kz" }, - content: { - content_type: "multimodal_text", - parts: [ - { - content_type: "image_asset_pointer", - asset_pointer: STALE_POINTER, - width: 1024, - height: 1024, - }, - ], - }, - status: "finished_successfully", - create_time: 1000, - }, - }, - "node-newest": { - message: { - id: "img-msg-newest", - author: { role: "tool", name: "t2uay3k.sj1i4kz" }, - content: { - content_type: "multimodal_text", - parts: [ - { - content_type: "image_asset_pointer", - asset_pointer: NEWEST_POINTER, - width: 1024, - height: 1024, - }, - ], - }, - status: "finished_successfully", - create_time: 2000, - }, - }, - }, - }), - body: null, - }; - } - if (u.match(/\/backend-api\/files\/[^/]+\/download/)) { - const fileId = u.match(/\/backend-api\/files\/([^/]+)\/download/)?.[1] ?? "unknown"; - return { - status: 200, - headers: makeHeaders({ "Content-Type": "application/json" }), - text: JSON.stringify({ - download_url: `https://chatgpt.com/backend-api/estuary/content?id=${fileId}`, - }), - body: null, - }; - } - if (u.startsWith("https://chatgpt.com/backend-api/estuary/content")) { - const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - return { - status: 200, - headers: makeHeaders({ "Content-Type": "image/png" }), - text: `data:image/png;base64,${pngBytes.toString("base64")}`, - body: null, - }; - } - return { status: 404, headers: makeHeaders(), text: "not mocked", body: null }; - }); - - try { - const executor = new ChatGptWebExecutor(); - const result = await executor.execute({ - model: "gpt-5.5", - body: { - messages: [{ role: "user", content: "generate an image of a kitten" }], - }, - stream: false, - credentials: { apiKey: "test-session-cookie" }, - signal: AbortSignal.timeout(20_000), - log: null, - }); - - assert.equal(result.response.status, 200, "executor itself does not error"); - const json = await result.response.json(); - const content = String(json?.choices?.[0]?.message?.content || ""); - - assert.ok( - EmptyCloseWebSocket.instances.length >= 1, - "a WebSocket connection was opened and closed without a frame" - ); - assert.ok( - conversationPollCalls >= 1, - "the conversation-poll fallback was invoked after the websocket yielded nothing" - ); - - assert.match( - content, - /!\[image\]\([^)]*\/v1\/chatgpt-web\/image\/[a-f0-9]+\)/, - "conversation-poll fallback should have recovered the image the websocket lost" - ); - assert.equal( - json.x_image_resolution_failed, - undefined, - "resolution succeeded — no unresolved-pointer flag expected" - ); - } finally { - __setTlsFetchOverrideForTesting(null); - if (previousWebSocket === undefined) delete (globalThis as Record).WebSocket; - else (globalThis as Record).WebSocket = previousWebSocket; - if (previousTimeout === undefined) delete process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS; - else process.env.OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS = previousTimeout; - } - } -); diff --git a/tests/unit/chatgpt-web-citations-escape.test.ts b/tests/unit/chatgpt-web-citations-escape.test.ts deleted file mode 100644 index 540183e959..0000000000 --- a/tests/unit/chatgpt-web-citations-escape.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// ChatGPT-web citation link-text escaping (CodeQL js/incomplete-sanitization, -// PR #6569 release-blocker). -// -// `markdownLinkText()` builds the `[text]` half of a Markdown link from an -// untrusted citation label. It escaped `[` and `]` but NOT the backslash -// itself, so a label ending in (or containing) a backslash produced a broken -// link: e.g. `[Path C:\](url)` — the trailing `\` escapes the closing `]`, -// consuming the link's bracket. The escape character must be escaped first. - -import test from "node:test"; -import assert from "node:assert/strict"; - -const { cleanChatGptText } = await import( - "../../open-sse/executors/chatgpt-web/citations.ts" -); - -const S = ""; // marker start -const SEP = ""; // marker separator -const E = ""; // marker end - -// Build a raw `url` citation marker:  url 