Files
OmniRoute/tests/unit/chatgpt-web.test.ts
Diego Rodrigues de Sa e Souza a7ae9550bd Release v3.8.36 (#4854)
* chore(release): open v3.8.36 development cycle

* refactor(chatCore): extrai resolveCompressionSettings (#3501) (#4826)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 1/13)

* refactor(chatCore): extrai predicados puros de combo de compressão (#3501) (#4824)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 2/13)

* refactor(chatCore): extrai emitOutputStyleTelemetry (#3501) (#4811)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 3/13)

* refactor(chatCore): extrai writeCompressionAnalytics (bloco analytics completo, #3501) (#4817)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 4/13)

* refactor(chatCore): extrai runPluginOnRequestHook (#3501) (#4827)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 5/13)

* refactor(chatCore): extrai applyClientUsageBuffer (buffer/estimate de usage non-streaming, #3501) (#4832)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 6/13)

* refactor(chatCore): extrai buildPostCallGuardrailContext (contexto guardrail post-call, #3501) (#4831)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 7/13)

* refactor(chatCore): extrai storeSemanticCacheResponse (cache-store non-streaming, #3501) (#4828)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 8/13)

* refactor(chatCore): extrai buildNonStreamingResponseHeaders (headers de resposta non-streaming, #3501) (#4835)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 9/13)

* refactor(chatCore): extrai maybeConvertJsonBodyToSse (#3089 JSON→SSE streaming, #3501) (#4833)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 10/13)

* refactor(chatCore): extrai assembleStreamingResponseHeaders (headers de resposta streaming, #3501) (#4836)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 11/13)

* refactor(chatCore): extrai storeStreamingSemanticCacheResponse (cache-store streaming, #3501) (#4829)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 12/13)

* refactor(chatCore): extrai assembleStreamingPipeline (chain de transforms streaming, #3501) (#4837)

Integrated into release/v3.8.36 (#3501 chatCore extraction stack 13/13)

* ci(quality): shift heavy validations to the PR→release fast-path (release-acceleration) (#4857)

* feat(quality): add check:test-runner-api gate (vitest-only dirs must use vitest API)

* feat(release): reusable CHANGELOG i18n-mirror sync script

* chore(ops): add prune-stale-worktrees.sh (dry-run by default)

* ci(quality): run test-runner-api + docs-all + vitest + full unit suite on PR->release fast-path

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(quota): cota exclusiva lista qtSd/ no /v1/models (#4806) + limite EPSILON não bloqueia (#4830)

Integrated into release/v3.8.36 — quota-exclusive qtSd/ listing (#4806) + EPSILON placeholder no longer blocks; rebuilt from stale base (3 defining commits cherry-picked clean over release tip)

* feat(sse): add Google Flow video-generation provider (#4569) (#4769)

Integrated into release/v3.8.36 — Google Flow video-generation provider (#4569), release-green validated (typecheck + 21 tests + file-size)

* fix(api): auth on compression run-telemetry + document OMNIROUTE_EVAL_CREDENTIALS (#4694, #4720) (#4796)

Integrated into release/v3.8.36 — auth on compression run-telemetry + OMNIROUTE_EVAL_CREDENTIALS doc, release-green validated (typecheck + 3 tests + env-doc-sync)

* fix(translator): strip top-level client_metadata on the OpenAI passthrough (port from 9router#1157) (#4624)

Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated)

* fix(translator): normalize `developer` role to `system` for OpenAI-format providers (#4625)

Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated)

* fix(translator): emit </think> close marker for Anthropic thinking blocks (#4633)

Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated)

* fix(translator): normalize tools to Anthropic-native shape for non-Anthropic providers (#4650)

Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated)

* fix(gemini): preserve `pattern` in antigravity tool schema sanitizer (#4651)

Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated)

* fix(perplexity): validate API keys via /v1/models endpoint (#4654)

Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated)

* fix(image): prevent compatible nodes from shadowing provider aliases (#4656)

Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated)

* fix(cli-tools): tolerate JSONC (comments, trailing commas) in tool settings (#4659)

Integrated into release/v3.8.36 — port (rebuilt from stale base; defining commit cherry-picked clean over release tip, release-green validated)

* fix(security): validate kiro region to prevent SSRF (GHSA-6mwv-4mrm-5p3m) (#4629)

Integrated into release/v3.8.36 — kiro region SSRF guard (GHSA-6mwv-4mrm-5p3m), port rebuilt clean over release tip

* fix(cli): harden the systray2 tray runtime (port of 9router#1080) (#4628)

Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated

* fix(test): validate anthropic-compatible connections via POST /v1/messages (#4657)

Integrated into release/v3.8.36 — anthropic-compat validation via POST /v1/messages (port 584cf66a), rebuilt clean + baseline; release-green

* fix(executors): strip params unsupported by the target provider/model (#4658)

Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated

* fix(claude-oauth): respect 429 backoff on usage endpoint to reduce spam (#4655)

Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated

* feat(api/v1): include alias-backed models in /v1/models listing (#4630)

Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated

* chore(quality): rebaseline catalog.ts 1574->1577 (#4630 aliases sobre quota-exclusive da release) (#4879)

rebaseline

* feat(compression): Kiro/CodeWhisperer tool-result compression engine (#4635)

Integrated into release/v3.8.36 — port rebuilt clean, release-green

* fix(security): don't trust loopback socket as local when behind reverse proxy (#4632)

Integrated into release/v3.8.36 — port rebuilt clean, release-green

* fix(opencode): preserve DeepSeek reasoning content in streamed responses (#4631)

Integrated into release/v3.8.36 — DeepSeek reasoning_content injection (port #1099); release-green

* fix(copilot,antigravity): cap maxOutputTokens at 16384 to stop "Invalid Argument" 400 (#4636)

Integrated into release/v3.8.36 — cap maxOutputTokens 16384 antigravity (port #779); release-green

* fix(dashboard): show custom vision models in LLM selector (#4653)

Integrated into release/v3.8.36 — custom vision models in LLM selector (port 5e5e78d3); release-green

* fix(claude): omit adaptive thinking + output_config.effort for haiku (#4661)

Integrated into release/v3.8.36 — haiku adaptive-thinking omit (port); release-green

* feat(provider): CodeBuddy CN (copilot.tencent.com) — full stack (#4664)

Integrated into release/v3.8.36 — CodeBuddy CN provider (port efd20be8); usage.ts import + public-creds allowlist line reconciled; release-green

* feat(combo): Fusion strategy — parallel panel + judge synthesis (16th strategy) (#4652)

Integrated into release/v3.8.36 — Fusion combo strategy (16th, port 87e5c1c6); combo.ts baseline reconciled; release-green

* feat(proxy-pool): Deno Deploy relays + group action buttons (#4643)

Integrated into release/v3.8.36 — Deno Deploy relays (port #1437); proxies.ts baseline reconciled + env docs restored; release-green

* fix(security): pin image fetch DNS resolution to prevent SSRF rebinding (GHSA-cmhj-wh2f-9cgx) (#4634)

Integrated into release/v3.8.36 — pin DNS for image fetch SSRF rebinding guard (GHSA-cmhj-wh2f-9cgx, port c7d07448); caller DNS stubs + test-file baseline reconciled; release-green

* fix(github): route Copilot Codex models to /responses (port from 9router#102) (#4626)

Integrated into release/v3.8.36 — route Copilot Codex models to /responses (port #102); release-green

* fix(copilot): never route Gemini/Claude variants to /responses (chat-completions only) (#4627)

Integrated into release/v3.8.36 — never route Gemini/Claude to /responses (port #1536); fused with #4626 codex routing via supportsResponsesEndpoint gate; release-green

* docs(ops): add canonical incident response runbook (#4868)

Integrated into release/v3.8.36

* docs(perf): add per-endpoint p50/p95/p99 latency + cost budgets (#4867)

Integrated into release/v3.8.36

* fix(proxy): fan out direct dispatcher streams (#4803)

Integrated into release/v3.8.36

* fix(antigravity): exclude standard Gemini rate limit message from quota exhaustion keywords (#4810)

Integrated into release/v3.8.36

* fix(sse): skip third-party tool-name cloak for Anthropic server tools (#4808)

Integrated into release/v3.8.36

* fix(install): make transformers optional for CUDA-host installs (#4807)

Integrated into release/v3.8.36

* fix(combo): propagate selected connection ID to fallback error responses for correct model lockout (#4809)

Integrated into release/v3.8.36

* fix db storage tuning settings (#4834)

Integrated into release/v3.8.36

* fix(sse): drop ccp pin when pinned provider is durably unhealthy (failover + anti-flap) (#4864)

Integrated into release/v3.8.36

* fix(claude): skip mcp__ tool-name cloak + guard missing connectionId (#4861)

Integrated into release/v3.8.36

* chore(quality): reconcile env-doc + file-size base-reds in release/v3.8.36 (#4886)

- env-doc-sync: document PIN_DROP_BACKOFF_LEVEL / PIN_DROP_GRACE_MS (added by the
  ccp-pin health gate #4864) in .env.example + ENVIRONMENT.md.
- file-size: rebaseline image-generation-handler.test.ts 1996 -> 2019 to its actual
  size (pre-existing drift).

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(codex): drop non-standard codex.* events that break responses.stream (env-gated, #4602) (#4715)

Integrated into release/v3.8.36

* feat(routing): honor X-Route-Model header to override body.model (#4863)

Integrated into release/v3.8.36

* feat(live-ws): allow non-loopback clients via LIVE_WS_ALLOWED_HOSTS (closes #4873) (#4877)

Integrated into release/v3.8.36 (live-ws + combo-api commits; Tailscale CGNAT commit held pending opt-in/opt-out decision)

* chore(claude,codex): bump pinned CLI identity — Claude 2.1.158→2.1.187, Codex 0.132.0→0.142.0 (#4883)

Integrated into release/v3.8.36

* fix(security): SSRF allowlist bypass via x-relay-path nos relays Deno/Vercel (#4899)

Integrated into release/v3.8.36

* feat(quota): recuperação proativa de conexões em cooldown (cron heal) [Fase 3 #8] (#4900)

Integrated into release/v3.8.36

* fix(quota): policy inválida não vaza allow + guard connectionIds vazio [Fase 3 #10] (#4901)

Integrated into release/v3.8.36

* feat(quota): saturação real do Claude no fair-share via /api/oauth/usage (#4885)

Integrated into release/v3.8.36

* chore(dashboard): rename Qoder display label from "Qoder AI" to "Qoder" (#4733)

Integrated into release/v3.8.36

* fix(ci): include coverage/lcov.info in coverage-report artifact for SonarQube (#4670)

Integrated into release/v3.8.36

* fix(cli): bump better-sqlite3 runtime pin to 12.10.1 for Node 26 (#4685)

Integrated into release/v3.8.36

* docs: clarify Kiro is ~50 credits/month per account, not unlimited (#4690)

Integrated into release/v3.8.36

* docs(agentbridge): document Electron NODE_EXTRA_CA_CERTS, real model IDs, identity caveat (#4718)

Integrated into release/v3.8.36

* docs(ops): document the release-green family (green-prs, check:release-green, babysit, nightly) (#4679)

Integrated into release/v3.8.36

* fix(translator): replay reasoning_content on plain Xiaomi MiMo turns (port from 9router#1321) (#4639)

Integrated into release/v3.8.36

* feat(opencode-go): advertise glm-5.2 and kimi-k2.7-code (align with official Go endpoints) (#4711)

Integrated into release/v3.8.36

* feat(db): track API endpoint dimension on usage_history (#4676)

Integrated into release/v3.8.36 (migration renumbered 103→105; endpoint plumbed through extracted usage-stats helpers)

* fix(cli): SIGKILL systray child PID before IPC close to avoid macOS NSStatusItem orphan (#4732)

Integrated into release/v3.8.36

* feat(proxy-pool): Cloudflare Workers proxy deployer + pool integration (#4640)

Integrated into release/v3.8.36 (relay type added to RELAY_TYPES set; dropdown UX preserved + Cloudflare item added; proxies.ts file-size rebaselined 1057→1060)

* chore(quality): conserta base-red de release/v3.8.36 (gates + 7 testes + build MDX) (#4915)

A base tinha base-red sistêmica herdada de PRs de outras sessões, bloqueando
TODOS os PRs do ciclo (o TIA roda a suíte full em fail-safe p/ diffs hub).

4 Fast Quality Gates:
- test-discovery (#4877): live-server-allowlist.test.ts em tests/unit/server/
  (não-coletado) + vitest → nunca rodava. Convertido p/ node:test em tests/unit/security/.
- any-budget:t11 (#4664): 3 explicit-any em tokenRefresh.ts tipados (sem crescer file-size).
- docs-symbols (#4868): rotas inexistentes → /api/system/version e
  PUT /api/providers/{id} {isActive:false}.
- docs-all fabricated-claim (#4868 + #4718): 5 bin/*.sh reais criados (rollback,
  snapshot-data, restore-data, restore-policies, cold-start-bench) + _ops-common.sh
  (snapshot VACUUM INTO, guards de confirmação/TTY, testes de contrato); NODE_EXTRA_CA_CERTS
  (env de runtime Node) na allowlist do checker.

7 testes unit base-red (de features alheias à quota):
- oauth-providers-config (#4664): teste alinhado ao provider codebuddy-cn do registry.
- antigravity-model-aliases (#4636): maxOutputTokens esperado 32769→16384 (cap intencional).
- provider-request-capture #4091 (#4861): exemplo do teste trocado de mcp__ (que #4861
  isenta de cloak por causa dos 400s de assimetria de histórico) para um tool de terceiro
  cloakável — preserva o invariante de #4091 SEM reverter #4861.
- combo-error-response: convertido de vitest p/ node:test (era coletado pelo glob node:test
  e crashava); api/** e server/** removidos do vitest.config (config morta).

Build MDX (dast-smoke, #4679):
- docs/ops/RELEASE_GREEN.md não tinha frontmatter `title` → fumadocs-mdx rejeitava no
  webpack compile ("invalid frontmatter: title expected string"), quebrando o next build
  (e o deploy). Frontmatter title adicionado (único doc do collection sem ele).

17/17 Fast Quality Gates + suíte unit completa (17737 testes, 0 fail) + vitest verdes localmente.

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* feat(quota): saturação proativa por headers de tokens (universal) [Fase 3 #2] (#4907)

storeRateLimitHeaders só capturava os headers de REQUESTS (RPM/min), que não
refletem a pressão de TOKENS. Agora também parseia os headers de tokens (em toda
resposta, sucesso também) para throttle proativo antes do 429:
- Anthropic: anthropic-ratelimit-tokens-{limit,remaining,reset} (+ input/output), RFC3339.
- OpenAI: x-ratelimit-{limit,remaining,reset}-tokens, reset em duração (6m0s).

saturation = 1 − remaining/limit; resetAt normalizado a epoch (parse de duração
ReDoS-safe). getTokenHeaderSaturation por (provider, connectionId). fetchGeneric-
Saturation passa a usar esse sinal (complementa o oauth/usage do #1, que segue
primário p/ Claude). Fail-open, cache mantido, request-path inalterado.

16 testes novos + regressão (oauth/usage #1 8/8, signals 6/6) = 30/30;
typecheck:core + eslint limpos.

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* feat(quota): estratégia de combo "headroom" — seleção por folga de cota [Fase 3 #4] (#4908)

Nova estratégia de roteamento que escolhe a conexão com MAIS folga de plano:
headroom = 1 − max(util_5h, util_7d) (técnica do dario), via getSaturation
(melhorado p/ Claude no #1). Proativo em vez de só fill-first reativo.

- Helper PURO headroomRanking.ts (computeHeadroom + rankByHeadroom; saturação
  injetada, não-mutante, tie-break estável, fail-open).
- Orderer async em combo/quotaStrategies.ts (reusa a maquinaria reset-aware de
  expansão de conexões + concorrência limitada; seam injetável).
- Registrada como "headroom" em routingStrategies (combo-only); fill-first segue
  default — nenhuma estratégia existente tocada.
- baseline file-size combo.ts 3168->3180 (só +12L de dispatch; lógica fora do god-file).

16 testes novos + combo-strategies 15/15 = 31/31; typecheck:core + eslint +
file-size limpos.

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* feat(quota): cap per-(key,model) — quota_allocation_model_caps [Fase 3 #7] (#4927)

* feat(quota): cap per-(key,model) com tabela quota_allocation_model_caps [Fase 3 #7]

Fecha o buraco onde uma API key pode drenar o pool inteiro consumindo um único modelo.

Tabela nova: quota_allocation_model_caps(pool_id, api_key_id, model, cap_value, cap_unit)
PK composta (pool_id, api_key_id, model). cap_unit alinhado ao QuotaUnit existente.

Comportamento: keyA acima do cap para modelo M → bloqueada somente em M; ainda
permitida em qualquer outro modelo no mesmo pool. Cap <= EPSILON → ignorado (seed).

Consumo por-(key,model) usa bucket segregado no quota_consumption existente
(poolId mangled ':model:<model>') com window fixa 'hourly'; nenhuma nova tabela
ou método de store necessário.

Módulo novo: src/lib/db/quotaModelCaps.ts (getModelCap/setModelCap/deleteModelCap/listModelCaps)
enforce.ts ganha o pre-check em enforceQuotaShare + recording em recordConsumption.
EnforceInput e RecordConsumptionInput ganham model?: string (backward-compatible).
localDb.ts re-exporta os 4 helpers (Hard Rule #2).

TDD: tests/unit/quota-per-key-model.test.ts — 4 cenários (bloqueia em M, permite em M2,
sem cap → sem bloqueio, EPSILON → ignorado). Todos os gates de qualidade passam.

* feat(quota): plumba model resolvido no hot path para ativar o per-(key,model) cap [Fase 3 #7]

A tabela/enforce do commit anterior estavam INERTES: o hot path não passava `model`
ao enforce nem ao record, então nenhum model-cap disparava em produção.

Plumbagem (model resolvido = mesma var usada no log/roteamento, pós background-redirect/alias):
- chatCore.ts: enforceQuotaShare ganha `model`; scheduleQuotaShareConsumption recebe `model`.
- chatCore/quotaShareConsumption.ts: threade `model` no RecordConsumptionInput (non-streaming).
- spendRecorder.ts: recordStreamingConsumption já recebia `model` — agora o coloca no
  RecordConsumptionInput (streaming accrue por-modelo).
- embeddings.ts: enforce + record ganham `model`.

Namespace do cap = id do modelo RESOLVIDO (o mesmo de modelForScope/pendingScope/getUnsupportedParams),
não o requestedModel cru nem o finalModelToUpstream (sem prefixo de provider). Operador configura
o cap contra esse id. `model || undefined` em todos os pontos: vazio/null → check pulado (fail-safe,
zero latência — só um campo no objeto).

Teste de integração novo (tests/unit/quota-per-key-model-hotpath.test.ts): prova end-to-end que
N consumos via scheduleQuotaShareConsumption({model}) → enforceQuotaShare({model}) bloqueia, e que
outro modelo no mesmo pool ainda passa; + guard de que enforce SEM model nunca dispara model-cap.

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* feat(quota): session stickiness p/ integridade de prompt-cache [Fase 3 #5] (#4929)

* feat(quota): session stickiness p/ integridade de prompt-cache [Fase 3 #5]

Adiciona stickiness de sessão ao roteamento de combo: uma conversa multi-turno
é roteada para a MESMA conexão enquanto ela permanecer saudável, evitando a
perda do prompt-cache do provider (custo 5-10× sem stickiness, efeito conhecido
no dario/clewdr).

Implementação:
- `open-sse/services/combo/sessionStickiness.ts` (novo, <800 linhas):
  mapa em memória (messageHash → connectionId) com TTL 15 min + cap 500 entradas;
  `applySessionStickiness` promove a conexão sticky ao índice 0 dos targets
  ordenados pelo strategy, guardado por `computeHeadroom > 0.15` (threshold);
  quando saturada (headroom ≤ 0.15), o binding é limpo e a seleção normal reage.
  Hash da sessão = SHA-256 dos primeiros chars da 1ª mensagem user → 16 hex chars.
  Seam de teste: `__setStickinessHeadroomFetcherForTests`.
- `open-sse/services/combo.ts`: import + 2 pontos de integração (pré-eval-scores
  e pós-success), dentro do orçamento congelado de 3180 linhas.
- `tests/unit/combo-session-stickiness.test.ts`: 19 testes node:test + assert/strict,
  todos via injeção de fetcher (zero rede/DB).

Threshold 0.15: conexão a >85% de utilização está a um burst de rate-limit;
o benefício de cache não compensa manter-se numa conexão degradada. Valor
alinhado com a zona de soft-penalty do restante do engine de quota-share.

* test(combo): isola combo-strategies da session stickiness (#5)

selectedConnectionFor reusa o mesmo body, então o sticky map (#5) fixava a
connection após a 1ª chamada e quebrava o round-robin tie-break do teste
reset-aware. Limpa o sticky map no início da helper — a stickiness tem suíte
própria (combo-session-stickiness). Sem enfraquecer asserts.

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* feat(quota): buckets multi-janela por conexão (5h/7d/per-model) [Fase 3 #3] (#4928)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* refactor(providers): decompõe catálogo providers.ts em módulos de dados (godfile sweep, #3501) (#4917)

Integrado em release/v3.8.36 (godfile sweep providers.ts, #3501)

* refactor(pricing): decompõe pricing.ts em shared-tiers + DEFAULT_PRICING particionado (godfile sweep, #3501) (#4918)

Integrado em release/v3.8.36 (godfile sweep pricing.ts, #3501)

* refactor(api): extrai camada-folha pura de validation.ts (URL/headers/transport) (#4921)

Integrado em release/v3.8.36 (validation.ts split fatia 1 — leaf layer)

* refactor(api): extrai validators web-cookie + Meta AI de validation.ts (#4922)

Integrado em release/v3.8.36 (validation.ts split fatia 2 — web-cookie + Meta AI)

* refactor(api): extrai validators enterprise-cloud + probe compartilhado de validation.ts (#4923)

Integrado em release/v3.8.36 (validation.ts split fatia 3 — enterprise-cloud + probe)

* refactor(api): extrai validators áudio/speech + misc apikey de validation.ts (#4930)

Integrado em release/v3.8.36 (validation.ts split fatia 4 — áudio/speech + misc apikey)

* feat(quota): estratégia dedicada de quota-share (DRR + P2C in-flight + gating per-model) [Fase 3 #9] (#4939)

* feat(quota): estratégia dedicada de quota-share (DRR + P2C in-flight + gating per-model) [Fase 3 #9]

Estratégia interna "quota-share" isolada num módulo dedicado — NÃO toca a seleção/
fair-share genérica (decisão do dono: não mexer no que já funciona). Os combos qtSd/
(quotaCombos.ts) passam de fill-first para essa strategy; combo.ts ganha só 1 branch
de dispatch que delega 100% ao módulo (nenhum case existente alterado).

- quotaShareStrategy.ts: gating per-model (isBucketSaturated do #3) + DRR (quantum
  proporcional ao weight) + P2C sobre carga in-flight.
- quotaShareInflight.ts: contador in-flight com TTL/lease de 120s — fallback do
  decrement-on-abort sem precisar instrumentar o combo genérico.
- "quota-share" registrada como strategy INTERNA (não exposta na UI).
- testes de síntese (quota-combo-balancing, quota-multiprovider) alinhados: a strategy
  esperada dos combos qtSd/ passa de "fill-first" para "quota-share" (alinhamento ao novo
  comportamento intencional, não mascaramento — os 73 testes de qtSd/ seguem verdes).

* test(quota-share): alinha 2 scope-guards ao godfile sweep (base-reds que bloqueavam o CI)

Dois testes de "arquivo contém X" quebraram por decomposições de godfile que outras
sessões mergearam no release DURANTE a validação de #9 — NÃO são regressão de #9
(que não toca validation/oauth). Alinhados ao novo layout, asserts preservados:
- proxy-bypass-scope-guard #3226: bypassProxyPatch foi extraído de validation.ts para
  validation/headers.ts (split #4921–#4930) → o teste lê a camada de validação.
- sse-error-passthrough #3324: a windsurf authHint foi extraída de providers.ts para
  providers/oauth.ts → o teste lê o novo local.

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* refactor(api): extrai validators search + embedding/rerank de validation.ts (#4932)

Integrated into release/v3.8.36

* refactor(api): extrai format-validators (OpenAI/Anthropic) de validation.ts (#4933)

Integrated into release/v3.8.36

* refactor(db): extrai model-permission matching de db/apiKeys.ts (#4936)

Integrated into release/v3.8.36

* refactor(db): extrai row-parsers + tipos compartilhados de db/apiKeys.ts (#4943)

Integrated into release/v3.8.36

* refactor(db): extrai column-mapping (snake↔camel) de db/core.ts (#4947)

Integrated into release/v3.8.36

* refactor(db): extrai schema-column reconciliation de db/core.ts (#4948)

Integrated into release/v3.8.36

* refactor(sse): extrai scalar/format helpers de services/usage.ts (#4949)

Integrated into release/v3.8.36

* refactor(sse): extrai quota-core (UsageQuota + builders) de services/usage.ts (#4950)

Integrated into release/v3.8.36

* fix(translator): regroup parallel tool results adjacent to their assistant (#4714) (#4882)

Integrated into release/v3.8.36 (fixes #4714)

* fix(qoder): exchange PAT for jt-* job token before Cosy chat (#4683) (#4884)

Integrated into release/v3.8.36 (fixes #4683)

* refactor(sse): dedup fallback tool_call id helper (#4736)

Integrated into release/v3.8.36

* refactor(open-sse): extract safeParseJSON util, dedup tryParseJSON (#4735)

Integrated into release/v3.8.36

* fix(compression): eliminate ReDoS in math_inline preservation pattern (#4795) (#4838)

Integrated into release/v3.8.36 (fixes #4795)

* fix(combo): fetch models dynamically from custom provider endpoints (#4860)

Integrated into release/v3.8.36

* feat(providers): update volcengine-ark model list with DeepSeek V4 (#4905)

Integrated into release/v3.8.36

* fix(translator): provider thinking compatibility (DeepSeek/Gemini) (#4946)

Integrated into release/v3.8.36

* feat(combo): task-aware routing strategy (#4945)

Integrated into release/v3.8.36

* refactor(sse): extrai a família MiniMax de services/usage.ts (#4952)

Integrated into release/v3.8.36

* refactor(sse): extrai a família GLM de services/usage.ts (#4953)

Integrated into release/v3.8.36

* refactor(sse): extrai a família Antigravity de services/usage.ts (#4956)

Integrated into release/v3.8.36

* fix(dashboard): show custom provider given-name instead of internal id across dashboard pages (#4603) (#4960)

Integrated into release/v3.8.36 (fixes #4603)

* fix(api): evict stale in-memory rate-limit windows to stop slow heap leak (#4041) (#4957)

Integrated into release/v3.8.36 (fixes #4041)

* fix(api): parse /v1/responses body once instead of 3-4x on the hot path (#4041) (#4958)

Integrated into release/v3.8.36 (fixes #4041)

* fix(translator): preserve legitimate empty-string tool arguments in openai-to-claude streaming (#4951) (#4959)

Integrated into release/v3.8.36 (fixes #4951)

* chore(quality): reconcile file-size baseline for #4960 provider-display-name (#4961)

Integrated into release/v3.8.36

* fix(dashboard): restore home provider-topology card hidden by #4596 default (#4963)

Integrated into release/v3.8.36 — restores home topology card (#4596 regression)

* fix(build): drop @omniroute/open-sse from optimizePackageImports (build OOM) (#4968)

Integrated into release/v3.8.36 — fixes build OOM (optimizePackageImports open-sse)

* fix(quota): migração 107 ativa estratégia quota-share nos combos qtSd/ existentes [Fase 3 #9] (#4962)

Integrated into release/v3.8.36

* feat(quota): respeita max_concurrent por conexão no roteamento (#4965)

Integrated into release/v3.8.36

* feat(quota): combo quota-share espera cooldown curto e re-despacha (Variante A) (#4967)

Integrated into release/v3.8.36

* fix(quality): resolve base-reds da release — db-rules allowlist + task-aware router precedence (#4973)

Dois base-reds pré-existentes que reprovavam o CI da release v3.8.36 (Fast
Quality Gates + Unit Tests fast-path), independentes de qualquer feature em voo:

1. check:db-rules / allowlist: os módulos db-internal caseMapping (#4947) e
   schemaColumns (#4948), extraídos de db/core.ts e importados só por ele, não
   estavam em INTENTIONALLY_INTERNAL. Registrados na allowlist (correção
   canônica — são internos legítimos, não re-exportados pelo localDb).

2. auto-strategy honra LKGP/cost (combo-routing-engine.test.ts, 2 testes): o
   task-aware reordering (#4945, reorderByTaskWeight) roda para strategy "auto" e
   era aplicado DEPOIS do router explícito (selectWithStrategy: lkgp/cost),
   sobrescrevendo o orderedTargets[0] que o operador escolheu. Instrumentação
   provou: post-filter [0]=claude (LKGP) → post-task [0]=gpt-oss. Correção: quando
   o auto usa router explícito, preserva o [0] dele e deixa o task-aware refinar
   só a cauda de fallback. gpt-oss-120b PERMANECE tool-capable (não é mudança de
   catálogo; o model-capabilities-registry test segue verde).

Validado: 121 testes (combo-routing-engine + combo-task-aware + registry) verdes,
red-check confirmado, db-rules/file-size/typecheck/lint/prettier OK.

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* feat(quota): serializa concorrência por conexão no caminho quota-share (FASE 2.1) (#4970)

O gating de quota-share em selectQuotaShareTarget é fail-open: uma conexão
at-cap só é despriorizada, nunca bloqueada. Com 1 conexão por conta de
assinatura (caso comum), chamadas concorrentes ainda floodam a conta (→ 429 +
cooldown) — provado live na .15: 3 chamadas concorrentes com max_concurrent=1
despacharam todas em 94ms.

Adiciona um semáforo POR CONEXÃO em torno do dispatch quota-share: chamadas
excedentes esperam na fila em vez de floodar (key qsconn:<connectionId>, cap =
max_concurrent da conexão). Fail-open em fila saturada/timeout para nunca
piorar disponibilidade. Gated por strategy===quota-share + kill-switch
resilienceSettings.quotaShareConcurrencyLimit (default on; UI no ResilienceTab).

Lógica extraível isolada no leaf puro combo/quotaShareConcurrency.ts
(unit-testado: estabilidade da key, no-op sem cap, serialização real,
fail-open). Settings + schema + UI espelham comboCooldownWait.

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* docs(resilience): document Quota-Share Concurrency Control (max_concurrent + serialization + cooldown-wait) (#4980)

Documents the v3.8.36 quota-share concurrency layers in RESILIENCE_GUIDE.md:
per-connection max_concurrent cap, the quota-share request serialization semaphore
(FASE 2.1, qsconn:<connectionId>, fail-open, kill-switch), and the combo
cooldown-aware retry — so operators know how to cap a subscription account's
concurrency and why the routing gate alone cannot contain a single-connection flood.

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(dashboard): proxy-pool success gating, sync timestamp, opt-in Redis (#4878) (#4988)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(sse): fail over on 400 responses carrying rate-limit text (#4976) (#4986)

* fix(sse): fail over on 400 responses carrying rate-limit text (#4976)

* chore(quality): rebaseline accountFallback.ts file-size for #4976 fix

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(compression): stop RTK over-truncating file-read tool results (#4559) (#4987)

* fix(compression): stop RTK over-truncating file-read tool results (#4559)

* chore(quality): trim #4559 comment to keep rtk/index.ts within size cap

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(sse): honor per-account proxies and fingerprint rotation in opencode executor (#4954) (#4989)

* fix(sse): honor per-account proxies and fingerprint rotation in opencode executor (#4954)

* chore(quality): rebaseline auth.ts file-size for #4954 (+39: synthetic no-auth providerSpecificData hydration of fingerprints/accountProxies; irreducible credential-path wiring, covered by opencode-proxy-rotation-4954.test.ts + 159 auth/noauth regression)

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(sse): soft-penalize exhausted providers in auto-combo scoring (#4540) (#4990)

* fix(sse): soft-penalize exhausted providers in auto-combo scoring (#4540)

* chore(quality): document STATUS_SOFT_DEPRIORITIZE_FACTOR + rebaseline combo.ts for #4540

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(dashboard): switch to visible filter after auto-hiding failed models in test-all (#4887) (#4991)

* fix(dashboard): switch to visible filter after auto-hiding failed models in OAuth provider test-all (#4887)

* test(dashboard): move #4887 test into tests/unit/ui so a CI runner collects it

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(pollinations): only enable jsonMode when JSON output is requested (#3981) (#5009)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(antigravity): default safetySettings to all-OFF for parity with native Gemini paths (#5003) (#5008)

* fix(antigravity): default safetySettings to all-OFF for parity with native Gemini paths (#5003)

* docs(changelog): restore #3981 pollinations entry eaten by merge

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(chatgpt-web): map advertised gpt-5.5/5.4-pro/5.2-pro slugs to prevent silent model substitution (#4665) (#5010)

* fix(chatgpt-web): map advertised gpt-5.5/5.4-pro/5.2-pro slugs to prevent silent model substitution (#4665)

MODEL_MAP was missing the advertised catalog ids gpt-5.5, gpt-5.5-pro,
gpt-5.4-pro and gpt-5.2-pro, so MODEL_MAP[model] ?? model sent the dot-form
id verbatim to the ChatGPT backend-api, which silently rejected it and served
the default Plus model. Map each to its dash-form slug. gpt-4-5 is already
dash-form and falls through correctly, so it is intentionally left unmapped.

Extends the executor MODEL_MAP test with the four ids and adds a drift guard
asserting every advertised dot-form catalog id reaches the backend in dash-form
(never verbatim), guarding future catalog<->map drift.

file-size: tests/unit/chatgpt-web.test.ts frozen baseline 2809->2855 (+46) for
the added test cases and drift-guard test; executor source unchanged in baseline.

* docs(changelog): restore #3981/#5003 entries eaten by merge

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* feat(combos): add editable per-combo description field persisted via /api/combos (#5005) (#5011)

* feat(combos): add editable per-combo description field persisted via /api/combos (#5005)

* docs(changelog): restore #3981/#5003/#4665 entries eaten by merge

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* Fix Ollama Cloud max reasoning effort (#4993)

Integrated into release/v3.8.36

* fix(copilot): replace execSync with execFile to prevent command injection (#5024)

Integrated into release/v3.8.36

* fix(plugin): auth.json dual-key fallback for auto-prefix migration (#5027)

Integrated into release/v3.8.36

* feat(endpoint): per-endpoint custom system prompt injection (#5022)

Integrated into release/v3.8.36

* fix(headroom): translate openai-responses input through OpenAI for compression (#5023)

Integrated into release/v3.8.36

* docs(changelog): add entries for #4993, #5024, #5027 (release notes credit)

* fix(api): stop /api/system/env/repair 500 on packaged install (#5006) (#5028)

* fix(api): stop /api/system/env/repair 500 on packaged install — lazy createRequire in sync-env.mjs (#5006)

scripts/dev/sync-env.mjs ran createRequire(import.meta.url) at module
top-level. When webpack bundles it into the standalone env-repair route,
import.meta.url is frozen to the build-machine path (file:///home/runner/...)
and createRequire throws during module evaluation, so the whole route
module fails to load and every GET returns HTTP 500 — breaking the
onboarding wizard on packaged/global installs.

- Move createRequire into the guarded better-sqlite3 block (only place
  that needs it); a bad import.meta.url now returns the safe default.
- resolveRootDir() falls back to process.cwd() when fileURLToPath throws.
- route.ts passes an explicit rootDir (process.cwd()) so the helper never
  derives the root from the frozen import.meta.url, matching the .env
  target used by createEnvBackup().
- Regression guard: assert sync-env.mjs has no top-level createRequire +
  getEnvSyncPlan(oauth) works with explicit rootDir without throwing.

* docs(changelog): restore #4993/#5023/#5024/#5027 + custom-system-prompt/headroom entries eaten by release merge

* chore(quality): rebaseline 3 inherited base-reds from release merge

Files NOT touched by this PR — grew on release/v3.8.36 via --admin merges and
inherited here through 'git merge origin/release':
- open-sse/executors/base.ts 1414->1416 (#4993 Ollama Cloud max-effort)
- src/lib/db/settings.ts 1149->1151 (#5023 custom system prompt)
- src/app/(dashboard)/.../endpoint/EndpointPageClient.tsx 2570->2612 (custom system prompt UI)

* chore(release): finalize v3.8.36 CHANGELOG + docs (2026-06-25)

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: Makcim Ivanov <makcimbx@gmail.com>
Co-authored-by: Chewji <126886556+Chewji9875@users.noreply.github.com>
Co-authored-by: Anton <39598727+NomenAK@users.noreply.github.com>
Co-authored-by: Demiurge The Single <megamen932@gmail.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Éder Costa <eder.almeida.costa@gmail.com>
Co-authored-by: Jefferson Felizardo <jeffer1312@gmail.com>
Co-authored-by: Arthur Bodera <abodera@gmail.com>
Co-authored-by: Hamsa_M <116961508+hamsa0x7@users.noreply.github.com>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
2026-06-25 13:17:40 -03:00

2855 lines
100 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from "node:test";
import assert from "node:assert/strict";
const { ChatGptWebExecutor, __derivePublicBaseUrlForTesting, __resetChatGptWebCachesForTesting } =
await import("../../open-sse/executors/chatgpt-web.ts");
const { describeChatGptWebHttpError } = await import(
"../../open-sse/executors/chatgptWebErrors.ts"
);
const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts");
const { __setTlsFetchOverrideForTesting, looksLikeSse, TlsClientUnavailableError } =
await import("../../open-sse/services/chatgptTlsClient.ts");
// ─── Helpers ────────────────────────────────────────────────────────────────
function mockChatGptStreamText(events) {
const chunks = [];
for (const evt of events) {
chunks.push(`data: ${JSON.stringify(evt)}\r\n\r\n`);
}
chunks.push("data: [DONE]\r\n\r\n");
return chunks.join("");
}
function makeHeaders(map = {}) {
const h = new Headers();
for (const [k, v] of Object.entries(map)) h.set(k, String(v));
return h;
}
async function withEnv(overrides, fn) {
const keys = [
"OMNIROUTE_PUBLIC_BASE_URL",
"OMNIROUTE_BASE_URL",
"NEXT_PUBLIC_BASE_URL",
"BASE_URL",
"PORT",
];
const previous = new Map(keys.map((key) => [key, process.env[key]]));
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(overrides, key)) {
const value = overrides[key];
if (value == null) delete process.env[key];
else process.env[key] = String(value);
} else {
delete process.env[key];
}
}
try {
return await fn();
} finally {
for (const [key, value] of previous) {
if (value == null) delete process.env[key];
else process.env[key] = value;
}
}
}
/** Dispatch the TLS-impersonating fetch by URL pathname.
* Default: session 200 with accessToken, sentinel 200 no PoW, conv 200 empty stream. */
function installMockFetch({
session,
sentinel,
conv,
dpl,
fileDownload,
attachmentDownload,
signedDownload,
userConfig,
onSession,
onSentinel,
onConv,
onFileDownload,
onAttachmentDownload,
onUserConfig,
}: any = {}) {
const calls = {
session: 0,
dpl: 0,
sentinel: 0,
conv: 0,
fileDownload: 0,
attachmentDownload: 0,
signedDownload: 0,
userConfig: 0,
userConfigUrls: [],
userConfigMethods: [],
urls: [],
headers: [],
bodies: [],
};
__setTlsFetchOverrideForTesting(async (url, opts = {}) => {
const u = String(url);
calls.urls.push(u);
calls.headers.push(opts.headers || {});
calls.bodies.push(opts.body);
// DPL warmup — GET https://chatgpt.com/ (root). Match before /api/auth/session.
if (
(u === "https://chatgpt.com/" || u === "https://chatgpt.com") &&
(opts.method || "GET") === "GET"
) {
calls.dpl++;
const cfg = dpl ?? {
status: 200,
body: '<html data-build="prod-test123"><script src="https://cdn.oaistatic.com/_next/static/chunks/main-test.js"></script></html>',
};
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "text/html" }),
text: cfg.body,
body: null,
};
}
if (u.includes("/api/auth/session")) {
calls.session++;
if (onSession) onSession(opts);
const cfg = session ?? {
status: 200,
body: {
accessToken: "jwt-abc",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "user-1" },
},
};
const headers = makeHeaders({ "Content-Type": "application/json" });
if (cfg.setCookie) headers.set("set-cookie", cfg.setCookie);
return {
status: cfg.status,
headers,
text: typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {}),
body: null,
};
}
// /backend-api/settings/user_last_used_model_config?model_slug=...&thinking_effort=...
// Match before sentinel since /settings/* is its own surface.
if (u.includes("/backend-api/settings/user_last_used_model_config")) {
calls.userConfig++;
calls.userConfigUrls.push(u);
calls.userConfigMethods.push((opts.method || "GET").toUpperCase());
if (onUserConfig) onUserConfig(opts, u);
const cfg = userConfig ?? { status: 200, body: { is_disabled: false } };
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {}),
body: null,
};
}
if (u.includes("/sentinel/chat-requirements")) {
calls.sentinel++;
if (onSentinel) onSentinel(opts);
const cfg = sentinel ?? {
status: 200,
body: { token: "req-token", proofofwork: { required: false } },
};
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify(cfg.body || {}),
body: null,
};
}
// /backend-api/conversation/<conv_id>/attachment/<file_id>/download
// Must match BEFORE the conversation-endpoint regex below since the
// conv-prefix regex is broad.
{
const m1 = u.match(/\/backend-api\/conversation\/[^/]+\/attachment\/([^/]+)\/download/);
if (m1) {
calls.attachmentDownload++;
if (onAttachmentDownload) onAttachmentDownload(opts, m1[1]);
const cfg = attachmentDownload ?? {
status: 200,
body: { download_url: `https://files.oaiusercontent.com/${m1[1]}?sig=mock` },
};
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {}),
body: null,
};
}
}
// /backend-api/files/<file_id>/download
{
const m1 = u.match(/\/backend-api\/files\/([^/]+)\/download/);
if (m1) {
calls.fileDownload++;
if (onFileDownload) onFileDownload(opts, m1[1]);
const cfg = fileDownload ?? {
status: 200,
body: { download_url: `https://files.oaiusercontent.com/${m1[1]}?sig=mock` },
};
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {}),
body: null,
};
}
}
// The signed estuary URL the executor follows after fetching either
// download endpoint. Mock returns a tiny PNG header so the executor's
// `imageUrlToCachedImageUrl` decoder produces a valid Buffer and the
// image makes it into the cache, surfaced as /v1/chatgpt-web/image/<id>.
if (/^https:\/\/files\.oaiusercontent\.com\//.test(u)) {
calls.signedDownload++;
const cfg = signedDownload ?? { status: 200 };
if (cfg.status >= 400) {
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "text/plain" }),
text: cfg.body || "",
body: null,
};
}
const tinyPng = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52,
]);
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "image/png" }),
// tls-client-node packages binary bodies as a data:<mime>;base64,...
// string when isByteResponse is set; the mock mirrors that contract.
text: `data:image/png;base64,${tinyPng.toString("base64")}`,
body: null,
};
}
// Match only the exact conversation endpoint, not /conversations (plural — warmup).
if (
u.endsWith("/backend-api/f/conversation") ||
u.endsWith("/backend-api/conversation") ||
/\/backend-api\/(f\/)?conversation\?/.test(u)
) {
calls.conv++;
if (onConv) onConv(opts);
const cfg = conv ?? {
status: 200,
events: [
{
conversation_id: "conv-1",
message: {
id: "msg-1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Hello, world!"] },
status: "in_progress",
},
},
{
conversation_id: "conv-1",
message: {
id: "msg-1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Hello, world!"] },
status: "finished_successfully",
},
},
],
};
if (cfg.error) {
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({ detail: cfg.error }),
body: null,
};
}
return {
status: cfg.status,
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
text: mockChatGptStreamText(cfg.events || []),
body: null,
};
}
return {
status: 404,
headers: makeHeaders(),
text: "not mocked",
body: null,
};
});
return {
calls,
restore() {
__setTlsFetchOverrideForTesting(null);
},
};
}
function reset() {
__resetChatGptWebCachesForTesting();
}
// ─── Registration ───────────────────────────────────────────────────────────
test("ChatGptWebExecutor is registered in executor index", () => {
assert.ok(hasSpecializedExecutor("chatgpt-web"));
assert.ok(hasSpecializedExecutor("cgpt-web"));
const executor = getExecutor("chatgpt-web");
assert.ok(executor instanceof ChatGptWebExecutor);
});
test("ChatGptWebExecutor alias resolves to same type", () => {
const a = getExecutor("chatgpt-web");
const b = getExecutor("cgpt-web");
assert.ok(a instanceof ChatGptWebExecutor);
assert.ok(b instanceof ChatGptWebExecutor);
});
test("ChatGptWebExecutor sets correct provider name", () => {
const executor = new ChatGptWebExecutor();
assert.equal(executor.getProvider(), "chatgpt-web");
});
// ─── Public image URL derivation ────────────────────────────────────────────
test("Image URL base: OMNIROUTE_PUBLIC_BASE_URL wins and strips accidental /v1", async () => {
await withEnv(
{
OMNIROUTE_PUBLIC_BASE_URL: " http://192.168.107.55:20128/v1/ ",
NEXT_PUBLIC_BASE_URL: "http://localhost:20128",
},
async () => {
assert.equal(
__derivePublicBaseUrlForTesting({ host: "localhost:20128" }),
"http://192.168.107.55:20128"
);
}
);
});
test("Image URL base: local NEXT_PUBLIC_BASE_URL does not mask LAN Host header", async () => {
await withEnv(
{
NEXT_PUBLIC_BASE_URL: "http://localhost:20128",
BASE_URL: "http://localhost:20128",
},
async () => {
assert.equal(
__derivePublicBaseUrlForTesting({ host: "192.168.107.55:20128" }),
"http://192.168.107.55:20128"
);
}
);
});
test("Image URL base: forwarded headers override raw Host", async () => {
await withEnv({}, async () => {
assert.equal(
__derivePublicBaseUrlForTesting({
host: "localhost:20128",
"x-forwarded-host": "omni.example.com",
"x-forwarded-proto": "https",
}),
"https://omni.example.com"
);
});
});
test("Image URL base: non-local OMNIROUTE_BASE_URL remains a compatibility fallback", async () => {
await withEnv({ OMNIROUTE_BASE_URL: "https://omni.example.com/v1" }, async () => {
assert.equal(__derivePublicBaseUrlForTesting(null), "https://omni.example.com");
});
});
test("Image URL base: falls back to localhost with PORT", async () => {
await withEnv({ PORT: "20129" }, async () => {
assert.equal(__derivePublicBaseUrlForTesting(null), "http://localhost:20129");
});
});
// ─── Token exchange path ────────────────────────────────────────────────────
test("Token exchange: cookie sent to /api/auth/session, accessToken used as Bearer on later calls", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "my-cookie-value" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(m.calls.session, 1);
assert.equal(m.calls.sentinel, 1);
assert.equal(m.calls.conv, 1);
// Find headers by call type instead of by index — call order is
// session → dpl → sentinel → conv but indices shift if any call is cached.
const sessionIdx = m.calls.urls.findIndex((u) => u.includes("/api/auth/session"));
const sentinelIdx = m.calls.urls.findIndex((u) => u.includes("/sentinel/chat-requirements"));
const convIdx = m.calls.urls.findIndex((u) => u.includes("/backend-api/f/conversation"));
const sessionHeaders = m.calls.headers[sessionIdx];
assert.equal(sessionHeaders.Cookie, "__Secure-next-auth.session-token=my-cookie-value");
const sentinelHeaders = m.calls.headers[sentinelIdx];
assert.equal(sentinelHeaders.Authorization, "Bearer jwt-abc");
assert.equal(sentinelHeaders["chatgpt-account-id"], "user-1");
const convHeaders = m.calls.headers[convIdx];
assert.equal(convHeaders.Authorization, "Bearer jwt-abc");
} finally {
m.restore();
}
});
test("Token cache: two calls within TTL only hit /api/auth/session once", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
const opts = {
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "cookie-v1" },
signal: AbortSignal.timeout(10_000),
log: null,
};
await executor.execute(opts);
await executor.execute(opts);
assert.equal(m.calls.session, 1, "session exchange should only happen once");
assert.equal(m.calls.conv, 2);
} finally {
m.restore();
}
});
test("Refreshed cookie: surfaced via onCredentialsRefreshed callback", async () => {
reset();
const m = installMockFetch({
session: {
status: 200,
body: {
accessToken: "jwt-abc",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "user-1" },
},
setCookie: "__Secure-next-auth.session-token=ROTATED-VALUE; Path=/; HttpOnly; Secure",
},
});
try {
let refreshed = null;
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "old-cookie" },
signal: AbortSignal.timeout(10_000),
log: null,
onCredentialsRefreshed: (creds) => {
refreshed = creds;
},
});
assert.ok(refreshed, "callback should have fired");
// Refreshed cookie is stored as a full cookie line so it round-trips through
// buildSessionCookieHeader on the next request (works for chunked tokens too).
assert.equal(refreshed.apiKey, "__Secure-next-auth.session-token=ROTATED-VALUE");
} finally {
m.restore();
}
});
// ─── Sentinel + PoW ─────────────────────────────────────────────────────────
test("Sentinel: chat-requirements is hit before /backend-api/conversation", async () => {
reset();
const order = [];
const m = installMockFetch({
onSession: () => order.push("session"),
onSentinel: () => order.push("sentinel"),
onConv: () => order.push("conv"),
});
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.deepEqual(order, ["session", "sentinel", "conv"]);
} finally {
m.restore();
}
});
test("Sentinel: chat-requirements token forwarded on conv request", async () => {
reset();
const m = installMockFetch({
sentinel: { status: 200, body: { token: "REQ-TOKEN-XYZ", proofofwork: { required: false } } },
});
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const convHeaders = m.calls.headers[convIdx];
assert.equal(convHeaders["openai-sentinel-chat-requirements-token"], "REQ-TOKEN-XYZ");
} finally {
m.restore();
}
});
test("PoW: when required, proof token is sent with valid prefix", async () => {
reset();
const m = installMockFetch({
sentinel: {
status: 200,
body: {
token: "req-token",
proofofwork: { required: true, seed: "deadbeef", difficulty: "00fff" },
},
},
});
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(15_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const convHeaders = m.calls.headers[convIdx];
const proof = convHeaders["openai-sentinel-proof-token"];
assert.ok(proof, "proof token should be present");
assert.match(proof, /^[gw]AAAAAB/);
} finally {
m.restore();
}
});
test("Turnstile: required flag does NOT block — conv endpoint accepts requests", async () => {
// ChatGPT's Sentinel often reports turnstile.required: true even on requests
// the conversation endpoint will accept without a Turnstile token. We pass
// through and let /f/conversation decide.
reset();
const m = installMockFetch({
sentinel: {
status: 200,
body: {
token: "x",
turnstile: { required: true, dx: "challenge-data" },
proofofwork: { required: false },
},
},
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
assert.equal(m.calls.conv, 1, "should reach conversation endpoint despite turnstile.required");
} finally {
m.restore();
}
});
// ─── Streaming / non-streaming ──────────────────────────────────────────────
test("Non-streaming: returns OpenAI chat.completion JSON", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
const json = await result.response.json();
assert.equal(json.object, "chat.completion");
assert.equal(json.choices[0].message.role, "assistant");
assert.equal(json.choices[0].message.content, "Hello, world!");
assert.equal(json.choices[0].finish_reason, "stop");
assert.ok(json.id.startsWith("chatcmpl-cgpt-"));
assert.ok(json.usage.total_tokens > 0);
} finally {
m.restore();
}
});
test("Streaming: produces valid SSE chunks ending with [DONE]", async () => {
reset();
const m = installMockFetch({
conv: {
status: 200,
events: [
{
conversation_id: "c1",
message: {
id: "m1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Hello "] },
status: "in_progress",
},
},
{
conversation_id: "c1",
message: {
id: "m1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Hello world!"] },
status: "finished_successfully",
},
},
],
},
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }], stream: true },
stream: true,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
assert.equal(result.response.headers.get("Content-Type"), "text/event-stream");
const text = await result.response.text();
const lines = text.split("\n").filter((l) => l.startsWith("data: "));
assert.ok(lines.length >= 3);
const first = JSON.parse(lines[0].slice(6));
assert.equal(first.choices[0].delta.role, "assistant");
const lastLine = text.trim().split("\n").filter(Boolean).pop();
assert.equal(lastLine, "data: [DONE]");
} finally {
m.restore();
}
});
test("Streaming: cumulative parts are diffed into non-overlapping deltas", async () => {
reset();
const m = installMockFetch({
conv: {
status: 200,
events: [
{
conversation_id: "c1",
message: {
id: "m1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Foo"] },
status: "in_progress",
},
},
{
conversation_id: "c1",
message: {
id: "m1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Foo bar"] },
status: "in_progress",
},
},
{
conversation_id: "c1",
message: {
id: "m1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Foo bar baz"] },
status: "finished_successfully",
},
},
],
},
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }], stream: true },
stream: true,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const text = await result.response.text();
const contentDeltas = text
.split("\n")
.filter((l) => l.startsWith("data: ") && l !== "data: [DONE]")
.map((l) => {
try {
return JSON.parse(l.slice(6));
} catch {
return null;
}
})
.filter((j) => j?.choices?.[0]?.delta?.content)
.map((j) => j.choices[0].delta.content);
assert.deepEqual(contentDeltas, ["Foo", " bar", " baz"]);
} finally {
m.restore();
}
});
// ─── Errors ─────────────────────────────────────────────────────────────────
test("Error: 401 on /api/auth/session returns 401 with re-paste hint", async () => {
reset();
const m = installMockFetch({ session: { status: 401, body: {} } });
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "expired-cookie" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 401);
const json = await result.response.json();
assert.match(json.error.message, /session-token/);
} finally {
m.restore();
}
});
test("Error: 200 with no accessToken returns 401", async () => {
reset();
const m = installMockFetch({ session: { status: 200, body: {} } });
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "stale-cookie" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 401);
assert.equal(m.calls.sentinel, 0, "should not reach sentinel");
} finally {
m.restore();
}
});
test("Error: 403 from sentinel returns 403 SENTINEL_BLOCKED", async () => {
reset();
const m = installMockFetch({ sentinel: { status: 403, body: { detail: "blocked" } } });
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 403);
const json = await result.response.json();
assert.equal(json.error.code, "SENTINEL_BLOCKED");
assert.equal(m.calls.conv, 0);
} finally {
m.restore();
}
});
test("Error: 429 from conversation returns 429 with rate-limit message", async () => {
reset();
const m = installMockFetch({ conv: { status: 429, error: "rate" } });
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 429);
const json = await result.response.json();
assert.match(json.error.message, /rate limited/);
} finally {
m.restore();
}
});
test("Error: empty messages returns 400 without any fetch", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 400);
assert.equal(m.calls.session, 0);
} finally {
m.restore();
}
});
test("Error: missing apiKey returns 401 without any fetch", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: {},
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 401);
assert.equal(m.calls.session, 0);
} finally {
m.restore();
}
});
// ─── Cookie prefix stripping ────────────────────────────────────────────────
test("Cookie: bare value gets prepended with cookie name", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "rawValue" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(m.calls.headers[0].Cookie, "__Secure-next-auth.session-token=rawValue");
} finally {
m.restore();
}
});
test("Cookie: unchunked cookie line is passed through verbatim", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "__Secure-next-auth.session-token=actualvalue" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(m.calls.headers[0].Cookie, "__Secure-next-auth.session-token=actualvalue");
} finally {
m.restore();
}
});
test("Cookie: chunked .0/.1 cookies are passed through verbatim (NextAuth reassembles)", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: {
apiKey:
"__Secure-next-auth.session-token.0=partA; __Secure-next-auth.session-token.1=partB",
},
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(
m.calls.headers[0].Cookie,
"__Secure-next-auth.session-token.0=partA; __Secure-next-auth.session-token.1=partB"
);
} finally {
m.restore();
}
});
test("Cookie: 'Cookie: ' DevTools prefix is stripped", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: {
apiKey:
"Cookie: __Secure-next-auth.session-token.0=A; __Secure-next-auth.session-token.1=B",
},
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(
m.calls.headers[0].Cookie,
"__Secure-next-auth.session-token.0=A; __Secure-next-auth.session-token.1=B"
);
} finally {
m.restore();
}
});
// ─── Session continuity ─────────────────────────────────────────────────────
test("Session continuity: each call starts a fresh conversation (Temporary Chat mode)", async () => {
// Conversation continuity is intentionally disabled because the executor
// uses history_and_training_disabled: true (Temporary Chat), whose
// conversation_ids expire quickly upstream and 404 on re-use. Each call
// sends the full history with conversation_id: null.
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "First question" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
await executor.execute({
model: "gpt-5.3-instant",
body: {
messages: [
{ role: "user", content: "First question" },
{ role: "assistant", content: "Hello, world!" },
{ role: "user", content: "Follow-up" },
],
},
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(m.calls.conv, 2);
const convIndices = m.calls.urls
.map((u, i) => (u.endsWith("/backend-api/f/conversation") ? i : -1))
.filter((i) => i >= 0);
assert.equal(convIndices.length, 2);
const secondBody = JSON.parse(m.calls.bodies[convIndices[1]]);
assert.equal(secondBody.conversation_id, null, "should start a fresh conversation");
// History is folded into the system message (so the model doesn't try to
// continue prior assistant turns); only the latest user message is sent.
const userMessages = secondBody.messages.filter((m) => m.author?.role === "user");
assert.equal(userMessages.length, 1, "only the latest user message is in the messages array");
assert.equal(userMessages[0].content.parts[0], "Follow-up");
const systemMsg = secondBody.messages.find((m) => m.author?.role === "system");
assert.ok(systemMsg, "history should be packaged in a system message");
assert.match(systemMsg.content.parts[0], /First question/);
assert.match(systemMsg.content.parts[0], /Hello, world!/);
} finally {
m.restore();
}
});
// ─── Request inspection ─────────────────────────────────────────────────────
test("Request: conversation POST has correct browser-like headers", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
assert.equal(m.calls.urls[convIdx], "https://chatgpt.com/backend-api/f/conversation");
const convHeaders = m.calls.headers[convIdx];
assert.match(convHeaders["User-Agent"], /Mozilla/);
assert.equal(convHeaders["Origin"], "https://chatgpt.com");
assert.equal(convHeaders["Sec-Fetch-Site"], "same-origin");
assert.equal(convHeaders["Accept"], "text/event-stream");
} finally {
m.restore();
}
});
test("Request: payload has correct ChatGPT shape", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: {
messages: [
{ role: "system", content: "Be concise" },
{ role: "user", content: "What is 2+2?" },
],
},
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const body = JSON.parse(m.calls.bodies[convIdx]);
assert.equal(body.action, "next");
assert.equal(body.model, "gpt-5-3-instant");
// Plain text request → Temporary Chat stays ON. We disable it only for
// image-gen prompts (see "Image gen: image-intent prompts" tests below).
assert.equal(body.history_and_training_disabled, true);
// System message preserves the user-supplied system prompt; the user
// message is the latest query.
assert.equal(body.messages[0].author.role, "system");
assert.match(body.messages[0].content.parts[0], /Be concise/);
assert.equal(body.messages[body.messages.length - 1].author.role, "user");
assert.equal(body.messages[body.messages.length - 1].content.parts[0], "What is 2+2?");
} finally {
m.restore();
}
});
// ─── Provider registry ──────────────────────────────────────────────────────
test("Provider registry: chatgpt-web exposes the current ChatGPT Web model catalog", async () => {
const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts");
const entry = getRegistryEntry("chatgpt-web");
assert.ok(entry, "chatgpt-web should be in the registry");
assert.equal(entry.executor, "chatgpt-web");
assert.equal(entry.format, "openai");
assert.equal(entry.authHeader, "cookie");
const ids = (entry.models || []).map((m) => m.id);
// Mirrors /backend-api/models for ChatGPT Web. Retired GPT-5/GPT-5.1
// entries should stay out of this list.
assert.deepEqual(ids, [
"gpt-5.5-pro",
"gpt-5.5-thinking",
"gpt-5.5",
"gpt-5.4-pro",
"gpt-5.4-thinking",
"gpt-5.4-thinking-mini",
"gpt-5.3",
"gpt-5.3-mini",
"gpt-5.2-pro",
"gpt-5.2-thinking",
"gpt-5.2-instant",
"o3",
"gpt-4-5",
]);
});
test("Executor MODEL_MAP: dot-form OmniRoute IDs translate to dash-form ChatGPT slugs", async () => {
reset();
const m = installMockFetch();
try {
const cases: Array<[string, string]> = [
["gpt-5.3", "gpt-5-3"],
["gpt-5.5-thinking", "gpt-5-5-thinking"],
["gpt-5.4-thinking-mini", "gpt-5-4-t-mini"],
["gpt-5.2-thinking", "gpt-5-2-thinking"],
["o3", "o3"],
// Regression #4665: these advertised catalog ids were missing from
// MODEL_MAP and fell through as their dot-form slug verbatim, which the
// ChatGPT backend-api silently rejects → served the default Plus model.
["gpt-5.5", "gpt-5-5"],
["gpt-5.5-pro", "gpt-5-5-pro"],
["gpt-5.4-pro", "gpt-5-4-pro"],
["gpt-5.2-pro", "gpt-5-2-pro"],
];
for (const [omniId, expectedSlug] of cases) {
m.calls.urls.length = 0;
m.calls.bodies.length = 0;
const executor = new ChatGptWebExecutor();
await executor.execute({
model: omniId,
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const body = JSON.parse(m.calls.bodies[convIdx]);
assert.equal(body.model, expectedSlug, `${omniId} should map to ${expectedSlug}`);
}
} finally {
m.restore();
}
});
test("MODEL_MAP drift guard: every advertised dot-form catalog id resolves to a dash-form backend slug (no verbatim fall-through) (#4665)", async () => {
reset();
const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts");
const ids = (getRegistryEntry("chatgpt-web")?.models || []).map((m) => m.id);
// Dot-form ids must never reach the ChatGPT backend verbatim — the backend
// only accepts dash-form slugs. (Ids that are already dash-form, e.g.
// "gpt-4-5", legitimately pass through unchanged and are exempt.)
const dotFormIds = ids.filter((id) => id.includes("."));
const m = installMockFetch();
try {
for (const omniId of dotFormIds) {
m.calls.urls.length = 0;
m.calls.bodies.length = 0;
const executor = new ChatGptWebExecutor();
await executor.execute({
model: omniId,
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const body = JSON.parse(m.calls.bodies[convIdx]);
assert.ok(
!body.model.includes("."),
`${omniId} reached the backend as "${body.model}" (still dot-form) — missing MODEL_MAP entry causes silent model substitution`,
);
assert.notEqual(
body.model,
omniId,
`${omniId} fell through MODEL_MAP verbatim — add a dash-form mapping`,
);
}
} finally {
m.restore();
}
});
// ─── thinking_effort PATCH user_last_used_model_config ─────────────────────
test("thinking_effort: high → PATCH user_last_used_model_config with extended", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.5-thinking",
body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" },
stream: false,
credentials: { apiKey: "cookie-1" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(m.calls.userConfig, 1, "exactly one PATCH issued");
assert.equal(m.calls.userConfigMethods[0], "PATCH");
const u = m.calls.userConfigUrls[0];
assert.match(u, /model_slug=gpt-5-5-thinking/);
assert.match(u, /thinking_effort=extended/);
} finally {
m.restore();
}
});
test("thinking_effort: low/medium → PATCH with standard", async () => {
for (const effort of ["low", "medium", "minimal"]) {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.4-thinking",
body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: effort },
stream: false,
credentials: { apiKey: `cookie-${effort}` },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(m.calls.userConfig, 1, `effort=${effort} should issue exactly one PATCH`);
assert.match(m.calls.userConfigUrls[0], /thinking_effort=standard/, `${effort} → standard`);
assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-4-thinking/);
} finally {
m.restore();
}
}
});
test("thinking_effort: instant model never triggers PATCH even with reasoning_effort", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" },
stream: false,
credentials: { apiKey: "cookie-instant" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(m.calls.userConfig, 0, "instant slug must not PATCH thinking_effort");
} finally {
m.restore();
}
});
test("thinking_effort: bare chatgpt.com slug (e.g. gpt-5-4-t-mini) passed as model still PATCHes", async () => {
// Regression: the abbreviated dash-form slug "gpt-5-4-t-mini" doesn't
// carry the literal "thinking" substring, and isn't a key in MODEL_MAP
// (only its dot-form alias is), so a substring-only check would silently
// skip the PATCH for callers that send the chatgpt.com slug directly.
for (const bareSlug of ["gpt-5-4-t-mini", "gpt-5-5-thinking", "o3"]) {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: bareSlug,
body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" },
stream: false,
credentials: { apiKey: `cookie-bare-${bareSlug}` },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(
m.calls.userConfig,
1,
`bare slug ${bareSlug} must trigger thinking_effort PATCH`
);
assert.ok(
m.calls.userConfigUrls[0].includes(`model_slug=${bareSlug}`),
`URL should contain model_slug=${bareSlug}`
);
} finally {
m.restore();
}
}
});
test("thinking_effort: thinking model without reasoning_effort skips PATCH", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.5-thinking",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "cookie-noeffort" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(m.calls.userConfig, 0, "no effort requested → no PATCH");
} finally {
m.restore();
}
});
test("thinking_effort: providerSpecificData.thinkingEffort=extended overrides body", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.4-thinking-mini",
body: {
messages: [{ role: "user", content: "hi" }],
reasoning_effort: "low", // would normally map to standard
},
stream: false,
credentials: {
apiKey: "cookie-override",
providerSpecificData: { thinkingEffort: "extended" },
},
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(m.calls.userConfig, 1);
assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-4-t-mini/);
assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/);
} finally {
m.restore();
}
});
test("thinking_effort: nested body.reasoning.effort=high → extended", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.2-thinking",
body: {
messages: [{ role: "user", content: "hi" }],
reasoning: { effort: "high" },
},
stream: false,
credentials: { apiKey: "cookie-nested" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(m.calls.userConfig, 1);
assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-2-thinking/);
assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/);
} finally {
m.restore();
}
});
test("thinking_effort: cached per (cookie, slug, effort) — second identical call skips PATCH", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
const opts = {
model: "gpt-5.5-thinking",
body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" },
stream: false,
credentials: { apiKey: "cookie-cache" },
signal: AbortSignal.timeout(10_000),
log: null,
};
await executor.execute(opts);
await executor.execute(opts);
assert.equal(m.calls.userConfig, 1, "second identical request hits cache");
} finally {
m.restore();
}
});
test("thinking_effort: switching effort within TTL triggers a fresh PATCH", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
const base = {
model: "gpt-5.5-thinking",
stream: false,
credentials: { apiKey: "cookie-switch" },
signal: AbortSignal.timeout(10_000),
log: null,
};
await executor.execute({
...base,
body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" },
});
await executor.execute({
...base,
body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "low" },
});
assert.equal(m.calls.userConfig, 2, "different effort key bypasses cache");
assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/);
assert.match(m.calls.userConfigUrls[1], /thinking_effort=standard/);
} finally {
m.restore();
}
});
test("thinking_effort: PATCH failure is non-fatal — conversation request still fires", async () => {
reset();
const m = installMockFetch({
userConfig: { status: 500, body: { error: "boom" } },
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.5-thinking",
body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" },
stream: false,
credentials: { apiKey: "cookie-fail" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(m.calls.userConfig, 1);
assert.equal(m.calls.conv, 1, "conversation still issued despite settings PATCH 500");
assert.equal(result.response.status, 200);
} finally {
m.restore();
}
});
test("Image registry: cgpt-web/gpt-5.3-instant routes to ChatGPT Web image handler", async () => {
const { parseImageModel, getImageProvider } =
await import("../../open-sse/config/imageRegistry.ts");
const parsed = parseImageModel("cgpt-web/gpt-5.3-instant");
assert.equal(parsed.provider, "chatgpt-web");
assert.equal(parsed.model, "gpt-5.3-instant");
const provider = getImageProvider(parsed.provider);
assert.equal(provider.format, "chatgpt-web");
assert.equal(provider.authHeader, "cookie");
});
// ─── Cookie rotation preserves Cloudflare cookies ───────────────────────────
test("Cookie rotation: full DevTools blob keeps cf_clearance/__cf_bm/_cfuvid", async () => {
// When the user pastes the recommended full DevTools Cookie line and
// NextAuth rotates the session-token chunks, only those chunks should
// change — the Cloudflare cookies must be preserved or every subsequent
// request gets cf-mitigated: challenge.
reset();
const m = installMockFetch({
session: {
status: 200,
body: {
accessToken: "jwt-abc",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "user-1" },
},
setCookie:
"__Secure-next-auth.session-token.0=NEW0; Path=/; HttpOnly, " +
"__Secure-next-auth.session-token.1=NEW1; Path=/; HttpOnly",
},
});
try {
let refreshed = null;
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: {
apiKey:
"__Secure-next-auth.session-token.0=OLD0; " +
"__Secure-next-auth.session-token.1=OLD1; " +
"cf_clearance=CFCLEAR; __cf_bm=CFBM; _cfuvid=CFUV; _puid=PUID",
},
signal: AbortSignal.timeout(10_000),
log: null,
onCredentialsRefreshed: (creds) => {
refreshed = creds;
},
});
assert.ok(refreshed, "callback should fire on rotation");
assert.match(refreshed.apiKey, /session-token\.0=NEW0/, "session-token.0 rotated");
assert.match(refreshed.apiKey, /session-token\.1=NEW1/, "session-token.1 rotated");
assert.match(refreshed.apiKey, /cf_clearance=CFCLEAR/, "cf_clearance preserved");
assert.match(refreshed.apiKey, /__cf_bm=CFBM/, "__cf_bm preserved");
assert.match(refreshed.apiKey, /_cfuvid=CFUV/, "_cfuvid preserved");
assert.match(refreshed.apiKey, /_puid=PUID/, "_puid preserved");
// Old session-token values must NOT survive in the merged blob.
assert.doesNotMatch(refreshed.apiKey, /OLD0/);
assert.doesNotMatch(refreshed.apiKey, /OLD1/);
} finally {
m.restore();
}
});
test("Cookie rotation: unchunked → chunked drops stale unchunked variant", async () => {
// When the original was unchunked (< 4KB session token) and rotation
// returns chunked (.0/.1), the stale unchunked entry must NOT survive in
// the merged blob — otherwise both old and new session-token cookies are
// sent on the next request and depending on parser precedence the server
// could read the stale value.
reset();
const m = installMockFetch({
session: {
status: 200,
body: {
accessToken: "jwt-abc",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "user-1" },
},
setCookie:
"__Secure-next-auth.session-token.0=NEW0; Path=/; HttpOnly, " +
"__Secure-next-auth.session-token.1=NEW1; Path=/; HttpOnly",
},
});
try {
let refreshed = null;
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: {
apiKey: "__Secure-next-auth.session-token=UNCHUNKED_OLD; cf_clearance=CFCLEAR",
},
signal: AbortSignal.timeout(10_000),
log: null,
onCredentialsRefreshed: (creds) => {
refreshed = creds;
},
});
assert.ok(refreshed);
// Stale unchunked variant must NOT appear (whole or in part).
assert.doesNotMatch(
refreshed.apiKey,
/__Secure-next-auth\.session-token=UNCHUNKED_OLD/,
"stale unchunked session-token must be dropped"
);
// Non-session-token cookies preserved.
assert.match(refreshed.apiKey, /cf_clearance=CFCLEAR/);
// New chunks present.
assert.match(refreshed.apiKey, /session-token\.0=NEW0/);
assert.match(refreshed.apiKey, /session-token\.1=NEW1/);
} finally {
m.restore();
}
});
test("Cookie rotation: chunked → unchunked drops stale chunks", async () => {
// Reverse case: original is chunked, rotation goes back to unchunked.
reset();
const m = installMockFetch({
session: {
status: 200,
body: {
accessToken: "jwt-abc",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "user-1" },
},
setCookie: "__Secure-next-auth.session-token=NEW_UNCHUNKED; Path=/; HttpOnly",
},
});
try {
let refreshed = null;
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: {
apiKey:
"__Secure-next-auth.session-token.0=OLD0; " +
"__Secure-next-auth.session-token.1=OLD1; " +
"cf_clearance=CFCLEAR",
},
signal: AbortSignal.timeout(10_000),
log: null,
onCredentialsRefreshed: (creds) => {
refreshed = creds;
},
});
assert.ok(refreshed);
assert.doesNotMatch(refreshed.apiKey, /OLD0/, "stale chunk .0 dropped");
assert.doesNotMatch(refreshed.apiKey, /OLD1/, "stale chunk .1 dropped");
assert.match(refreshed.apiKey, /session-token=NEW_UNCHUNKED/);
assert.match(refreshed.apiKey, /cf_clearance=CFCLEAR/);
} finally {
m.restore();
}
});
test("Cookie rotation: returns null when Set-Cookie has no session-token", async () => {
// When NextAuth doesn't rotate (Set-Cookie sets only unrelated cookies, or
// returns the same session-token value), the callback shouldn't fire.
reset();
const m = installMockFetch({
session: {
status: 200,
body: {
accessToken: "jwt-abc",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "user-1" },
},
setCookie: "some-other-cookie=value; Path=/",
},
});
try {
let refreshed = null;
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "cookie-v1" },
signal: AbortSignal.timeout(10_000),
log: null,
onCredentialsRefreshed: (creds) => {
refreshed = creds;
},
});
assert.equal(refreshed, null, "no rotation should not fire callback");
} finally {
m.restore();
}
});
// ─── Echo suppression in extractContent ─────────────────────────────────────
test("Stream parser: echoed prior assistant turn is suppressed (streaming)", async () => {
// chatgpt.com sometimes echoes prior assistant turns at the start of the
// stream with status: finished_successfully BEFORE the new generation
// starts. The parser must not emit echoed bytes — otherwise the SSE
// consumer sees old content prepended to the new answer.
reset();
const m = installMockFetch({
conv: {
status: 200,
events: [
// Echo of a prior assistant turn — full content, finished, never
// transitions through in_progress.
{
conversation_id: "c1",
message: {
id: "echo-1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["OLD ECHO ANSWER"] },
status: "finished_successfully",
},
},
// The real new turn — streams as in_progress, then finishes.
{
conversation_id: "c1",
message: {
id: "new-1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Hello"] },
status: "in_progress",
},
},
{
conversation_id: "c1",
message: {
id: "new-1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Hello world"] },
status: "finished_successfully",
},
},
],
},
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }], stream: true },
stream: true,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const text = await result.response.text();
const contentDeltas = text
.split("\n")
.filter((l) => l.startsWith("data: ") && l !== "data: [DONE]")
.map((l) => {
try {
return JSON.parse(l.slice(6));
} catch {
return null;
}
})
.filter((j) => j?.choices?.[0]?.delta?.content)
.map((j) => j.choices[0].delta.content);
const joined = contentDeltas.join("");
assert.equal(joined, "Hello world", "only the new turn is emitted");
assert.doesNotMatch(joined, /OLD ECHO/, "echoed content must not appear in stream");
} finally {
m.restore();
}
});
test("Stream parser: echoed prior assistant turn is suppressed (non-streaming)", async () => {
reset();
const m = installMockFetch({
conv: {
status: 200,
events: [
{
conversation_id: "c1",
message: {
id: "echo-1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["OLD ECHO ANSWER"] },
status: "finished_successfully",
},
},
{
conversation_id: "c1",
message: {
id: "new-1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["Hello world"] },
status: "in_progress",
},
},
],
},
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const json = await result.response.json();
assert.equal(json.choices[0].message.content, "Hello world");
} finally {
m.restore();
}
});
test("Stream parser: instant single-event reply still surfaces via fallback", async () => {
// Edge case: a real reply that arrives in a single event with status
// already finished_successfully (cached/instant). End-of-stream fallback
// should emit it; otherwise streaming consumers would get nothing.
reset();
const m = installMockFetch({
conv: {
status: 200,
events: [
{
conversation_id: "c1",
message: {
id: "instant-1",
author: { role: "assistant" },
content: { content_type: "text", parts: ["instant reply"] },
status: "finished_successfully",
},
},
],
},
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const json = await result.response.json();
assert.equal(json.choices[0].message.content, "instant reply");
} finally {
m.restore();
}
});
// ─── TLS client unavailable ─────────────────────────────────────────────────
test("Error: TlsClientUnavailableError returns 502 with TLS_UNAVAILABLE code", async () => {
reset();
// Make the override throw TlsClientUnavailableError on the conversation
// call (after a successful session/sentinel/dpl pass). The executor catches
// the error and surfaces TLS_UNAVAILABLE so operators can identify missing
// native binary issues quickly.
let convAttempted = false;
__setTlsFetchOverrideForTesting(async (url) => {
if (url === "https://chatgpt.com/" || url === "https://chatgpt.com") {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/html" }),
text: '<html data-build="prod-test"></html>',
body: null,
};
}
if (url.includes("/api/auth/session")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({
accessToken: "jwt",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "u" },
}),
body: null,
};
}
if (url.includes("/sentinel/chat-requirements")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({ token: "t", proofofwork: { required: false } }),
body: null,
};
}
if (url.endsWith("/backend-api/f/conversation")) {
convAttempted = true;
throw new TlsClientUnavailableError("native binary not loaded");
}
return {
status: 200,
headers: makeHeaders(),
text: "",
body: null,
};
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.ok(convAttempted);
assert.equal(result.response.status, 502);
const json = await result.response.json();
assert.equal(json.error.code, "TLS_UNAVAILABLE");
} finally {
__setTlsFetchOverrideForTesting(null);
}
});
// ─── looksLikeSse heuristic ─────────────────────────────────────────────────
test("looksLikeSse: detects SSE bodies", () => {
assert.equal(looksLikeSse('data: {"v":"hi"}\n\n'), true);
assert.equal(looksLikeSse("\r\n\r\ndata: foo"), true, "leading blank lines OK");
assert.equal(looksLikeSse("event: end\ndata: []"), true);
assert.equal(looksLikeSse("id: 42\ndata: x"), true);
assert.equal(looksLikeSse(": comment\ndata: x"), true, "SSE comment lines start with :");
assert.equal(looksLikeSse("retry: 3000\n"), true);
});
test("looksLikeSse: rejects non-SSE bodies that previously passed as 200", () => {
// The original peek heuristic only looked for `{` to detect JSON errors,
// letting Cloudflare HTML challenge pages and plain-text 4xx bodies
// masquerade as 200 SSE responses. looksLikeSse must reject these.
assert.equal(looksLikeSse('{"detail":"rate limited"}'), false, "JSON error");
assert.equal(looksLikeSse("<!DOCTYPE html>\n<html>"), false, "HTML doctype");
assert.equal(looksLikeSse("<html><head>"), false, "HTML page");
assert.equal(looksLikeSse("Just a moment..."), false, "Cloudflare plain-text challenge");
assert.equal(looksLikeSse("Attention Required! | Cloudflare"), false);
assert.equal(looksLikeSse(""), false, "empty body");
assert.equal(looksLikeSse(" \n\n"), false, "whitespace only");
assert.equal(looksLikeSse("error: rate limit"), false, "non-SSE field name");
});
// ─── Image generation ──────────────────────────────────────────────────────
/** Build a SSE event stream that mimics ChatGPT's image-generation reply.
* Text turn first, then a finalized multimodal_text with one image_asset_pointer. */
function imageGenEvents({ pointer, text = "Here's your kitten:" }) {
return [
{
conversation_id: "conv-img-1",
message: {
id: "msg-1",
author: { role: "assistant" },
content: { content_type: "text", parts: [text] },
status: "in_progress",
},
},
{
conversation_id: "conv-img-1",
message: {
id: "msg-1",
author: { role: "assistant" },
content: {
content_type: "multimodal_text",
parts: [
{
content_type: "image_asset_pointer",
asset_pointer: pointer,
width: 1024,
height: 1024,
},
],
},
status: "finished_successfully",
},
},
];
}
test("Image gen: file-service:// pointer resolves to download URL and is appended as markdown (non-streaming)", async () => {
reset();
const m = installMockFetch({
conv: { status: 200, events: imageGenEvents({ pointer: "file-service://file-kitten1" }) },
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "generate an image of a kitten" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
const json = await result.response.json();
const content = json.choices[0].message.content;
assert.match(content, /Here's your kitten:/);
// The signed chatgpt.com URL is downloaded server-side and re-served
// from the OmniRoute cache; clients see a stable /v1/chatgpt-web/image
// path, never the session-signed estuary URL (which 403s anonymously).
assert.match(content, /!\[image\]\([^)]*\/v1\/chatgpt-web\/image\/[a-f0-9]+\)/);
assert.doesNotMatch(content, /files\.oaiusercontent\.com/);
assert.equal(m.calls.fileDownload, 1, "fetched download URL once");
assert.equal(m.calls.signedDownload, 1, "fetched signed bytes once");
} finally {
m.restore();
}
});
test("Image gen: file-service:// pointer is appended in streaming SSE", async () => {
reset();
const m = installMockFetch({
conv: {
status: 200,
events: imageGenEvents({ pointer: "file-service://file-kitten2", text: "ok:" }),
},
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "draw a kitten" }] },
stream: true,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
const reader = result.response.body.getReader();
const decoder = new TextDecoder();
let body = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
body += decoder.decode(value);
}
assert.match(body, /!\[image\]\([^)]*\/v1\/chatgpt-web\/image\/[a-f0-9]+\)/);
assert.doesNotMatch(body, /files\.oaiusercontent\.com/);
assert.match(body, /data: \[DONE\]/);
assert.equal(m.calls.fileDownload, 1);
assert.equal(m.calls.signedDownload, 1);
} finally {
m.restore();
}
});
test("Image gen: sediment:// pointer prefers /files/<id>/download over /attachment", async () => {
reset();
const m = installMockFetch({
conv: { status: 200, events: imageGenEvents({ pointer: "sediment://file-sed1" }) },
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "make a kitten" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
const json = await result.response.json();
const content = json.choices[0].message.content;
// The mock returns the signed URL via /files/<id>/download. We try
// that path first (it's the same kind of estuary URL chatgpt.com
// returns either way, and we care more about not hitting an extra
// round-trip); the /attachment endpoint is a fallback for when the
// primary 404s. The mock /files/ response also doubles as the image
// bytes that are cached behind the emitted OmniRoute image URL.
assert.match(
content,
/!\[image\]\([^)]*\/v1\/chatgpt-web\/image\/[a-f0-9]+\)/,
"image rendered"
);
assert.doesNotMatch(content, /files\.oaiusercontent\.com/);
assert.equal(m.calls.fileDownload, 1, "tried /files/ endpoint first");
assert.equal(m.calls.attachmentDownload, 0, "did not need /attachment fallback");
assert.equal(m.calls.signedDownload, 1, "fetched signed bytes once");
} finally {
m.restore();
}
});
test("Image gen: failed download URL is dropped silently — no broken markdown", async () => {
reset();
// Both /files/<id>/download AND the /conversation/<cid>/attachment/<fid>/
// download fallback have to fail for the resolver to give up. The fallback
// is the path that recovers `file_00000000XXX` shaped IDs returned by
// chatgpt.com for image-edit results, so the failure assertion has to
// close BOTH doors.
const m = installMockFetch({
conv: { status: 200, events: imageGenEvents({ pointer: "file-service://file-broken" }) },
fileDownload: { status: 500, body: { error: "boom" } },
attachmentDownload: { status: 500, body: { error: "boom" } },
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "kitten" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
const json = await result.response.json();
const content = json.choices[0].message.content;
// Text retained, markdown placeholder NOT emitted (no broken ![image]() link).
assert.match(content, /Here's your kitten:/);
assert.doesNotMatch(content, /!\[image\]\(/);
} finally {
m.restore();
}
});
test("Image gen: image-intent prompt disables Temporary Chat", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "generate an image of a kitten" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const body = JSON.parse(m.calls.bodies[convIdx]);
assert.equal(body.history_and_training_disabled, false, "Temporary Chat OFF for image gen");
} finally {
m.restore();
}
});
test("Image gen: text-only prompt keeps Temporary Chat ON", async () => {
reset();
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "what is the capital of France?" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const body = JSON.parse(m.calls.bodies[convIdx]);
assert.equal(body.history_and_training_disabled, true, "Temporary Chat ON for text request");
} finally {
m.restore();
}
});
test("Image gen: Open WebUI follow-up/title/tag tool prompts do NOT trigger image gen", async () => {
reset();
const toolPrompts = [
`### Task:\nSuggest 3-5 relevant follow-up questions...\n### Output:\nJSON format: { "follow_ups": ["Q1?", "Q2?", "Q3?"] }\n### Chat History:\n<chat_history>\nUSER: generate an image of a football game\nASSISTANT: _Generating image…_\n</chat_history>`,
`### Task:\nGenerate a concise, 3-5 word title with an emoji summarizing the chat history.\n### Output:\nJSON format: { "title": "your concise title here" }\n### Chat History:\n<chat_history>\nUSER: draw an image of a kitten\n</chat_history>`,
`### Task:\nGenerate 1-3 broad tags categorizing the main themes of the chat history\n### Output:\nJSON format: { "tags": ["tag1"] }\n### Chat History:\n<chat_history>\nUSER: render a logo for my startup\n</chat_history>`,
];
for (const prompt of toolPrompts) {
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: prompt }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const body = JSON.parse(m.calls.bodies[convIdx]);
assert.equal(
body.history_and_training_disabled,
true,
`tool prompt should keep Temporary Chat ON: ${prompt.slice(0, 50)}...`
);
} finally {
m.restore();
}
}
});
test("Image gen: Open WebUI image-generation context suppresses duplicate chat image gen", async () => {
reset();
const contexts = [
"<context>The requested image has been created by the system successfully and is now being shown to the user. Let the user know that the image they requested has been generated and is now shown in the chat.</context>",
"<context>The requested image has been edited and created and is now being shown to the user. Let them know that it has been generated.</context>",
"<context>Image generation was attempted but failed because of an error. The system is currently unable to generate the image.</context>",
];
for (const context of contexts) {
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: {
messages: [
{ role: "system", content: context },
{ role: "user", content: "draw an image of a tennis match at night" },
],
},
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const body = JSON.parse(m.calls.bodies[convIdx]);
assert.equal(
body.history_and_training_disabled,
true,
"Open WebUI already handled image generation, so chat path should stay temporary"
);
assert.equal(body.conversation_id, null);
assert.match(
body.messages[body.messages.length - 1].content.parts[0],
/Briefly acknowledge the image result/
);
assert.doesNotMatch(body.messages[body.messages.length - 1].content.parts[0], /tennis match/);
} finally {
m.restore();
}
}
});
test("Image gen: heuristic catches common phrasings", async () => {
reset();
const phrases = [
"draw me a kitten",
"create an image of a sunset",
"make a picture of mountains",
"render a logo for my startup",
"show me an illustration of a dragon",
"/imagine a futuristic city",
"paint a portrait of Einstein",
"produce a photo of a beach",
];
for (const phrase of phrases) {
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: phrase }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const body = JSON.parse(m.calls.bodies[convIdx]);
assert.equal(
body.history_and_training_disabled,
false,
`Phrase ${JSON.stringify(phrase)} should classify as image-gen`
);
} finally {
m.restore();
}
}
});
test("Image gen: signed URL bytes are cached and exposed via /v1/chatgpt-web/image URL", async () => {
reset();
// Real-world flow: /files/<id>/download returns a chatgpt.com estuary URL
// signed for the user's session — that URL 403s for any anonymous client,
// so we fetch the bytes, cache them locally, and emit an OmniRoute image URL.
const pngBytes = Buffer.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a, // PNG magic
0x00,
0x00,
0x00,
0x0d,
0x49,
0x48,
0x44,
0x52, // IHDR chunk
]);
const dataUriFromTlsClient = `data:image/png;base64,${pngBytes.toString("base64")}`;
const tls = await import("../../open-sse/services/chatgptTlsClient.ts");
__resetChatGptWebCachesForTesting();
const downloadUrl = "https://chatgpt.com/backend-api/estuary/content?id=file-data1&sig=abc";
const calls = { signed: 0, urls: [] };
tls.__setTlsFetchOverrideForTesting(async (url, opts = {}) => {
const u = String(url);
calls.urls.push(u);
if (u === "https://chatgpt.com/" && (opts.method || "GET") === "GET") {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/html" }),
text: '<html data-build="prod-test"><script src="https://cdn.oaistatic.com/main.js"></script></html>',
body: null,
};
}
if (u.includes("/api/auth/session")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({
accessToken: "jwt-x",
expires: new Date(Date.now() + 3600_000).toISOString(),
user: { id: "u1" },
}),
body: null,
};
}
if (u.includes("/sentinel/chat-requirements")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({ token: "t", proofofwork: { required: false } }),
body: null,
};
}
if (u.match(/\/backend-api\/files\/[^/]+\/download/)) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify({ download_url: downloadUrl }),
body: null,
};
}
if (u.startsWith(downloadUrl)) {
calls.signed++;
// tls-client-node returns binary bodies as a "data:<mime>;base64,..."
// string (see its response.js bytes() impl); the executor decodes it
// back into bytes before putting the image in OmniRoute's cache.
return {
status: 200,
headers: makeHeaders({ "Content-Type": "image/png" }),
text: dataUriFromTlsClient,
body: null,
};
}
if (u.endsWith("/backend-api/f/conversation") || u.endsWith("/backend-api/conversation")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
text: mockChatGptStreamText(imageGenEvents({ pointer: "file-service://file-data1" })),
body: null,
};
}
return { status: 404, headers: makeHeaders(), text: "not mocked", body: null };
});
await withEnv(
{
OMNIROUTE_PUBLIC_BASE_URL: "http://192.168.107.55:20128/v1",
NEXT_PUBLIC_BASE_URL: "http://localhost:20128",
},
async () => {
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "draw kitten" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
const json = await result.response.json();
const content = json.choices[0].message.content;
// The executor caches the bytes in memory and emits a URL pointing
// at /v1/chatgpt-web/image/<uuid> instead of embedding a data URI —
// see open-sse/services/chatgptImageCache.ts and the matching route
// in src/app/api/v1/chatgpt-web/image/[id]/route.ts.
const m = content.match(
/!\[image\]\((http:\/\/192\.168\.107\.55:20128\/v1\/chatgpt-web\/image\/([a-f0-9]+))\)/
);
assert.ok(m, `expected URL-style markdown, got: ${content.slice(0, 200)}`);
assert.equal(calls.signed, 1, "fetched signed URL once");
// Verify the cached bytes match the PNG we fed in by going through
// the cache module directly.
const cacheMod = await import("../../open-sse/services/chatgptImageCache.ts");
const entry = cacheMod.getChatGptImage(m[2]);
assert.ok(entry, "cache entry exists for the emitted id");
assert.equal(entry.mime, "image/png");
assert.deepEqual(Array.from(entry.bytes), Array.from(pngBytes));
assert.deepEqual(entry.context, {
conversationId: "conv-img-1",
parentMessageId: "msg-1",
});
} finally {
tls.__setTlsFetchOverrideForTesting(null);
}
}
);
});
test("Image gen: prior data: image URIs are stripped from history before upstream", async () => {
// Open WebUI replays the full conversation each turn. After we generate an
// image and emit ![image](data:image/png;base64,...), that 2-3MB string
// comes back as the assistant message on the next turn. Sending it back
// upstream blows past chatgpt.com's body limits → "empty response body" 502.
reset();
const m = installMockFetch();
try {
const huge = "iVBORw0KGgo" + "A".repeat(2_000_000); // ~2MB base64
const assistantMsg = `Sure, here you go:\n\n![image](data:image/png;base64,${huge})\n`;
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: {
messages: [
{ role: "user", content: "draw a kitten" },
{ role: "assistant", content: assistantMsg },
{ role: "user", content: "now make it a puppy" },
],
},
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
assert.ok(convIdx >= 0, "conversation request was sent");
const body = m.calls.bodies[convIdx];
assert.ok(body.length < 50_000, `body should be small, got ${body.length}`);
const parsed = JSON.parse(body);
const allParts = JSON.stringify(parsed.messages);
assert.doesNotMatch(allParts, /data:image/, "no data: URI in upstream body");
assert.match(allParts, /generated image/, "placeholder is present");
} finally {
m.restore();
}
});
test("Image edit: cached OmniRoute image URL continues the saved ChatGPT conversation", async () => {
reset();
const { storeChatGptImage } = await import("../../open-sse/services/chatgptImageCache.ts");
const imageId = storeChatGptImage(Buffer.from([1, 2, 3]), "image/png", 30_000, {
conversationId: "conv-image-1",
parentMessageId: "msg-image-1",
});
const imageUrl = `http://192.168.107.55:20128/v1/chatgpt-web/image/${imageId}`;
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: {
messages: [
{ role: "user", content: "draw a kitten" },
{ role: "assistant", content: `Here it is:\n\n![image](${imageUrl})` },
{ role: "user", content: "make it nighttime with softer lighting" },
],
},
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
assert.ok(convIdx >= 0, "conversation request was sent");
const body = JSON.parse(m.calls.bodies[convIdx]);
assert.equal(body.conversation_id, "conv-image-1");
assert.equal(body.parent_message_id, "msg-image-1");
assert.equal(body.history_and_training_disabled, false);
assert.equal(body.messages.length, 1, "saved ChatGPT conversation carries prior image state");
assert.equal(body.messages[0].author.role, "user");
assert.match(body.messages[0].content.parts[0], /nighttime/);
} finally {
m.restore();
}
});
test("Image edit: Open WebUI image context suppresses duplicate edit continuation", async () => {
reset();
const { storeChatGptImage } = await import("../../open-sse/services/chatgptImageCache.ts");
const imageId = storeChatGptImage(Buffer.from([1, 2, 3]), "image/png", 30_000, {
conversationId: "conv-image-2",
parentMessageId: "msg-image-2",
});
const imageUrl = `http://192.168.107.55:20128/v1/chatgpt-web/image/${imageId}`;
const m = installMockFetch();
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.3-instant",
body: {
messages: [
{
role: "system",
content:
"<context>The requested image has been edited and created and is now being shown to the user. Let them know that it has been generated.</context>",
},
{ role: "user", content: "draw a kitten" },
{ role: "assistant", content: `Here it is:\n\n![image](${imageUrl})` },
{ role: "user", content: "make it nighttime with softer lighting" },
],
},
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
const body = JSON.parse(m.calls.bodies[convIdx]);
assert.equal(body.conversation_id, null);
assert.notEqual(body.parent_message_id, "msg-image-2");
assert.equal(body.history_and_training_disabled, true);
assert.match(
body.messages[body.messages.length - 1].content.parts[0],
/Briefly acknowledge the image result/
);
assert.doesNotMatch(body.messages[body.messages.length - 1].content.parts[0], /nighttime/);
} finally {
m.restore();
}
});
test("Image gen: dedupes the same pointer across in-progress + finished events", async () => {
reset();
// Repeat the same pointer in BOTH the in_progress event and the
// finished_successfully event. The resolver should fetch the URL once.
const events = [
{
conversation_id: "conv-d",
message: {
id: "msg-1",
author: { role: "assistant" },
content: {
content_type: "multimodal_text",
parts: [
{ content_type: "image_asset_pointer", asset_pointer: "file-service://file-dedupe" },
],
},
status: "in_progress",
},
},
{
conversation_id: "conv-d",
message: {
id: "msg-1",
author: { role: "assistant" },
content: {
content_type: "multimodal_text",
parts: [
{ content_type: "image_asset_pointer", asset_pointer: "file-service://file-dedupe" },
],
},
status: "finished_successfully",
},
},
];
const m = installMockFetch({ conv: { status: 200, events } });
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "kitten" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
const json = await result.response.json();
// Markdown emitted exactly once (single image, not duplicated).
const matches = json.choices[0].message.content.match(/!\[image\]\(/g) ?? [];
assert.equal(matches.length, 1, "markdown emitted once");
assert.equal(m.calls.fileDownload, 1, "download URL fetched once");
} finally {
m.restore();
}
});
test("Image gen: bytes-fetch failure drops markdown (no signed-URL fallback)", async () => {
// Memory principle #3: never hand back the chatgpt.com signed estuary URL.
// It 403s for any anonymous client, so emitting it as markdown produces
// broken images. The resolver returns null when the bytes fetch fails;
// imageMarkdown skips empty URL lists, so no `![image](…)` appears.
reset();
const m = installMockFetch({
conv: { status: 200, events: imageGenEvents({ pointer: "file-service://file-broken-bytes" }) },
signedDownload: { status: 500, body: "boom" },
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "draw a kitten" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
const json = await result.response.json();
const content = json.choices[0].message.content;
assert.doesNotMatch(content, /!\[image\]/, "no markdown for failed bytes fetch");
assert.doesNotMatch(
content,
/files\.oaiusercontent\.com/,
"signed URL is never leaked to client"
);
assert.equal(m.calls.fileDownload, 1, "download URL was attempted");
assert.equal(m.calls.signedDownload, 1, "signed-bytes fetch was attempted and failed");
} finally {
m.restore();
}
});
test("Image cache: byte cap evicts oldest before count cap kicks in", async () => {
reset();
const cacheMod = await import("../../open-sse/services/chatgptImageCache.ts");
// 4 MB images × 3 stores against a 10 MB byte cap: third store should
// evict the first to fit. Verifies the byte budget bites BEFORE the
// 200-entry count cap, which is the whole point of the byte budget.
const original = process.env.OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB;
process.env.OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB = "10";
try {
cacheMod.__resetChatGptImageCacheForTesting();
const big = Buffer.alloc(4 * 1024 * 1024, 1);
const id1 = cacheMod.storeChatGptImage(big, "image/png");
const id2 = cacheMod.storeChatGptImage(big, "image/png");
assert.ok(cacheMod.getChatGptImage(id1), "id1 still resident after id2");
assert.ok(cacheMod.getChatGptImage(id2), "id2 resident");
const id3 = cacheMod.storeChatGptImage(big, "image/png");
assert.equal(cacheMod.getChatGptImage(id1), null, "id1 evicted to make room for id3");
assert.ok(cacheMod.getChatGptImage(id2), "id2 still resident");
assert.ok(cacheMod.getChatGptImage(id3), "id3 resident");
// Total bytes never exceeds the cap once we've evicted.
const bytes = cacheMod.__getChatGptImageCacheBytesForTesting();
assert.ok(
bytes <= 10 * 1024 * 1024,
`cache bytes (${bytes}) should be within the configured 10 MB cap`
);
} finally {
if (original == null) delete process.env.OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB;
else process.env.OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB = original;
cacheMod.__resetChatGptImageCacheForTesting();
}
});
test("Image edit: file_0000XXXX (chatgpt-web edit result) falls back to /conversation/.../attachment/.../download", async () => {
// The /files/<id>/download endpoint rejects the new `file_00000000XXX`
// pointer shape that chatgpt.com returns for image-EDIT results — it
// 422s. The pointer is conversation-scoped and only resolves through
// /conversation/<cid>/attachment/<fid>/download. Without the fallback
// we'd render a broken image link or no markdown at all.
reset();
const m = installMockFetch({
conv: {
status: 200,
events: imageGenEvents({
pointer: "file-service://file_00000000f1fc7246af3a3934a8c55b9c",
}),
},
fileDownload: { status: 422, body: { detail: "edit-shape pointer not directly fetchable" } },
});
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "now make it nighttime" }] },
stream: false,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 200);
const json = await result.response.json();
const content = json.choices[0].message.content;
assert.match(
content,
/!\[image\]\([^)]*\/v1\/chatgpt-web\/image\/[a-f0-9]+\)/,
"image rendered via fallback"
);
assert.equal(m.calls.fileDownload, 1, "tried /files/ first");
assert.equal(
m.calls.attachmentDownload,
1,
"fell back to /conversation/.../attachment/.../download"
);
assert.equal(m.calls.signedDownload, 1, "fetched signed bytes once");
} finally {
m.restore();
}
});
test("Image gen: ChatGPT-internal tool_invoked metadata does NOT spuriously trigger image gen heartbeats", async () => {
// Regression for: the executor used to set imageGenAsync = true on any
// server_ste_metadata event with `tool_invoked: true`, but ChatGPT marks
// *all* internal tool usage (reasoning, web search, calc, file_search)
// with that flag. Plain text turns ended up emitting "Generating image…"
// text and a 30s WebSocket wait. Specific image-gen signals only.
reset();
const events = [
{
type: "server_ste_metadata",
metadata: { tool_invoked: true, turn_use_case: "default" },
},
{
conversation_id: "conv-x",
message: {
id: "msg-x",
author: { role: "assistant" },
content: { content_type: "text", parts: ["GPT-4o-mini has weaker reasoning."] },
status: "in_progress",
},
},
{
conversation_id: "conv-x",
message: {
id: "msg-x",
author: { role: "assistant" },
content: { content_type: "text", parts: ["GPT-4o-mini has weaker reasoning."] },
status: "finished_successfully",
},
},
];
const m = installMockFetch({ conv: { status: 200, events } });
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.3-instant",
body: { messages: [{ role: "user", content: "limitations of gpt-4o-mini?" }] },
stream: true,
credentials: { apiKey: "test" },
signal: AbortSignal.timeout(10_000),
log: null,
});
const reader = result.response.body.getReader();
const decoder = new TextDecoder();
let body = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
body += decoder.decode(value);
}
assert.doesNotMatch(body, /Generating image/, "no spurious image-gen placeholder text");
assert.match(body, /weaker reasoning/, "actual answer streamed");
} finally {
m.restore();
}
});
test("Image edit handler: bytes-hash match drives executor with cached conversation context", async () => {
// The /v1/images/edits flow exists because Open WebUI's image-edit toggle
// posts multipart bodies (prompt + uploaded image bytes) and would
// otherwise trip Next.js's Server Action handler. We hash the uploaded
// bytes, find the cached entry, and synthesize a chat thread that drives
// the executor through its continuation path — same code paths as the
// chat-message edit flow.
reset();
const cacheMod = await import("../../open-sse/services/chatgptImageCache.ts");
cacheMod.__resetChatGptImageCacheForTesting();
const sourceBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
cacheMod.storeChatGptImage(sourceBytes, "image/png", 60_000, {
conversationId: "conv-edit-handler",
parentMessageId: "msg-edit-handler",
});
const m = installMockFetch({
// Conv response must include an image pointer so the handler sees
// markdown in the assistant message and treats the edit as successful.
conv: {
status: 200,
events: imageGenEvents({ pointer: "file-service://file-edited-day", text: "Done:" }),
},
});
try {
const { handleImageEdit } = await import("../../open-sse/handlers/imageGeneration.ts");
const result = await handleImageEdit({
provider: "chatgpt-web",
model: "gpt-5.3-instant",
body: { prompt: "turn it to day time" },
imageBytes: sourceBytes,
credentials: { apiKey: "test" },
log: null,
});
assert.equal(result.success, true, `expected success, got error: ${(result as any).error}`);
const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation"));
assert.ok(convIdx >= 0, "conversation request was sent");
const sentBody = JSON.parse(m.calls.bodies[convIdx]);
assert.equal(sentBody.conversation_id, "conv-edit-handler");
assert.equal(sentBody.parent_message_id, "msg-edit-handler");
assert.equal(sentBody.history_and_training_disabled, false);
assert.match(sentBody.messages[sentBody.messages.length - 1].content.parts[0], /day time/);
} finally {
m.restore();
}
});
test("Image edit handler: no cached match returns 400 (does not silently generate unrelated image)", async () => {
// If the user uploads a foreign image (or the cache TTL elapsed), there's
// no chatgpt.com conversation node to continue and chatgpt-web's image_gen
// tool can't actually edit. Surface that with a clear, actionable error
// instead of generating an unrelated image and confusing the user.
reset();
const cacheMod = await import("../../open-sse/services/chatgptImageCache.ts");
cacheMod.__resetChatGptImageCacheForTesting();
const m = installMockFetch();
try {
const { handleImageEdit } = await import("../../open-sse/handlers/imageGeneration.ts");
const foreignBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0xde, 0xad, 0xbe, 0xef]);
const result = await handleImageEdit({
provider: "chatgpt-web",
model: "gpt-5.3-instant",
body: { prompt: "turn it to day time" },
imageBytes: foreignBytes,
credentials: { apiKey: "test" },
log: null,
});
assert.equal(result.success, false);
assert.equal((result as any).status, 400);
assert.match(String((result as any).error), /generated through this OmniRoute instance/);
assert.equal(m.calls.session, 0, "no upstream calls were attempted");
assert.equal(m.calls.conv, 0, "no chat-completion was attempted");
} finally {
m.restore();
}
});
test("Image gen handler: n>4 is rejected before any upstream call", async () => {
// Each chatgpt-web image is a separate ~30s chat turn. Without a clamp,
// body.n=1000 would pin the executor for hours before HTTP timeout.
// Verify the cap rejects at the boundary without burning a single upstream
// request — important so a rogue client can't trivially DoS the worker.
reset();
const m = installMockFetch();
try {
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
const result = await handleImageGeneration({
body: { prompt: "draw a kitten", n: 5, model: "cgpt-web/gpt-5.3-instant" },
credentials: { apiKey: "test" },
log: null,
});
assert.equal(result.success, false);
assert.equal((result as any).status, 400);
assert.match(String((result as any).error), /n=1\.\.4/);
assert.equal(m.calls.session, 0, "no session exchange was attempted");
assert.equal(m.calls.conv, 0, "no conversation request was attempted");
} finally {
m.restore();
}
});
test("Image cache: deleting an entry decrements the byte counter", async () => {
// Regression guard: an earlier draft tracked entry count but not bytes,
// and TTL eviction removed the entry without crediting back its size —
// the counter would only ever grow.
reset();
const cacheMod = await import("../../open-sse/services/chatgptImageCache.ts");
cacheMod.__resetChatGptImageCacheForTesting();
const id = cacheMod.storeChatGptImage(Buffer.alloc(1024, 7), "image/png", 10);
assert.equal(cacheMod.__getChatGptImageCacheBytesForTesting(), 1024);
// Wait past the 10 ms TTL, then trigger eviction by reading.
await new Promise((r) => setTimeout(r, 25));
assert.equal(cacheMod.getChatGptImage(id), null, "entry expired");
assert.equal(
cacheMod.__getChatGptImageCacheBytesForTesting(),
0,
"bytes credited back on TTL evict"
);
});
// ─── describeChatGptWebHttpError ─────────────────────────────────────────────
test("describeChatGptWebHttpError maps 413 to a payload-too-large message with guidance", () => {
const msg = describeChatGptWebHttpError(413);
// Must NOT be the cryptic generic — should explain it's a size limit and how to recover.
assert.notEqual(
msg,
"ChatGPT returned HTTP 413",
"413 should get a tailored message, not the generic fallback"
);
assert.match(msg, /413/, "message keeps the status code");
assert.match(msg, /too large|payload|size limit/i, "message explains it's a size/payload limit");
assert.match(
msg,
/context|compress/i,
"message points the user at reducing context / compression"
);
});
test("describeChatGptWebHttpError preserves the existing 401/403/404/429 mappings", () => {
assert.match(describeChatGptWebHttpError(401), /session may have expired/i);
assert.match(describeChatGptWebHttpError(403), /session may have expired/i);
assert.match(describeChatGptWebHttpError(404), /no longer available|fresh conversation/i);
assert.match(describeChatGptWebHttpError(429), /rate limited/i);
});
test("describeChatGptWebHttpError falls back to the generic message for unmapped statuses", () => {
assert.equal(describeChatGptWebHttpError(500), "ChatGPT returned HTTP 500");
assert.equal(describeChatGptWebHttpError(502), "ChatGPT returned HTTP 502");
});