mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 05:02:15 +03:00
* chore(release): open v3.8.45 development cycle
* chore(release): parallel-cycle flow — sync-next-cycle script + Hard Rule #21 semantics (#6203)
Integrated into release/v3.8.45
* perf(test): tsx/esm loader + tsx 4.23 + órfãos recuperados + CI via npm scripts (plano testes+CI, Pacote 1) (#6214)
* perf(test): tsx/esm loader, tsx 4.23 bump, orphan tests recovered, CI runs npm scripts
Pacote 1 (quick wins) do plano mestre testes+CI:
- Swap --import tsx -> --import tsx/esm on the 19 test scripts: the repo is pure
ESM and the CJS hook costs ~1.3s PER test process (2,462 processes/run).
Measured: bootstrap 2.3s -> 1.1s; real-suite A/B (tests/unit/db, 12 files)
22.2s -> 14.1s wall (-36%), 82/82 pass. Non-test scripts keep the full hook.
- Bump tsx ^4.22.3 -> ^4.23.0 (fix for privatenumber/tsx#809 startup regression;
helps module resolution on big graphs — hook cost unchanged, honest note).
- Recover 22 ORPHAN test files (tests/unit/feature-triage/*.test.mjs, 53 cases,
53/53 pass) that matched no glob and ran in NO CI job; drop the dead
'executors' dir from the braces glob.
- Single source of truth for the unit-suite invocation: new test:unit:ci:shard
(shard via TEST_SHARD env) called by ci.yml test-unit/node24/node26/coverage
and quality.yml fast-unit — closing two silent drifts: CI was NOT importing
setupPolyfill.ts, and the fast path glob OMITTED tests/unit/memory + usage.
quality.yml TIA step + ci.yml test-integration get the tsx/esm swap only.
Validation: full unit suite 21,153 tests / 21,135 pass / 13 skip (5 fails =
known load-flake family, 10/10 green rerun isolated); vitest 237/237; smoke
db+feature-triage 135/135. Record run 2 = ci.yml workflow_dispatch on the
stacked pacote-2 branch (clean runners).
* fix(test): dashboard UI tests keep full tsx hook; recover 15 more .mjs orphans; extend discovery gate to .mjs
Follow-up do dispatch de validacao (run 28720431562), que pegou 2 problemas reais:
1. tests/unit/dashboard/** (11 arquivos, 102 casos) importam componentes React cujo
grafo puxa @lobehub/icons — o build es/ dele faz require() interno de arquivos com
sintaxe ESM, que so funciona com o patch CJS do tsx (sem ele: SyntaxError
'Unexpected token export' no CI; local vira crawl de ~60s/arquivo). Esses 11
arquivos rodam agora numa 2a invocacao com --import tsx COMPLETO (mesmo shard),
e o resto da suite mantem tsx/esm (-50% bootstrap). Validado: 102/102.
2. check:test-discovery falhou porque ancorava textualmente os globs nos workflows —
COLLECTORS atualizado p/ o modelo fonte-unica (ancora = nome do script
test:unit:ci:shard nos workflows) + varredura ESTENDIDA a .test.mjs, que era o
ponto cego que deixou os orfaos apodrecerem. A extensao revelou +15 orfaos .mjs
(top-level + db/) alem dos 22 de feature-triage — TODOS religados via glob
tests/unit/**/*.test.mjs (171/171 pass). Um deles (encryption-error-handling)
codificava o contrato PRE-hardening (decrypt falho retornava ciphertext cru —
vazamento); alinhado ao contrato shipped (null + log) com comentario.
Gate: [test-discovery] OK — 2892 arquivos, 22 collectors, 60 orfaos congelados
(divida rastreada, shrink-only).
* ci: dedup heavy pipeline — compat to nightly, coverage folded into unit shards, i18n single job, draft-skip (#6215)
Pacote 2+3-ci do plano mestre testes+CI (aprovado 2026-07-04). O CI pesado rodava a
suite unit 4x por sync da release-PR (95 jobs, 208 min-maquina) e o ciclo v3.8.44
disparou 123 desses runs (88 cancelados, 0 uteis) porque a release-PR viva fica
aberta o ciclo inteiro.
- D2: matrizes Node 24/26 (build + 8 jobs de teste, ~28% do custo por run) saem do
per-sync e viram .github/workflows/nightly-compat.yml (diario, fail-fast off,
resolve a release ativa como o nightly-release-green, abre issue de tracking em
falha). ci.yml/ci-summary limpos das referencias.
- D3: a matrix Coverage Shard x8 (~18% do custo) e eliminada — o job test-unit roda
os MESMOS shards sob c8/NODE_V8_COVERAGE e sobe os artifacts coverage-shard-N; o
job de merge (test-coverage) so repontou needs (padrao do CI do nodejs/node).
timeout test-unit 15->25min pelo overhead de instrumentacao.
- D4: a matrix i18n de ~40 jobs de <1min (saturava sozinha os 20 slots de
concorrencia da conta Free) vira 1 job que itera os idiomas com ::group:: por
idioma e artifact unico com resultados nomeados por idioma (antes 40 result.txt
colidiam no merge-multiple do ci-summary).
- P3: jobs pesados pulam pull_requests DRAFT (predicado em 10 jobs-raiz; o resto
pula pela cadeia de needs; ci-summary segue rodando como sinal unico) — a skill
/generate-release ja abre a release-PR viva como draft e flipa ready no 0a.0a
(commit eb04fc5 no repo .agents/skills).
- C5 (CodeQL schedule) NAO incluido: bloqueado na acao do dono Settings -> CodeQL
Default->Advanced (documentado no proprio codeql.yml).
Validacao: js-yaml parse ok; check:workflows zizmor 156 < baseline 159 (ratchet
verde); validacao de execucao = workflow_dispatch deste ci.yml neste branch ate
package-artifact + electron-package-smoke verdes (registrada no PR).
* feat(quality): no-new-warnings por PR — ESLint bulk suppressions + lint-guard fork-condicional (Pacote 4) (#6218)
* feat(quality): no-new-warnings per PR via native ESLint bulk suppressions
Pacote 4 do plano mestre testes+CI (aprovado 2026-07-04). O ratchet de
eslintWarnings so rodava no CI pesado (release-PR) -> o drift acumulava invisivel
e explodia na release (+41/+37/+88 por ciclo, rebaselinado as cegas — historico
no proprio quality-baseline.json). Modelo novo (SonarSource Clean-as-You-Code +
ESLint bulk suppressions nativo >=9.24):
- config/quality/eslint-suppressions.json congela a divida existente por
arquivo+regra: 476 arquivos / 4.273 violacoes.
- npm run lint + lint-staged (pre-commit) + novo job lint-guard no quality.yml
rodam suppressions-aware: violacao NOVA fica vermelha NO PR que a introduz
(bulk suppressions ainda eleva estouros de baseline por arquivo a error).
- 3 regras warn promovidas a error em src/** (react-hooks/exhaustive-deps,
@next/next/no-img-element, import/no-anonymous-default-export) — divida
existente congelada, ocorrencia nova = erro imediato.
- collect-metrics mede sob o baseline congelado -> a metrica eslintWarnings
vira 'divida liquida nova' (~0 em regime); baseline apertado 4279->0 no mesmo
PR (exigencia do require-tighten). Aperto do ESTOQUE congelado: npx eslint .
--prune-suppressions na reconciliacao da release.
- Principio Zero: lint-guard usa continue-on-error para PR de FORK (report-only;
a campanha /green-prs aplica o fix via co-autoria) — bloqueante so para
branches internas, a origem real do drift.
Validacao: negativo (any novo em tests/) exit 1; negativo (img em src/, regra
promovida) exit 1; positivo escopado exit 0; baseline gerado por --suppress-all
no tip (tree inteiro passa por construcao); YAML js-yaml ok.
* fix(quality): clear the 6 residual warnings so lint-guard runs clean at --max-warnings 0
The committed baseline still let 6 warnings through the lint-guard gate:
5 now-unused inline eslint-disable directives (the file-level suppressions
made them redundant — removed via eslint --fix, suppressions regenerated to
absorb the re-exposed occurrences) and 1 anonymous default export in
tests/load/k6-soak.js (outside the src/** severity-override scope — named
the k6 scenario function instead).
Verified on the clean tree: lint-guard exit=0; any-canary (new 'const x: any'
in open-sse) exit=1 — the gate bites on NEW violations while the 4,273
frozen ones stay suppressed (476 files).
* fix(ci): lint-guard continue-on-error must be boolean on non-PR events
github.event.pull_request is undefined on workflow_dispatch — the bare property
expression made the job fail at plan time (run 28722888456: 4 jobs green, run red,
lint-guard never materialized). Guard with event_name check so the expression is
always boolean: PR de fork = report-only (Principio Zero), resto = blocking.
* docs(changelog): v3.8.45 bullets for the tests+quality+CI pipeline overhaul (#6214, #6215, #6218)
i18n CHANGELOG mirrors intentionally left to the release reconciliation
(release:sync-changelog-i18n), per cycle practice.
* fix(api): stabilize relay SSRF-guard binding for minified builds (#6149) (#6224)
* fix(mcp): forward extra context through static tool loops (#6178) (#6228)
* fix(services): 9Router embed route + pre-spawn port probe (#6205) (#6227)
* fix(backend): system-first memory injection for strict providers (#6135) (#6225)
* fix(auth): clear error for stale-key decryption failures (#6148) (#6226)
* fix(backend): record reasoning source for zero-metered reasoning models (#6187) (#6229)
* fix(providers): refresh stale NVIDIA NIM model registry (#6108) (#6223)
* fix(backend): distinct max_input_tokens for GPT-family models (#6191) (#6230)
* fix(oauth): extract keychain-import-only guard to restore file-size freeze (base-red) (#6158)
`src/app/api/oauth/[provider]/[action]/route.ts` grew to 959 lines, past its
frozen cap of 924 (`check:file-size` → Fast Quality Gates red on release/v3.8.44).
The growth came from #6054 (graceful 400 for keychain-import-only providers / zed):
a doc block, two Sets (KEYCHAIN_IMPORT_ONLY_PROVIDERS, OAUTH_FLOW_ACTIONS) and a
keychainImportOnlyResponse() helper, plus two duplicated guard blocks in GET/POST.
That is a cohesive, self-contained leaf, so extract it to a new
`keychainImportOnly.ts` exposing `keychainImportOnlyGuard(provider, action)`
(returns the 400 NextResponse or null). The two route callsites collapse to a
2-line guard each. route.ts: 959 -> 918 (< 924, freeze restored). No behavior
change.
Tests (Rule #8/#18):
- Existing tests/unit/oauth-keychain-import-only-6041.test.ts (route-level GET/POST
zed 400) still pass unchanged — behavior preserved.
- New tests/unit/oauth-keychain-import-only-guard.test.ts pins the extracted guard
in isolation (zed+flow -> 400, normal provider -> null, zed+non-flow -> null).
* fix(dashboard): stop model-test error freezing the page (React #31 object toast) (#6161)
Clicking 'test' on a provider model (e.g. a ClinePass flash model) could freeze
the entire dashboard. Root cause: POST /api/models/test returned an OBJECT in
`error` on the Zod-validation and invalid-JSON paths (`validation.error.format()`
/ a details object). The client does `notify.error(data.error)`, and
NotificationToast renders the message directly as a React child — an object throws
React #31 ('Objects are not valid as a React child'), crashing the tree = frozen
page instead of a toast.
Fixed in three layers (defense in depth):
1. Server (root cause): /api/models/test now returns a STRING `error` on every
path — flattens Zod issues to text, returns 'Invalid JSON body' for bad JSON.
2. Client: onTestModel funnels the response through extractApiErrorMessage() so any
object-shaped error is coerced to a string before notify.error.
3. Toast: NotificationToast coerces title/message via toToastText() — a resilient
catch-all so no future caller can freeze the page with a non-string.
Tests (Rule #18, both node:test / blocking suite):
- tests/unit/models-test-error-shape.test.ts — asserts STRING error on Zod-fail,
missing-field, and invalid-JSON (fails on the pre-fix route: 3/3 red -> green).
- tests/unit/notification-toast-coercion.test.ts — toToastText coercion matrix.
* fix(dashboard): remove the always-on Auto-Routing (combo) banner from the home page (#6164)
The blue "Auto-Routing Active — OmniRoute is automatically routing requests
using combo-based strategies" banner was rendered unconditionally on the home
page (`/home`, the default dashboard landing) — it did NOT reflect whether
auto-routing was actually active, and reappeared on every fresh browser / private
window / cleared localStorage (dismissal is stored per-browser). It added noise
to the landing page without conveying live state.
Remove it: drop the <AutoRoutingBanner /> usage + import from home/page.tsx and
delete the now-unused component and its test.
* fix(cline): force upstream streaming for Cline/ClinePass (streaming-only API) (#6165)
* fix(cline): force upstream streaming for Cline/ClinePass (streaming-only API)
Cline's API (api.cline.bot) only implements streaming (streamText). A
non-streaming request returns HTTP 500 "generateText is not implemented" (Claude
models) or HTTP 502 "empty response" (others). Live-verified on the VPS:
stream:true → works (STREAM_OK), stream:false → fails. This is why testing a Cline
model in the dashboard (the test button sends stream:false) failed.
Fix (reuses the existing isClaudeCodeCompatible mechanism, no new handler):
- Flag `cline` and `clinepass` registry entries with `forceStream: true`.
- In chatCore, OR `providerRequiresStreaming` into `upstreamStream` (line 1591)
so the upstream request always streams for these providers, while the client's
original `stream` intent still drives the response format. The existing
non-streaming branch (parseNonStreamingResponseBody) already accumulates the
upstream SSE and converts it back to JSON for stream:false clients — the same
path Claude-Code-compatible providers already use.
Tests (Rule #18): tests/unit/cline-force-stream.test.ts pins the registry flags +
resolveStreamFlag forcing behavior. Live VPS before/after recorded on the PR.
* fix(sse): cline forceStream must stream upstream only, keep client JSON
The #2081 wiring fed providerRequiresStreaming into resolveStreamFlag,
forcing the client-facing stream flag to true for forceStream providers.
That skips the if(!stream) branch that drains a forced upstream SSE and
converts it back to JSON, so a stream:false caller (model-test button,
plain JSON API) got STREAM_EARLY_EOF instead of a JSON body.
Keep providerRequiresStreaming only on upstreamStream (force upstream to
stream); leave the client-facing stream as the client sent it, so
readNonStreamingResponseBody accumulates the SSE into JSON. The promised
handleForcedSSEToJson (#2081 comment) was never implemented — this uses
the existing non-streaming SSE-buffering path (same as isClaudeCodeCompatible).
Live-verified on VPS: cline stream:true worked, stream:false failed.
* fix(providers): correct Kiro model catalog to real upstream ids (#6170)
* fix(providers): correct Kiro model catalog to real upstream ids
Kiro's API (generateAssistantResponse) returns 400 "Invalid model. Please
select a different model" for any id it does not recognize. The registry
exposed fabricated ids (copied from OmniRoute's own Anthropic catalog) that
Kiro never serves, so every call to them 400'd. Live-verified on the VPS:
Removed (400 Invalid model):
- auto-kiro (no "auto" model id — was sent verbatim upstream)
- claude-fable-5 (Kiro offers no Fable)
- claude-opus-4.8/4.7/4.6 (Kiro offers no Opus)
Corrected:
- claude-sonnet-4.6 -> claude-sonnet-4.5 (Kiro's Sonnet is 4.5; 4.5 -> 200)
Kept:
- claude-sonnet-5 (real Kiro model, plan-gated per account)
- claude-haiku-4.5, deepseek-3.2, glm-5, minimax-m2.5/m2.1,
qwen3-coder-next (all proven 200 on the VPS)
Aligns the free-model catalog and drops the orphaned auto-kiro price key.
Regression guard: tests/unit/kiro-catalog-real-models.test.ts (3/3).
Kiro cluster #6112/#6113/#6099.
* test(providers): align stale Kiro-catalog tests to the corrected upstream ids
The fabricated Kiro ids removed in the parent commit (claude-fable-5,
claude-opus-4.8/4.7/4.6, claude-sonnet-4.6) were still asserted as present by
three pre-existing tests, which encoded the bug:
- catalog-updates-v3x: now asserts Kiro does NOT expose Fable 5 / Opus (kept the
legit cc exposure) and guards the real claude-sonnet-4.5 pricing.
- model-family-fallback-notation: the dot-notation example moves from kiro/ to
anthropic/ (which genuinely serves Opus/Fable in dot notation) — coverage kept.
- provider-models-route: the Kiro local-catalog assertion now expects the real
Sonnet 5 / Sonnet 4.5 set and negatively guards the fabricated ids.
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* fix(sse): surface ChatGPT-web image silent-drop as an accurate error (#6208)
When ChatGPT Web generates an image as an image_asset_pointer but the pointer
fails to resolve to a downloadable URL (unknown asset scheme, download 403/
expired, oversize), resolveImagePointers returned [] — indistinguishable from
'no image produced' — so the image-generation handler reported the misleading
502 'completed without returning image markdown'. The image genuinely existed
upstream; OmniRoute dropped it silently.
Fix: the executor flags x_image_resolution_failed when a pointer existed but
none resolved (and logs the unresolved asset scheme for follow-up), and the
handler surfaces a truthful 'generated but not retrievable' 502 instead of
'no image markdown'. Adds executorFactory DI for unit testing.
TDD: tests/unit/chatgpt-web-image-silentdrop.test.ts (red -> green), plus the
existing chatgpt-web / image-generation-handler suites stay green.
Reported via community triage (mesh escalated backlog).
* fix(dashboard): providers page data-timeout guard + live-ws standalone wiring (#6211)
* fix(dashboard): providers page data-timeout guard + live-ws standalone wiring
Captura de trabalho em progresso: timeout de dados na página de providers,
ajuste em ProviderLimits e instrumentation-node, com testes novos
(providers-page-data-timeout, live-ws-standalone-wiring).
* chore(quality): rebaseline ProviderLimits/index.tsx file-size (+6, #6211 data-timeout guard)
Cohesive fix growth from PR #6211's data-timeout guard on the quota page's two
first-paint fetches (1121->1127). The fast-path PR->release skips check:file-size,
so the bump lands with the PR. Justification recorded in file-size-baseline.json.
* fix(translator): strip reasoning param for nvidia z-ai/glm-5.2 (#6181)
* fix(translator): strip reasoning param for nvidia z-ai/glm-5.2
NVIDIA NIM OpenAI-compatible wrapper rejects the reasoning body field
and returns HTTP 400 "Unsupported parameter(s): `reasoning`".
Add a StripRule scoped to provider=nvidia + model /z-ai\/glm-5\.2/i.
Mirrors PR #6102 drop pattern (minimax-m2.7 thinking).
* docs(translator): tighten nvidia glm-5.2 strip-rule comment
* fix(translator): anchor glm-5.2 strip rule with word boundary
* fix: add nvidia to PROVIDER_TOOL_LIMITS (1536) to prevent tool truncation (#6177)
NVIDIA NIM API (nvidia/* models) silently truncates the tool list to 128
(the default MAX_TOOLS_LIMIT) because nvidia is not in PROVIDER_TOOL_LIMITS.
Tools beyond index 127 are dropped, causing agents to lose access to
critical tools like task, read, or high-index MCP tools.
Verified that NVIDIA NIM API supports up to 1536 tools by direct testing.
End-to-end confirmed: 198 tools sent, model successfully called tools at
indices 193, 195, and 197 (previously dropped by truncation to 128).
Follows the same pattern as #5563 (grok-cli: 200), integrated in v3.8.43.
* feat(provider): add Claude 5 Sonnet to Claude Web provider (#6200) (#6209)
* feat(provider): add Claude 5 Sonnet to Claude Web provider (#6200)
* test(providers): guard claude-web claude-sonnet-5 registry entry (#6209)
Adds the missing regression test the PR-test-policy gate requires: asserts the
claude-web registry exposes claude-sonnet-5 (Claude 5 Sonnet web) alongside the
existing 4.6 Sonnet / 4.5 Haiku entries. Fails on the release base (no entry).
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
---------
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
* fix(cli): detect POSIX auto-set HOSTNAME via os.hostname() to fix bind address (#6194) (#6195)
POSIX shells (bash/zsh) always set HOSTNAME to the machine name. The
.env loader uses first-wins semantics, so HOSTNAME=0.0.0.0 in .env is
silently ignored. This causes the server to bind to the LAN hostname
instead of 0.0.0.0, breaking localhost access and all internal
self-requests (ModelSync, HealthCheck, cloud sync).
The fix compares process.env.HOSTNAME against os.hostname(): when they
match, it's the POSIX auto-set signature and HOSTNAME is ignored.
OMNIROUTE_SERVER_HOST takes precedence as the dedicated escape hatch.
Backward compatibility is preserved: users who set HOSTNAME to a value
that doesn't match the machine name (e.g. Windows CMD/PowerShell users
with HOSTNAME in .env) will still have their value honoured.
Closes #6194
* feat(sse): surface Kiro adaptive-thinking reasoning as reasoning_content (#6213)
Kiro/CodeWhisperer streams Claude's reasoning as native `reasoningContentEvent`
frames when adaptive thinking is enabled, but the Kiro executor had no handler
for them, so `reasoning_effort` requests returned no reasoning. Wire it end to
end:
- translator (openai-to-kiro): enable Kiro thinking when the request carries
`reasoning_effort`, Anthropic `output_config.effort`, or a `thinking` block
(`{type:"enabled",budget_tokens}` mapped to a level; `{type:"adaptive"}`
defaults to `high`, matching Anthropic's documented default). Prepends the
Kiro `<thinking_mode>`/`<max_thinking_length>` prompt directive and sets
top-level `additionalModelRequestFields` ({output_config.effort,
thinking:{type:"adaptive"}, max_tokens}). Gated on `supportsReasoning`; drops
non-default temperature/top_p (rejected by adaptive-only Claude models).
- executor transformRequest: forward `additionalModelRequestFields` to AWS
(previously dropped by the strict top-level allowlist).
- executor stream loop: parse `reasoningContentEvent` (and reasoningText
variants) into the OpenAI reasoning_content channel.
Verified against the live CodeWhisperer stream: reasoningContentEvent frames are
returned, and larger effort/budget measurably deepens reasoning up to the model
cap. Unit tests cover the effort sources, forwarding, temp/top_p stripping, and
native reasoning-frame parsing.
* fix(chatcore): exempt opencode client from the default 128-tool truncation (#6193)
* fix(chatcore): exempt opencode client from the default 128-tool truncation
The default MAX_TOOLS_LIMIT (128) cap made truncateToolList blind-slice
tools.slice(0, 128), dropping opencode's built-in task tool and part of
its MCP tools when the inbound list exceeded 128 — so models routed
through OmniRoute could not launch subagents or reach all their tools.
Detect the opencode client (any x-opencode-* header, or 'opencode' in
the user-agent) and bypass ONLY the speculative 128 default. A known
provider ceiling (proactive PROVIDER_TOOL_LIMITS or a detected limit)
always wins and still truncates, even for opencode, so upstreams with
real hard limits (e.g. grok-cli 200) keep their 400-avoidance guard.
Non-opencode clients are unchanged.
- requestFormat.ts: add isOpencodeClient(headers, userAgent) + expose it
on resolveChatCoreRequestFormat.
- toolLimitDetector.ts: add getKnownToolLimit(); getEffectiveToolLimit
becomes getKnownToolLimit(provider) ?? DEFAULT_LIMIT (byte-identical
for existing callers).
- upstreamBody.ts: truncateToolList takes bypassDefaultToolLimit and
encodes the precedence; fix cosmetic debug-log count.
- chatCore.ts: thread the flag into prepareUpstreamBody.
- tests: extend tool-limit-detector unit tests.
* refactor(tools): accept nullable provider in tool-limit resolvers
Address PR review: widen getKnownToolLimit / getEffectiveToolLimit to
(provider: string | null | undefined) to match the call sites in
truncateToolList, and add unit assertions covering null/undefined
providers (getKnownToolLimit -> null, getEffectiveToolLimit -> 128).
---------
Co-authored-by: DKotsyuba <16292493+DKotsyuba@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
* fix(providers): refresh GitHub Copilot catalog (#6154)
* fix(providers): refresh github copilot catalog
Limit GitHub Copilot discovery to the curated supported model set and keep the provider cooldown panel client-safe by moving countdown formatting out of localDb.
* chore(quality): rebaseline providerPageHelpers.ts file-size (+13, #6154 copilot catalog)
The GitHub Copilot catalog refresh grows the provider-page model-section helper
(1021->1034). Fast-path PR->release skips check:file-size, so the bump lands with
the PR. Justification recorded in file-size-baseline.json.
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
---------
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* chore(quality): rebaseline kiro-translator file-size debt from #6213
The #6213 kiro adaptive-thinking feature grew openai-to-kiro.ts (853->890) and its
test (1093->1234); the fast-path PR->release does not gate check:file-size on merge,
so the growth accumulated on the release tip. Rebaselined to keep the tip green.
Justification recorded in file-size-baseline.json.
* fix(doctor): resolve two false-positive WARNs (#6162) (#6163)
* fix(doctor): resolve two false-positive WARNs (#6162)
The `omniroute doctor` command reported two warnings on healthy installs
even though the underlying checks actually passed. Both came from the
doctor probing state that already worked; they looked like bugs but users
couldn't tell without manual digging.
Issue 1 — Server liveness HTTP 401
/api/health and /api/health/degradation both require the management
token. Doctor called them without auth → 401 → WARN, even when the
Next.js server was clearly alive and listening.
Fix: probe the configured health endpoint first; on 401/403, fall
back to a publicly served static asset (/favicon.ico) to confirm the
server is alive. WARN now only fires when both probes fail.
Issue 2 — CLI Tools '@/shared' import
tool-detector.ts (and 3 other cli-helper files) import @/shared/...
aliases that resolve via tsconfig.json paths. The CLI ships raw TS
source (no compile step) and runs through tsx, but tsx does not honor
tsconfig paths at runtime, and tsconfig-paths only hooks CJS
Module._resolveFilename while doctor uses ESM `import()`.
Fix: replace @/shared/... with relative imports in the 4 cli-helper
files. This is the same pattern these files already use for ./config-
generator/* imports. No new dependency, no architectural change, and
the fix doesn't regress Next.js itself which keeps using @/shared.
Verified on v3.8.43 (Node v24.17, Windows 11):
Before: 7 ok, 2 warning(s), 0 failure(s)
After: 8 ok, N warning(s), 0 failure(s)
where N accurately reflects which CLI tools are installed and
configured for OmniRoute (e.g. Hermes Agent installed but not
pointed at 20128 → 2 real warnings, not 1 false-positive).
Refs #6162
* fix(doctor): derive fallback URL from primary URL via new URL()
Per Gemini code-assist review feedback: the previous fallback constructed
the /favicon.ico URL from defaults (127.0.0.1:PORT) which ignored custom
host/port/protocol configurations supplied via:
- OMNIROUTE_DOCTOR_LIVENESS_URL
- OMNIROUTE_DOCTOR_HOST
- --liveness-url / --host CLI flags
Parse the primary URL with new URL() to preserve protocol, host, port, and
subpaths. The previous default-based fallback remains as a catch-all for
invalid primary URLs.
* test(doctor): add regression tests for #6162 fixes
Two new test files lock the fix and satisfy the PR Test Policy gate
("production code change without tests"):
- tests/unit/cli-helper-tool-detector-paths-6162.test.ts
Locks the @/shared → relative imports fix across all 4 cli-helper
files. Asserts (a) no @/shared alias remains in the cli-helper
sources, and (b) each file is importable at runtime via tsx/ESM,
which would have thrown "Cannot find package '@/shared'" before
the fix.
- tests/unit/cli-doctor-liveness-fallback-6162.test.ts
Locks the /favicon.ico fallback in doctor.mjs. Asserts the
fallback probe exists, derives its URL from the primary URL via
new URL() (per Gemini review feedback), and that the buggy
'Server responded with HTTP 401' WARN path is gone.
Both tests use only node:test + node:assert/strict so they slot into
the existing 'test' and 'test:unit' scripts with no extra config.
* test(doctor): fix primary.ok regex in fallback test
The earlier regex /primary\.ok\s*\?/ required a '?' immediately after,
but the actual doctor.mjs code uses a multi-line if-block:
if (primary.ok) {
return ok(...);
}
Use /\bprimary\.ok\b/ instead so the assertion matches the existing
branching.
---------
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
* fix(doubao-web): switch provider to Dola global (#6235)
* fix(doubao-web): switch provider to Dola global
* fix(doubao-web): use .dola.com cookie domain for s_v_web_id + rebaseline test
The Dola switch left s_v_web_id with a host-only "www.dola.com" domain, which fails
the token-source contract (domain must start with "." or "http") — the sibling
sessionid/ttwid cookies and the canonical cookieDomain already use ".dola.com", which
also matches www.dola.com. Also rebaselines web-cookie-providers-new.test.ts (850->890)
for the provider-switch regression cases.
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
---------
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
* fix(providers): register zed in OAuth PROVIDERS to fix Unknown provider error (#6041) (#6078)
Registers a minimal import_token entry for the existing Zed IDE keychain-import
provider so getProvider("zed") no longer throws "Unknown provider: zed" when the
UI probes the OAuth capability endpoint; generateAuthData returns { supported: false }.
Test runner fix: the regression test imported from "vitest" but lives in tests/unit/
(node:test territory, outside the vitest include globs) — it ran in no runner. Converted
to node:test + node:assert so it actually executes (8/8 green).
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* fix(oauth): align zed in OAUTH_PROVIDER_IDS + config enum after #6078 merge
#6078 registered zed in the OAuth PROVIDERS registry but did not add it to the
constants PROVIDERS id map nor the oauth-providers-config enumeration test, leaving
that test red on the release tip (getProvider enumeration vs EXPECTED mismatch).
Adds ZED to the id constants + zed to EXPECTED_PROVIDER_KEYS/EXPECTED_CONFIG_BY_PROVIDER.
* fix(mitm): strip colons from macOS cert fingerprint before keychain match (#6134) (#6204)
fix(mitm): strip colons from macOS cert fingerprint before keychain match (#6134). Extracted testable macCertOutputHasFingerprint helper + regression guard. Thanks @rianonehub. Integrated into release/v3.8.45.
* docs(architecture): sync stale DB-layer counts (45+/55 → 95+/110+) in REPOSITORY_MAP, db-schema diagram and llm.txt (+42 i18n mirrors) (#6167)
docs(architecture): sync stale DB-layer counts (45+/55 → 95+/110+) across REPOSITORY_MAP, db-schema diagram, llm.txt + 42 i18n mirrors (#6167). Docs-only; check:docs-all passes locally on the reconstruction. Reds are pre-existing base-red drift on release/v3.8.45 (dast-smoke #6228; executor-kiro.test.ts eslint anys; changelog/package.json version drift) — none introduced by this PR. Integrated into release/v3.8.45.
* fix(api): count tool_use/tool_result/thinking blocks in count_tokens estimate (port from 9router#2337) (#6221)
fix(api): count tool_use/tool_result/thinking blocks in count_tokens estimate (#6221, port from 9router#2337). TDD-covered (6/6); typed the test casts to clear no-new-eslint. Reds are pre-existing base-red drift on release/v3.8.45 (dast-smoke #6228; executor-kiro.test.ts eslint anys; changelog/package.json version drift) — none introduced by this PR. Thanks @luweiCN. Integrated into release/v3.8.45.
* fix(antigravity): strip trailing assistant prefill turn for Vertex Claude models (#6114)
fix(antigravity): strip trailing assistant prefill for Vertex Claude models (#6114). TDD-covered (6/6), merged on TDD strength per owner. Reds are pre-existing base-red drift on release/v3.8.45. Thanks @anki1kr. Integrated into release/v3.8.45.
* fix(security): require management auth for mutable cloud routes (#6233) (#6233)
fix(security): require management auth for mutable cloud routes (#6233). Verified: 3 PR tests + full authz/route-guard suite 241/241 green. Thanks @vittoroliveira-dev. Integrated into release/v3.8.45.
* fix(dashboard): use connection.id (UUID) not connection.provider (category) in onboarding wizard href (issue #6144) (#6166)
refactor(dashboard): extract tested buildProviderDetailsHref helper for onboarding wizard (#6166). Behavioral #6144 fix already on tip via #6145; this lands the tested-helper hardening. Thanks @KooshaPari. Integrated into release/v3.8.45.
* feat(rankings): add 'Configured Only' filter to Free Provider Rankings page (#6245)
feat(rankings): add 'Configured Only' filter to Free Provider Rankings (#6245, closes #6150). 9/9 test green. Thanks @Iammilansoni. Integrated into release/v3.8.45.
* fix(i18n): add 118 missing Italian translations (#6212)
i18n(it): add 118 Italian translations (#6212). Audited net-additive (0 keys dropped, valid JSON). Thanks @serverless83. Integrated into release/v3.8.45.
* test(dashboard): realign #6145 onboarding-href guard to the #6166 helper refactor (#6270)
Realign the #6145 onboarding-href guard to the #6166 helper refactor (buildProviderDetailsHref). Test-only; unblocks the fast-path unit job across the open PR queue. Base-reds only (dast-smoke #6228, docs version-drift, executor-kiro anys). Integrated into release/v3.8.45.
* feat(providers): add Yuanbao (web) cookie-session provider (#6196) (#6256)
feat(providers): add Yuanbao (web) cookie-session provider (#6196). TDD-covered; base-reds only (dast-smoke #6228, docs version-drift, executor-kiro anys — #6145 guard fixed on tip via #6270). Integrated into release/v3.8.45.
* feat(providers): route built-in agentrouter through dynamic CC wire image (#6056) (#6255)
feat(providers): route built-in agentrouter through dynamic CC wire image (#6056). TDD-covered (agentrouter-cc-wire-image.test.ts). Base-reds only. Integrated into release/v3.8.45.
* feat(providers): bulk-add API keys for Cloudflare Workers AI (#6174) (#6254)
feat(providers): bulk-add API keys for Cloudflare Workers AI (#6174). Per-entry providerSpecificData (fixes shared-object reuse); TDD guard bulk-api-key-parser-cloudflare.test.ts. Base-reds only. Integrated into release/v3.8.45. (thanks @muflifadla38)
* feat(dashboard): routing/settings UX clarity — share %, Cloud Sync rename, base-URL override (#6147) (#6253)
feat(dashboard): routing/settings UX clarity (#6147) — effective share %, Cloud Sync→Remote Settings Sync rename, opt-in advanced base-URL override. TDD guard routing-settings-ux-6147.test.ts (6/6). Base-reds only. Integrated into release/v3.8.45.
* feat(combo): add option to disable session stickiness (#6168) (#6252)
feat(combo): add option to disable session stickiness (#6168) — per-combo/global, precedence config→settings→false (preserves #3825). TDD guard combo-disable-session-stickiness.test.ts (8/8). Base-reds only. Integrated into release/v3.8.45. (thanks @RCrushMe)
* feat(docker): OMNIROUTE_NO_SUDO env flag for root-less MITM cert trust (#6122) (#6249)
feat(docker): OMNIROUTE_NO_SUDO env flag for root-less MITM cert trust (#6122). resolveSudoSpawn strips sudo when set; argv-array spawn preserved (Hard Rule #13). TDD guard mitm-systemCommands-no-sudo.test.ts (5/5). Base-reds only. Integrated into release/v3.8.45. (thanks @powellnorma)
* feat(providers): add Requesty as an OpenAI-compatible gateway provider (#6120) (#6250)
feat(providers): add Requesty as an OpenAI-compatible gateway provider (#6120). Fixed the APIKEY_PROVIDERS count guard (167→168) the feature had missed. TDD guard requesty-provider.test.ts (4/4) + providers-constants-split (4/4). Base-reds only. Integrated into release/v3.8.45. (thanks @chirag127)
* fix(providers): remove deprecated MiMo v2 entries (#6248)
chore(providers): remove deprecated MiMo V2 catalog entries (superseded by V2.5); realign provider-catalog tests. Merged — thank you, @backryun! Base-reds only. Integrated into release/v3.8.45.
* fix(github-skills): add missing import, add unit tests, fix settings JSON parse (#6186)
feat(skills): GitHub skill-discovery subsystem — search/score/scan/import agent skills from GitHub, MCP tools (scope-gated) + /api/github-skills route (host-pinned api.github.com, sanitized errors). Merged — thank you, @Moseyuh333! Pre-merge fixes: passed toolDef.scopes so tools gate correctly under scope enforcement, routed the GET error path through sanitizeErrorMessage+500 (Hard Rule #12), and realigned the agent-skills count guards (catalog/routes/generator/mcp) for the new catalog entry. Base-reds only. Integrated into release/v3.8.45.
* Fix/5976 continued (#6216)
fix(combo): 5 streaming-path fixes (#5976) — locked-stream 500, error-frame-only-if-no-content, Gemini MALFORMED_RESPONSE→content_filter failover, correlationId substring, per-model-500 lockout skip + request-logger UI. Merged — thank you, @hartmark! Maintainer follow-up: releaseQualityClone cancels the abandoned quality-check tee branch (per-request memory) + regression test. All 41 combo/streaming quality tests green. Base-reds only. Integrated into release/v3.8.45.
* feat(dashboard): filter Free Provider Rankings by configured/available (#6150) (#6251)
feat(dashboard): configured-only / available-only filters on Free Provider Rankings (#6150) — server-side query params + tested lib helper; supersedes the client-side #6245 toggle with an available-only dimension. Lib logic 11/11 green; UI validated live on VPS. Base-reds only. Integrated into release/v3.8.45.
* ci: unblock test jobs from the Build gate (start at minute 0) (#6275)
test-unit x8, test-vitest, test-integration x2 and test-security all had
needs: build but never download the next-build artifact — the dependency
only serialized ~20min of Build wall-clock in front of every test run.
Switch them to needs: changes with the same skip condition Build uses
(docs-only PRs and drafts still skip), so the test chain
(test-unit -> test-coverage -> quality-gate -> sonarqube) now runs in
parallel with Build instead of after it.
Jobs that genuinely consume the artifact keep needs: build unchanged:
test-e2e x9, package-artifact, electron-package-smoke.
Expected effect on a full ci.yml run: critical path drops from
build + tests (~30min+) to max(build, tests) — roughly 15-20min saved
per run, no extra runner minutes beyond starting the same jobs earlier.
* ci(build): switch Next.js production build to Turbopack (1.9x faster) (#6273)
Next 16 ships Turbopack as the stable production bundler. Benchmarked on a
32-core box against the same tree: webpack 1035s -> Turbopack 539s (1.92x).
The build script already supports the switch via OMNIROUTE_USE_TURBOPACK=1
(scripts/build/build-next-isolated.mjs) and next.config.mjs already mirrors
the resolveAlias stubs in the turbopack block, so this only flips the CI env.
The webpack .build/next/cache actions/cache step is removed in the same
commit: Turbopack does not use the webpack cache dir (its persistent FS
cache is experimental and NOT enabled), so restoring ~0.5 GB per run would
be pure wasted download. Revert restores webpack + its cache together.
Validation: standalone output smoke-tested (server.js boots, health 200,
dashboard 307); the 428 build warnings are the known benign 'overly broad
file pattern' static-analysis notices for dynamic fs usage (covered at
runtime by outputFileTracingIncludes). Downstream e2e x9, package-artifact
and electron-package-smoke consume this artifact, so a green CI run here
validates the Turbopack artifact end-to-end. nightly-compat and npm-publish
stay on webpack until this PR proves out.
* feat(build): make Turbopack the default bundler for dev and build (#6283)
Turbopack (stable in Next 16) becomes the code default in the three entry
points that previously required an explicit OMNIROUTE_USE_TURBOPACK=1:
- scripts/build/build-next-isolated.mjs (production build)
- scripts/dev/run-next.mjs (dev server)
- scripts/dev/run-next-playwright.mjs (playwright dev runner)
OMNIROUTE_USE_TURBOPACK=0 remains the webpack escape hatch (Windows /
native-binding / bundler-compat issues), and only the documented '0'
opts out — junk values keep the default.
Benchmarked on this codebase (same tree, Next 16.2.9): webpack 1035s vs
Turbopack 539s on a 32-core box; ~20min vs 6min59s on ubuntu-latest.
Artifact validated end-to-end (standalone smoke + e2e/package-artifact/
electron-package-smoke CI jobs, Docker amd64+arm64 builds clean with the
v3.8.27 ImportTracer panic gone on 16.2.9).
TDD: tests/unit/build-bundler-default-turbopack.test.ts (new) +
run-next-playwright.test.ts extended with the unset-env default case;
both red before the flip, green after. ENVIRONMENT.md updated.
* feat(docker): build the image with Turbopack (v3.8.27 panic gone on Next 16.2.9) (#6285)
Flips the builder stage to OMNIROUTE_USE_TURBOPACK=1 and rewrites the stale
panic comment: the v3.8.27-era TurbopackInternalError ('entered unreachable
code: there must be a path to a root' in ImportTracer::get_traces) no longer
reproduces on Next 16.2.9.
Validation (2026-07-05, this exact Dockerfile, default
OMNIROUTE_BUILD_MEMORY_MB=4096, no overrides):
- amd64: docker build 659s, exit 0, zero panic/OOM strings in the full log,
container smoke-tested (/api/monitoring/health 200)
- arm64 (qemu): exit 0, zero panic strings
Webpack stays available as the escape hatch:
--build-arg / -e OMNIROUTE_USE_TURBOPACK=0. The V8 heap ceiling is kept:
Turbopack's compile is native Rust, but prerender/export still runs on V8.
* ci: opt-in self-hosted VPS runners for the release window (anti-queue) (#6284)
Adds the on-demand self-hosted runner plumbing for /generate-release:
- scripts/vps/release-runner-up.sh: starts the runner VM on Proxmox, waits
for >=1 'omni-release' runner to report online via the GitHub API, then
flips the USE_VPS_RUNNER repo variable to true. Any failure/timeout sets
it back to false and exits 1 so the caller falls back to hosted runners.
- scripts/vps/release-runner-down.sh: flips USE_VPS_RUNNER=false FIRST
(so no job gets scheduled onto a dying runner), then gracefully shuts
the VM down. Idempotent.
- ci.yml: build, test-unit x8 and test-vitest pick their runner
dynamically. Self-hosted is used ONLY when USE_VPS_RUNNER == 'true'
AND the event is own-origin (push/dispatch, or a PR whose head repo is
this repository). Fork PRs and the var's default/absent state always
fall back to ubuntu-latest.
Why: the Free plan caps hosted concurrency at 20 jobs; a release run
saturates it and queues. The benchmarked VPS (32-core, 4 runners) matches
hosted per-job times (unit ~8.5min/shard, build 9min turbo) but eliminates
the queue, which is the real release bottleneck (~30-50min on a busy day).
Scope is conservative: only the three job families benchmarked on the VPS;
e2e/electron/integration stay hosted (playwright/xvfb provisioning not
validated on the runner workspace yet).
* docs(changelog): restore v3.8.45/v3.8.44 sections eaten by the #6193 merge auto-resolve + CI-perf campaign bullets (#6273 #6275 #6283 #6284 #6285)
* fix(dashboard): null-guard connection in EditConnectionModal base-URL override (#6147) (#6287)
fix(dashboard): null-guard connection in EditConnectionModal (#6147) — fixes 'Cannot read properties of null (reading authType)' crash on every provider-detail page entry. TDD: connModals.test.tsx null-mount 9/9. Base-reds only. Integrated into release/v3.8.45.
* chore(release-green): clear test-masking + docs-all HARD reds for the v3.8.45 pre-flight
- test-masking: allowlist the 4 verified-legitimate assert reductions of the
cycle (#6248 MiMo V2 removal, #6170 Kiro catalog correction, #6154 Copilot
catalog refresh) and register the #6164 AutoRoutingBanner test deletion with
a real replacement guard (tests/unit/home-no-autorouting-banner.test.ts —
asserts the banner stays out of home/page.tsx and the component stays deleted)
- docs-sync: executors count 68 -> 73 in ARCHITECTURE.md + CODEBASE_DOCUMENTATION.md
- env-doc-sync: document OMNIROUTE_NO_SUDO (#6249/#6122) in .env.example +
docs/reference/ENVIRONMENT.md
* fix(quality): clear the cycle's 11 net-new ESLint errors + make validate-release-green suppressions-aware
- executor-kiro/save-call-log/call-logs-correlation tests: replace 15 'as any'
casts with typed shapes (net-new no-explicit-any errors from #6213/#6216);
prune the now-empty suppression entries so the frozen baseline stays exact
- github-skills + usage/call-logs routes: raw toLowerCase().includes() search
replaced by matchesSearch() (no-restricted-syntax — Turkish-safe search,
behavior covered by tests/unit/call-logs-correlation-substring.test.ts and
tests/unit/github-collector.test.ts)
- validate-release-green.mjs: run ESLint with --suppressions-location (match
the npm run lint contract — frozen debt is not a release red) and raise the
lint timeout 15->30min (a full pass takes ~14min alone; the 15min ceiling
expired under concurrent suite load and surfaced as 'could not parse eslint
json')
* fix(skills): generate the missing omni-github-skills registry entry + align catalog count tests
PR #6186 added omni-github-skills to the agent-skills catalog (API 22 -> 23)
but did not run the generator, so skills/omni-github-skills/SKILL.md never
existed and 6 integration assertions split between the old (42/43) and new
counts. Generated via scripts/skills/generate-agent-skills.mjs --apply and
aligned agent-skills-discovery to the real totals (43 = 23 API + 20 CLI;
handlers return 44 with config-codex-cli). 30/30 discovery+content tests green.
* fix(combo): restrict the #6216 empty-stream failover to truly empty bodies (restores #3399/#3685 contracts)
The 'streaming no recognized content' branch added by #6216 marked ANY
stream that ended without content deltas as invalid — sweeping in two
regression-guarded pass-through contracts: an empty stream terminated by an
explicit 'data: [DONE]' (#3399 context-cache protection) and an incomplete
Claude lifecycle (ping only, no message_start; #3685 — stream-readiness
timeout territory, not failover). Both unit guards were red on the branch
and green on main (86/86 vs 84/86).
The branch now fires only for a truly EMPTY body (zero bytes — the Gemini
HTTP-200-empty case that motivated #6216), tracked via sawAnyBytes. New
guard: '#5976 truly EMPTY streaming body (zero bytes) -> invalid for combo
failover'. 87/87 across both files.
Also in this pre-flight batch:
- agentSkillTools-mcp: api.have upper bound 22 -> 23 (the #6186 catalog
addition updated the totals but missed this bound)
- delete tests/unit/free-provider-rankings-configured-filter.test.ts:
#6251 (server-side configuredOnly/availableOnly) superseded the #6245
client-side toggle it pinned; replacement declared in the test-masking
allowlist (tests/unit/freeProviderRankings-filters.test.ts, 11/11)
* chore(quality): prune stale ESLint suppressions (4,273 -> 4,233)
Entries whose violations no longer exist (cleaned by cycle merges and the
pre-flight fixes) made 'npm run lint' exit 2 with 'suppressions left that do
not occur anymore'. Regenerated via --prune-suppressions; net-new policy
unchanged.
* fix(proxy): #6246 stop the v3.8.44 proxy IP-leak + over-deactivation regression (#6296)
Merged into release/v3.8.45. Reds pré-existentes classificados: dast-smoke (infra), check:file-size (drift de baseline de god-files congelados), e 3 testes de contagem agentSkills stale (43→44 já corrigidos no tip pelo #6186 — somem no squash sobre o tip). Núcleo do fix #6246 (proxy IP-leak + over-deactivation).
* fix(proxy): make "Test All" read-only + add bulk enable/disable (#6246) (#6299)
Merged into release/v3.8.45. Delta do par #6246 (Test-All read-only + bulk enable/disable), reconciliado com o núcleo #6296 já mergeado. CHANGELOG restaurado (ambos bullets do #6246 coexistem). Reds pré-existentes: dast-smoke (infra) + file-size drift.
* fix(resilience): evict sticky affinity on pinned-account failover (#6219) (#6231)
Merged into release/v3.8.45. Sticky affinity failover (#6219). Sincronizado com tip; CHANGELOG restaurado (base bullets preservados, net +1). Reds pré-existentes: dast-smoke + file-size drift.
* fix(sse): drop commentary-phase text in Responses passthrough (#6199) (#6232)
Merged into release/v3.8.45. Responses commentary-phase filter (#6199). Sincronizado; CHANGELOG restaurado net +1. Reds: dast-smoke + file-size drift.
* fix: bug-fix sweep — log path, AgentBridge DNS, opencode-go headers, GitLab Duo tools, M365 EDU (#6197 #6127 #6198 #5997 #6220 #6210) (#6234)
Merged into release/v3.8.45. Bug-fix sweep (#6197 log path, #6127/#6198 AgentBridge DNS, #6210 M365 EDU, #6220 GitLab Duo tools, #5997 opencode-go headers). 27 testes verdes. CHANGELOG restaurado net +5. Reds: dast-smoke + file-size drift.
* fix(docker): add id= to BuildKit cache mounts for strict builders (#6291)
Merged into release/v3.8.45. Dockerfile-only: explicit id= on BuildKit cache mounts (fixes strict-frontend parse error). Reds pré-existentes (dast-smoke/file-size drift) não relacionados a mudança de Dockerfile. Thanks @karimalsalah.
* fix(sse): strip zero-width markers from streamed tool-call arguments (follow-up to #5857) (#6292)
Merged into release/v3.8.45. Strip zero-width markers from streamed tool-call arguments (#5857 follow-up). Test 50/50. CHANGELOG reconciled net +1. Reds pré-existentes (dast-smoke/file-size drift). Thanks @DKotsyuba.
* ci(quality): merge-integrity fast-gates + pre-flight hermetic mode (#6300)
Merged into release/v3.8.45. CI merge-integrity fast-gates (changelog-integrity + agent-skills-sync) + pre-flight hermetic mode. O gate novo detectou 11 SKILL.md gerados fora de sync (drift pré-existente no tip: omni-api-keys endpoints, omni-github-skills do #6186) — regenerados neste PR, Merge-integrity GREEN no CI. Reds restantes base-red (dast-smoke/file-size drift/live-data flaky). Testes novos 17/17.
* fix(a2a): finish the #6186 catalog-count update — 3 hardcoded 22s left in production
#6186 added omni-github-skills (API 22 -> 23) and updated computeCoverage's
total, but left the old count hardcoded in the A2A layer: listCapabilities
metadata reported coverage.api.total 22 (type literal + value) and
SkillCoverageSchema pinned z.literal(22) — so the schema would REJECT the
correct runtime value. Aligned all three to 23 + the unit fixtures
(listCapabilities-a2a, agentSkills-schemas, 46/46 with agentSkillTools-mcp).
* fix(quality): type the 7 net-new 'as any' casts from #6292 (Lint red on the release tip)
#6292 rewrote/added zero-width-marker tests with 7 new 'as any' result casts,
pushing the file past its frozen suppression (84) — and when violations
exceed the suppressed count ESLint reports ALL of them, so the ci.yml Lint
job went red with 90 errors. The 7 new sites get typed shapes (delta /
arguments / choices / output accessors — same pattern as fecf888fd); the
pre-existing debt stays frozen at the exact new count (83). 50/50 tests
green, file lint-clean under the suppressions baseline.
* fix(api): Zod-validate POST /api/github-skills + document new gate envs + pin merge-integrity actions
Three latent heavy-CI reds surfaced by the VPS validation dispatch (the fast
path never runs these gates):
- t06 route-validation: POST /api/github-skills destructured request.json()
blind — a non-array 'targets' would .map-crash. Now validateBody(zod)
with defaults preserved (Hard Rule #7). Guard:
tests/unit/github-skills-route-validation.test.ts (4/4).
- env-doc-sync: document OMNIROUTE_SKIP_SYSTEM_TRUST (#6310) and the
changelog-integrity gate envs CHANGELOG_BASE_REF/ALLOW_CHANGELOG_REMOVALS
(#6300) in .env.example + ENVIRONMENT.md.
- zizmor ratchet: the new merge-integrity job's checkout/setup-node uses were
unpinned (+1 finding, 160 > baseline 159); pinned by SHA -> 158 (< baseline).
* fix(quality): clear the 2 remaining heavy-gate reds on the release tip
- check:error-helper: githubSkillTools.ts (#6186 wave) built MCP install
error results with raw err.message — routed through sanitizeErrorMessage()
(Hard Rule #12; 19/19 agentSkillTools tests green)
- check:mutation-test-coverage: tests/unit/combo-provider-cooldown-sibling.test.ts
(#6216) was missing from stryker.conf tap.testFiles — added so its mutant
kills count on nightly-mutation
* fix(security): 405 method-first for /api/keys/{id}/devices (dast-smoke QUERY check)
Schemathesis's newer unsupported-methods check (unpinned tool drift) sends
QUERY /api/keys/{id}/devices and demands 405 Method Not Allowed; the path had
no HIGH_RISK_METHOD_RULES entry, so the auth layer answered 401 first. Add
the devices rule (GET-only) so undocumented methods get a clean method-first
405 — same pattern as the v3.8.44 TRACE fix. TDD:
tests/unit/dast-method-not-allowed.test.ts gains the devices QUERY case (4/4).
* fix(mitm): test suite and CI must never mutate the OS trust store (OMNIROUTE_SKIP_SYSTEM_TRUST) (#6310)
Incident 2026-07-05 on the self-hosted release runner (VM 113): the
integration test 'POST /cert: installs trust when cert exists' exercised the
REAL install path, wrote a 105-byte fake PEM (FakeMITMCertForTestingOnly)
into /usr/local/share/ca-certificates and update-ca-certificates baked the
invalid entry into ca-certificates.crt — breaking ALL system TLS on the VM
(curl error 77, apt cert failures, and the intermittent gzip-corrupted
next-build artifacts that failed 6/9 e2e shards in run 28754447912). Hosted
runners are ephemeral, so the same mutation went unnoticed for months.
- installCert/uninstallCert: skip the OS dispatch under
OMNIROUTE_SKIP_SYSTEM_TRUST=1 — AFTER the input checks, so the #4546
environment-skip contract (missing file throws -> structured skip) and the
already-installed/not-installed early returns are preserved.
- installTproxyCa/uninstallTproxyCa: same guard, only when no run dep is
injected (DI'd tests keep exercising the full command sequence with mocks).
- tests/_setup/isolateDataDir.ts sets the env for every node:test process;
ci.yml/quality.yml/nightly-release-green.yml set it workflow-wide (e2e runs
the real app outside the test setup).
TDD: tests/unit/system-trust-test-guard.test.ts (guard exported to every test
process; guarded install resolves on a real file without touching the OS;
missing-file contract preserved). 82/82 across the affected cert/tproxy/
agent-bridge suites.
* ci(vps): hermetic nightly pre-flight on the release runner (descoped: e2e/integration/electron stay hosted) (#6305)
* ci(vps): extend the dynamic omni-release runner to e2e/integration/electron + nightly pre-flight
Completes the #6284 rollout to the jobs where the VPS pays the most:
- test-e2e (9 shards, 15-20min/shard hosted — dominated by setup, not tests),
test-integration (2 shards) and electron-package-smoke now pick the
self-hosted omni-release runner under the same gate: vars.USE_VPS_RUNNER
== 'true' AND own-origin (fork PRs never reach self-hosted).
- nightly-release-green (the release pre-flight) becomes runner-dynamic too:
on the VPS it runs in a clean env — no operator OMNIROUTE_API_KEY, no
local noauth CLIs — eliminating the machine-specific false positives that
dominated the 2026-07-05 pre-flight; passes --hermetic (no-op until the
#6300 validator lands, then belt-and-suspenders).
Validation plan (per operator request): release-runner-up.sh -> full ci.yml
workflow_dispatch on this branch exercising e2e/integration/electron on the
VPS (proves playwright --with-deps + xvfb on the runner) -> down.sh -> VM
off verified.
* fix(mitm): test suite and CI must never mutate the OS trust store (OMNIROUTE_SKIP_SYSTEM_TRUST)
Incident 2026-07-05 on the self-hosted release runner (VM 113): the
integration test 'POST /cert: installs trust when cert exists' exercised the
REAL install path, wrote a 105-byte fake PEM (FakeMITMCertForTestingOnly)
into /usr/local/share/ca-certificates and update-ca-certificates baked the
invalid entry into ca-certificates.crt — breaking ALL system TLS on the VM
(curl error 77, apt cert failures, and the intermittent gzip-corrupted
next-build artifacts that failed 6/9 e2e shards in run 28754447912). Hosted
runners are ephemeral, so the same mutation went unnoticed for months.
- installCert/uninstallCert: skip the OS dispatch under
OMNIROUTE_SKIP_SYSTEM_TRUST=1 — AFTER the input checks, so the #4546
environment-skip contract (missing file throws -> structured skip) and the
already-installed/not-installed early returns are preserved.
- installTproxyCa/uninstallTproxyCa: same guard, only when no run dep is
injected (DI'd tests keep exercising the full command sequence with mocks).
- tests/_setup/isolateDataDir.ts sets the env for every node:test process;
ci.yml/quality.yml/nightly-release-green.yml set it workflow-wide (e2e runs
the real app outside the test setup).
TDD: tests/unit/system-trust-test-guard.test.ts (guard exported to every test
process; guarded install resolves on a real file without touching the OS;
missing-file contract preserved). 82/82 across the affected cert/tproxy/
agent-bridge suites.
* ci(vps): descope — e2e/integration/electron stay on hosted runners; keep the hermetic nightly pre-flight dynamic
Validation verdict (runs 28754447912 + 28757670732, VM 113):
- e2e cannot run >1 per VM: both jobs bind port 20128 ('already used') — needs
a per-job port in the playwright runner before any VM rollout.
- concurrent ~1GB artifact downloads truncate on the VM uplink (gzip 'invalid
compressed data' — with 2 runners the e2e shard passed; corruption returned
at 4) — actions/download-artifact has no integrity retry here.
- integration shard 2 exceeded its 15-min timeout twice on the VM.
The VPS remains a win for whole-machine jobs: build/unit/vitest (already
dynamic via #6284) and nightly-release-green (single job, clean env, hermetic
pre-flight) — which this PR keeps.
* chore(quality): v3.8.45 cycle-close file-size rebaseline (Phase 0 drift absorption)
13 files grown by the cycle's merged PRs (#6216 streaming+request-logger UI;
#6251/#6253 dashboard UX) — legitimate merged-feature growth absorbed by the
release captain per the Phase 0 drift policy; all entries stay frozen (cannot
grow further). Justification key: _rebaseline_2026_07_06_v3845_release_close.
* chore(quality): v3.8.45 cycle-close cognitive/cyclomatic rebaseline (Phase 0 drift absorption)
cognitive 867->877 (+10), cyclomatic 2028->2035 (+7) — inherited cycle drift
measured by check:release-green (hermetic) on the release tip; the captain's
pre-flight fixes are gate/test/workflow changes (complexity-neutral).
Justification keys: _rebaseline_2026_07_06_v3845_release_close.
* docs(changelog): v3.8.45 reconciliation — fold Unreleased into the version section, 30 missing bullets, contributors hall
Phase 0a reconciliation (/generate-release): every commit since v3.8.44 now
has a bullet or a Maintenance rollup (82 commits; the only ref-less residues
are the bump/recording commits); #6193 bullet gains its PR ref; #6298
diagnosis credited (@subhansh-dev, landed via #6234); [3.8.44] header dated;
'### 🙌 Contributors' table injected (27 external + maintainer) + 42 i18n
mirrors resynced.
* chore(release): v3.8.45 — 2026-07-06
* fix(resilience): 502/503/504 keep the connection-unavailability path — only the exact 500 skips lockout (#5976 contract)
The #6216 branch used 'status >= 500', so a 503 on a per-model-quota /
openai-compatible provider returned cooldownMs 0 — no model lockout AND no
connection cooldown — hot-looping the failing upstream and breaking the
resilience-http-e2e 'priority combo falls back on 503' guard on the release
PR (the request after a 503 hit the same primary again). #6216's OWN unit
contract pins the narrow behavior ('Gemini 503 should NOT skip cooldown',
combo-provider-cooldown-sibling.test.ts) but computes the condition inline
instead of exercising auth.ts. Code aligned to the tested contract:
status === 500 skips (intermittent, not model-specific); 502/503/504 keep
the pre-#6216 model-lockout path. Validated: the failing integration test
flips red -> green locally (19s, deterministic before).
* fix(security): crypto-backed randomNumericId in doubao-web (CodeQL js/insecure-randomness)
The synthetic Dola device/web id was built from Math.random; CodeQL flags it
as insecure randomness in a security context. Not a secret, but
crypto.getRandomValues costs the same and closes alert #692 at the source.
Doubao executor tests 14/14 green. Alerts #693-695 (incomplete-url-substring
in unit-test asserts) dismissed as false positives per the v3.8.35 precedent
(Hard Rule #14).
* chore(quality): shave the #5976 fix comment back under the auth.ts file-size freeze (2447)
---------
Co-authored-by: Danny S <36470572+kanztu@users.noreply.github.com>
Co-authored-by: Luis Alejandro Vega <LuisAlejandroVega@redesprivadasvirtuales.com>
Co-authored-by: Milan Soni <123074437+Iammilansoni@users.noreply.github.com>
Co-authored-by: R. Beltran <rbeltran8000@gmail.com>
Co-authored-by: VXNCXNX <93332837+VXNCXNX@users.noreply.github.com>
Co-authored-by: Denis Kotsyuba <kocubads96@gmail.com>
Co-authored-by: DKotsyuba <16292493+DKotsyuba@users.noreply.github.com>
Co-authored-by: backryun <bakryun0718@proton.me>
Co-authored-by: Aris <arissunandar399@gmail.com>
Co-authored-by: Ankit <177378174+anki1kr@users.noreply.github.com>
Co-authored-by: Rian Priskanova <rian@evercore.technology>
Co-authored-by: Vittor Guilherme Borges de Oliveira <vittoroliveira.dev@gmail.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: serverless83 <35410475+serverless83@users.noreply.github.com>
Co-authored-by: Moseyuh333 <148680980+Moseyuh333@users.noreply.github.com>
Co-authored-by: Markus Hartung <mail@hartmark.se>
Co-authored-by: HenryHaniHannoush <karimmalsalah@gmail.com>
3108 lines
112 KiB
TypeScript
3108 lines
112 KiB
TypeScript
/**
|
||
* 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 { isThinkingCapableModel, resolveChatGptModel } from "./chatgpt-web/models.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 USER_LAST_USED_MODEL_CONFIG_URL = `${CHATGPT_BASE}/backend-api/settings/user_last_used_model_config`;
|
||
|
||
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<string, string>();
|
||
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 ID → ChatGPT internal slug. The public ChatGPT Web catalog
|
||
// keeps OmniRoute's historical dot-form IDs (e.g. "gpt-5.5-pro"), while
|
||
// ChatGPT's backend routes use dash-form slugs (e.g. "gpt-5-5-pro"). The slug
|
||
// catalog comes from /backend-api/models on a logged-in account;
|
||
// "gpt-5-4-t-mini" is ChatGPT's abbreviated slug for "GPT-5.4 Thinking Mini".
|
||
|
||
// ─── Browser-like default headers ──────────────────────────────────────────
|
||
|
||
function browserHeaders(): Record<string, string> {
|
||
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<string, string> {
|
||
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<string, TokenEntry>();
|
||
|
||
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<string, string>();
|
||
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<TokenEntry> {
|
||
const cached = tokenLookup(cookie);
|
||
if (cached) return cached;
|
||
|
||
const headers: Record<string, string> = {
|
||
...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<string, number>();
|
||
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<void> {
|
||
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<string, string> = {
|
||
...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)}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Thinking-effort preference (PATCH user_last_used_model_config) ────────
|
||
// chatgpt.com has two thinking levels for its dedicated thinking-models:
|
||
// • standard — default, faster
|
||
// • extended — longer reasoning budget
|
||
// The browser sets the level by PATCHing `/backend-api/settings/user_last_used_model_config`
|
||
// once, then issues the conversation request — the conversation endpoint itself
|
||
// has no `thinking_effort` field; the server reads the user's stored preference
|
||
// at routing time. We mirror that handshake when an OpenAI-style request
|
||
// includes `reasoning_effort` (or a direct `providerSpecificData.thinkingEffort`
|
||
// override).
|
||
//
|
||
// Cached per (cookie, slug, effort): the preference persists server-side, so
|
||
// re-PATCHing the same combination is wasted bytes. Refreshed on TTL expiry or
|
||
// whenever the caller switches efforts.
|
||
|
||
const thinkingEffortCache = new Map<string, number>();
|
||
const THINKING_EFFORT_TTL_MS = 5 * 60 * 1000;
|
||
const THINKING_EFFORT_CACHE_MAX = 400;
|
||
|
||
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 setUserThinkingEffort(
|
||
modelSlug: string,
|
||
effort: "standard" | "extended",
|
||
accessToken: string,
|
||
accountId: string | null,
|
||
sessionId: string,
|
||
deviceId: string,
|
||
cookie: string,
|
||
signal: AbortSignal | null | undefined,
|
||
log:
|
||
| {
|
||
debug?: (tag: string, msg: string) => void;
|
||
warn?: (tag: string, msg: string) => void;
|
||
}
|
||
| null
|
||
| undefined
|
||
): Promise<void> {
|
||
const cacheKey = `${cookieKey(cookie)}:${modelSlug}:${effort}`;
|
||
const now = Date.now();
|
||
const last = thinkingEffortCache.get(cacheKey);
|
||
if (last && now - last < THINKING_EFFORT_TTL_MS) {
|
||
log?.debug?.("CGPT-WEB", `thinking_effort cached (${modelSlug}=${effort}) — skip PATCH`);
|
||
return;
|
||
}
|
||
if (thinkingEffortCache.size >= THINKING_EFFORT_CACHE_MAX && !thinkingEffortCache.has(cacheKey)) {
|
||
const first = thinkingEffortCache.keys().next().value;
|
||
if (first) thinkingEffortCache.delete(first);
|
||
}
|
||
|
||
const url =
|
||
`${USER_LAST_USED_MODEL_CONFIG_URL}` +
|
||
`?model_slug=${encodeURIComponent(modelSlug)}` +
|
||
`&thinking_effort=${encodeURIComponent(effort)}`;
|
||
const headers: Record<string, string> = {
|
||
...browserHeaders(),
|
||
...oaiHeaders(sessionId, deviceId),
|
||
Accept: "application/json",
|
||
Authorization: `Bearer ${accessToken}`,
|
||
Cookie: buildSessionCookieHeader(cookie),
|
||
Priority: "u=4",
|
||
};
|
||
if (accountId) headers["chatgpt-account-id"] = accountId;
|
||
|
||
try {
|
||
const r = await tlsFetchChatGpt(url, {
|
||
method: "PATCH",
|
||
headers,
|
||
timeoutMs: 15_000,
|
||
signal,
|
||
});
|
||
if (r.status >= 400) {
|
||
log?.warn?.(
|
||
"CGPT-WEB",
|
||
`thinking_effort PATCH ${r.status} for ${modelSlug}=${effort} (continuing)`
|
||
);
|
||
return;
|
||
}
|
||
thinkingEffortCache.set(cacheKey, now);
|
||
log?.debug?.("CGPT-WEB", `thinking_effort PATCH OK (${modelSlug}=${effort})`);
|
||
} catch (err) {
|
||
log?.warn?.(
|
||
"CGPT-WEB",
|
||
`thinking_effort PATCH failed: ${err instanceof Error ? err.message : String(err)}`
|
||
);
|
||
}
|
||
}
|
||
|
||
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<ChatRequirements> {
|
||
const config = buildPrekeyConfig(CHATGPT_USER_AGENT, dplInfo.dpl, dplInfo.scriptSrc);
|
||
const prekey = await buildPrepareToken(config, log);
|
||
|
||
const headers: Record<string, string> = {
|
||
...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<string, unknown> = { 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<string, string> = {
|
||
...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(/<script[^>]+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<T>(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: "<prefix>" + 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=<server 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<void> {
|
||
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<string> {
|
||
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 || "<empty>"}; 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<string> {
|
||
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<string> {
|
||
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 ``
|
||
* — 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<Record<string, unknown>>): 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<Record<string, unknown>>)
|
||
.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[] };
|
||
}
|
||
|
||
/**
|
||
* 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 `<chat_history>` 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 = [
|
||
/<chat_history>/i,
|
||
/^### Task:/im,
|
||
/\bJSON format:\s*\{/i,
|
||
/\bfollow_?ups\b.*\barray of strings\b/i,
|
||
];
|
||
|
||
const OPENWEBUI_IMAGE_CONTEXT_MARKERS = [
|
||
/<context>\s*The requested image has been (?:created|edited and created) by the system successfully/i,
|
||
/<context>\s*The requested image has been edited and created and is now being shown to the user/i,
|
||
/<context>\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: "standard" | "extended" | null;
|
||
continuation?: ChatGptImageConversationContext | null;
|
||
}
|
||
): Record<string, unknown> {
|
||
// 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 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] },
|
||
});
|
||
|
||
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",
|
||
...(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<string, unknown>;
|
||
};
|
||
conversation_id?: string;
|
||
error?: string | { message?: string; code?: string };
|
||
type?: 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<string, unknown>;
|
||
}
|
||
|
||
async function* readChatGptSseEvents(
|
||
body: ReadableStream<Uint8Array>,
|
||
signal?: AbortSignal | null
|
||
): AsyncGenerator<ChatGptStreamEvent> {
|
||
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;
|
||
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;
|
||
}
|
||
|
||
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<Uint8Array>,
|
||
signal?: AbortSignal | null
|
||
): AsyncGenerator<ContentChunk> {
|
||
// 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 emittedLen = 0;
|
||
let isLive = false;
|
||
// Dedupe pointers across echoes / repeated events. Order-preserving Set.
|
||
const imagePointers = new Map<string, ImagePointerRef>();
|
||
// 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;
|
||
|
||
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 === "stream_handoff") {
|
||
handoff = true;
|
||
yield {
|
||
conversationId: conversationId ?? undefined,
|
||
handoff: true,
|
||
};
|
||
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<string, unknown>).metadata as
|
||
Record<string, unknown> | 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 = "";
|
||
emittedLen = 0;
|
||
isLive = false;
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
};
|
||
}
|
||
|
||
yield {
|
||
delta: "",
|
||
answer: currentParts,
|
||
conversationId: conversationId ?? undefined,
|
||
messageId: currentId ?? undefined,
|
||
imagePointers: imagePointers.size > 0 ? Array.from(imagePointers.values()) : undefined,
|
||
imageGenAsync,
|
||
handoff,
|
||
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<string, unknown>;
|
||
}
|
||
|
||
interface ChatGptConversationDetail {
|
||
mapping?: Record<string, { message?: ChatGptDetailMessage | null }>;
|
||
}
|
||
|
||
interface FinalAssistantAnswer {
|
||
text: string;
|
||
messageId?: string;
|
||
finished: boolean;
|
||
}
|
||
|
||
function textFromContentPart(part: unknown): string {
|
||
if (typeof part === "string") return part;
|
||
if (!part || typeof part !== "object") return "";
|
||
const obj = part as Record<string, unknown>;
|
||
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, finished, sort };
|
||
}
|
||
}
|
||
|
||
if (!best) return null;
|
||
return { text: best.text, messageId: best.messageId, finished: best.finished };
|
||
}
|
||
|
||
function delayWithAbort(ms: number, signal?: AbortSignal | null): Promise<void> {
|
||
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<ConversationDetailFetchResult> {
|
||
const url = `${CHATGPT_BASE}/backend-api/conversation/${encodeURIComponent(conversationId)}`;
|
||
const headers: Record<string, string> = {
|
||
...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<FinalAssistantAnswer | null> {
|
||
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<string | null>;
|
||
|
||
/**
|
||
* 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"). One image per line.
|
||
return "\n\n" + urls.map((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<string[]> {
|
||
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<Uint8Array>,
|
||
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<ImagePointerRef[]>) | null,
|
||
// Optional poller for GPT-5.5 Pro's stream_handoff path. Inline text keeps
|
||
// streaming as-is; once ChatGPT hands off, we append the final assistant
|
||
// answer fetched from the conversation detail endpoint. Text requests stay
|
||
// in Temporary Chat, so these polls should not create sidebar/history items.
|
||
pollFinalAnswer: ((conversationId: string) => Promise<FinalAssistantAnswer | null>) | null,
|
||
log: { warn?: (tag: string, msg: string) => void } | null,
|
||
signal?: AbortSignal | null
|
||
): ReadableStream<Uint8Array> {
|
||
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 emittedText = "";
|
||
let polledFinalAnswer = "";
|
||
let parentCandidateMessageId: string | null = null;
|
||
|
||
const emitTextDelta = (content: string): void => {
|
||
const cleaned = cleanChatGptText(content);
|
||
if (!cleaned) return;
|
||
emittedText += cleaned;
|
||
controller.enqueue(
|
||
encoder.encode(
|
||
sseChunk({
|
||
id: cid,
|
||
object: "chat.completion.chunk",
|
||
created,
|
||
model,
|
||
system_fingerprint: null,
|
||
choices: [
|
||
{
|
||
index: 0,
|
||
delta: { content: cleaned },
|
||
finish_reason: null,
|
||
logprobs: null,
|
||
},
|
||
],
|
||
})
|
||
)
|
||
);
|
||
};
|
||
|
||
const appendFinalAnswer = (text: string): void => {
|
||
const cleaned = cleanChatGptText(text);
|
||
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" : "";
|
||
emitTextDelta(`${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.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.messageId) parentCandidateMessageId = chunk.messageId;
|
||
break;
|
||
}
|
||
|
||
if (chunk.delta) {
|
||
emitTextDelta(chunk.delta);
|
||
}
|
||
}
|
||
|
||
if (pollFinalAnswer && conversationId && handoff) {
|
||
const stopHb = startHeartbeat();
|
||
try {
|
||
const polled = await pollFinalAnswer(conversationId);
|
||
if (polled?.text) {
|
||
polledFinalAnswer = polled.text;
|
||
if (polled.messageId) parentCandidateMessageId = polled.messageId;
|
||
}
|
||
} finally {
|
||
stopHb();
|
||
}
|
||
}
|
||
|
||
if (polledFinalAnswer) {
|
||
appendFinalAnswer(polledFinalAnswer);
|
||
}
|
||
|
||
// 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/<id>), 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 `<img>` 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<Uint8Array>,
|
||
model: string,
|
||
cid: string,
|
||
created: number,
|
||
currentMsg: string,
|
||
resolver: ImageResolver | null,
|
||
pollAsyncImage: ((conversationId: string) => Promise<ImagePointerRef[]>) | null,
|
||
pollFinalAnswer: ((conversationId: string) => Promise<FinalAssistantAnswer | null>) | null,
|
||
log: { warn?: (tag: string, msg: string) => void } | null,
|
||
signal?: AbortSignal | null
|
||
): Promise<Response> {
|
||
let fullAnswer = "";
|
||
let conversationId: string | null = null;
|
||
let imagePointers: ImagePointerRef[] | undefined;
|
||
let imageGenAsync = false;
|
||
let handoff = false;
|
||
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.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;
|
||
imagePointers = chunk.imagePointers;
|
||
imageGenAsync = chunk.imageGenAsync ?? false;
|
||
handoff = handoff || (chunk.handoff ?? false);
|
||
if (chunk.messageId) parentCandidateMessageId = chunk.messageId;
|
||
break;
|
||
}
|
||
if (chunk.answer) fullAnswer = chunk.answer;
|
||
}
|
||
|
||
if (pollFinalAnswer && conversationId && (handoff || !fullAnswer.trim())) {
|
||
const polled = await pollFinalAnswer(conversationId);
|
||
if (polled?.text) {
|
||
fullAnswer = polled.text;
|
||
if (polled.messageId) parentCandidateMessageId = polled.messageId;
|
||
}
|
||
}
|
||
|
||
fullAnswer = cleanChatGptText(fullAnswer);
|
||
|
||
// 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<string, string> | null): string | null {
|
||
const headers = clientHeaders ?? {};
|
||
const lower: Record<string, string> = {};
|
||
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/<id>. 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<string, string> | 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: "<azure-blob-sas-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?: { debug?: (tag: string, msg: string) => void; warn?: (tag: string, msg: string) => void };
|
||
/**
|
||
* Absolute base URL that downstream clients should use to fetch cached
|
||
* images served by /v1/chatgpt-web/image/<id>. 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<string | null> {
|
||
const headers: Record<string, string> = {
|
||
...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/<id>/download and
|
||
* /conversation/<cid>/attachment/<fid>/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<string | null> {
|
||
const headers: Record<string, string> = {
|
||
...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:<mime>;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:<mime>;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/<id> 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<string | null> {
|
||
// 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<string, string> = {
|
||
...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<WsWaitOutcome> {
|
||
return new Promise((resolve) => {
|
||
const found = new Map<string, ImagePointerRef>();
|
||
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<string, unknown>;
|
||
const candidates: ChatGptStreamEvent[] = [];
|
||
const innerPayload = obj.payload as Record<string, unknown> | undefined;
|
||
const updateContent = innerPayload?.update_content as Record<string, unknown> | undefined;
|
||
if (updateContent?.message) {
|
||
candidates.push({
|
||
message: updateContent.message 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<string, unknown>;
|
||
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<ImagePointerRef[]> {
|
||
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
|
||
return [];
|
||
}
|
||
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) return [];
|
||
ctx.log?.warn?.(
|
||
"CGPT-WEB",
|
||
`WebSocket attempt ${attempt + 1} ended in transport error before any frame; retrying`
|
||
);
|
||
}
|
||
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<string, string | null>();
|
||
|
||
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<string, unknown> | null)?.messages as
|
||
Array<Record<string, unknown>> | 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): inject a `<tool>` contract when `tools` are
|
||
// present; parsed back on the response side. Mirrors qwen-web/perplexity-web.
|
||
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(
|
||
(body || {}) as Record<string, unknown>,
|
||
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 deviceId = deviceIdFor(cookie);
|
||
await runSessionWarmup(
|
||
tokenEntry.accessToken,
|
||
tokenEntry.accountId,
|
||
sessionId,
|
||
deviceId,
|
||
cookie,
|
||
signal,
|
||
log
|
||
);
|
||
|
||
// 2a''. Resolve model + effort and apply thinking-effort preference for
|
||
// thinking-capable models. Dedicated thinking models mirror the browser's
|
||
// user-config PATCH; GPT-5.5 Pro sends the effort with the conversation
|
||
// body because the Pro standard/extended budget is part of that turn.
|
||
const resolvedModel = resolveChatGptModel(model, body, credentials.providerSpecificData);
|
||
const modelSlug = resolvedModel.slug;
|
||
const requestedEffort = resolvedModel.effort;
|
||
if (requestedEffort && isThinkingCapableModel(model, modelSlug)) {
|
||
await setUserThinkingEffort(
|
||
modelSlug,
|
||
requestedEffort,
|
||
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.5 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.5 Pro text request — keeping Temporary Chat enabled");
|
||
}
|
||
|
||
const parentMessageId = continuation?.parentMessageId ?? randomUUID();
|
||
const cgptBody = buildConversationBody(parsed, modelSlug, parentMessageId, {
|
||
persistConversation,
|
||
thinkingEffort: requestedEffort,
|
||
continuation,
|
||
});
|
||
|
||
const headers: Record<string, string> = {
|
||
...browserHeaders(),
|
||
...oaiHeaders(sessionId, deviceId),
|
||
"Content-Type": "application/json",
|
||
Accept: "text/event-stream",
|
||
Authorization: `Bearer ${tokenEntry.accessToken}`,
|
||
Cookie: buildSessionCookieHeader(cookie),
|
||
};
|
||
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<Uint8Array>;
|
||
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 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,
|
||
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,
|
||
pollFinalAnswer,
|
||
log,
|
||
signal
|
||
);
|
||
if (toolMode) {
|
||
finalResponse = await buildToolModeResponse(finalResponse, requestedTools, stream, {
|
||
cid,
|
||
created,
|
||
model,
|
||
});
|
||
}
|
||
}
|
||
|
||
return { response: finalResponse, url: CONV_URL, headers, transformedBody: cgptBody };
|
||
}
|
||
}
|
||
|
||
// Strip ChatGPT's internal entity markup. The browser renders these as proper
|
||
// inline citations / chips via JS; for a plain text completion we just want
|
||
// the human-readable form.
|
||
// entity["city","Paris","capital of France"] → Paris
|
||
// entity["…","value", …] → value
|
||
const ENTITY_RE = /entity\["[^"]*","([^"]*)"[^\]]*\]/g;
|
||
|
||
function cleanChatGptText(text: string): string {
|
||
return text.replace(ENTITY_RE, "$1");
|
||
}
|
||
|
||
function stringToStream(text: string): ReadableStream<Uint8Array> {
|
||
const encoder = new TextEncoder();
|
||
return new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
controller.enqueue(encoder.encode(text));
|
||
controller.close();
|
||
},
|
||
});
|
||
}
|
||
|
||
// Test-only: clear caches between tests
|
||
export function __resetChatGptWebCachesForTesting(): void {
|
||
tokenCache.clear();
|
||
warmupCache.clear();
|
||
thinkingEffortCache.clear();
|
||
deviceIdCache.clear();
|
||
__resetChatGptImageCacheForTesting();
|
||
dplCache = null;
|
||
}
|
||
|
||
export const __derivePublicBaseUrlForTesting = derivePublicBaseUrl;
|