mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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>
This commit is contained in:
committed by
GitHub
parent
cadc3f10b7
commit
a7ae9550bd
@@ -700,7 +700,7 @@ test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests",
|
||||
assert.equal(call.headers.Version, getCodexClientVersion());
|
||||
assert.equal(call.headers["Openai-Beta"], "responses=experimental");
|
||||
assert.equal(call.headers["X-Codex-Beta-Features"], "responses_websockets");
|
||||
assert.equal(call.headers["User-Agent"], "codex-cli/0.132.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(call.headers["User-Agent"], "codex-cli/0.142.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(call.headers["x-codex-window-id"], "conv_codex_fingerprint:0");
|
||||
assert.ok(call.headers["x-client-request-id"], "expected Codex request id header");
|
||||
assert.ok(call.headers["x-codex-turn-metadata"], "expected Codex turn metadata header");
|
||||
|
||||
44
tests/unit/accountfallback-ratelimit-400-4976.test.ts
Normal file
44
tests/unit/accountfallback-ratelimit-400-4976.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #4976 — A 400 response whose body carries rate-limit semantics (e.g. MiMoCode's
|
||||
// "Detected high-frequency non-compliant requests from you.") was misclassified as a
|
||||
// non-fallbackable generic 400, so MiMo-auto combo never failed over and the raw
|
||||
// failure surfaced to Cline as `[502]: fetch failed`. checkFallbackError must now
|
||||
// detect rate-limit text on a 400 and treat it as fallback-worthy
|
||||
// (RATE_LIMIT_EXCEEDED, connection-cooldown scope) WITHOUT regressing the #2101
|
||||
// malformed-400 infinite-loop guard.
|
||||
|
||||
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
|
||||
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
|
||||
|
||||
test("#4976 400 with rate-limit text (MiMoCode) → fallback with RATE_LIMIT_EXCEEDED", () => {
|
||||
const res = checkFallbackError(
|
||||
400,
|
||||
"Detected high-frequency non-compliant requests from you.",
|
||||
0,
|
||||
null,
|
||||
"mimocode"
|
||||
);
|
||||
assert.equal(res.shouldFallback, true);
|
||||
assert.equal(res.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
});
|
||||
|
||||
test("#4976 400 with Chinese rate-limit text → fallback with RATE_LIMIT_EXCEEDED", () => {
|
||||
const res = checkFallbackError(400, "检测到您的请求频率过高,请稍后再试", 0, null, "mimocode");
|
||||
assert.equal(res.shouldFallback, true);
|
||||
assert.equal(res.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
});
|
||||
|
||||
test("#4976 regression: a generic non-rate-limit 400 still does NOT fall over (#2101 guard)", () => {
|
||||
const res = checkFallbackError(400, "Invalid JSON: unexpected token at position 12");
|
||||
assert.equal(res.shouldFallback, false);
|
||||
});
|
||||
|
||||
test("#4976 regression: a malformed 400 stays MODEL_CAPACITY, not reclassified as rate-limit", () => {
|
||||
// Malformed-request detection must win over the new rate-limit text check so the
|
||||
// #2101 infinite-loop guard (zero-cooldown MODEL_CAPACITY) is preserved.
|
||||
const res = checkFallbackError(400, "messages must alternate between user and assistant");
|
||||
assert.equal(res.shouldFallback, true);
|
||||
assert.equal(res.reason, RateLimitReason.MODEL_CAPACITY);
|
||||
});
|
||||
80
tests/unit/anthropic-compat-validation-v1-messages.test.ts
Normal file
80
tests/unit/anthropic-compat-validation-v1-messages.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Regression test for the anthropic-compatible connection validator.
|
||||
//
|
||||
// Upstream fix: GET /models is not part of the Anthropic API spec; many
|
||||
// compatible proxies either 404, 401, or 403 on /models even with a valid
|
||||
// API key. The connection test must therefore not reject the credentials
|
||||
// solely on a 401/403 from /models — it must fall back to POST /v1/messages
|
||||
// (the canonical Anthropic auth probe) and treat any non-401/403 messages
|
||||
// response as proof that the key was accepted.
|
||||
//
|
||||
// Ported from decolua/9router 584cf66a (Co-author: Rehan Choirul).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test(
|
||||
"anthropic-compatible validation falls back to /messages when /models returns 403",
|
||||
async () => {
|
||||
const calls: { url: string; method: string }[] = [];
|
||||
globalThis.fetch = async (url: any, init: any = {}) => {
|
||||
const u = String(url);
|
||||
const method = String(init?.method || "GET").toUpperCase();
|
||||
calls.push({ url: u, method });
|
||||
if (u.endsWith("/models")) {
|
||||
return new Response(JSON.stringify({ error: "forbidden on models" }), { status: 403 });
|
||||
}
|
||||
// /messages: upstream accepts the key but rejects the toy payload with 400.
|
||||
return new Response(JSON.stringify({ error: "bad request" }), { status: 400 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "anthropic-compatible-403-on-models",
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: { baseUrl: "https://proxy.example.com/v1/messages" },
|
||||
});
|
||||
|
||||
// BEFORE the fix this returned { valid: false, error: "Invalid API key" }
|
||||
// because validateAnthropicCompatibleProvider short-circuited on the 403
|
||||
// from GET /models without ever probing POST /v1/messages.
|
||||
assert.equal(result.valid, true, "403 on /models alone must NOT mark the key invalid");
|
||||
assert.equal(result.error, null);
|
||||
|
||||
// The validator must actually exercise the messages endpoint.
|
||||
const messagesCall = calls.find(
|
||||
(call) => call.url.endsWith("/messages") && call.method === "POST"
|
||||
);
|
||||
assert.ok(messagesCall, "expected a POST /messages probe after /models 403");
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"anthropic-compatible validation still rejects when /messages itself returns 401",
|
||||
async () => {
|
||||
// Symmetry guard: the fix must NOT make every 403/401 pass. Only the
|
||||
// messages probe is authoritative — if it also rejects auth, the key is bad.
|
||||
globalThis.fetch = async (url: any) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith("/models")) {
|
||||
return new Response(JSON.stringify({ error: "no models endpoint" }), { status: 403 });
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "invalid_api_key" }), { status: 401 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "anthropic-compatible-truly-bad-key",
|
||||
apiKey: "sk-bad",
|
||||
providerSpecificData: { baseUrl: "https://proxy.example.com/v1/messages" },
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Invalid API key");
|
||||
}
|
||||
);
|
||||
109
tests/unit/anthropic-stream-thinking-close-marker.test.ts
Normal file
109
tests/unit/anthropic-stream-thinking-close-marker.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// Regression test for the Anthropic-compatible stream "thinking block never closes" bug.
|
||||
//
|
||||
// When clients consume the OpenAI-compatible stream that OmniRoute synthesises from
|
||||
// Claude-native SSE, they need an explicit signal that the thinking/reasoning section
|
||||
// has ended; otherwise the UI stays stuck on the "thinking" indicator even after the
|
||||
// upstream stream has cleanly completed.
|
||||
//
|
||||
// Inspired by upstream decolua/9router PR #454.
|
||||
//
|
||||
// Before the fix, the `content_block_stop` event for a thinking block emitted NO
|
||||
// terminating chunk at all (a previous drift had emitted `reasoning_content: ""`,
|
||||
// which is semantically a no-op and does not signal "thinking complete" to clients
|
||||
// such as Claude Code).
|
||||
//
|
||||
// The fix emits a `content: "</think>"` chunk on close — matching the convention
|
||||
// already used throughout OmniRoute (see openai-responses.ts / responsesTransformer.ts
|
||||
// which split on `</think>` to separate reasoning from final content).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { claudeToOpenAIResponse } = await import(
|
||||
"../../open-sse/translator/response/claude-to-openai.ts"
|
||||
);
|
||||
|
||||
function newState() {
|
||||
return {
|
||||
toolCalls: new Map(),
|
||||
toolNameMap: new Map(),
|
||||
messageId: "msg_test",
|
||||
model: "claude-3-7-sonnet",
|
||||
toolCallIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
test("claudeToOpenAIResponse emits </think> close marker on thinking content_block_stop", () => {
|
||||
const state = newState();
|
||||
|
||||
// Open thinking block.
|
||||
claudeToOpenAIResponse(
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "thinking", thinking: "" },
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
// Stream reasoning delta.
|
||||
claudeToOpenAIResponse(
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "thinking_delta", thinking: "Plan first." },
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
// Close the thinking block.
|
||||
const closeChunks = claudeToOpenAIResponse(
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
state
|
||||
);
|
||||
|
||||
assert.ok(Array.isArray(closeChunks), "stop event must return an array of chunks");
|
||||
assert.ok(
|
||||
closeChunks.length >= 1,
|
||||
"stop event for thinking block must emit at least one close-marker chunk"
|
||||
);
|
||||
|
||||
const hasCloseMarker = closeChunks.some(
|
||||
(chunk) => chunk?.choices?.[0]?.delta?.content === "</think>"
|
||||
);
|
||||
assert.ok(
|
||||
hasCloseMarker,
|
||||
`expected a chunk with delta.content === "</think>"; got ${JSON.stringify(closeChunks)}`
|
||||
);
|
||||
|
||||
// After close, state flag must be cleared so subsequent thinking blocks are tracked correctly.
|
||||
assert.equal(state.inThinkingBlock, false);
|
||||
});
|
||||
|
||||
test("claudeToOpenAIResponse does not emit </think> on stop of non-thinking blocks", () => {
|
||||
const state = newState();
|
||||
|
||||
// Open + immediately close a text block — must NOT inject </think>.
|
||||
claudeToOpenAIResponse(
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
state
|
||||
);
|
||||
const closeChunks = claudeToOpenAIResponse(
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
state
|
||||
);
|
||||
|
||||
const arr = Array.isArray(closeChunks) ? closeChunks : [];
|
||||
const hasCloseMarker = arr.some(
|
||||
(chunk) => chunk?.choices?.[0]?.delta?.content === "</think>"
|
||||
);
|
||||
assert.equal(
|
||||
hasCloseMarker,
|
||||
false,
|
||||
"text-block close must not emit </think> sentinel"
|
||||
);
|
||||
});
|
||||
@@ -55,6 +55,17 @@ test("classify429: AG 'Individual quota reached' message → quota_exhausted", (
|
||||
assert.equal(classify429(msg), "quota_exhausted");
|
||||
});
|
||||
|
||||
test("classify429: standard Gemini rate limit 'resource has been exhausted' -> rate_limited or unknown, not quota_exhausted", () => {
|
||||
const msg =
|
||||
"RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. queries per minute limit was reached).";
|
||||
const result = classify429(msg);
|
||||
assert.notEqual(
|
||||
result,
|
||||
"quota_exhausted",
|
||||
"RESOURCE_EXHAUSTED rate limit should not be classified as quota_exhausted"
|
||||
);
|
||||
});
|
||||
|
||||
// ── DB persistence (the missing wire — Bug #2) ───────────────────────────────
|
||||
|
||||
test("markConnectionQuotaExhausted persists 24h cooldown; isConnectionRateLimited returns true", async () => {
|
||||
|
||||
@@ -20,7 +20,10 @@ function getPublicModel(id: string) {
|
||||
// source of truth here (was duplicated as an inline if-ladder in usage.ts). It operates on
|
||||
// the UPSTREAM quota namespace, where `gemini-3.5-flash-low` is the Medium tier's bucket.
|
||||
test("toClientAntigravityQuotaModelId maps upstream quota buckets to client tiers", () => {
|
||||
assert.equal(toClientAntigravityQuotaModelId("gemini-3.5-flash-extra-low"), "gemini-3.5-flash-low");
|
||||
assert.equal(
|
||||
toClientAntigravityQuotaModelId("gemini-3.5-flash-extra-low"),
|
||||
"gemini-3.5-flash-low"
|
||||
);
|
||||
// Dual-meaning id: in the quota namespace this bucket is the Medium tier.
|
||||
assert.equal(toClientAntigravityQuotaModelId("gemini-3.5-flash-low"), "gemini-3.5-flash-medium");
|
||||
assert.equal(toClientAntigravityQuotaModelId("gemini-3-flash-agent"), "gemini-3.5-flash-high");
|
||||
@@ -189,9 +192,7 @@ test("AntigravityExecutor.transformRequest maps Gemini 3.5 Flash tiers to live u
|
||||
// executor never sets those fields, so they were vacuously true and gave false
|
||||
// confidence. #3821-review LEDGER-10.)
|
||||
assert.equal(result.model, "gemini-3-flash-agent");
|
||||
assert.deepEqual(result.request.contents, [
|
||||
{ role: "user", parts: [{ text: "Hello" }] },
|
||||
]);
|
||||
assert.deepEqual(result.request.contents, [{ role: "user", parts: [{ text: "Hello" }] }]);
|
||||
});
|
||||
|
||||
test("AntigravityExecutor.transformRequest sends Claude through Gemini-compatible Cloud Code schema", async () => {
|
||||
@@ -220,7 +221,10 @@ test("AntigravityExecutor.transformRequest sends Claude through Gemini-compatibl
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
const request = result.request as any;
|
||||
assert.deepEqual(request.contents, [{ role: "user", parts: [{ text: "Hello" }] }]);
|
||||
assert.equal(request.generationConfig.maxOutputTokens, 32769);
|
||||
// Capped to MAX_ANTIGRAVITY_OUTPUT_TOKENS (16384) by the executor (#4636) to avoid
|
||||
// the Antigravity Cloud Code 400 on maxOutputTokens > 16384, overriding the
|
||||
// thinkingBudget+1 bump (which would otherwise be 32769).
|
||||
assert.equal(request.generationConfig.maxOutputTokens, 16384);
|
||||
assert.equal(request.generationConfig.temperature, 0.5);
|
||||
assert.equal(request.generationConfig.topK, 40);
|
||||
assert.equal(request.generationConfig.topP, 1);
|
||||
|
||||
60
tests/unit/antigravity-safetysettings-5003.test.ts
Normal file
60
tests/unit/antigravity-safetysettings-5003.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
import { DEFAULT_SAFETY_SETTINGS } from "../../open-sse/translator/helpers/geminiHelper.ts";
|
||||
|
||||
// Regression for #5003: the Antigravity (Google Cloud Code) request builder explicitly set
|
||||
// `safetySettings: undefined`, which `JSON.stringify` drops entirely. With no safetySettings
|
||||
// reaching Cloud Code, Google applies its server-side safety defaults that false-flag benign
|
||||
// technical prompts as `prohibited_content` (HTTP 200 with a blocked body that combo failover
|
||||
// treats as terminal). The native Gemini paths all default to all-OFF
|
||||
// (DEFAULT_SAFETY_SETTINGS); Antigravity must match for parity.
|
||||
|
||||
test("transformRequest defaults safetySettings to all-OFF when none supplied (#5003)", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const body = {
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "hi" }] }],
|
||||
generationConfig: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executor.transformRequest("antigravity/claude-sonnet-4-6", body, true, {
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
const innerRequest = result.request as Record<string, unknown>;
|
||||
assert.deepEqual(
|
||||
innerRequest.safetySettings,
|
||||
DEFAULT_SAFETY_SETTINGS,
|
||||
"safetySettings must default to all-OFF for parity with native Gemini paths"
|
||||
);
|
||||
});
|
||||
|
||||
test("transformRequest honors a caller-supplied safetySettings (#5003)", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const callerSafety = [
|
||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
|
||||
];
|
||||
const body = {
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "hi" }] }],
|
||||
generationConfig: {},
|
||||
safetySettings: callerSafety,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executor.transformRequest("antigravity/claude-sonnet-4-6", body, true, {
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
const innerRequest = result.request as Record<string, unknown>;
|
||||
assert.deepEqual(
|
||||
innerRequest.safetySettings,
|
||||
callerSafety,
|
||||
"a caller-supplied safetySettings must not be clobbered"
|
||||
);
|
||||
});
|
||||
116
tests/unit/api/combo-error-response.test.ts
Normal file
116
tests/unit/api/combo-error-response.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Unit tests for the standardized combo error response helper.
|
||||
*
|
||||
* Bug #3 from `plans/2026-06-23-omniroute-v3.8.34-deep-audit.md`:
|
||||
* every 4xx response from `/api/combos/{id}` must include a stable
|
||||
* machine-readable `code` token, an `error.message`, optional
|
||||
* `error.details`, and `requestId` correlation. These tests assert the
|
||||
* shape and HTTP status for every branch the route uses.
|
||||
*
|
||||
* Runner: node:test (the runner that collects tests/unit/api/**), not vitest —
|
||||
* the original vitest import crashed under the node:test runner that the
|
||||
* package.json glob also feeds it to (check:test-discovery).
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildComboErrorBody, comboErrorResponse } from "@/lib/api/comboErrorResponse";
|
||||
import { ERROR_CODES } from "@/shared/constants/errorCodes";
|
||||
|
||||
describe("buildComboErrorBody", () => {
|
||||
it("emits the canonical { error: { code, message, category } } envelope", () => {
|
||||
const body = buildComboErrorBody("COMBO_001");
|
||||
assert.equal(body.error.code, "COMBO_001");
|
||||
assert.equal(body.error.message, "Request body is not valid JSON");
|
||||
assert.equal(body.error.category, "COMBO");
|
||||
});
|
||||
|
||||
it("includes details when provided", () => {
|
||||
const body = buildComboErrorBody("COMBO_002", {
|
||||
issues: [{ path: ["name"], message: "Required" }],
|
||||
});
|
||||
assert.equal(body.error.code, "COMBO_002");
|
||||
assert.deepEqual(body.error.details, {
|
||||
issues: [{ path: ["name"], message: "Required" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits details when undefined", () => {
|
||||
const body = buildComboErrorBody("COMBO_007");
|
||||
assert.ok(!("details" in body.error));
|
||||
});
|
||||
|
||||
it("falls back to INTERNAL_001 for an unknown code", () => {
|
||||
const body = buildComboErrorBody(
|
||||
"COMBO_999" as unknown as Parameters<typeof buildComboErrorBody>[0]
|
||||
);
|
||||
// An unknown code falls through to INTERNAL_001 from the catalog.
|
||||
assert.equal(body.error.code, "INTERNAL_001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("comboErrorResponse", () => {
|
||||
it("returns the catalog httpStatus when no override is given", async () => {
|
||||
const res = comboErrorResponse("COMBO_001");
|
||||
assert.equal(res.status, ERROR_CODES.COMBO_001.httpStatus);
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error.code, "COMBO_001");
|
||||
});
|
||||
|
||||
it("respects an explicit status override", async () => {
|
||||
const res = comboErrorResponse("COMBO_006", 409, { name: "qs:foo" });
|
||||
assert.equal(res.status, 409);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error.code, "COMBO_006");
|
||||
assert.deepEqual(body.error.details, { name: "qs:foo" });
|
||||
});
|
||||
|
||||
it("attaches x-request-id when a Request is supplied", async () => {
|
||||
const req = new Request("https://example.com/api/combos/abc", {
|
||||
headers: { "x-request-id": "test-corr-id-1234" },
|
||||
});
|
||||
const res = comboErrorResponse("COMBO_004", 400, undefined, req);
|
||||
assert.ok(res.headers.get("x-request-id"));
|
||||
});
|
||||
|
||||
it("does NOT leak internal combo names in DAG errors (sanitized reason tag)", async () => {
|
||||
// The route should translate a thrown `Error("cycle detected: combo-A")`
|
||||
// into `{ reason: "cycle-detected" }` — never the raw message.
|
||||
const reason = /cycle/i.test("cycle detected: combo-A") ? "cycle-detected" : "invalid-graph";
|
||||
const res = comboErrorResponse("COMBO_005", 400, {
|
||||
comboName: "user-facing-only",
|
||||
reason,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error.code, "COMBO_005");
|
||||
assert.equal(body.error.details.reason, "cycle-detected");
|
||||
// Crucial: the raw "combo-A" string must NOT appear in the response body.
|
||||
const text = JSON.stringify(body);
|
||||
assert.ok(!text.includes("combo-A"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("all five route 4xx branches have a defined COMBO_* code", () => {
|
||||
// The route uses these codes; this is a regression test against accidental
|
||||
// removal of a code from the catalog (would break clients parsing `code`).
|
||||
const expectedCodes = [
|
||||
"COMBO_001", // JSON parse failure (route.ts L49-58)
|
||||
"COMBO_002", // zod schema failure (route.ts L65)
|
||||
"COMBO_003", // composite tier config (route.ts L117)
|
||||
"COMBO_004", // name collision (route.ts L124)
|
||||
"COMBO_005", // DAG validation (route.ts L139)
|
||||
"COMBO_006", // quota-share conflict 409 (route.ts L71-78)
|
||||
"COMBO_007", // not found 404 (route.ts L31)
|
||||
] as const;
|
||||
|
||||
for (const code of expectedCodes) {
|
||||
it(`${code} is registered with httpStatus 400 or 409 or 404`, () => {
|
||||
const def = ERROR_CODES[code];
|
||||
assert.notEqual(def, undefined);
|
||||
assert.ok([400, 404, 409].includes(def.httpStatus));
|
||||
assert.equal(def.category, "COMBO");
|
||||
});
|
||||
}
|
||||
});
|
||||
42
tests/unit/apikeys-model-permissions-split.test.ts
Normal file
42
tests/unit/apikeys-model-permissions-split.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Characterization of the db/apiKeys.ts model-permission split (god-file decomposition): the pure
|
||||
// permission-matching logic (Claude-Code alias/prefix resolution + wildcard/glob matching) moved into
|
||||
// db/apiKeys/modelPermissions.ts. Behavior-preserving move — these functions decide whether a model is
|
||||
// allowed for a key, so the locks here exercise the wildcard semantics directly. The DB-backed
|
||||
// isModelAllowedForKey flow stays covered by api-key-policy / combo-provider-wildcard.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const M = await import("../../src/lib/db/apiKeys/modelPermissions.ts");
|
||||
|
||||
test("module exposes the matching helpers + Claude-Code alias sets", () => {
|
||||
for (const name of [
|
||||
"modelPatternMatches",
|
||||
"matchesWildcardPattern",
|
||||
"segmentMatchesWildcard",
|
||||
"hasClaudeCodeWildcardPermission",
|
||||
"isPotentialUnprefixedClaudeCodeModel",
|
||||
"addModelCandidate",
|
||||
"stripExtendedContextSuffix",
|
||||
]) {
|
||||
assert.equal(typeof (M as Record<string, unknown>)[name], "function", `missing ${name}`);
|
||||
}
|
||||
assert.ok((M.CLAUDE_CODE_PROVIDER_PREFIXES as Set<string>).has("cc"));
|
||||
assert.ok((M.CLAUDE_CODE_SHORT_ALIASES as Set<string>).has("sonnet"));
|
||||
});
|
||||
|
||||
test("matchesWildcardPattern honors segment globs and exact matches", () => {
|
||||
assert.equal(M.matchesWildcardPattern("openai/*", "openai/gpt-4o"), true);
|
||||
assert.equal(M.matchesWildcardPattern("openai/*", "anthropic/claude"), false);
|
||||
assert.equal(M.matchesWildcardPattern("gpt-4o", "gpt-4o"), true);
|
||||
});
|
||||
|
||||
test("modelPatternMatches matches a candidate against an exact or wildcard pattern", () => {
|
||||
assert.equal(M.modelPatternMatches("openai/*", ["openai/gpt-4o"]), true);
|
||||
assert.equal(M.modelPatternMatches("gpt-4o", ["gpt-4o"]), true);
|
||||
assert.equal(M.modelPatternMatches("gpt-4o", ["gpt-4o-mini"]), false);
|
||||
});
|
||||
|
||||
test("stripExtendedContextSuffix removes the extended-context marker", () => {
|
||||
const out = M.stripExtendedContextSuffix("claude-sonnet-4-5[1m]");
|
||||
assert.equal(out.includes("[1m]"), false);
|
||||
});
|
||||
90
tests/unit/apikeys-row-parsers-split.test.ts
Normal file
90
tests/unit/apikeys-row-parsers-split.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// Characterization of the db/apiKeys.ts row-parsers split (god-file decomposition): the pure column
|
||||
// parsers that coerce raw SQLite values into typed shapes moved into db/apiKeys/rowParsers.ts, and the
|
||||
// two shared row types (AccessSchedule / RateLimitRule) into db/apiKeys/types.ts. Behavior-preserving
|
||||
// move — these locks pin the coercion semantics (JSON lists, 0/1 flags, nullable timestamps, schedule
|
||||
// validation) and that the host still re-exports the two public types. DB-backed hydration stays
|
||||
// covered by api-key-policy / combo-provider-wildcard.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const P = await import("../../src/lib/db/apiKeys/rowParsers.ts");
|
||||
|
||||
test("module exposes all fifteen parsers", () => {
|
||||
for (const name of [
|
||||
"parseAllowedModels",
|
||||
"parseAllowedCombos",
|
||||
"parseNoLog",
|
||||
"parseAutoResolve",
|
||||
"parseDisableNonPublicModels",
|
||||
"parseAllowUsageCommand",
|
||||
"parseIsActive",
|
||||
"parseAccessSchedule",
|
||||
"parseRateLimits",
|
||||
"parseAllowedConnections",
|
||||
"parseAllowedQuotas",
|
||||
"parseStringList",
|
||||
"parseNullableTimestamp",
|
||||
"parseIsBanned",
|
||||
"parseStreamDefaultMode",
|
||||
]) {
|
||||
assert.equal(typeof (P as Record<string, unknown>)[name], "function", `missing ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("parseAllowedModels keeps only string entries, tolerates junk", () => {
|
||||
assert.deepEqual(P.parseAllowedModels('["a","b",1,null]'), ["a", "b"]);
|
||||
assert.deepEqual(P.parseAllowedModels(""), []);
|
||||
assert.deepEqual(P.parseAllowedModels("not json"), []);
|
||||
assert.deepEqual(P.parseAllowedModels(null), []);
|
||||
});
|
||||
|
||||
test("flag parsers honor the 0/1/true/false matrix", () => {
|
||||
assert.equal(P.parseNoLog(1), true);
|
||||
assert.equal(P.parseNoLog("1"), true);
|
||||
assert.equal(P.parseNoLog(0), false);
|
||||
// isActive defaults to active unless explicitly falsy
|
||||
assert.equal(P.parseIsActive(undefined), true);
|
||||
assert.equal(P.parseIsActive(0), false);
|
||||
assert.equal(P.parseIsActive("0"), false);
|
||||
assert.equal(P.parseIsBanned(1), true);
|
||||
assert.equal(P.parseIsBanned(0), false);
|
||||
});
|
||||
|
||||
test("parseStreamDefaultMode collapses to legacy unless json", () => {
|
||||
assert.equal(P.parseStreamDefaultMode("json"), "json");
|
||||
assert.equal(P.parseStreamDefaultMode("legacy"), "legacy");
|
||||
assert.equal(P.parseStreamDefaultMode("anything"), "legacy");
|
||||
});
|
||||
|
||||
test("parseNullableTimestamp trims and nulls empties", () => {
|
||||
assert.equal(P.parseNullableTimestamp(" 2026-01-01 "), "2026-01-01");
|
||||
assert.equal(P.parseNullableTimestamp(" "), null);
|
||||
assert.equal(P.parseNullableTimestamp(42), null);
|
||||
});
|
||||
|
||||
test("parseAccessSchedule validates shape + clamps days, else null", () => {
|
||||
const ok = P.parseAccessSchedule(
|
||||
JSON.stringify({ enabled: true, from: "08:00", until: "18:00", days: [0, 3, 9], tz: "UTC" })
|
||||
);
|
||||
assert.ok(ok);
|
||||
assert.deepEqual(ok?.days, [0, 3]); // 9 dropped (out of 0..6)
|
||||
assert.equal(P.parseAccessSchedule('{"enabled":true}'), null);
|
||||
assert.equal(P.parseAccessSchedule("[]"), null);
|
||||
assert.equal(P.parseAccessSchedule(""), null);
|
||||
});
|
||||
|
||||
test("parseRateLimits keeps well-formed numeric rules only", () => {
|
||||
const out = P.parseRateLimits(
|
||||
JSON.stringify([{ limit: 10, window: 60 }, { limit: "x", window: 1 }, null])
|
||||
);
|
||||
assert.deepEqual(out, [{ limit: 10, window: 60 }]);
|
||||
assert.equal(P.parseRateLimits("not array"), null);
|
||||
assert.equal(P.parseRateLimits(""), null);
|
||||
});
|
||||
|
||||
test("host re-exports the two public row types (compile-time) and the parsers stay wired", async () => {
|
||||
// type-only re-export can't be probed at runtime; assert the host module still loads + exposes its API
|
||||
const HOST = await import("../../src/lib/db/apiKeys.ts");
|
||||
assert.equal(typeof (HOST as Record<string, unknown>).getApiKeys, "function");
|
||||
assert.equal(typeof (HOST as Record<string, unknown>).isModelAllowedForKey, "function");
|
||||
});
|
||||
@@ -88,6 +88,31 @@ test("sanitizeReasoningEffortForProvider: xiaomi-mimo normalizes max → xhigh b
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeReasoningEffortForProvider: Ollama Cloud preserves max", () => {
|
||||
const log = makeLog();
|
||||
const body = {
|
||||
model: "glm-5.2",
|
||||
reasoning_effort: "max",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
};
|
||||
const result = sanitizeReasoningEffortForProvider(body, "ollama-cloud", "glm-5.2", log);
|
||||
assert.equal(result, body, "Ollama Cloud accepts max literally");
|
||||
assert.equal((result as any).reasoning_effort, "max");
|
||||
assert.equal(log.messages.length, 0);
|
||||
});
|
||||
|
||||
test("sanitizeReasoningEffortForProvider: Ollama Cloud preserves nested max", () => {
|
||||
const body = {
|
||||
model: "glm-5.2",
|
||||
reasoning: { effort: "max", summary: "auto" },
|
||||
input: [],
|
||||
};
|
||||
const result = sanitizeReasoningEffortForProvider(body, "ollama-cloud", "glm-5.2", null);
|
||||
assert.equal(result, body, "Ollama Cloud accepts max literally");
|
||||
assert.equal((result as any).reasoning.effort, "max");
|
||||
assert.equal((result as any).reasoning.summary, "auto");
|
||||
});
|
||||
|
||||
test("sanitizeReasoningEffortForProvider: OpenRouter DeepSeek normalizes max → xhigh", () => {
|
||||
const log = makeLog();
|
||||
const body = {
|
||||
|
||||
35
tests/unit/build/check-test-runner-api.test.ts
Normal file
35
tests/unit/build/check-test-runner-api.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { findRunnerMismatches } from "../../../scripts/check/check-test-runner-api.mjs";
|
||||
|
||||
function tmpRepo() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "runner-api-"));
|
||||
fs.mkdirSync(path.join(root, "tests/unit/autoCombo"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
test("flags a vitest-only-dir test that imports node:test", () => {
|
||||
const root = tmpRepo();
|
||||
fs.writeFileSync(
|
||||
path.join(root, "tests/unit/autoCombo/bad.test.ts"),
|
||||
`import { describe, it } from "node:test";\ndescribe("x", () => it("y", () => {}));\n`
|
||||
);
|
||||
const bad = findRunnerMismatches(root);
|
||||
assert.equal(bad.length, 1);
|
||||
assert.match(bad[0].file, /autoCombo\/bad\.test\.ts$/);
|
||||
assert.match(bad[0].reason, /vitest-only/);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("accepts a vitest-only-dir test that imports vitest", () => {
|
||||
const root = tmpRepo();
|
||||
fs.writeFileSync(
|
||||
path.join(root, "tests/unit/autoCombo/good.test.ts"),
|
||||
`import { describe, it } from "vitest";\ndescribe("x", () => it("y", () => {}));\n`
|
||||
);
|
||||
assert.equal(findRunnerMismatches(root).length, 0);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
45
tests/unit/build/optional-transformers-dependency.test.ts
Normal file
45
tests/unit/build/optional-transformers-dependency.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function readJson<T = Record<string, unknown>>(relPath: string): T {
|
||||
return JSON.parse(readFileSync(join(repoRoot, relPath), "utf8")) as T;
|
||||
}
|
||||
|
||||
test("@huggingface/transformers is optional so onnxruntime CUDA install failures cannot abort OmniRoute install", () => {
|
||||
const pkg = readJson<{
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
}>("package.json");
|
||||
|
||||
assert.equal(
|
||||
pkg.dependencies?.["@huggingface/transformers"],
|
||||
undefined,
|
||||
"transformers must not be a regular dependency because it pulls onnxruntime-node install scripts"
|
||||
);
|
||||
assert.equal(pkg.optionalDependencies?.["@huggingface/transformers"], "3.5.2");
|
||||
});
|
||||
|
||||
test("package-lock marks transformers and its onnxruntime runtime as optional", () => {
|
||||
const lock = readJson<{
|
||||
packages: Record<string, { optional?: boolean; dependencies?: Record<string, string>; optionalDependencies?: Record<string, string> }>;
|
||||
}>("package-lock.json");
|
||||
|
||||
assert.equal(
|
||||
lock.packages[""]?.dependencies?.["@huggingface/transformers"],
|
||||
undefined,
|
||||
"root lock dependencies must not keep transformers as mandatory"
|
||||
);
|
||||
assert.equal(lock.packages[""]?.optionalDependencies?.["@huggingface/transformers"], "3.5.2");
|
||||
|
||||
for (const packagePath of [
|
||||
"node_modules/@huggingface/transformers",
|
||||
"node_modules/onnxruntime-node",
|
||||
"node_modules/onnxruntime-common",
|
||||
]) {
|
||||
assert.equal(lock.packages[packagePath]?.optional, true, `${packagePath} should be optional`);
|
||||
}
|
||||
});
|
||||
47
tests/unit/build/sync-changelog-i18n.test.ts
Normal file
47
tests/unit/build/sync-changelog-i18n.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { syncChangelogSection } from "../../../scripts/release/sync-changelog-i18n.mjs";
|
||||
|
||||
function repo() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cl-i18n-"));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "CHANGELOG.md"),
|
||||
"# Changelog\n\n## [Unreleased]\n\n---\n\n## [9.9.9] — 2026-01-01\n\n- **feat:** big new thing\n- **fix:** another\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n"
|
||||
);
|
||||
for (const loc of ["fr", "de"]) {
|
||||
fs.mkdirSync(path.join(root, "docs/i18n", loc), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "docs/i18n", loc, "CHANGELOG.md"),
|
||||
"# Changelog\n\n---\n\n## [9.9.9] — TBD\n\n_stub_\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n"
|
||||
);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
test("replaces the version section in every mirror with the root section", () => {
|
||||
const root = repo();
|
||||
const n = syncChangelogSection(root, "9.9.9", "9.9.8");
|
||||
assert.equal(n, 2);
|
||||
const fr = fs.readFileSync(path.join(root, "docs/i18n/fr/CHANGELOG.md"), "utf8");
|
||||
assert.match(fr, /big new thing/);
|
||||
assert.doesNotMatch(fr, /_stub_/);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("inserts the section when a mirror lacks it", () => {
|
||||
const root = repo();
|
||||
// remove the 9.9.9 section from the fr mirror entirely
|
||||
fs.writeFileSync(
|
||||
path.join(root, "docs/i18n/fr/CHANGELOG.md"),
|
||||
"# Changelog\n\n---\n\n## [9.9.8] — 2025-12-01\n\n- old\n"
|
||||
);
|
||||
const n = syncChangelogSection(root, "9.9.9", "9.9.8");
|
||||
assert.equal(n, 2);
|
||||
const fr = fs.readFileSync(path.join(root, "docs/i18n/fr/CHANGELOG.md"), "utf8");
|
||||
assert.match(fr, /## \[9\.9\.9\]/);
|
||||
assert.match(fr, /big new thing/);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
78
tests/unit/chatcore-client-usage-buffer.test.ts
Normal file
78
tests/unit/chatcore-client-usage-buffer.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// Characterization of applyClientUsageBuffer — the non-streaming usage buffer/estimate block
|
||||
// extracted from handleChatCore (chatCore god-file decomposition, #3501). Deps are injected so the
|
||||
// buffer-vs-estimate branch and the in-place mutation of translatedResponse.usage are observable.
|
||||
// Locks: usage present → buffer+filter; usage absent → estimate from content length; empty content
|
||||
// (length 2 from JSON.stringify("")) still estimates; the mutation target is translatedResponse.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { applyClientUsageBuffer } = await import(
|
||||
"../../open-sse/handlers/chatCore/clientUsageBuffer.ts"
|
||||
);
|
||||
|
||||
function makeDeps(overrides: Record<string, unknown> = {}) {
|
||||
const calls = { buffer: [] as unknown[], estimate: [] as unknown[], filter: [] as unknown[] };
|
||||
const deps = {
|
||||
addBufferToUsage: (u: unknown) => {
|
||||
calls.buffer.push(u);
|
||||
return { ...(u as object), _buffered: true };
|
||||
},
|
||||
estimateUsage: (...a: unknown[]) => {
|
||||
calls.estimate.push(a);
|
||||
return { _estimated: true };
|
||||
},
|
||||
filterUsageForFormat: (u: unknown, _fmt: unknown) => {
|
||||
calls.filter.push(u);
|
||||
return { ...(u as object), _filtered: true };
|
||||
},
|
||||
...overrides,
|
||||
} as Parameters<typeof applyClientUsageBuffer>[3];
|
||||
return { deps, calls };
|
||||
}
|
||||
|
||||
test("usage present → buffer then filter, mutates in place", () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
const resp: Record<string, unknown> = { usage: { prompt_tokens: 5 } };
|
||||
applyClientUsageBuffer(resp, { messages: [] }, "openai", deps);
|
||||
assert.equal(calls.buffer.length, 1);
|
||||
assert.equal(calls.estimate.length, 0);
|
||||
assert.equal((resp.usage as Record<string, unknown>)._buffered, true);
|
||||
assert.equal((resp.usage as Record<string, unknown>)._filtered, true);
|
||||
});
|
||||
|
||||
test("no usage but content present → estimate then filter", () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
const resp: Record<string, unknown> = {
|
||||
choices: [{ message: { content: "hello world" } }],
|
||||
};
|
||||
applyClientUsageBuffer(resp, { messages: [] }, "openai", deps);
|
||||
assert.equal(calls.buffer.length, 0);
|
||||
assert.equal(calls.estimate.length, 1);
|
||||
assert.equal((resp.usage as Record<string, unknown>)._estimated, true);
|
||||
assert.equal((resp.usage as Record<string, unknown>)._filtered, true);
|
||||
// estimateUsage receives (body, contentLength, format)
|
||||
const args = calls.estimate[0] as unknown[];
|
||||
assert.equal(args[2], "openai");
|
||||
assert.equal(typeof args[1], "number");
|
||||
});
|
||||
|
||||
test("empty content → JSON.stringify('') length 2 > 0 still estimates", () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
const resp: Record<string, unknown> = {};
|
||||
applyClientUsageBuffer(resp, {}, "claude", deps);
|
||||
// content "" → JSON.stringify("") = '""' length 2 → contentLength 2 > 0
|
||||
assert.equal(calls.estimate.length, 1);
|
||||
const args = calls.estimate[0] as unknown[];
|
||||
assert.equal(args[1], 2);
|
||||
});
|
||||
|
||||
test("content length is computed from choices[0].message.content", () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
const resp: Record<string, unknown> = {
|
||||
choices: [{ message: { content: "abc" } }],
|
||||
};
|
||||
applyClientUsageBuffer(resp, {}, "openai", deps);
|
||||
// JSON.stringify("abc") = '"abc"' → length 5
|
||||
const args = calls.estimate[0] as unknown[];
|
||||
assert.equal(args[1], 5);
|
||||
});
|
||||
129
tests/unit/chatcore-compression-analytics-write.test.ts
Normal file
129
tests/unit/chatcore-compression-analytics-write.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// Characterization of writeCompressionAnalytics — the full compression analytics write extracted
|
||||
// from handleChatCore's request-setup compression path (chatCore god-file decomposition, #3501).
|
||||
// Returns the write promise; uses a real temp DB. Locks: the analytics row mapping (tokens, engine,
|
||||
// validation_fallback, output_mode), the per-engine breakdown insert, and fail-open.
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-comp-analytics-test-"));
|
||||
process.env.DATA_DIR = testDataDir;
|
||||
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
const { writeCompressionAnalytics } = await import(
|
||||
"../../open-sse/handlers/chatCore/compressionAnalyticsWrite.ts"
|
||||
);
|
||||
|
||||
type Stats = Parameters<typeof writeCompressionAnalytics>[0]["stats"];
|
||||
|
||||
function makeStats(overrides: Record<string, unknown> = {}): Stats {
|
||||
return {
|
||||
originalTokens: 200,
|
||||
compressedTokens: 80,
|
||||
fallbackApplied: false,
|
||||
engine: "rtk",
|
||||
compressionComboId: null,
|
||||
durationMs: 12,
|
||||
rtkRawOutputPointers: [],
|
||||
engineBreakdown: [],
|
||||
...overrides,
|
||||
} as unknown as Stats;
|
||||
}
|
||||
|
||||
function analyticsRow(requestId: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
return coreDb
|
||||
.getDbInstance()
|
||||
.prepare(
|
||||
"SELECT mode, engine, original_tokens AS orig, compressed_tokens AS comp, tokens_saved AS saved, validation_fallback AS vf FROM compression_analytics WHERE request_id = ?"
|
||||
)
|
||||
.get(requestId) as Record<string, unknown> | undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function breakdownCount(requestId: string): number {
|
||||
try {
|
||||
const row = coreDb
|
||||
.getDbInstance()
|
||||
.prepare("SELECT COUNT(*) AS n FROM compression_engine_breakdown WHERE request_id = ?")
|
||||
.get(requestId) as { n: number } | undefined;
|
||||
return row?.n ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(pred: () => boolean, timeoutMs = 3000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline && !pred()) {
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
}
|
||||
|
||||
function baseOpts(requestId: string, statsOverrides = {}) {
|
||||
return {
|
||||
stats: makeStats(statsOverrides),
|
||||
provider: "openai",
|
||||
effectiveModel: "gpt-x",
|
||||
effectiveServiceTier: "standard",
|
||||
comboName: null,
|
||||
mode: "rtk",
|
||||
compressionComboId: null,
|
||||
skillRequestId: requestId,
|
||||
cavemanOutputModeApplied: false,
|
||||
cavemanOutputModeIntensity: null,
|
||||
} as Parameters<typeof writeCompressionAnalytics>[0];
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await coreDb.ensureDbInitialized();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
coreDb.resetDbInstance();
|
||||
try {
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test("writes the analytics row with mapped tokens/engine and resolves", async () => {
|
||||
await writeCompressionAnalytics(baseOpts("ca-req-1"));
|
||||
const row = analyticsRow("ca-req-1");
|
||||
assert.ok(row, "expected a compression_analytics row");
|
||||
assert.equal(row!.engine, "rtk");
|
||||
assert.equal(row!.orig, 200);
|
||||
assert.equal(row!.comp, 80);
|
||||
assert.equal(row!.saved, 120);
|
||||
assert.equal(row!.vf, 0);
|
||||
});
|
||||
|
||||
test("validation_fallback is 1 when the stats flagged a fallback", async () => {
|
||||
await writeCompressionAnalytics(baseOpts("ca-req-2", { fallbackApplied: true }));
|
||||
const row = analyticsRow("ca-req-2");
|
||||
assert.equal(row!.vf, 1);
|
||||
});
|
||||
|
||||
test("inserts a per-engine breakdown when present", async () => {
|
||||
await writeCompressionAnalytics(
|
||||
baseOpts("ca-req-3", {
|
||||
engineBreakdown: [
|
||||
{ engine: "rtk", originalTokens: 200, compressedTokens: 120, durationMs: 5 },
|
||||
{ engine: "caveman", originalTokens: 120, compressedTokens: 80, durationMs: 4 },
|
||||
],
|
||||
})
|
||||
);
|
||||
await waitFor(() => breakdownCount("ca-req-3") >= 2);
|
||||
assert.equal(breakdownCount("ca-req-3"), 2);
|
||||
});
|
||||
|
||||
test("never rejects even on a bad write (fail-open)", async () => {
|
||||
coreDb.resetDbInstance();
|
||||
await assert.doesNotReject(writeCompressionAnalytics(baseOpts("ca-req-4")));
|
||||
await coreDb.ensureDbInitialized();
|
||||
});
|
||||
64
tests/unit/chatcore-compression-combo-predicates.test.ts
Normal file
64
tests/unit/chatcore-compression-combo-predicates.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Characterization of the pure compression-combo predicates extracted from handleChatCore's
|
||||
// compression setup (chatCore god-file decomposition, #3501). No DB, no handler state.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { isBuiltinStackedPipeline, isStackedCompressionCombo } = await import(
|
||||
"../../open-sse/handlers/chatCore/compressionComboPredicates.ts"
|
||||
);
|
||||
|
||||
test("isBuiltinStackedPipeline true only for the rtk(standard)→caveman(full) shape", () => {
|
||||
assert.equal(
|
||||
isBuiltinStackedPipeline([{ engine: "rtk" }, { engine: "caveman" }] as never),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isBuiltinStackedPipeline([
|
||||
{ engine: "rtk", intensity: "standard" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
] as never),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("isBuiltinStackedPipeline false for wrong length / engines / intensities / config", () => {
|
||||
assert.equal(isBuiltinStackedPipeline(undefined), false);
|
||||
assert.equal(isBuiltinStackedPipeline([] as never), false);
|
||||
assert.equal(isBuiltinStackedPipeline([{ engine: "rtk" }] as never), false);
|
||||
assert.equal(
|
||||
isBuiltinStackedPipeline([{ engine: "caveman" }, { engine: "rtk" }] as never),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isBuiltinStackedPipeline([
|
||||
{ engine: "rtk", intensity: "aggressive" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
] as never),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isBuiltinStackedPipeline([
|
||||
{ engine: "rtk", config: { x: 1 } },
|
||||
{ engine: "caveman" },
|
||||
] as never),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("isStackedCompressionCombo true when the combo has >= 1 pipeline layer", () => {
|
||||
assert.equal(isStackedCompressionCombo(null), false);
|
||||
assert.equal(
|
||||
isStackedCompressionCombo({ id: "c", pipeline: [], languagePacks: [], outputMode: false, outputModeIntensity: "full" } as never),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isStackedCompressionCombo({
|
||||
id: "c",
|
||||
pipeline: [{ engine: "rtk" }],
|
||||
languagePacks: [],
|
||||
outputMode: false,
|
||||
outputModeIntensity: "full",
|
||||
} as never),
|
||||
true
|
||||
);
|
||||
});
|
||||
44
tests/unit/chatcore-compression-settings.test.ts
Normal file
44
tests/unit/chatcore-compression-settings.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Characterization of resolveCompressionSettings — the compression settings read extracted from
|
||||
// handleChatCore's Proactive Context Compression setup (chatCore god-file decomposition, #3501).
|
||||
// Uses a real temp DB (getCompressionSettings reads/seeds the settings row). Locks: the derived
|
||||
// enabled / contextEditingEnabled flags and the disabled fallback shape.
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-comp-settings-test-"));
|
||||
process.env.DATA_DIR = testDataDir;
|
||||
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
const { resolveCompressionSettings } = await import(
|
||||
"../../open-sse/handlers/chatCore/compressionSettings.ts"
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
await coreDb.ensureDbInitialized();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
coreDb.resetDbInstance();
|
||||
try {
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test("returns the settings object from the DB", async () => {
|
||||
const result = await resolveCompressionSettings();
|
||||
assert.ok(result.settings, "expected a settings object from the seeded DB");
|
||||
});
|
||||
|
||||
test("derives enabled and contextEditingEnabled from the settings", async () => {
|
||||
const result = await resolveCompressionSettings();
|
||||
// `enabled` mirrors settings.enabled exactly
|
||||
assert.equal(result.enabled, result.settings!.enabled);
|
||||
// `contextEditingEnabled` is the strict === true derivation of the nested flag
|
||||
assert.equal(result.contextEditingEnabled, result.settings!.contextEditing?.enabled === true);
|
||||
assert.equal(typeof result.contextEditingEnabled, "boolean");
|
||||
});
|
||||
73
tests/unit/chatcore-json-body-to-sse.test.ts
Normal file
73
tests/unit/chatcore-json-body-to-sse.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Characterization of maybeConvertJsonBodyToSse — the #3089 non-SSE JSON → SSE conversion
|
||||
// extracted from handleChatCore's streaming entry (chatCore god-file decomposition, #3501). Real
|
||||
// Response objects + injected deps make the content-type gate, the synthesize-or-rebuild branch,
|
||||
// and the header rewrites observable. Locks: pass-through for SSE/ndjson/no-body, conversion to
|
||||
// text/event-stream when synthesizable, and rebuild-with-consumed-body otherwise.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { maybeConvertJsonBodyToSse } = await import(
|
||||
"../../open-sse/handlers/chatCore/jsonBodyToSse.ts"
|
||||
);
|
||||
|
||||
const ctx = { log: undefined, provider: "openai", model: "gpt-x" };
|
||||
|
||||
function makeDeps(synth: (s: string) => string | null) {
|
||||
return {
|
||||
withBodyTimeout: async <T>(p: Promise<T>) => p,
|
||||
synthesizeOpenAiSseFromJson: synth,
|
||||
} as Parameters<typeof maybeConvertJsonBodyToSse>[2];
|
||||
}
|
||||
|
||||
test("already text/event-stream → returned unchanged (same reference)", async () => {
|
||||
const resp = new Response("data: {}\n\n", {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => "X"));
|
||||
assert.equal(out, resp);
|
||||
});
|
||||
|
||||
test("application/x-ndjson → returned unchanged", async () => {
|
||||
const resp = new Response('{"a":1}\n', { headers: { "content-type": "application/x-ndjson" } });
|
||||
const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => "X"));
|
||||
assert.equal(out, resp);
|
||||
});
|
||||
|
||||
test("no body → returned unchanged", async () => {
|
||||
const resp = new Response(null, { headers: { "content-type": "application/json" } });
|
||||
const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => "X"));
|
||||
assert.equal(out, resp);
|
||||
});
|
||||
|
||||
test("application/json + synthesizable → SSE response with text/event-stream, no content-length", async () => {
|
||||
const resp = new Response('{"choices":[]}', {
|
||||
headers: { "content-type": "application/json", "content-length": "14" },
|
||||
});
|
||||
const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => "data: synth\n\n"));
|
||||
assert.notEqual(out, resp);
|
||||
assert.equal(out.headers.get("content-type"), "text/event-stream");
|
||||
assert.equal(out.headers.get("content-length"), null);
|
||||
assert.equal(await out.text(), "data: synth\n\n");
|
||||
});
|
||||
|
||||
test("application/json + not synthesizable → rebuilt with consumed body, content-type unchanged", async () => {
|
||||
const resp = new Response('{"not":"chat"}', {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => null));
|
||||
assert.notEqual(out, resp);
|
||||
// not converted → content-type stays application/json (rebuilt headers)
|
||||
assert.equal(out.headers.get("content-type"), "application/json");
|
||||
assert.equal(await out.text(), '{"not":"chat"}');
|
||||
});
|
||||
|
||||
test("status and statusText are preserved on conversion", async () => {
|
||||
const resp = new Response('{"choices":[]}', {
|
||||
status: 201,
|
||||
statusText: "Created",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const out = await maybeConvertJsonBodyToSse(resp, ctx, makeDeps(() => "data: x\n\n"));
|
||||
assert.equal(out.status, 201);
|
||||
assert.equal(out.statusText, "Created");
|
||||
});
|
||||
73
tests/unit/chatcore-nonstreaming-response-headers.test.ts
Normal file
73
tests/unit/chatcore-nonstreaming-response-headers.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Characterization of buildNonStreamingResponseHeaders — the cache-MISS response header builder
|
||||
// extracted from handleChatCore's non-streaming success path (chatCore god-file decomposition,
|
||||
// #3501). attachOmniRouteMetaHeaders + now are injected so the static headers, the meta payload,
|
||||
// and the optional compression header are observable. Locks: Content-Type + cache MISS, latencyMs =
|
||||
// now - startTime, and the compression header only when meta is present.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { buildNonStreamingResponseHeaders } = await import(
|
||||
"../../open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts"
|
||||
);
|
||||
|
||||
function makeDeps(now = 1000) {
|
||||
const metaCalls: Array<{ headers: Record<string, string>; meta: Record<string, unknown> }> = [];
|
||||
const deps = {
|
||||
attachOmniRouteMetaHeaders: (headers: Record<string, string>, meta: Record<string, unknown>) => {
|
||||
metaCalls.push({ headers, meta });
|
||||
headers["x-omniroute-meta"] = "attached";
|
||||
},
|
||||
now: () => now,
|
||||
} as Parameters<typeof buildNonStreamingResponseHeaders>[1];
|
||||
return { deps, metaCalls };
|
||||
}
|
||||
|
||||
function baseArgs(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
provider: "openai",
|
||||
model: "gpt-x",
|
||||
startTime: 600,
|
||||
responseUsage: { prompt_tokens: 5 },
|
||||
estimatedCost: 0.0012,
|
||||
requestId: "req-1",
|
||||
compressionResponseMeta: undefined,
|
||||
...overrides,
|
||||
} as Parameters<typeof buildNonStreamingResponseHeaders>[0];
|
||||
}
|
||||
|
||||
test("static headers: Content-Type json + cache MISS", () => {
|
||||
const { deps } = makeDeps();
|
||||
const h = buildNonStreamingResponseHeaders(baseArgs(), deps);
|
||||
assert.equal(h["Content-Type"], "application/json");
|
||||
// cache marker key/value
|
||||
assert.ok(Object.values(h).includes("MISS"));
|
||||
});
|
||||
|
||||
test("meta receives provider/model/cacheHit false/latency/usage/cost/requestId", () => {
|
||||
const { deps, metaCalls } = makeDeps(1000);
|
||||
buildNonStreamingResponseHeaders(baseArgs({ startTime: 600 }), deps);
|
||||
assert.equal(metaCalls.length, 1);
|
||||
const meta = metaCalls[0].meta;
|
||||
assert.equal(meta.provider, "openai");
|
||||
assert.equal(meta.model, "gpt-x");
|
||||
assert.equal(meta.cacheHit, false);
|
||||
assert.equal(meta.latencyMs, 400); // now 1000 - startTime 600
|
||||
assert.deepEqual(meta.usage, { prompt_tokens: 5 });
|
||||
assert.equal(meta.costUsd, 0.0012);
|
||||
assert.equal(meta.requestId, "req-1");
|
||||
});
|
||||
|
||||
test("no compression meta → no compression header", () => {
|
||||
const { deps } = makeDeps();
|
||||
const h = buildNonStreamingResponseHeaders(baseArgs({ compressionResponseMeta: undefined }), deps);
|
||||
assert.ok(!Object.values(h).includes("engine:x"));
|
||||
});
|
||||
|
||||
test("compression meta present → compression header set to that value", () => {
|
||||
const { deps } = makeDeps();
|
||||
const h = buildNonStreamingResponseHeaders(
|
||||
baseArgs({ compressionResponseMeta: "engine:x; source=header" }),
|
||||
deps
|
||||
);
|
||||
assert.ok(Object.values(h).includes("engine:x; source=header"));
|
||||
});
|
||||
102
tests/unit/chatcore-output-style-telemetry.test.ts
Normal file
102
tests/unit/chatcore-output-style-telemetry.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Characterization of emitOutputStyleTelemetry — the output-style run-telemetry hook extracted from
|
||||
// handleChatCore's request-setup compression path (chatCore god-file decomposition, #3501).
|
||||
// Fire-and-forget; uses a real temp DB and polls compression_run_telemetry. Locks: the null guard
|
||||
// (no-op), and that an applied output-style result records a run-telemetry row with the resolved
|
||||
// request id / model / provider / source.
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-os-telemetry-test-"));
|
||||
process.env.DATA_DIR = testDataDir;
|
||||
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
const { emitOutputStyleTelemetry } = await import(
|
||||
"../../open-sse/handlers/chatCore/outputStyleTelemetry.ts"
|
||||
);
|
||||
|
||||
function rowFor(requestId: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
return coreDb
|
||||
.getDbInstance()
|
||||
.prepare(
|
||||
"SELECT request_id, model, provider, source FROM compression_run_telemetry WHERE request_id = ?"
|
||||
)
|
||||
.get(requestId) as Record<string, unknown> | undefined;
|
||||
} catch {
|
||||
// table is created lazily by the first fire-and-forget insert; treat "no such table" as "no row"
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRow(requestId: string, timeoutMs = 3000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (rowFor(requestId)) break;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
return rowFor(requestId);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await coreDb.ensureDbInitialized();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
coreDb.resetDbInstance();
|
||||
try {
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test("null outputStyleResult is a no-op (returns synchronously, no throw)", async () => {
|
||||
assert.doesNotThrow(() =>
|
||||
emitOutputStyleTelemetry({
|
||||
outputStyleResult: null,
|
||||
skillRequestId: "os-noop",
|
||||
traceId: "t",
|
||||
effectiveModel: "gpt-x",
|
||||
provider: "openai",
|
||||
compressionComboId: null,
|
||||
estimatedTokens: 100,
|
||||
})
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
assert.equal(rowFor("os-noop"), undefined);
|
||||
});
|
||||
|
||||
test("applied output-style result records a run-telemetry row (source=active-profile when combo id set)", async () => {
|
||||
emitOutputStyleTelemetry({
|
||||
outputStyleResult: { body: {} as never, applied: true, appliedStyles: [], skippedReason: undefined },
|
||||
skillRequestId: "os-req-1",
|
||||
traceId: "trace-1",
|
||||
effectiveModel: "gpt-os",
|
||||
provider: "openai",
|
||||
compressionComboId: "combo-9",
|
||||
estimatedTokens: 250,
|
||||
});
|
||||
const row = await waitForRow("os-req-1");
|
||||
assert.ok(row, "expected a compression_run_telemetry row");
|
||||
assert.equal(row!.model, "gpt-os");
|
||||
assert.equal(row!.provider, "openai");
|
||||
assert.equal(row!.source, "active-profile");
|
||||
});
|
||||
|
||||
test("falls back to traceId for request id and source=default without a combo id", async () => {
|
||||
emitOutputStyleTelemetry({
|
||||
outputStyleResult: { body: {} as never, applied: true, appliedStyles: [] },
|
||||
skillRequestId: null,
|
||||
traceId: "os-trace-2",
|
||||
effectiveModel: "gpt-x",
|
||||
provider: "anthropic",
|
||||
compressionComboId: null,
|
||||
estimatedTokens: 10,
|
||||
});
|
||||
const row = await waitForRow("os-trace-2");
|
||||
assert.ok(row, "expected the row keyed by the traceId fallback");
|
||||
assert.equal(row!.source, "default");
|
||||
});
|
||||
73
tests/unit/chatcore-plugin-onrequest.test.ts
Normal file
73
tests/unit/chatcore-plugin-onrequest.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Characterization of runPluginOnRequestHook — the plugin onRequest gate extracted from
|
||||
// handleChatCore's request entry (chatCore god-file decomposition, #3501). Hooks are in-memory.
|
||||
// Locks: the discriminated result — blocked (403 Response) vs body-rewrite vs pass-through — and
|
||||
// fail-open on a throwing hook.
|
||||
import { test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { registerHook, unregisterHook } = await import("../../src/lib/plugins/hooks.ts");
|
||||
const { runPluginOnRequestHook } = await import(
|
||||
"../../open-sse/handlers/chatCore/pluginOnRequest.ts"
|
||||
);
|
||||
|
||||
const PLUGIN = "test-onrequest-plugin";
|
||||
|
||||
afterEach(() => {
|
||||
unregisterHook("onRequest", PLUGIN);
|
||||
});
|
||||
|
||||
function baseArgs(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
requestId: "req-1",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
model: "gpt-x",
|
||||
provider: "openai",
|
||||
apiKeyInfo: null,
|
||||
...overrides,
|
||||
} as Parameters<typeof runPluginOnRequestHook>[0];
|
||||
}
|
||||
|
||||
test("no registered hooks → pass-through (blocked:false, no body)", async () => {
|
||||
const gate = await runPluginOnRequestHook(baseArgs());
|
||||
assert.equal(gate.blocked, false);
|
||||
});
|
||||
|
||||
test("a blocking hook → blocked:true with a 403 JSON Response", async () => {
|
||||
registerHook("onRequest", PLUGIN, async () => ({
|
||||
blocked: true,
|
||||
response: { error: "nope" },
|
||||
}));
|
||||
const gate = await runPluginOnRequestHook(baseArgs());
|
||||
assert.equal(gate.blocked, true);
|
||||
if (!gate.blocked) return;
|
||||
assert.equal(gate.response.status, 403);
|
||||
const payload = await gate.response.json();
|
||||
assert.deepEqual(payload, { error: "nope" });
|
||||
});
|
||||
|
||||
test("a blocking hook without a response → generic plugin_block 403", async () => {
|
||||
registerHook("onRequest", PLUGIN, async () => ({ blocked: true }));
|
||||
const gate = await runPluginOnRequestHook(baseArgs());
|
||||
assert.equal(gate.blocked, true);
|
||||
if (!gate.blocked) return;
|
||||
assert.equal(gate.response.status, 403);
|
||||
const payload = (await gate.response.json()) as { error?: { type?: string } };
|
||||
assert.equal(payload.error?.type, "plugin_block");
|
||||
});
|
||||
|
||||
test("a body-rewriting hook → blocked:false with the new body", async () => {
|
||||
const rewritten = { messages: [{ role: "user", content: "rewritten" }] };
|
||||
registerHook("onRequest", PLUGIN, async () => ({ body: rewritten }));
|
||||
const gate = await runPluginOnRequestHook(baseArgs());
|
||||
assert.equal(gate.blocked, false);
|
||||
if (gate.blocked) return;
|
||||
assert.deepEqual(gate.body, rewritten);
|
||||
});
|
||||
|
||||
test("a throwing hook → fail-open pass-through (blocked:false)", async () => {
|
||||
registerHook("onRequest", PLUGIN, async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const gate = await runPluginOnRequestHook(baseArgs());
|
||||
assert.equal(gate.blocked, false);
|
||||
});
|
||||
72
tests/unit/chatcore-postcall-guardrail-context.test.ts
Normal file
72
tests/unit/chatcore-postcall-guardrail-context.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Characterization of buildPostCallGuardrailContext — the context object passed to
|
||||
// guardrailRegistry.runPostCallHooks, extracted from handleChatCore (chatCore god-file
|
||||
// decomposition, #3501). resolveDisabledGuardrails is injected so the mapping is observable in
|
||||
// isolation. Locks: the field mapping (sourceFormat/targetFormat), the constants (method "POST",
|
||||
// stream false), endpoint/headers null-coalescing, and that disabled-resolution gets apiKeyInfo/
|
||||
// body/headers.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { buildPostCallGuardrailContext } = await import(
|
||||
"../../open-sse/handlers/chatCore/postCallGuardrailContext.ts"
|
||||
);
|
||||
|
||||
function baseArgs(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
apiKeyInfo: { id: "key-1" },
|
||||
body: { messages: [] },
|
||||
clientRawRequest: { headers: { "x-test": "1" }, endpoint: "/v1/chat/completions" },
|
||||
log: { tag: "log" },
|
||||
model: "gpt-x",
|
||||
provider: "openai",
|
||||
responsePayloadFormat: "openai",
|
||||
clientResponseFormat: "claude",
|
||||
...overrides,
|
||||
} as Parameters<typeof buildPostCallGuardrailContext>[0];
|
||||
}
|
||||
|
||||
test("maps fields, constants, and source/target formats", () => {
|
||||
const seen: unknown[] = [];
|
||||
const ctx = buildPostCallGuardrailContext(baseArgs(), (arg: unknown) => {
|
||||
seen.push(arg);
|
||||
return ["g1"];
|
||||
});
|
||||
assert.equal(ctx.method, "POST");
|
||||
assert.equal(ctx.stream, false);
|
||||
assert.equal(ctx.model, "gpt-x");
|
||||
assert.equal(ctx.provider, "openai");
|
||||
assert.equal(ctx.sourceFormat, "openai");
|
||||
assert.equal(ctx.targetFormat, "claude");
|
||||
assert.equal(ctx.endpoint, "/v1/chat/completions");
|
||||
assert.deepEqual(ctx.disabledGuardrails, ["g1"]);
|
||||
// resolveDisabledGuardrails received apiKeyInfo/body/headers
|
||||
assert.deepEqual(seen[0], {
|
||||
apiKeyInfo: { id: "key-1" },
|
||||
body: { messages: [] },
|
||||
headers: { "x-test": "1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("null clientRawRequest → endpoint/headers null", () => {
|
||||
const ctx = buildPostCallGuardrailContext(baseArgs({ clientRawRequest: null }), () => []);
|
||||
assert.equal(ctx.endpoint, null);
|
||||
assert.equal(ctx.headers, null);
|
||||
});
|
||||
|
||||
test("missing endpoint → null (|| null), headers passes through", () => {
|
||||
const ctx = buildPostCallGuardrailContext(
|
||||
baseArgs({ clientRawRequest: { headers: { a: "b" } } }),
|
||||
() => []
|
||||
);
|
||||
assert.equal(ctx.endpoint, null);
|
||||
assert.deepEqual(ctx.headers, { a: "b" });
|
||||
});
|
||||
|
||||
test("null apiKeyInfo coalesces to null for resolveDisabledGuardrails", () => {
|
||||
let received: { apiKeyInfo?: unknown } = {};
|
||||
buildPostCallGuardrailContext(baseArgs({ apiKeyInfo: null }), (arg: { apiKeyInfo?: unknown }) => {
|
||||
received = arg;
|
||||
return [];
|
||||
});
|
||||
assert.equal(received.apiKeyInfo, null);
|
||||
});
|
||||
109
tests/unit/chatcore-semantic-cache-store.test.ts
Normal file
109
tests/unit/chatcore-semantic-cache-store.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// Characterization of storeSemanticCacheResponse — the Phase 9.1 non-streaming semantic-cache
|
||||
// store extracted from handleChatCore (chatCore god-file decomposition, #3501). Deps are injected
|
||||
// so the gating logic + signature derivation are observable without the real cache backend.
|
||||
// Locks: the 3-way gate (enabled + cacheable-for-write + small-enough), the signature args
|
||||
// (messages ?? input, temperature, top_p, apiKeyId), and the `prompt + completion || 0` token count.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { storeSemanticCacheResponse } = await import(
|
||||
"../../open-sse/handlers/chatCore/semanticCacheStore.ts"
|
||||
);
|
||||
|
||||
type Stored = { sig: unknown; model: string; response: unknown; tokens: number };
|
||||
|
||||
function makeDeps(overrides: Record<string, unknown> = {}) {
|
||||
const stored: Stored[] = [];
|
||||
const calls = { cacheable: 0, small: 0, signature: 0 };
|
||||
const deps = {
|
||||
isCacheableForWrite: (..._a: unknown[]) => {
|
||||
calls.cacheable++;
|
||||
return true;
|
||||
},
|
||||
isSmallEnoughForSemanticCache: (..._a: unknown[]) => {
|
||||
calls.small++;
|
||||
return true;
|
||||
},
|
||||
generateSignature: (...a: unknown[]) => {
|
||||
calls.signature++;
|
||||
return `sig:${JSON.stringify(a)}`;
|
||||
},
|
||||
setCachedResponse: (sig: unknown, model: string, response: unknown, tokens: number) => {
|
||||
stored.push({ sig, model, response, tokens });
|
||||
},
|
||||
...overrides,
|
||||
} as Parameters<typeof storeSemanticCacheResponse>[1];
|
||||
return { deps, stored, calls };
|
||||
}
|
||||
|
||||
function baseArgs(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
body: { messages: [{ role: "user", content: "hi" }], temperature: 0, top_p: 1 },
|
||||
headers: undefined,
|
||||
translatedResponse: { id: "resp-1" },
|
||||
model: "gpt-x",
|
||||
apiKeyId: "key-1",
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
log: undefined,
|
||||
...overrides,
|
||||
} as Parameters<typeof storeSemanticCacheResponse>[0];
|
||||
}
|
||||
|
||||
test("happy path → stores under a signature, tokensSaved = prompt + completion", () => {
|
||||
const { deps, stored } = makeDeps();
|
||||
storeSemanticCacheResponse(baseArgs(), deps);
|
||||
assert.equal(stored.length, 1);
|
||||
assert.equal(stored[0].model, "gpt-x");
|
||||
assert.deepEqual(stored[0].response, { id: "resp-1" });
|
||||
assert.equal(stored[0].tokens, 15);
|
||||
});
|
||||
|
||||
test("disabled → no store, no gate calls past enabled", () => {
|
||||
const { deps, stored, calls } = makeDeps();
|
||||
storeSemanticCacheResponse(baseArgs({ enabled: false }), deps);
|
||||
assert.equal(stored.length, 0);
|
||||
assert.equal(calls.cacheable, 0);
|
||||
});
|
||||
|
||||
test("not cacheable-for-write → no store", () => {
|
||||
const { deps, stored } = makeDeps({ isCacheableForWrite: () => false });
|
||||
storeSemanticCacheResponse(baseArgs(), deps);
|
||||
assert.equal(stored.length, 0);
|
||||
});
|
||||
|
||||
test("too large → no store", () => {
|
||||
const { deps, stored } = makeDeps({ isSmallEnoughForSemanticCache: () => false });
|
||||
storeSemanticCacheResponse(baseArgs(), deps);
|
||||
assert.equal(stored.length, 0);
|
||||
});
|
||||
|
||||
test("signature uses messages when present, with model/temperature/top_p/apiKeyId", () => {
|
||||
const { deps, stored } = makeDeps();
|
||||
storeSemanticCacheResponse(baseArgs(), deps);
|
||||
const sig = stored[0].sig as string;
|
||||
assert.ok(sig.includes("gpt-x"));
|
||||
assert.ok(sig.includes("key-1"));
|
||||
});
|
||||
|
||||
test("falls back to body.input when messages absent", () => {
|
||||
let captured: unknown[] = [];
|
||||
const { deps } = makeDeps({
|
||||
generateSignature: (...a: unknown[]) => {
|
||||
captured = a;
|
||||
return "sig";
|
||||
},
|
||||
});
|
||||
storeSemanticCacheResponse(
|
||||
baseArgs({ body: { input: "the-input", temperature: 0, top_p: 1 } }),
|
||||
deps
|
||||
);
|
||||
// args: (model, messages ?? input, temperature, top_p, apiKeyId)
|
||||
assert.equal(captured[1], "the-input");
|
||||
});
|
||||
|
||||
test("missing usage → tokensSaved coerces to 0 (NaN || 0)", () => {
|
||||
const { deps, stored } = makeDeps();
|
||||
storeSemanticCacheResponse(baseArgs({ usage: undefined }), deps);
|
||||
assert.equal(stored[0].tokens, 0);
|
||||
});
|
||||
96
tests/unit/chatcore-streaming-cache-store.test.ts
Normal file
96
tests/unit/chatcore-streaming-cache-store.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Characterization of storeStreamingSemanticCacheResponse — the streaming Phase 9.1 cache store
|
||||
// extracted from handleChatCore's onStreamComplete (chatCore god-file decomposition, #3501). Deps
|
||||
// are injected so the gate + `_streamed` strip + signature + token accounting are observable
|
||||
// without the real cache backend. Locks: the 4-way gate (enabled + status 200 + body + cacheable),
|
||||
// the `_streamed` strip, skip-on-too-large, `Number(...) || 0` tokens, and fail-open on throw.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { storeStreamingSemanticCacheResponse } = await import(
|
||||
"../../open-sse/handlers/chatCore/streamingSemanticCacheStore.ts"
|
||||
);
|
||||
|
||||
type Stored = { sig: unknown; model: string; body: Record<string, unknown>; tokens: number };
|
||||
|
||||
function makeDeps(overrides: Record<string, unknown> = {}) {
|
||||
const stored: Stored[] = [];
|
||||
const deps = {
|
||||
isCacheableForWrite: () => true,
|
||||
isSmallEnoughForSemanticCache: () => true,
|
||||
generateSignature: (...a: unknown[]) => `sig:${JSON.stringify(a)}`,
|
||||
setCachedResponse: (sig: unknown, model: string, body: Record<string, unknown>, tokens: number) =>
|
||||
stored.push({ sig, model, body, tokens }),
|
||||
...overrides,
|
||||
} as Parameters<typeof storeStreamingSemanticCacheResponse>[1];
|
||||
return { deps, stored };
|
||||
}
|
||||
|
||||
function baseArgs(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
streamStatus: 200,
|
||||
streamResponseBody: { id: "resp-1", _streamed: true },
|
||||
body: { messages: [{ role: "user", content: "hi" }], temperature: 0, top_p: 1 },
|
||||
headers: undefined,
|
||||
model: "gpt-x",
|
||||
apiKeyId: "key-1",
|
||||
streamUsage: { prompt_tokens: 12, completion_tokens: 8 },
|
||||
log: undefined,
|
||||
...overrides,
|
||||
} as Parameters<typeof storeStreamingSemanticCacheResponse>[0];
|
||||
}
|
||||
|
||||
test("happy path → stores cleaned body (no _streamed), tokens = prompt + completion", () => {
|
||||
const { deps, stored } = makeDeps();
|
||||
storeStreamingSemanticCacheResponse(baseArgs(), deps);
|
||||
assert.equal(stored.length, 1);
|
||||
assert.equal(stored[0].model, "gpt-x");
|
||||
assert.equal(stored[0].tokens, 20);
|
||||
assert.equal("_streamed" in stored[0].body, false);
|
||||
assert.equal(stored[0].body.id, "resp-1");
|
||||
});
|
||||
|
||||
test("non-200 stream status → no store", () => {
|
||||
const { deps, stored } = makeDeps();
|
||||
storeStreamingSemanticCacheResponse(baseArgs({ streamStatus: 500 }), deps);
|
||||
assert.equal(stored.length, 0);
|
||||
});
|
||||
|
||||
test("disabled → no store", () => {
|
||||
const { deps, stored } = makeDeps();
|
||||
storeStreamingSemanticCacheResponse(baseArgs({ enabled: false }), deps);
|
||||
assert.equal(stored.length, 0);
|
||||
});
|
||||
|
||||
test("missing response body → no store", () => {
|
||||
const { deps, stored } = makeDeps();
|
||||
storeStreamingSemanticCacheResponse(baseArgs({ streamResponseBody: null }), deps);
|
||||
assert.equal(stored.length, 0);
|
||||
});
|
||||
|
||||
test("not cacheable-for-write → no store", () => {
|
||||
const { deps, stored } = makeDeps({ isCacheableForWrite: () => false });
|
||||
storeStreamingSemanticCacheResponse(baseArgs(), deps);
|
||||
assert.equal(stored.length, 0);
|
||||
});
|
||||
|
||||
test("too large → no store (early return inside try)", () => {
|
||||
const { deps, stored } = makeDeps({ isSmallEnoughForSemanticCache: () => false });
|
||||
storeStreamingSemanticCacheResponse(baseArgs(), deps);
|
||||
assert.equal(stored.length, 0);
|
||||
});
|
||||
|
||||
test("missing usage → tokens coerce to 0", () => {
|
||||
const { deps, stored } = makeDeps();
|
||||
storeStreamingSemanticCacheResponse(baseArgs({ streamUsage: null }), deps);
|
||||
assert.equal(stored[0].tokens, 0);
|
||||
});
|
||||
|
||||
test("a throwing dep is swallowed (fail-open, non-critical)", () => {
|
||||
const { deps } = makeDeps({
|
||||
setCachedResponse: () => {
|
||||
throw new Error("cache write boom");
|
||||
},
|
||||
});
|
||||
assert.doesNotThrow(() => storeStreamingSemanticCacheResponse(baseArgs(), deps));
|
||||
});
|
||||
102
tests/unit/chatcore-streaming-pipeline.test.ts
Normal file
102
tests/unit/chatcore-streaming-pipeline.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Characterization of assembleStreamingPipeline — the streaming transform-chain assembly extracted
|
||||
// from handleChatCore's streaming success path (chatCore god-file decomposition, #3501). All
|
||||
// transform factories are injected; a fake "stream" records each pipeThrough so the exact chain
|
||||
// order and the branch conditions (PII explicit vs feature-flag, progress, echo) are observable
|
||||
// without real ReadableStreams. Locks: transform order, the progress header side-effect, and the
|
||||
// PII branch precedence (explicit createPiiTransform wins over the feature flag).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { assembleStreamingPipeline } = await import(
|
||||
"../../open-sse/handlers/chatCore/streamingPipeline.ts"
|
||||
);
|
||||
|
||||
// A fake stream: each pipeThrough appends the transform's tag and returns a new fake stream.
|
||||
function fakeStream(tag: string, log: string[]) {
|
||||
return {
|
||||
tag,
|
||||
pipeThrough(t: { __tag: string }) {
|
||||
log.push(t.__tag);
|
||||
return fakeStream(t.__tag, log);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(over: Record<string, unknown> = {}) {
|
||||
const log: string[] = [];
|
||||
const deps = {
|
||||
wantsProgress: () => false,
|
||||
pipeWithDisconnect: (..._a: unknown[]) => fakeStream("pii-base", log),
|
||||
isFeatureFlagEnabled: () => false,
|
||||
createPiiSseTransform: () => ({ __tag: "pii-flag" }),
|
||||
createProgressTransform: () => ({ __tag: "progress" }),
|
||||
createSseHeartbeatTransform: () => ({ __tag: "heartbeat" }),
|
||||
shapeForClientFormat: (f: unknown) => f,
|
||||
createModelEchoTransform: () => ({ __tag: "echo" }),
|
||||
...over,
|
||||
} as Parameters<typeof assembleStreamingPipeline>[1];
|
||||
return { deps, log };
|
||||
}
|
||||
|
||||
function baseArgs(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
providerResponse: {},
|
||||
transformStream: {},
|
||||
streamController: { signal: {} as AbortSignal },
|
||||
createPiiTransform: undefined,
|
||||
clientRawRequestHeaders: null,
|
||||
clientResponseFormat: "openai",
|
||||
echoModel: null,
|
||||
responseHeaders: {} as Record<string, string>,
|
||||
...over,
|
||||
} as Parameters<typeof assembleStreamingPipeline>[0];
|
||||
}
|
||||
|
||||
test("baseline (no pii, no progress, no echo) → only heartbeat in the chain", () => {
|
||||
const { deps, log } = makeDeps();
|
||||
assembleStreamingPipeline(baseArgs(), deps);
|
||||
assert.deepEqual(log, ["heartbeat"]);
|
||||
});
|
||||
|
||||
test("feature-flag PII → pii-flag transform applied before heartbeat", () => {
|
||||
const { deps, log } = makeDeps({ isFeatureFlagEnabled: () => true });
|
||||
assembleStreamingPipeline(baseArgs(), deps);
|
||||
assert.deepEqual(log, ["pii-flag", "heartbeat"]);
|
||||
});
|
||||
|
||||
test("explicit createPiiTransform wins over the feature flag", () => {
|
||||
const { deps, log } = makeDeps({ isFeatureFlagEnabled: () => true });
|
||||
const explicit = () => ({ __tag: "pii-explicit" });
|
||||
assembleStreamingPipeline(baseArgs({ createPiiTransform: explicit }), deps);
|
||||
assert.deepEqual(log, ["pii-explicit", "heartbeat"]);
|
||||
});
|
||||
|
||||
test("progress enabled → progress transform + progress header set", () => {
|
||||
const { deps, log } = makeDeps({ wantsProgress: () => true });
|
||||
const args = baseArgs();
|
||||
assembleStreamingPipeline(args, deps);
|
||||
assert.deepEqual(log, ["progress", "heartbeat"]);
|
||||
assert.ok(Object.values(args.responseHeaders).includes("enabled"));
|
||||
});
|
||||
|
||||
test("progress disabled → no progress header", () => {
|
||||
const { deps } = makeDeps({ wantsProgress: () => false });
|
||||
const args = baseArgs();
|
||||
assembleStreamingPipeline(args, deps);
|
||||
assert.deepEqual(args.responseHeaders, {});
|
||||
});
|
||||
|
||||
test("echoModel set → echo transform applied last", () => {
|
||||
const { deps, log } = makeDeps();
|
||||
assembleStreamingPipeline(baseArgs({ echoModel: "alias-x" }), deps);
|
||||
assert.deepEqual(log, ["heartbeat", "echo"]);
|
||||
});
|
||||
|
||||
test("full chain order: pii → progress → heartbeat → echo", () => {
|
||||
const { deps, log } = makeDeps({
|
||||
isFeatureFlagEnabled: () => true,
|
||||
wantsProgress: () => true,
|
||||
});
|
||||
assembleStreamingPipeline(baseArgs({ echoModel: "alias-x" }), deps);
|
||||
assert.deepEqual(log, ["pii-flag", "progress", "heartbeat", "echo"]);
|
||||
});
|
||||
65
tests/unit/chatcore-streaming-response-headers.test.ts
Normal file
65
tests/unit/chatcore-streaming-response-headers.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Characterization of assembleStreamingResponseHeaders — the streaming response header builder
|
||||
// extracted from handleChatCore's streaming success path (chatCore god-file decomposition, #3501).
|
||||
// buildStreamingResponseHeaders is injected so the merge of upstream headers + request-id + the
|
||||
// optional compression header is observable. Locks: zeroed latency/usage/cost at stream start, the
|
||||
// x-omniroute-request-id, and the compression header only when meta is present.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { assembleStreamingResponseHeaders } = await import(
|
||||
"../../open-sse/handlers/chatCore/streamingResponseHeaders.ts"
|
||||
);
|
||||
|
||||
function makeBuild() {
|
||||
const calls: Array<{ headers: unknown; meta: Record<string, unknown> }> = [];
|
||||
const build = (headers: unknown, meta: Record<string, unknown>) => {
|
||||
calls.push({ headers, meta });
|
||||
return { "x-upstream": "kept" };
|
||||
};
|
||||
return { build: build as Parameters<typeof assembleStreamingResponseHeaders>[1], calls };
|
||||
}
|
||||
|
||||
function baseArgs(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
providerHeaders: new Headers({ "content-type": "text/event-stream" }),
|
||||
provider: "openai",
|
||||
model: "gpt-x",
|
||||
pendingRequestId: "preq-1",
|
||||
compressionResponseMeta: undefined,
|
||||
...overrides,
|
||||
} as Parameters<typeof assembleStreamingResponseHeaders>[0];
|
||||
}
|
||||
|
||||
test("merges upstream headers and sets x-omniroute-request-id", () => {
|
||||
const { build } = makeBuild();
|
||||
const h = assembleStreamingResponseHeaders(baseArgs(), build);
|
||||
assert.equal(h["x-upstream"], "kept");
|
||||
assert.equal(h["x-omniroute-request-id"], "preq-1");
|
||||
});
|
||||
|
||||
test("buildStreamingResponseHeaders receives zeroed latency/usage/cost and cacheHit false", () => {
|
||||
const { build, calls } = makeBuild();
|
||||
assembleStreamingResponseHeaders(baseArgs(), build);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].meta.cacheHit, false);
|
||||
assert.equal(calls[0].meta.latencyMs, 0);
|
||||
assert.equal(calls[0].meta.usage, null);
|
||||
assert.equal(calls[0].meta.costUsd, 0);
|
||||
assert.equal(calls[0].meta.provider, "openai");
|
||||
assert.equal(calls[0].meta.model, "gpt-x");
|
||||
});
|
||||
|
||||
test("no compression meta → no compression header", () => {
|
||||
const { build } = makeBuild();
|
||||
const h = assembleStreamingResponseHeaders(baseArgs({ compressionResponseMeta: undefined }), build);
|
||||
assert.ok(!Object.values(h).includes("engine:z"));
|
||||
});
|
||||
|
||||
test("compression meta present → compression header set", () => {
|
||||
const { build } = makeBuild();
|
||||
const h = assembleStreamingResponseHeaders(
|
||||
baseArgs({ compressionResponseMeta: "engine:z; source=routing" }),
|
||||
build
|
||||
);
|
||||
assert.ok(Object.values(h).includes("engine:z; source=routing"));
|
||||
});
|
||||
@@ -1119,6 +1119,13 @@ test("Executor MODEL_MAP: dot-form OmniRoute IDs translate to dash-form ChatGPT
|
||||
["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;
|
||||
@@ -1141,6 +1148,45 @@ test("Executor MODEL_MAP: dot-form OmniRoute IDs translate to dash-form ChatGPT
|
||||
}
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -121,12 +121,13 @@ test("INTENTIONALLY_INTERNAL is exported from check-db-rules.mjs", () => {
|
||||
assert.ok(INTENTIONALLY_INTERNAL.size > 0, "INTENTIONALLY_INTERNAL must not be empty");
|
||||
});
|
||||
|
||||
test("INTENTIONALLY_INTERNAL contains the expected 29 audited modules", () => {
|
||||
test("INTENTIONALLY_INTERNAL contains the expected 32 audited modules", () => {
|
||||
const expected = [
|
||||
"_rowTypes",
|
||||
"accessTokens",
|
||||
"apiKeyColumnFallbacks",
|
||||
"apiKeyUsageLimitFields",
|
||||
"caseMapping",
|
||||
"cleanup",
|
||||
"cliToolState",
|
||||
"comboForecast",
|
||||
@@ -141,11 +142,13 @@ test("INTENTIONALLY_INTERNAL contains the expected 29 audited modules", () => {
|
||||
"migrationRunner",
|
||||
"notion",
|
||||
"obsidian",
|
||||
"optimizationSettings",
|
||||
"pluginMetrics",
|
||||
"prompts",
|
||||
"providerNodeSelect",
|
||||
"providerStats",
|
||||
"recovery",
|
||||
"schemaColumns",
|
||||
"secrets",
|
||||
"serviceModels",
|
||||
"stateReset",
|
||||
|
||||
@@ -23,10 +23,9 @@ test("flags a clientId behind a process.env fallback (env || literal)", () => {
|
||||
});
|
||||
|
||||
test("flags clientSecret and apiKey literals too", () => {
|
||||
const src = [
|
||||
`clientSecret: "GOCSPX-secret-literal",`,
|
||||
`apiKey: "AIzaSyLeakedFirebaseKey",`,
|
||||
].join("\n");
|
||||
const src = [`clientSecret: "GOCSPX-secret-literal",`, `apiKey: "AIzaSyLeakedFirebaseKey",`].join(
|
||||
"\n"
|
||||
);
|
||||
const v = findLiteralCreds(src, new Set(), "x.ts");
|
||||
assert.equal(v.length, 2);
|
||||
});
|
||||
@@ -41,7 +40,7 @@ test("does NOT flag resolvePublicCredMulti() with literal env-name args", () =>
|
||||
assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []);
|
||||
});
|
||||
|
||||
test("does NOT flag empty-string fallback (process.env || \"\")", () => {
|
||||
test('does NOT flag empty-string fallback (process.env || "")', () => {
|
||||
const src = `clientIdDefault: process.env.GITLAB_OAUTH_CLIENT_ID || "",`;
|
||||
assert.deepEqual(findLiteralCreds(src, new Set(), "x.ts"), []);
|
||||
});
|
||||
@@ -75,10 +74,7 @@ test("a NEW literal is still flagged even with the real frozen allowlist", () =>
|
||||
});
|
||||
|
||||
test("real scanned files produce ZERO violations with the frozen allowlist (gate exits 0)", () => {
|
||||
const scanned = [
|
||||
"open-sse/config/providerRegistry.ts",
|
||||
"src/lib/oauth/constants/oauth.ts",
|
||||
];
|
||||
const scanned = ["open-sse/config/providerRegistry.ts", "src/lib/oauth/constants/oauth.ts"];
|
||||
for (const rel of scanned) {
|
||||
const src = fs.readFileSync(path.join(repoRoot, rel), "utf8") as string;
|
||||
const v = findLiteralCreds(src, KNOWN_LITERAL_CREDS, rel);
|
||||
@@ -91,10 +87,7 @@ test("every frozen literal is actually present in a scanned file (no dead allowl
|
||||
// per-provider plugins (#3993), so providerRegistry.ts is now a re-export barrel;
|
||||
// entries keyed by an explicit `file:line:value` are checked against the file named
|
||||
// in the key (which is where the literal actually lives), not this anchor blob.
|
||||
const anchorFiles = [
|
||||
"open-sse/config/providerRegistry.ts",
|
||||
"src/lib/oauth/constants/oauth.ts",
|
||||
];
|
||||
const anchorFiles = ["open-sse/config/providerRegistry.ts", "src/lib/oauth/constants/oauth.ts"];
|
||||
const anchorBlob = anchorFiles
|
||||
.map((rel) => fs.readFileSync(path.join(repoRoot, rel), "utf8") as string)
|
||||
.join("\n");
|
||||
@@ -107,9 +100,15 @@ test("every frozen literal is actually present in a scanned file (no dead allowl
|
||||
// match on file content rather than the exact line number.
|
||||
const file = entry.replace(/:\d+:.*$/, "");
|
||||
const src = fs.readFileSync(path.join(repoRoot, file), "utf8") as string;
|
||||
assert.ok(src.includes(value), `frozen literal not found in its source file ${file}: ${value}`);
|
||||
assert.ok(
|
||||
src.includes(value),
|
||||
`frozen literal not found in its source file ${file}: ${value}`
|
||||
);
|
||||
} else {
|
||||
assert.ok(anchorBlob.includes(value), `frozen literal not found in any anchor file: ${value}`);
|
||||
assert.ok(
|
||||
anchorBlob.includes(value),
|
||||
`frozen literal not found in any anchor file: ${value}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -127,8 +126,16 @@ test("with an empty allowlist the real scanned files surface zero violations (al
|
||||
) as string;
|
||||
const regViolations = findLiteralCreds(reg, new Set(), "providerRegistry.ts");
|
||||
const oauthViolations = findLiteralCreds(oauth, new Set(), "oauth.ts");
|
||||
assert.equal(regViolations.length, 0, `providerRegistry.ts should be clean, got: ${regViolations.join(", ")}`);
|
||||
assert.equal(oauthViolations.length, 0, `oauth.ts should be clean, got: ${oauthViolations.join(", ")}`);
|
||||
assert.equal(
|
||||
regViolations.length,
|
||||
0,
|
||||
`providerRegistry.ts should be clean, got: ${regViolations.join(", ")}`
|
||||
);
|
||||
assert.equal(
|
||||
oauthViolations.length,
|
||||
0,
|
||||
`oauth.ts should be clean, got: ${oauthViolations.join(", ")}`
|
||||
);
|
||||
});
|
||||
|
||||
// --- 6A.8: expanded scope (open-sse/** and src/lib/oauth/**) ---
|
||||
@@ -164,21 +171,33 @@ test("6A.8 stale: known literal that was removed from the codebase is detected a
|
||||
const liveViolations = findLiteralCreds(src, new Set(), "file.ts");
|
||||
// The allowlist has an entry "old-literal" but it's not in live violations.
|
||||
const stale = (reportStale as (a: Set<string>, b: string[], c: string) => string[])(
|
||||
new Set(["old-literal"]), liveViolations, "check-public-creds"
|
||||
new Set(["old-literal"]),
|
||||
liveViolations,
|
||||
"check-public-creds"
|
||||
);
|
||||
assert.deepEqual(stale, ["old-literal"]);
|
||||
});
|
||||
|
||||
test("6A.8: open-sse/services/usage.ts FP — function-signature apiKey is suppressed by allowlist", () => {
|
||||
// open-sse/services/usage.ts L543: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")`
|
||||
// open-sse/services/usage/minimax.ts L213: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")`
|
||||
// The CRED_KEY_RE matches `apiKey:` in the TypeScript function-parameter type annotation.
|
||||
// "minimax" and "minimax-cn" are provider-name strings in the type, NOT credentials.
|
||||
// Frozen in KNOWN_LITERAL_CREDS as FPs by file:line:value key.
|
||||
const realSrc = fs.readFileSync(path.join(repoRoot, "open-sse/services/usage.ts"), "utf8") as string;
|
||||
// Frozen in KNOWN_LITERAL_CREDS as FPs by file:line:value key. The MiniMax family was
|
||||
// extracted from services/usage.ts into services/usage/minimax.ts (god-file decomposition),
|
||||
// so the FP moved with the getMiniMaxUsage signature.
|
||||
const minimaxPath = "open-sse/services/usage/minimax.ts";
|
||||
const realSrc = fs.readFileSync(path.join(repoRoot, minimaxPath), "utf8") as string;
|
||||
// With empty allowlist the FP shows up (it IS flagged by the regex).
|
||||
const vWithEmpty = findLiteralCreds(realSrc, new Set(), "open-sse/services/usage.ts");
|
||||
assert.ok(vWithEmpty.some((v) => v.includes("minimax")), `expected FP 'minimax' violations with empty allowlist, got: ${vWithEmpty.join(", ")}`);
|
||||
const vWithEmpty = findLiteralCreds(realSrc, new Set(), minimaxPath);
|
||||
assert.ok(
|
||||
vWithEmpty.some((v) => v.includes("minimax")),
|
||||
`expected FP 'minimax' violations with empty allowlist, got: ${vWithEmpty.join(", ")}`
|
||||
);
|
||||
// With KNOWN_LITERAL_CREDS the FPs are suppressed.
|
||||
const vWithAllowlist = findLiteralCreds(realSrc, KNOWN_LITERAL_CREDS, "open-sse/services/usage.ts");
|
||||
assert.deepEqual(vWithAllowlist, [], `FP violations should be suppressed, got: ${vWithAllowlist.join(", ")}`);
|
||||
const vWithAllowlist = findLiteralCreds(realSrc, KNOWN_LITERAL_CREDS, minimaxPath);
|
||||
assert.deepEqual(
|
||||
vWithAllowlist,
|
||||
[],
|
||||
`FP violations should be suppressed, got: ${vWithAllowlist.join(", ")}`
|
||||
);
|
||||
});
|
||||
|
||||
47
tests/unit/claude-codex-identity-version-sync.test.ts
Normal file
47
tests/unit/claude-codex-identity-version-sync.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* tests/unit/claude-codex-identity-version-sync.test.ts
|
||||
*
|
||||
* Guards the pinned CLI identity versions against drift. The Claude Code version
|
||||
* lives in FOUR places (claudeIdentity, anthropicHeaders, claudeCodeCompatible,
|
||||
* ccBridgeTransforms) and MUST stay in lockstep — a partial bump produces an
|
||||
* inconsistent wire fingerprint. The Codex client version lives in codexClient.
|
||||
*
|
||||
* When you capture a newer claude-cli / codex release, bump ALL constants and
|
||||
* update the pinned values below in the same change.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const id = await import("../../open-sse/executors/claudeIdentity.ts");
|
||||
const hdr = await import("../../open-sse/config/anthropicHeaders.ts");
|
||||
const compat = await import("../../open-sse/services/claudeCodeCompatible.ts");
|
||||
const bridge = await import("../../open-sse/services/ccBridgeTransforms.ts");
|
||||
const codexCfg = await import("../../open-sse/config/codexClient.ts");
|
||||
|
||||
test("Claude CLI version constants are in lockstep across all 4 sources", () => {
|
||||
const V = id.CLAUDE_CODE_VERSION;
|
||||
assert.equal(hdr.CLAUDE_CLI_VERSION, V, "anthropicHeaders.CLAUDE_CLI_VERSION drift");
|
||||
assert.equal(compat.CLAUDE_CODE_COMPATIBLE_VERSION, V, "claudeCodeCompatible version drift");
|
||||
assert.equal(bridge.DEFAULT_CLAUDE_CODE_VERSION, V, "ccBridgeTransforms version drift");
|
||||
assert.equal(
|
||||
hdr.CLAUDE_CLI_USER_AGENT,
|
||||
`claude-cli/${V} (external, cli)`,
|
||||
"CLAUDE_CLI_USER_AGENT drift"
|
||||
);
|
||||
assert.equal(
|
||||
compat.CLAUDE_CODE_COMPATIBLE_USER_AGENT,
|
||||
`claude-cli/${V} (external, sdk-cli)`,
|
||||
"CLAUDE_CODE_COMPATIBLE_USER_AGENT drift"
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude CLI is pinned to the captured 2.1.187 release", () => {
|
||||
assert.equal(id.CLAUDE_CODE_VERSION, "2.1.187");
|
||||
});
|
||||
|
||||
test("Codex client is pinned to the captured 0.142.0 release", () => {
|
||||
assert.equal(codexCfg.getCodexClientVersion(), "0.142.0");
|
||||
assert.equal(codexCfg.getCodexUserAgent(), "codex-cli/0.142.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(codexCfg.getCodexDefaultHeaders().Version, "0.142.0");
|
||||
});
|
||||
99
tests/unit/claude-haiku-no-adaptive-thinking.test.ts
Normal file
99
tests/unit/claude-haiku-no-adaptive-thinking.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { normalizeClaudeHaikuConstraints } from "@omniroute/open-sse/services/claudeHaikuConstraints.ts";
|
||||
|
||||
// Claude Haiku 4.5 (and other haiku-tier models) reject:
|
||||
// 1. `thinking.type:"adaptive"` (only Sonnet/Opus support adaptive thinking)
|
||||
// 2. `output_config.effort` (also Sonnet/Opus only)
|
||||
// Several OmniRoute paths can still emit these on Haiku targets:
|
||||
// - Native Claude passthrough from newer Claude Code / Cowork clients
|
||||
// - OpenAI→Claude translator buckets when reasoning_effort is "max"/"xhigh"
|
||||
// - Per-model thinking defaults from the request flow
|
||||
// This normalizer is the final, provider-agnostic guard keyed on the resolved
|
||||
// upstream model. Mirrors upstream 9router 401d93bd5 (`claude.js`).
|
||||
|
||||
describe("normalizeClaudeHaikuConstraints", () => {
|
||||
it("converts thinking.type:'adaptive' to a manual 'enabled' shape on haiku", () => {
|
||||
const input = {
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
thinking: { type: "adaptive" },
|
||||
};
|
||||
const out = normalizeClaudeHaikuConstraints(input, input.model);
|
||||
assert.deepEqual(out.thinking, { type: "enabled", budget_tokens: 10000 });
|
||||
});
|
||||
|
||||
it("strips output_config.effort on haiku and keeps other output_config fields", () => {
|
||||
const input = {
|
||||
model: "claude-haiku-4.5",
|
||||
output_config: { effort: "high", other_field: "keep-me" },
|
||||
};
|
||||
const out = normalizeClaudeHaikuConstraints(input, input.model);
|
||||
assert.deepEqual(out.output_config, { other_field: "keep-me" });
|
||||
});
|
||||
|
||||
it("removes output_config entirely when stripping effort leaves it empty", () => {
|
||||
const input = {
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
output_config: { effort: "max" },
|
||||
};
|
||||
const out = normalizeClaudeHaikuConstraints(input, input.model);
|
||||
assert.equal((out as Record<string, unknown>).output_config, undefined);
|
||||
});
|
||||
|
||||
it("applies BOTH transforms when the body carries adaptive thinking + effort", () => {
|
||||
const input = {
|
||||
model: "claude-haiku-4.5",
|
||||
thinking: { type: "adaptive" },
|
||||
output_config: { effort: "xhigh" },
|
||||
};
|
||||
const out = normalizeClaudeHaikuConstraints(input, input.model);
|
||||
assert.deepEqual(out.thinking, { type: "enabled", budget_tokens: 10000 });
|
||||
assert.equal((out as Record<string, unknown>).output_config, undefined);
|
||||
});
|
||||
|
||||
it("is a no-op for Sonnet (adaptive is valid there)", () => {
|
||||
const input = {
|
||||
model: "claude-sonnet-4-5",
|
||||
thinking: { type: "adaptive" },
|
||||
output_config: { effort: "high" },
|
||||
};
|
||||
const out = normalizeClaudeHaikuConstraints(input, input.model);
|
||||
assert.strictEqual(out, input);
|
||||
});
|
||||
|
||||
it("is a no-op for Opus (adaptive is valid there)", () => {
|
||||
const input = {
|
||||
model: "claude-opus-4-7",
|
||||
thinking: { type: "adaptive" },
|
||||
output_config: { effort: "max" },
|
||||
};
|
||||
const out = normalizeClaudeHaikuConstraints(input, input.model);
|
||||
assert.strictEqual(out, input);
|
||||
});
|
||||
|
||||
it("leaves haiku bodies that have no adaptive thinking or effort untouched", () => {
|
||||
const input = {
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
};
|
||||
const out = normalizeClaudeHaikuConstraints(input, input.model);
|
||||
assert.strictEqual(out, input);
|
||||
});
|
||||
|
||||
it("returns the body untouched when model is null/undefined", () => {
|
||||
const input = { thinking: { type: "adaptive" }, output_config: { effort: "high" } };
|
||||
assert.strictEqual(normalizeClaudeHaikuConstraints(input, null), input);
|
||||
assert.strictEqual(normalizeClaudeHaikuConstraints(input, undefined), input);
|
||||
});
|
||||
|
||||
it("matches case-insensitively (any model id containing 'haiku')", () => {
|
||||
const input = {
|
||||
model: "claude-3-5-Haiku-latest",
|
||||
thinking: { type: "adaptive" },
|
||||
};
|
||||
const out = normalizeClaudeHaikuConstraints(input, input.model);
|
||||
assert.deepEqual(out.thinking, { type: "enabled", budget_tokens: 10000 });
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
cloakThirdPartyToolNames,
|
||||
needsThirdPartyCloak,
|
||||
isAnthropicServerToolType,
|
||||
} from "../../open-sse/services/claudeCodeToolRemapper.ts";
|
||||
import {
|
||||
sanitizeClaudeToolSchema,
|
||||
@@ -107,6 +108,48 @@ describe("cloakThirdPartyToolNames", () => {
|
||||
assert.equal(needsThirdPartyCloak("read_file"), true);
|
||||
assert.equal(needsThirdPartyCloak("mixture_of_agents"), true);
|
||||
});
|
||||
|
||||
it("needsThirdPartyCloak leaves mcp__ namespace untouched (#4861)", () => {
|
||||
// Genuine Claude Code MCP names Anthropic accepts natively; cloaking them
|
||||
// caused round-trip "Tool reference 'mcp__…' not found" 400s on claude OAuth.
|
||||
assert.equal(needsThirdPartyCloak("mcp__filesystem__read_file"), false);
|
||||
assert.equal(needsThirdPartyCloak("mcp__github__create_issue"), false);
|
||||
assert.equal(needsThirdPartyCloak("mcp__server"), false);
|
||||
});
|
||||
|
||||
it("preserves the reserved name of a versioned Anthropic server tool", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 5 }],
|
||||
};
|
||||
cloakThirdPartyToolNames(body);
|
||||
// Anthropic requires tools.N.web_search_20250305.name === "web_search".
|
||||
assert.equal((body.tools as AnyRecord[])[0].name, "web_search");
|
||||
// No reverse-map entry needed because nothing was cloaked.
|
||||
assert.equal((body._toolNameMap as Map<string, string> | undefined)?.has("WebSearch") ?? false, false);
|
||||
});
|
||||
|
||||
it("still cloaks a genuine third-party tool sitting next to a server tool", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [
|
||||
{ type: "web_search_20250305", name: "web_search" },
|
||||
{ name: "mixture_of_agents" },
|
||||
],
|
||||
};
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.equal((body.tools as AnyRecord[])[0].name, "web_search");
|
||||
assert.equal((body.tools as AnyRecord[])[1].name, "MixtureOfAgents");
|
||||
});
|
||||
|
||||
it("isAnthropicServerToolType detects versioned + non-versioned server tools", () => {
|
||||
assert.equal(isAnthropicServerToolType("web_search_20250305"), true);
|
||||
assert.equal(isAnthropicServerToolType("code_execution_20250522"), true);
|
||||
assert.equal(isAnthropicServerToolType("web_search"), true);
|
||||
assert.equal(isAnthropicServerToolType("web_search_preview"), true);
|
||||
// Not server tools — must remain cloakable.
|
||||
assert.equal(isAnthropicServerToolType("mixture_of_agents"), false);
|
||||
assert.equal(isAnthropicServerToolType("Bash"), false);
|
||||
assert.equal(isAnthropicServerToolType(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeClaudeToolSchemas — boolean schema preservation", () => {
|
||||
|
||||
66
tests/unit/claude-oauth-usage-429-backoff.test.ts
Normal file
66
tests/unit/claude-oauth-usage-429-backoff.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, beforeEach } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
|
||||
import {
|
||||
OAUTH_USAGE_429_COOLDOWN_MS,
|
||||
_resetClaudeOauthUsageCooldown,
|
||||
isClaudeOauthUsageCoolingDown,
|
||||
markClaudeOauthUsage429,
|
||||
} from "../../open-sse/services/claudeUsageCooldown.ts";
|
||||
|
||||
describe("claude OAuth usage 429 backoff", () => {
|
||||
beforeEach(() => {
|
||||
_resetClaudeOauthUsageCooldown();
|
||||
});
|
||||
|
||||
it("starts uncooled for any token", () => {
|
||||
assert.equal(isClaudeOauthUsageCoolingDown("tok-a"), false);
|
||||
assert.equal(isClaudeOauthUsageCoolingDown(undefined), false);
|
||||
});
|
||||
|
||||
it("treats a single 429 as entering cooldown for that token only", () => {
|
||||
const t0 = 1_000_000;
|
||||
markClaudeOauthUsage429("tok-a", t0);
|
||||
assert.equal(isClaudeOauthUsageCoolingDown("tok-a", t0 + 1), true);
|
||||
// A different token is unaffected — cooldown is per-token, not global.
|
||||
assert.equal(isClaudeOauthUsageCoolingDown("tok-b", t0 + 1), false);
|
||||
});
|
||||
|
||||
it("repeated 429s on the same token do NOT spam: stays cooling once entered", () => {
|
||||
const t0 = 2_000_000;
|
||||
markClaudeOauthUsage429("tok-a", t0);
|
||||
// Simulate the scheduler hitting 429 again 10s, 30s, 60s later
|
||||
for (const dt of [10_000, 30_000, 60_000]) {
|
||||
markClaudeOauthUsage429("tok-a", t0 + dt);
|
||||
assert.equal(
|
||||
isClaudeOauthUsageCoolingDown("tok-a", t0 + dt),
|
||||
true,
|
||||
`still cooling at +${dt}ms`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("cooldown expires after OAUTH_USAGE_429_COOLDOWN_MS and the token becomes eligible again", () => {
|
||||
const t0 = 3_000_000;
|
||||
markClaudeOauthUsage429("tok-a", t0);
|
||||
assert.equal(
|
||||
isClaudeOauthUsageCoolingDown("tok-a", t0 + OAUTH_USAGE_429_COOLDOWN_MS - 1),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isClaudeOauthUsageCoolingDown("tok-a", t0 + OAUTH_USAGE_429_COOLDOWN_MS + 1),
|
||||
false,
|
||||
"should be eligible again after cooldown window"
|
||||
);
|
||||
});
|
||||
|
||||
it("undefined/missing access token is a no-op (does not poison the map)", () => {
|
||||
const t0 = 4_000_000;
|
||||
markClaudeOauthUsage429(undefined, t0);
|
||||
assert.equal(isClaudeOauthUsageCoolingDown(undefined, t0 + 1), false);
|
||||
});
|
||||
|
||||
it("cooldown defaults to 3 minutes (matches upstream OAUTH_429_COOLDOWN_MS)", () => {
|
||||
assert.equal(OAUTH_USAGE_429_COOLDOWN_MS, 180_000);
|
||||
});
|
||||
});
|
||||
127
tests/unit/cli-tools-settings-jsonc.test.ts
Normal file
127
tests/unit/cli-tools-settings-jsonc.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Regression test for the JSONC-tolerant config reader used by every
|
||||
* cli-tools settings route.
|
||||
*
|
||||
* Ported from upstream `decolua/9router@6c10edf8`:
|
||||
* "fix(cli-tools): tolerate JSONC configs in CLI tool settings routes".
|
||||
*
|
||||
* Before the fix, `readSettings`/`readConfig` helpers only caught `ENOENT` and
|
||||
* re-threw on any other error — so a config file with a single trailing
|
||||
* comma (valid JSONC, emitted by tools like opencode) crashed the GET route
|
||||
* with a 500 and the dashboard misread the response as "tool not installed".
|
||||
*
|
||||
* After the fix:
|
||||
* - trailing commas are stripped before parsing (JSONC tolerated);
|
||||
* - any other parse error returns `null` (or the caller's fallback) so the
|
||||
* dashboard renders "installed but not configured".
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const { parseJsoncOrNull, readJsoncConfig } = await import(
|
||||
"../../src/app/api/cli-tools/_lib/jsoncConfig.ts"
|
||||
);
|
||||
|
||||
test("parseJsoncOrNull tolerates trailing commas in objects", () => {
|
||||
const jsonc = `{
|
||||
"model": "gpt-5",
|
||||
"tools": ["a", "b",],
|
||||
"nested": { "x": 1, },
|
||||
}`;
|
||||
const parsed = parseJsoncOrNull<{ model: string; tools: string[] }>(jsonc);
|
||||
assert.ok(parsed, "parser must accept JSONC with trailing commas");
|
||||
assert.equal(parsed.model, "gpt-5");
|
||||
assert.deepEqual(parsed.tools, ["a", "b"]);
|
||||
});
|
||||
|
||||
test("parseJsoncOrNull returns null on truly malformed JSON", () => {
|
||||
assert.equal(parseJsoncOrNull("{ not json at all"), null);
|
||||
});
|
||||
|
||||
test("readJsoncConfig returns fallback when file is missing", async () => {
|
||||
const missing = path.join(os.tmpdir(), `cli-tools-jsonc-missing-${Date.now()}.json`);
|
||||
assert.equal(await readJsoncConfig(missing), null);
|
||||
assert.deepEqual(await readJsoncConfig(missing, {}), {});
|
||||
});
|
||||
|
||||
test("readJsoncConfig parses a JSONC file with trailing commas (regression)", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cli-tools-jsonc-"));
|
||||
const file = path.join(dir, "settings.json");
|
||||
await fs.writeFile(
|
||||
file,
|
||||
`{
|
||||
"apiKey": "sk-test",
|
||||
"model": "claude-sonnet-4-5",
|
||||
}
|
||||
`,
|
||||
"utf-8"
|
||||
);
|
||||
try {
|
||||
const parsed = await readJsoncConfig<{ apiKey: string; model: string }>(file);
|
||||
assert.ok(parsed, "JSONC file with trailing comma must NOT crash the reader");
|
||||
assert.equal(parsed.apiKey, "sk-test");
|
||||
assert.equal(parsed.model, "claude-sonnet-4-5");
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readJsoncConfig returns fallback on corrupted config instead of throwing", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cli-tools-jsonc-bad-"));
|
||||
const file = path.join(dir, "settings.json");
|
||||
await fs.writeFile(file, "{ this is not valid json at all !!! ", "utf-8");
|
||||
try {
|
||||
// Must NOT throw — the dashboard renders "installed but not configured"
|
||||
// when this returns null, instead of "not installed" on a 500.
|
||||
assert.equal(await readJsoncConfig(file), null);
|
||||
assert.deepEqual(await readJsoncConfig(file, {}), {});
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Source-guard: every cli-tools settings route's read helper must go through
|
||||
* the JSONC-tolerant reader. A regression to raw `JSON.parse(content)` inside
|
||||
* a `readSettings` / `readConfig` helper would re-introduce the original
|
||||
* 500-on-JSONC bug, so we assert the routes do not contain that pattern.
|
||||
*
|
||||
* Same source-guard pattern as `tests/unit/source-guard-*.test.ts`.
|
||||
*/
|
||||
test("cli-tools settings routes use the JSONC-tolerant reader (source-guard)", async () => {
|
||||
// For each route, look for the GET-read helper region (top of file, up to
|
||||
// the first `export async function GET`) and assert it imports + uses the
|
||||
// JSONC-tolerant reader instead of raw `JSON.parse(content)`.
|
||||
const routes = [
|
||||
"claude-settings",
|
||||
"cline-settings",
|
||||
"droid-settings",
|
||||
"kilo-settings",
|
||||
"openclaw-settings",
|
||||
];
|
||||
const repoRoot = path.resolve(import.meta.dirname ?? ".", "..", "..");
|
||||
for (const r of routes) {
|
||||
const src = await fs.readFile(
|
||||
path.join(repoRoot, "src", "app", "api", "cli-tools", r, "route.ts"),
|
||||
"utf-8"
|
||||
);
|
||||
const getIdx = src.indexOf("export async function GET");
|
||||
assert.ok(getIdx > 0, `${r}: expected an exported GET handler`);
|
||||
const head = src.slice(0, getIdx);
|
||||
assert.ok(
|
||||
/from\s+["']\.\.\/_lib\/jsoncConfig["']/.test(src),
|
||||
`${r}: must import readJsoncConfig from ../_lib/jsoncConfig`
|
||||
);
|
||||
assert.ok(
|
||||
!/JSON\.parse\(\s*content\s*\)/.test(head),
|
||||
`${r}: read helper still calls raw JSON.parse(content) — port the JSONC fix`
|
||||
);
|
||||
assert.ok(
|
||||
/readJsoncConfig\s*[<(]/.test(head),
|
||||
`${r}: read helper must invoke readJsoncConfig`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -87,12 +87,12 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo
|
||||
"codex",
|
||||
{
|
||||
Authorization: "Bearer token",
|
||||
"User-Agent": "codex-cli/0.132.0 (Windows 10.0.26200; x64)",
|
||||
"User-Agent": "codex-cli/0.142.0 (Windows 10.0.26200; x64)",
|
||||
},
|
||||
{ model: "gpt-5.5", messages: [], stream: true }
|
||||
);
|
||||
|
||||
assert.equal(codex.headers["User-Agent"], "codex-cli/0.132.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(codex.headers["User-Agent"], "codex-cli/0.142.0 (Windows 10.0.26200; x64)");
|
||||
assert.deepEqual(Object.keys(JSON.parse(codex.bodyString)), ["model", "stream", "messages"]);
|
||||
|
||||
const copilot = applyFingerprint(
|
||||
|
||||
75
tests/unit/cli-tray-systray2.test.ts
Normal file
75
tests/unit/cli-tray-systray2.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Regression coverage for the systray2 runtime swap (port of upstream PR #1080).
|
||||
// The bug upstream: the bundled Go binary in node_modules/systray2/traybin
|
||||
// sometimes ships without the executable bit on macOS/Linux, causing the tray
|
||||
// to spawn() with EACCES and the icon never to appear.
|
||||
//
|
||||
// We can't load the real native binary in unit tests, but we can verify the
|
||||
// pure helpers that drive the swap: the package + version pin and the
|
||||
// chmod helper's behavior on a synthetic binary tree.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, statSync, chmodSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
SYSTRAY_PACKAGE,
|
||||
SYSTRAY_VERSION,
|
||||
chmodSystrayBinAt,
|
||||
resolveSystrayBinName,
|
||||
} from "../../bin/cli/runtime/trayRuntime.ts";
|
||||
|
||||
test("systray2 is pinned to a 2.x version (PR #1080 fix)", () => {
|
||||
assert.equal(SYSTRAY_PACKAGE, "systray2");
|
||||
// The original systray@1.0.5 binary is incompatible with modern macOS dyld;
|
||||
// upstream PR #1080 moved to the systray2 2.x line. Pin must stay ≥ 2.x.
|
||||
assert.match(SYSTRAY_VERSION, /^2\./, `expected systray2@2.x, got ${SYSTRAY_VERSION}`);
|
||||
});
|
||||
|
||||
test("resolveSystrayBinName returns null on win32 and a *_release name elsewhere", () => {
|
||||
assert.equal(resolveSystrayBinName("win32"), null);
|
||||
assert.equal(resolveSystrayBinName("darwin"), "tray_darwin_release");
|
||||
assert.equal(resolveSystrayBinName("linux"), "tray_linux_release");
|
||||
});
|
||||
|
||||
test("chmodSystrayBinAt sets +x on the bundled tray binary when present", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "omniroute-systray-bin-"));
|
||||
try {
|
||||
const platform = process.platform === "win32" ? "linux" : process.platform;
|
||||
const binName = resolveSystrayBinName(platform)!;
|
||||
const binDir = join(root, "node_modules", "systray2", "traybin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const binPath = join(binDir, binName);
|
||||
writeFileSync(binPath, "#!/bin/sh\nexit 0\n", { mode: 0o644 });
|
||||
chmodSync(binPath, 0o644); // explicit: not executable
|
||||
const result = chmodSystrayBinAt(root, platform);
|
||||
assert.equal(result.changed, true, "expected chmodSystrayBinAt to chmod the bin");
|
||||
// 0o111 covers the three execute bits — assert at least one is set.
|
||||
const mode = statSync(binPath).mode & 0o777;
|
||||
assert.ok((mode & 0o111) !== 0, `expected exec bits on bin, got mode=${mode.toString(8)}`);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("chmodSystrayBinAt is a no-op when the binary doesn't exist", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "omniroute-systray-bin-"));
|
||||
try {
|
||||
const result = chmodSystrayBinAt(root, "linux");
|
||||
assert.equal(result.changed, false);
|
||||
assert.equal(result.reason, "missing");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("chmodSystrayBinAt skips win32 (uses PowerShell tray, no Go binary)", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "omniroute-systray-bin-"));
|
||||
try {
|
||||
const result = chmodSystrayBinAt(root, "win32");
|
||||
assert.equal(result.changed, false);
|
||||
assert.equal(result.reason, "win32-skip");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
262
tests/unit/codebuddy-cn-provider.test.ts
Normal file
262
tests/unit/codebuddy-cn-provider.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
USAGE_SUPPORTED_PROVIDERS,
|
||||
FREE_APIKEY_PROVIDER_IDS,
|
||||
} from "../../src/shared/constants/providers.ts";
|
||||
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
|
||||
import { getExecutor } from "../../open-sse/executors/index.ts";
|
||||
import { CodeBuddyCnExecutor } from "../../open-sse/executors/codebuddy-cn.ts";
|
||||
import {
|
||||
PROVIDERS as OAUTH_PROVIDER_IDS,
|
||||
CODEBUDDY_CN_CONFIG,
|
||||
} from "../../src/lib/oauth/constants/oauth.ts";
|
||||
import PROVIDERS_MAP from "../../src/lib/oauth/providers/index.ts";
|
||||
import { supportsTokenRefresh } from "../../open-sse/services/tokenRefresh.ts";
|
||||
|
||||
test("codebuddy-cn is registered as an OAuth provider in the UI catalog", () => {
|
||||
const p = AI_PROVIDERS["codebuddy-cn"];
|
||||
assert.ok(p, "AI_PROVIDERS['codebuddy-cn'] must exist");
|
||||
assert.equal(p.id, "codebuddy-cn");
|
||||
assert.equal(p.alias, "cbcn");
|
||||
assert.equal(p.name, "CodeBuddy CN");
|
||||
});
|
||||
|
||||
test("codebuddy-cn registry entry has expected shape", () => {
|
||||
const r = REGISTRY["codebuddy-cn"];
|
||||
assert.ok(r, "REGISTRY['codebuddy-cn'] must exist");
|
||||
assert.equal(r.alias, "cbcn");
|
||||
assert.equal(r.executor, "codebuddy-cn");
|
||||
assert.equal(r.baseUrl, "https://copilot.tencent.com/v2/chat/completions");
|
||||
assert.equal(r.authHeader, "bearer");
|
||||
// Headers carry the Tencent CodeBuddy CLI fingerprint
|
||||
assert.equal(r.headers?.["X-Product"], "SaaS");
|
||||
assert.equal(r.headers?.["X-IDE-Type"], "CLI");
|
||||
assert.equal(r.headers?.["X-IDE-Name"], "CLI");
|
||||
assert.equal(r.headers?.["x-requested-with"], "XMLHttpRequest");
|
||||
assert.equal(r.headers?.["x-codebuddy-request"], "1");
|
||||
// 15 models from the upstream catalog
|
||||
const ids = r.models.map((m) => m.id);
|
||||
for (const expected of [
|
||||
"glm-5.2",
|
||||
"glm-5.1",
|
||||
"glm-5.0",
|
||||
"glm-5.0-turbo",
|
||||
"glm-5v-turbo",
|
||||
"glm-4.7",
|
||||
"minimax-m3",
|
||||
"minimax-m2.7",
|
||||
"kimi-k2.7",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
"hy3-preview",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v3-2-volc",
|
||||
]) {
|
||||
assert.ok(ids.includes(expected), `model ${expected} must be registered`);
|
||||
}
|
||||
assert.equal(r.models.length, 15);
|
||||
});
|
||||
|
||||
test("codebuddy-cn glm-5.2 carries 1M context and openai-style reasoning that cannot be disabled", () => {
|
||||
const r = REGISTRY["codebuddy-cn"];
|
||||
const m = r.models.find((x) => x.id === "glm-5.2");
|
||||
assert.ok(m, "glm-5.2 must exist");
|
||||
// contextLength is the OmniRoute analogue of upstream's contextWindow
|
||||
assert.equal(m.contextLength, 1000000);
|
||||
assert.equal(m.supportsReasoning, true);
|
||||
});
|
||||
|
||||
test("codebuddy-cn vision flag is set on the visual models", () => {
|
||||
const r = REGISTRY["codebuddy-cn"];
|
||||
for (const id of ["glm-5v-turbo", "minimax-m3", "kimi-k2.7", "deepseek-v4-pro"]) {
|
||||
const m = r.models.find((x) => x.id === id);
|
||||
assert.ok(m, `${id} must exist`);
|
||||
assert.equal(m.supportsVision, true, `${id} should have vision`);
|
||||
}
|
||||
});
|
||||
|
||||
test("getExecutor returns the CodeBuddyCnExecutor for 'codebuddy-cn' and the 'cbcn' alias", () => {
|
||||
const e = getExecutor("codebuddy-cn");
|
||||
assert.ok(e instanceof CodeBuddyCnExecutor, "executor must be CodeBuddyCnExecutor");
|
||||
const aliasExec = getExecutor("cbcn");
|
||||
assert.ok(aliasExec instanceof CodeBuddyCnExecutor, "alias 'cbcn' must resolve to same executor");
|
||||
});
|
||||
|
||||
test("CodeBuddyCnExecutor.transformRequest forces stream:true and adds OpenAI-style reasoning_summary", () => {
|
||||
const e = new CodeBuddyCnExecutor();
|
||||
const out = e.transformRequest(
|
||||
"glm-5.2",
|
||||
{ model: "glm-5.2", messages: [{ role: "user", content: "hi" }], stream: false },
|
||||
false,
|
||||
{} as unknown as Parameters<typeof e.transformRequest>[3]
|
||||
);
|
||||
assert.ok(out && typeof out === "object" && !Array.isArray(out));
|
||||
const body = out as Record<string, unknown>;
|
||||
assert.equal(body.stream, true, "stream must be forced to true");
|
||||
assert.equal(body.reasoning_effort, "medium", "default reasoning_effort must be 'medium'");
|
||||
assert.equal(body.reasoning_summary, "auto", "reasoning_summary must be 'auto'");
|
||||
});
|
||||
|
||||
test("CodeBuddyCnExecutor preserves explicit reasoning_effort", () => {
|
||||
const e = new CodeBuddyCnExecutor();
|
||||
const out = e.transformRequest(
|
||||
"glm-5.2",
|
||||
{ model: "glm-5.2", messages: [], reasoning_effort: "high" },
|
||||
true,
|
||||
{} as unknown as Parameters<typeof e.transformRequest>[3]
|
||||
);
|
||||
const body = out as Record<string, unknown>;
|
||||
assert.equal(body.reasoning_effort, "high");
|
||||
assert.equal(body.reasoning_summary, "auto");
|
||||
});
|
||||
|
||||
test("CodeBuddyCnExecutor strips reasoning_effort when caller asks for none/off", () => {
|
||||
const e = new CodeBuddyCnExecutor();
|
||||
for (const effort of ["none", "off"]) {
|
||||
const out = e.transformRequest(
|
||||
"glm-5.2",
|
||||
{ model: "glm-5.2", messages: [], reasoning_effort: effort },
|
||||
true,
|
||||
{} as unknown as Parameters<typeof e.transformRequest>[3]
|
||||
);
|
||||
const body = out as Record<string, unknown>;
|
||||
assert.equal(
|
||||
Object.prototype.hasOwnProperty.call(body, "reasoning_effort"),
|
||||
false,
|
||||
`reasoning_effort must be omitted for ${effort}`
|
||||
);
|
||||
assert.notEqual(body.reasoning_summary, "auto", `reasoning_summary must not be auto for ${effort}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("codebuddy-cn OAuth provider is wired with device_code flow and GET-poll on state", async () => {
|
||||
assert.equal(OAUTH_PROVIDER_IDS.CODEBUDDY_CN, "codebuddy-cn");
|
||||
const map = (PROVIDERS_MAP as Record<string, any>);
|
||||
const cb = map["codebuddy-cn"];
|
||||
assert.ok(cb, "PROVIDERS map must include 'codebuddy-cn'");
|
||||
assert.equal(cb.flowType, "device_code");
|
||||
assert.equal(typeof cb.requestDeviceCode, "function");
|
||||
assert.equal(typeof cb.pollToken, "function");
|
||||
|
||||
// Drive pollToken with a stubbed fetch and assert it hits the token URL via GET
|
||||
// with the state as a query param (upstream's distinguishing detail).
|
||||
const origFetch = globalThis.fetch;
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
globalThis.fetch = async (url: any, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
accessToken: "AT",
|
||||
refreshToken: "RT",
|
||||
tokenType: "Bearer",
|
||||
expiresIn: 3600,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
) as unknown as Response;
|
||||
};
|
||||
try {
|
||||
const result = await cb.pollToken(CODEBUDDY_CN_CONFIG, "STATE-123");
|
||||
assert.ok(result?.ok, "successful poll should be ok:true");
|
||||
assert.equal(result?.data?.access_token, "AT");
|
||||
const got = calls.at(-1);
|
||||
assert.ok(got, "fetch must have been called");
|
||||
assert.equal(got.init?.method, "GET", "poll method must be GET");
|
||||
assert.match(got.url, /\?state=STATE-123$/, "state must appear as ?state= query param");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("codebuddy-cn token refresh handler is wired in tokenRefresh.ts", () => {
|
||||
assert.equal(supportsTokenRefresh("codebuddy-cn"), true);
|
||||
});
|
||||
|
||||
test("codebuddy-cn is in USAGE_SUPPORTED_PROVIDERS and quota handler parses Tencent accounts", async () => {
|
||||
assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("codebuddy-cn"));
|
||||
const { getCodeBuddyCnUsage } = await import(
|
||||
"../../open-sse/services/usage/codebuddy-cn.ts"
|
||||
);
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
// Compose a mixed payload: one refill (CycleEndTime << DeductionEndTime) and
|
||||
// two bonus packs (CycleEndTime == DeductionEndTime), out of expiry order.
|
||||
const cycleEnd = "2026-08-01T00:00:00Z";
|
||||
const deductionEnd = 32503680000; // very far future (seconds)
|
||||
const bonus1End = "2026-07-01T00:00:00Z";
|
||||
const bonus2End = "2026-06-25T00:00:00Z";
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
Response: {
|
||||
Data: {
|
||||
Accounts: [
|
||||
{
|
||||
PackageName: "Refill Pack",
|
||||
CycleStartTime: "2026-07-01T00:00:00Z",
|
||||
CycleEndTime: cycleEnd,
|
||||
DeductionEndTime: deductionEnd,
|
||||
CycleCapacitySize: 500,
|
||||
CycleCapacityUsed: 12.34,
|
||||
},
|
||||
{
|
||||
PackageName: "Bonus A",
|
||||
CycleStartTime: "2026-05-01T00:00:00Z",
|
||||
CycleEndTime: bonus1End,
|
||||
DeductionEndTime: new Date(bonus1End).getTime() / 1000,
|
||||
CapacitySize: 50,
|
||||
CapacityUsed: 1,
|
||||
},
|
||||
{
|
||||
PackageName: "Bonus B",
|
||||
CycleStartTime: "2026-05-01T00:00:00Z",
|
||||
CycleEndTime: bonus2End,
|
||||
DeductionEndTime: new Date(bonus2End).getTime() / 1000,
|
||||
CapacitySize: 25,
|
||||
CapacityUsed: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
) as unknown as Response;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await getCodeBuddyCnUsage("ACCESS_TOKEN", undefined, undefined);
|
||||
assert.ok(result && typeof result === "object");
|
||||
const r = result as Record<string, any>;
|
||||
assert.equal(r.plan, "Refill Pack");
|
||||
assert.ok(r.quotas);
|
||||
// Refill (Monthly) uses CycleCapacity*; Bonus packs use plain Capacity*; soonest-expiring first.
|
||||
assert.ok(r.quotas.Monthly, "Monthly refill quota must be present");
|
||||
assert.equal(r.quotas.Monthly.total, 500);
|
||||
assert.equal(r.quotas.Monthly.used, 12.34);
|
||||
assert.ok(r.quotas["Bonus Pack 1"], "first bonus must be present");
|
||||
assert.ok(r.quotas["Bonus Pack 2"], "second bonus must be present");
|
||||
// Soonest-expiring first: Bonus B (Jun 25) before Bonus A (Jul 1).
|
||||
assert.equal(r.quotas["Bonus Pack 1"].total, 25, "Bonus Pack 1 should be the soonest (B)");
|
||||
assert.equal(r.quotas["Bonus Pack 2"].total, 50, "Bonus Pack 2 should be the later (A)");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("codebuddy-cn is treated as a managed dual-auth provider (oauth + apikey accepted by POST /api/providers)", async () => {
|
||||
// The provider creation gate trusts FREE_APIKEY_PROVIDER_IDS to admit
|
||||
// OAuth-category providers that also accept a direct API key (like qoder).
|
||||
assert.ok(
|
||||
FREE_APIKEY_PROVIDER_IDS.has("codebuddy-cn"),
|
||||
"codebuddy-cn must be admitted by the dual-auth gate"
|
||||
);
|
||||
});
|
||||
51
tests/unit/codex-drop-nonstandard-events.test.ts
Normal file
51
tests/unit/codex-drop-nonstandard-events.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Regression guard for #4715: the Codex HTTP transport forwards the upstream SSE
|
||||
// stream verbatim, including the non-standard `event: codex.rate_limits` frame
|
||||
// (no `data:` line). That frame breaks the OpenAI SDK's responses.stream() with
|
||||
// HTTP 502 "Controller is already closed". filterNonstandardCodexSse() strips
|
||||
// every `codex.*` event block from the byte stream while preserving standard ones.
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { filterNonstandardCodexSse } from "../../open-sse/executors/codex.ts";
|
||||
|
||||
function sseResponse(body: string): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
async function readAll(res: Response): Promise<string> {
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
describe("filterNonstandardCodexSse (#4715)", () => {
|
||||
it("drops codex.* event blocks but keeps standard response.* events", async () => {
|
||||
const stream =
|
||||
"event: response.created\ndata: {\"type\":\"response.created\"}\n\n" +
|
||||
"event: codex.rate_limits\n\n" +
|
||||
"event: response.output_text.delta\ndata: {\"delta\":\"hi\"}\n\n" +
|
||||
"event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n";
|
||||
const out = await readAll(filterNonstandardCodexSse(sseResponse(stream)));
|
||||
assert.ok(!out.includes("codex.rate_limits"), "codex.* frame must be stripped");
|
||||
assert.ok(out.includes("response.created"), "standard events preserved");
|
||||
assert.ok(out.includes("response.output_text.delta"), "standard delta preserved");
|
||||
assert.ok(out.includes("response.completed"), "terminal event preserved");
|
||||
});
|
||||
|
||||
it("passes through non-SSE responses untouched", async () => {
|
||||
const json = new Response("{\"ok\":true}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const out = filterNonstandardCodexSse(json);
|
||||
assert.equal(await out.text(), "{\"ok\":true}");
|
||||
});
|
||||
|
||||
it("drops a trailing codex.* block with no double-newline terminator (flush path)", async () => {
|
||||
const stream =
|
||||
"event: response.created\ndata: {}\n\n" + "event: codex.token_count\ndata: {}";
|
||||
const out = await readAll(filterNonstandardCodexSse(sseResponse(stream)));
|
||||
assert.ok(out.includes("response.created"));
|
||||
assert.ok(!out.includes("codex.token_count"));
|
||||
});
|
||||
});
|
||||
271
tests/unit/combo-cooldown-retry.test.ts
Normal file
271
tests/unit/combo-cooldown-retry.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* TDD — shouldWaitForComboCooldown (pure decision helper for the quota-share
|
||||
* combo cooldown-aware retry, Variante A).
|
||||
*
|
||||
* The helper decides whether a quota-share (qtSd/) combo that would otherwise
|
||||
* crystallize a 429 `model_cooldown` should instead WAIT for a short transient
|
||||
* cooldown and re-dispatch. It is pure (no I/O, no clock) so every branch is
|
||||
* exhaustively covered here:
|
||||
* - happy path (short transient cooldown within budget → wait)
|
||||
* - reason === "quota_exhausted" EXCLUDED (would otherwise wait until midnight)
|
||||
* - reason in the extra non-retryable set (auth_error/not_found/...) EXCLUDED
|
||||
* - waitMs above the configured ceiling → no wait
|
||||
* - waitMs <= 0 → no wait
|
||||
* - attempt >= maxAttempts → no wait
|
||||
* - budget insufficient (budgetLeftMs < waitMs) → no wait
|
||||
* - settings.enabled === false → no wait
|
||||
*
|
||||
* Security note (auth.ts:533 isRetryableModelLockoutReason): `quota_exhausted`
|
||||
* is classified RETRYABLE there, so the helper MUST exclude it explicitly or a
|
||||
* combo would wait until midnight. This file pins that exclusion.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { shouldWaitForComboCooldown, resolveComboCooldownWaitDecision, COMBO_COOLDOWN_WAIT_MARGIN_MS } =
|
||||
await import("../../open-sse/services/combo/comboCooldownRetry.ts");
|
||||
|
||||
function baseSettings(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
maxWaitMs: 5000,
|
||||
maxAttempts: 2,
|
||||
budgetMs: 8000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseInput(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
reason: "rate_limit",
|
||||
waitMs: 3000,
|
||||
attempt: 0,
|
||||
budgetLeftMs: 8000,
|
||||
settings: baseSettings(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("happy path: short transient cooldown within budget → wait=true", () => {
|
||||
const r = shouldWaitForComboCooldown(baseInput() as never);
|
||||
assert.equal(r.wait, true);
|
||||
assert.equal(r.waitMs, 3000);
|
||||
});
|
||||
|
||||
test("reason quota_exhausted is EXCLUDED even though it is otherwise 'retryable'", () => {
|
||||
const r = shouldWaitForComboCooldown(baseInput({ reason: "quota_exhausted" }) as never);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
|
||||
test("reason auth_error is EXCLUDED (non-retryable safety set)", () => {
|
||||
const r = shouldWaitForComboCooldown(baseInput({ reason: "auth_error" }) as never);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
|
||||
test("reason not_found is EXCLUDED", () => {
|
||||
const r = shouldWaitForComboCooldown(baseInput({ reason: "not_found" }) as never);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
|
||||
test("reason not_found_local is EXCLUDED", () => {
|
||||
const r = shouldWaitForComboCooldown(baseInput({ reason: "not_found_local" }) as never);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
|
||||
test("missing/unknown reason (null) → no wait (only an explicit transient reason qualifies)", () => {
|
||||
const r = shouldWaitForComboCooldown(baseInput({ reason: null }) as never);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
|
||||
test("waitMs above the configured ceiling → no wait", () => {
|
||||
const r = shouldWaitForComboCooldown(
|
||||
baseInput({ waitMs: 5001, settings: baseSettings({ maxWaitMs: 5000 }) }) as never
|
||||
);
|
||||
assert.equal(r.wait, false);
|
||||
// waitMs is still surfaced for logging even when we decline to wait.
|
||||
assert.equal(r.waitMs, 5001);
|
||||
});
|
||||
|
||||
test("waitMs exactly at the ceiling → wait (inclusive bound)", () => {
|
||||
const r = shouldWaitForComboCooldown(
|
||||
baseInput({ waitMs: 5000, settings: baseSettings({ maxWaitMs: 5000 }) }) as never
|
||||
);
|
||||
assert.equal(r.wait, true);
|
||||
});
|
||||
|
||||
test("waitMs <= 0 → no wait", () => {
|
||||
assert.equal(shouldWaitForComboCooldown(baseInput({ waitMs: 0 }) as never).wait, false);
|
||||
assert.equal(shouldWaitForComboCooldown(baseInput({ waitMs: -10 }) as never).wait, false);
|
||||
});
|
||||
|
||||
test("attempt >= maxAttempts → no wait", () => {
|
||||
const r = shouldWaitForComboCooldown(
|
||||
baseInput({ attempt: 2, settings: baseSettings({ maxAttempts: 2 }) }) as never
|
||||
);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
|
||||
test("attempt below maxAttempts (last allowed) → wait", () => {
|
||||
const r = shouldWaitForComboCooldown(
|
||||
baseInput({ attempt: 1, settings: baseSettings({ maxAttempts: 2 }) }) as never
|
||||
);
|
||||
assert.equal(r.wait, true);
|
||||
});
|
||||
|
||||
test("budget insufficient (budgetLeftMs < waitMs) → no wait", () => {
|
||||
const r = shouldWaitForComboCooldown(baseInput({ waitMs: 3000, budgetLeftMs: 2999 }) as never);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
|
||||
test("budget exactly equal to waitMs → wait (inclusive bound)", () => {
|
||||
const r = shouldWaitForComboCooldown(baseInput({ waitMs: 3000, budgetLeftMs: 3000 }) as never);
|
||||
assert.equal(r.wait, true);
|
||||
});
|
||||
|
||||
test("settings.enabled === false → no wait", () => {
|
||||
const r = shouldWaitForComboCooldown(
|
||||
baseInput({ settings: baseSettings({ enabled: false }) }) as never
|
||||
);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
|
||||
test("non-finite / garbage waitMs → no wait (defensive)", () => {
|
||||
assert.equal(shouldWaitForComboCooldown(baseInput({ waitMs: Number.NaN }) as never).wait, false);
|
||||
assert.equal(
|
||||
shouldWaitForComboCooldown(baseInput({ waitMs: Number.POSITIVE_INFINITY }) as never).wait,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("returned waitMs is clamped to a finite number (0 when input invalid)", () => {
|
||||
const r = shouldWaitForComboCooldown(baseInput({ waitMs: Number.NaN }) as never);
|
||||
assert.equal(Number.isFinite(r.waitMs), true);
|
||||
assert.equal(r.waitMs, 0);
|
||||
});
|
||||
|
||||
// ── resolveComboCooldownWaitDecision (target resolution + hint/fallback) ──────
|
||||
|
||||
const M = COMBO_COOLDOWN_WAIT_MARGIN_MS;
|
||||
|
||||
function decisionInput(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
targets: [{ provider: "openai", connectionId: "conn-1" }],
|
||||
earliestRetryAfter: new Date(Date.now() + 2000).toISOString(),
|
||||
attempt: 0,
|
||||
budgetLeftMs: 8000,
|
||||
settings: baseSettings(),
|
||||
lookupLock: (_p: string, _c: string) => ({ reason: "rate_limit", remainingMs: 2000 }),
|
||||
computeWaitMs: (_r: unknown) => 2000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("resolve: locked target with transient reason within budget → wait", () => {
|
||||
const r = resolveComboCooldownWaitDecision(decisionInput() as never);
|
||||
assert.equal(r.wait, true);
|
||||
// wait = max(lock remaining, hint) + margin so the lock reliably clears.
|
||||
assert.equal(r.waitMs, 2000 + M);
|
||||
assert.equal(r.reason, "rate_limit");
|
||||
});
|
||||
|
||||
test("resolve: quota_exhausted lock → no wait (the critical security guard)", () => {
|
||||
const r = resolveComboCooldownWaitDecision(
|
||||
decisionInput({
|
||||
lookupLock: () => ({ reason: "quota_exhausted", remainingMs: 3000 }),
|
||||
}) as never
|
||||
);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
|
||||
test("resolve: settings.enabled false short-circuits before any lookup", () => {
|
||||
let lookups = 0;
|
||||
const r = resolveComboCooldownWaitDecision(
|
||||
decisionInput({
|
||||
settings: baseSettings({ enabled: false }),
|
||||
lookupLock: () => {
|
||||
lookups += 1;
|
||||
return { reason: "rate_limit", remainingMs: 3000 };
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
assert.equal(r.wait, false);
|
||||
assert.equal(lookups, 0);
|
||||
});
|
||||
|
||||
test("resolve: no locked target → no wait", () => {
|
||||
const r = resolveComboCooldownWaitDecision(decisionInput({ lookupLock: () => null }) as never);
|
||||
assert.equal(r.wait, false);
|
||||
assert.equal(r.reason, null);
|
||||
});
|
||||
|
||||
test("resolve: expired lock (remainingMs <= 0) is ignored → no wait", () => {
|
||||
const r = resolveComboCooldownWaitDecision(
|
||||
decisionInput({ lookupLock: () => ({ reason: "rate_limit", remainingMs: 0 }) }) as never
|
||||
);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
|
||||
test("resolve: picks the soonest-to-recover locked target across many", () => {
|
||||
const r = resolveComboCooldownWaitDecision(
|
||||
decisionInput({
|
||||
targets: [
|
||||
{ provider: "openai", connectionId: "a" },
|
||||
{ provider: "openai", connectionId: "b" },
|
||||
],
|
||||
// 'a' recovers in 3s, 'b' in 2s → 'b' wins; both rate_limit so 'b' (2s)
|
||||
// drives the wait. No usable hint → wait = lock remaining + margin.
|
||||
lookupLock: (_p: string, c: string) =>
|
||||
c === "b"
|
||||
? { reason: "rate_limit", remainingMs: 2000 }
|
||||
: { reason: "rate_limit", remainingMs: 3000 },
|
||||
computeWaitMs: () => null,
|
||||
}) as never
|
||||
);
|
||||
assert.equal(r.wait, true);
|
||||
assert.equal(r.waitMs, 2000 + M);
|
||||
});
|
||||
|
||||
test("resolve: uses lock remainingMs (+margin) when the retry-after hint yields nothing", () => {
|
||||
const r = resolveComboCooldownWaitDecision(
|
||||
decisionInput({
|
||||
computeWaitMs: () => null,
|
||||
lookupLock: () => ({ reason: "rate_limit", remainingMs: 1500 }),
|
||||
}) as never
|
||||
);
|
||||
assert.equal(r.wait, true);
|
||||
assert.equal(r.waitMs, 1500 + M);
|
||||
});
|
||||
|
||||
test("resolve: honors a LONGER upstream hint over the lock remaining (still capped)", () => {
|
||||
// lock says 1s left but upstream hint says 3s → wait the longer 3s (+margin).
|
||||
const r = resolveComboCooldownWaitDecision(
|
||||
decisionInput({
|
||||
computeWaitMs: () => 3000,
|
||||
lookupLock: () => ({ reason: "rate_limit", remainingMs: 1000 }),
|
||||
}) as never
|
||||
);
|
||||
assert.equal(r.wait, true);
|
||||
assert.equal(r.waitMs, 3000 + M);
|
||||
});
|
||||
|
||||
test("resolve: hint above the ceiling → no wait even if lock remaining is short", () => {
|
||||
const r = resolveComboCooldownWaitDecision(
|
||||
decisionInput({
|
||||
settings: baseSettings({ maxWaitMs: 5000 }),
|
||||
computeWaitMs: () => 6000,
|
||||
lookupLock: () => ({ reason: "rate_limit", remainingMs: 1000 }),
|
||||
}) as never
|
||||
);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
|
||||
test("resolve: lock remaining above the ceiling → no wait (not a SHORT cooldown)", () => {
|
||||
const r = resolveComboCooldownWaitDecision(
|
||||
decisionInput({
|
||||
settings: baseSettings({ maxWaitMs: 5000 }),
|
||||
computeWaitMs: () => null,
|
||||
lookupLock: () => ({ reason: "rate_limit", remainingMs: 6000 }),
|
||||
}) as never
|
||||
);
|
||||
assert.equal(r.wait, false);
|
||||
});
|
||||
91
tests/unit/combo-description-5005.test.ts
Normal file
91
tests/unit/combo-description-5005.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* #5005 — Per-combo editable `description` field.
|
||||
*
|
||||
* The routing-combo API historically had NO `description` field: the Zod
|
||||
* `createComboSchema` / `updateComboSchema` stripped any `description` on save.
|
||||
* This proves the schema now ACCEPTS and PRESERVES `description`, and that the
|
||||
* value round-trips through the DB layer (persisted in the `data` JSON blob and
|
||||
* surfaced back by GET-style reads), with no new DB column.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-desc-5005-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { createComboSchema, updateComboSchema } = await import(
|
||||
"../../src/shared/validation/schemas.ts"
|
||||
);
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("createComboSchema preserves description instead of stripping it", () => {
|
||||
const parsed = createComboSchema.parse({
|
||||
name: "Described Combo",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
description: "Routes premium traffic to GPT-4.1",
|
||||
});
|
||||
assert.equal(parsed.description, "Routes premium traffic to GPT-4.1");
|
||||
});
|
||||
|
||||
test("updateComboSchema preserves description instead of stripping it", () => {
|
||||
const parsed = updateComboSchema.parse({
|
||||
description: "Updated description text",
|
||||
});
|
||||
assert.equal(parsed.description, "Updated description text");
|
||||
});
|
||||
|
||||
test("description round-trips through createCombo and getComboById/getComboByName", async () => {
|
||||
const created = await combosDb.createCombo({
|
||||
name: "Desc Combo",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
description: "My human-friendly note",
|
||||
});
|
||||
assert.equal(created.description, "My human-friendly note");
|
||||
|
||||
const byId = await combosDb.getComboById(created.id as string);
|
||||
assert.ok(byId, "combo should be retrievable by id");
|
||||
assert.equal(byId!.description, "My human-friendly note");
|
||||
|
||||
const byName = await combosDb.getComboByName("Desc Combo");
|
||||
assert.ok(byName, "combo should be retrievable by name");
|
||||
assert.equal(byName!.description, "My human-friendly note");
|
||||
});
|
||||
|
||||
test("description survives an updateCombo and is listed by getCombos", async () => {
|
||||
const created = await combosDb.createCombo({
|
||||
name: "Update Desc Combo",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
});
|
||||
|
||||
const updated = await combosDb.updateCombo(created.id as string, {
|
||||
description: "Added later via PUT",
|
||||
});
|
||||
assert.ok(updated);
|
||||
assert.equal(updated!.description, "Added later via PUT");
|
||||
|
||||
const all = await combosDb.getCombos();
|
||||
const found = all.find((c) => c.id === created.id);
|
||||
assert.ok(found, "updated combo should appear in getCombos");
|
||||
assert.equal(found!.description, "Added later via PUT");
|
||||
});
|
||||
209
tests/unit/combo-fusion-strategy.test.ts
Normal file
209
tests/unit/combo-fusion-strategy.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Fusion combo strategy — parallel panel + judge synthesis.
|
||||
*
|
||||
* Ported from upstream decolua/9router (Daniil Schovkunov). Adds Fusion as the 16th
|
||||
* combo strategy: fan the prompt out to every panel model in parallel, then a judge
|
||||
* model synthesizes one final answer from all panel responses.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-fusion-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-fusion-test-secret";
|
||||
|
||||
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
|
||||
|
||||
const noop = () => {};
|
||||
const log = { info: noop, warn: noop, debug: noop, error: noop };
|
||||
|
||||
type Body = Record<string, unknown>;
|
||||
|
||||
// Minimal OpenAI-chat Response-shaped object compatible with the engine's .ok + .clone().json() surface.
|
||||
function okResponse(content: string, { delayMs = 0 } = {}): Response | Promise<Response> {
|
||||
const body = JSON.stringify({ choices: [{ message: { role: "assistant", content } }] });
|
||||
const make = () =>
|
||||
new Response(body, { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
return delayMs > 0 ? new Promise((r) => setTimeout(() => r(make()), delayMs)) : make();
|
||||
}
|
||||
|
||||
function errResponse(status = 500): Response {
|
||||
return new Response(JSON.stringify({ error: { message: "boom" } }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function fusionCombo(models: string[], extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "test-fusion-combo",
|
||||
strategy: "fusion",
|
||||
models: models.map((m) => ({ model: m })),
|
||||
config: extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("fusion: single-model panel answers directly (nothing to fuse)", async () => {
|
||||
const calls: string[] = [];
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
calls.push(m);
|
||||
return okResponse("solo");
|
||||
};
|
||||
const res = await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
combo: fusionCombo(["p/only"]),
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [],
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0], "p/only");
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
test("fusion: fans out to the panel then routes a synthesis turn to the judge", async () => {
|
||||
const seen: string[] = [];
|
||||
const seenBodies: Body[] = [];
|
||||
const handleSingleModel = async (b: Body, m: string) => {
|
||||
seen.push(m);
|
||||
seenBodies.push(b);
|
||||
if (m === "p/judge") return okResponse("FINAL");
|
||||
return okResponse(`ans-${m}`);
|
||||
};
|
||||
|
||||
const res = await handleComboChat({
|
||||
body: {
|
||||
messages: [{ role: "user", content: "Q" }],
|
||||
stream: true,
|
||||
tools: [{ name: "x" }],
|
||||
},
|
||||
combo: fusionCombo(["p/a", "p/b", "p/c"], { judgeModel: "p/judge" }),
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [],
|
||||
});
|
||||
|
||||
// 3 panel calls + 1 judge call.
|
||||
assert.equal(seen.length, 4);
|
||||
assert.deepEqual(seen.slice(0, 3).sort(), ["p/a", "p/b", "p/c"]);
|
||||
assert.equal(seen[3], "p/judge");
|
||||
|
||||
// Panel calls are non-streaming with tools stripped.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const b = seenBodies[i];
|
||||
assert.equal(b.stream, false, "panel call should be non-streaming");
|
||||
assert.equal(b.tools, undefined, "panel call should have tools stripped");
|
||||
}
|
||||
|
||||
// Judge call carries every panel answer + keeps the client's stream flag.
|
||||
const judgeBody = seenBodies[3];
|
||||
const judgeMsgs = judgeBody.messages as Array<{ role: string; content: string }>;
|
||||
const judgeText = judgeMsgs[judgeMsgs.length - 1].content;
|
||||
assert.match(judgeText, /ans-p\/a/);
|
||||
assert.match(judgeText, /ans-p\/b/);
|
||||
assert.match(judgeText, /ans-p\/c/);
|
||||
assert.match(judgeText, /Source 1/);
|
||||
assert.equal(judgeBody.stream, true);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
test("fusion: defaults the judge to the first panel model when none is set", async () => {
|
||||
const seen: string[] = [];
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
seen.push(m);
|
||||
return okResponse(`ans-${m}`);
|
||||
};
|
||||
await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
combo: fusionCombo(["p/first", "p/second"]),
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [],
|
||||
});
|
||||
// Last call is the judge; defaults to panel[0].
|
||||
assert.equal(seen[seen.length - 1], "p/first");
|
||||
});
|
||||
|
||||
test("fusion: proceeds on quorum without waiting for a straggler (grace window)", async () => {
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
if (m === "p/slow") return okResponse("slow", { delayMs: 5000 });
|
||||
if (m === "p/judge") return okResponse("FINAL");
|
||||
return okResponse(`fast-${m}`);
|
||||
};
|
||||
|
||||
const t0 = Date.now();
|
||||
const seenBodies: Body[] = [];
|
||||
const wrapped = async (b: Body, m: string) => {
|
||||
seenBodies.push(b);
|
||||
return handleSingleModel(b, m);
|
||||
};
|
||||
await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
combo: fusionCombo(["p/x", "p/y", "p/slow"], {
|
||||
judgeModel: "p/judge",
|
||||
fusionTuning: { minPanel: 2, stragglerGraceMs: 50, panelHardTimeoutMs: 10000 },
|
||||
}),
|
||||
handleSingleModel: wrapped,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [],
|
||||
});
|
||||
const elapsed = Date.now() - t0;
|
||||
|
||||
// Two fast answers reach quorum; grace is 50ms, so we never wait ~5s for p/slow.
|
||||
assert.ok(elapsed < 2000, `should not wait for straggler (took ${elapsed}ms)`);
|
||||
|
||||
const judgeBody = seenBodies[seenBodies.length - 1];
|
||||
const judgeMsgs = judgeBody.messages as Array<{ role: string; content: string }>;
|
||||
const judgeText = judgeMsgs[judgeMsgs.length - 1].content;
|
||||
assert.match(judgeText, /fast-p\/x/);
|
||||
assert.match(judgeText, /fast-p\/y/);
|
||||
assert.ok(!/slow/.test(judgeText), "straggler answer should not appear in the judge prompt");
|
||||
});
|
||||
|
||||
test("fusion: returns the lone survivor directly when only one panel model succeeds", async () => {
|
||||
const seen: string[] = [];
|
||||
const handleSingleModel = async (_b: Body, m: string) => {
|
||||
seen.push(m);
|
||||
if (m === "p/ok") return okResponse("lone");
|
||||
return errResponse(500);
|
||||
};
|
||||
await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
combo: fusionCombo(["p/ok", "p/bad"], {
|
||||
judgeModel: "p/judge",
|
||||
fusionTuning: { minPanel: 2, stragglerGraceMs: 50, panelHardTimeoutMs: 5000 },
|
||||
}),
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [],
|
||||
});
|
||||
// No judge call — single answer means there is nothing to fuse.
|
||||
assert.ok(
|
||||
!seen.includes("p/judge"),
|
||||
"judge should not be invoked when only one panel model survives"
|
||||
);
|
||||
});
|
||||
|
||||
test("fusion: returns 503 when the whole panel fails", async () => {
|
||||
const handleSingleModel = async () => errResponse(500);
|
||||
const res = await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
combo: fusionCombo(["p/a", "p/b"], {
|
||||
fusionTuning: { minPanel: 2, stragglerGraceMs: 50, panelHardTimeoutMs: 5000 },
|
||||
}),
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [],
|
||||
});
|
||||
assert.equal(res.status, 503);
|
||||
});
|
||||
132
tests/unit/combo-headroom-ranking.test.ts
Normal file
132
tests/unit/combo-headroom-ranking.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* tests/unit/combo-headroom-ranking.test.ts
|
||||
*
|
||||
* Coverage for the PURE headroom-ranking helper
|
||||
* (open-sse/services/combo/headroomRanking.ts).
|
||||
*
|
||||
* headroom = 1 − max(util_5h, util_7d). Candidates with MORE free capacity
|
||||
* (higher headroom) rank first. Saturation is INJECTED (no network), so the
|
||||
* helper is fully deterministic / unit-testable. Ties preserve input order
|
||||
* (stable sort).
|
||||
*
|
||||
* Technique borrowed from the `dario` project: prefer the connection with the
|
||||
* most headroom instead of reactively filling one until it 429s.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const mod = await import("../../open-sse/services/combo/headroomRanking.ts");
|
||||
const { computeHeadroom, rankByHeadroom } = mod;
|
||||
|
||||
// ─── computeHeadroom (scalar) ────────────────────────────────────────────────
|
||||
|
||||
test("computeHeadroom: 1 − max(util5h, util7d)", () => {
|
||||
// weekly is the binding (higher util) dimension here
|
||||
assert.equal(computeHeadroom({ util5h: 0.2, util7d: 0.7 }), 1 - 0.7);
|
||||
// 5h is the binding dimension here
|
||||
assert.equal(computeHeadroom({ util5h: 0.9, util7d: 0.1 }), 1 - 0.9);
|
||||
});
|
||||
|
||||
test("computeHeadroom: clamps utilization into [0,1] and never returns <0 or >1", () => {
|
||||
// over-saturated upstream (util > 1) → headroom floored at 0
|
||||
assert.equal(computeHeadroom({ util5h: 1.4, util7d: 0 }), 0);
|
||||
// negative noise → treated as 0 util → full headroom
|
||||
assert.equal(computeHeadroom({ util5h: -0.3, util7d: -0.1 }), 1);
|
||||
});
|
||||
|
||||
test("computeHeadroom: missing / non-finite dimensions are treated as 0 util (generous)", () => {
|
||||
// fail-open: unknown saturation must not penalize a connection
|
||||
assert.equal(computeHeadroom({}), 1);
|
||||
assert.equal(computeHeadroom({ util5h: Number.NaN, util7d: undefined }), 1);
|
||||
assert.equal(computeHeadroom({ util5h: 0.5 }), 0.5);
|
||||
});
|
||||
|
||||
// ─── rankByHeadroom (ordering) ───────────────────────────────────────────────
|
||||
|
||||
const keyOf = (c: { id: string }) => c.id;
|
||||
|
||||
test("rankByHeadroom: higher headroom (more free) first — A busy(0.8) vs B free(0.2)", () => {
|
||||
const candidates = [{ id: "A" }, { id: "B" }];
|
||||
const sat = new Map([
|
||||
["A", { util5h: 0.8, util7d: 0.1 }],
|
||||
["B", { util5h: 0.2, util7d: 0.1 }],
|
||||
]);
|
||||
const ranked = rankByHeadroom(candidates, sat, keyOf);
|
||||
assert.deepEqual(
|
||||
ranked.map((c) => c.id),
|
||||
["B", "A"]
|
||||
);
|
||||
});
|
||||
|
||||
test("rankByHeadroom: uses max(5h,7d) — B's high weekly sinks it below A", () => {
|
||||
const candidates = [{ id: "A" }, { id: "B" }];
|
||||
const sat = new Map([
|
||||
["A", { util5h: 0.5, util7d: 0.5 }], // headroom 0.5
|
||||
["B", { util5h: 0.1, util7d: 0.95 }], // headroom 0.05 (weekly binds)
|
||||
]);
|
||||
const ranked = rankByHeadroom(candidates, sat, keyOf);
|
||||
assert.deepEqual(
|
||||
ranked.map((c) => c.id),
|
||||
["A", "B"]
|
||||
);
|
||||
});
|
||||
|
||||
test("rankByHeadroom: tie → stable (preserves original input order)", () => {
|
||||
const candidates = [{ id: "X" }, { id: "Y" }, { id: "Z" }];
|
||||
const sat = new Map([
|
||||
["X", { util5h: 0.3, util7d: 0.3 }],
|
||||
["Y", { util5h: 0.3, util7d: 0.3 }],
|
||||
["Z", { util5h: 0.3, util7d: 0.3 }],
|
||||
]);
|
||||
const ranked = rankByHeadroom(candidates, sat, keyOf);
|
||||
assert.deepEqual(
|
||||
ranked.map((c) => c.id),
|
||||
["X", "Y", "Z"]
|
||||
);
|
||||
});
|
||||
|
||||
test("rankByHeadroom: partial tie keeps stable order within the tied band", () => {
|
||||
// A and C both headroom 0.7; B headroom 0.2. Expect A, C (input order), then B.
|
||||
const candidates = [{ id: "A" }, { id: "B" }, { id: "C" }];
|
||||
const sat = new Map([
|
||||
["A", { util5h: 0.3, util7d: 0.0 }], // 0.7
|
||||
["B", { util5h: 0.8, util7d: 0.0 }], // 0.2
|
||||
["C", { util5h: 0.3, util7d: 0.0 }], // 0.7
|
||||
]);
|
||||
const ranked = rankByHeadroom(candidates, sat, keyOf);
|
||||
assert.deepEqual(
|
||||
ranked.map((c) => c.id),
|
||||
["A", "C", "B"]
|
||||
);
|
||||
});
|
||||
|
||||
test("rankByHeadroom: missing saturation entry → treated as full headroom (fail-open, ranks first)", () => {
|
||||
const candidates = [{ id: "known" }, { id: "unknown" }];
|
||||
const sat = new Map([
|
||||
["known", { util5h: 0.6, util7d: 0.6 }], // headroom 0.4
|
||||
// "unknown" absent → headroom 1.0 → should rank first
|
||||
]);
|
||||
const ranked = rankByHeadroom(candidates, sat, keyOf);
|
||||
assert.deepEqual(
|
||||
ranked.map((c) => c.id),
|
||||
["unknown", "known"]
|
||||
);
|
||||
});
|
||||
|
||||
test("rankByHeadroom: does not mutate the input array", () => {
|
||||
const candidates = [{ id: "A" }, { id: "B" }];
|
||||
const original = [...candidates];
|
||||
const sat = new Map([
|
||||
["A", { util5h: 0.9, util7d: 0.9 }],
|
||||
["B", { util5h: 0.1, util7d: 0.1 }],
|
||||
]);
|
||||
rankByHeadroom(candidates, sat, keyOf);
|
||||
assert.deepEqual(candidates, original, "input array order must be unchanged");
|
||||
});
|
||||
|
||||
test("rankByHeadroom: empty / single candidate is a no-op", () => {
|
||||
assert.deepEqual(rankByHeadroom([], new Map(), keyOf), []);
|
||||
const one = [{ id: "solo" }];
|
||||
assert.deepEqual(rankByHeadroom(one, new Map(), keyOf), one);
|
||||
});
|
||||
147
tests/unit/combo-headroom-strategy.test.ts
Normal file
147
tests/unit/combo-headroom-strategy.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* tests/unit/combo-headroom-strategy.test.ts
|
||||
*
|
||||
* Integration coverage for orderTargetsByHeadroom
|
||||
* (open-sse/services/combo/quotaStrategies.ts) — the async orderer that wires
|
||||
* per-connection saturation into the pure rankByHeadroom helper.
|
||||
*
|
||||
* Isolated from DB/network: targets carry explicit connectionIds and use a
|
||||
* provider with NO registered quota fetcher, so getQuotaAwareConnectionsForTarget
|
||||
* short-circuits (no getProviderConnections call). The getSaturation signal is
|
||||
* injected via __setHeadroomSaturationFetcherForTests.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const mod = await import("../../open-sse/services/combo/quotaStrategies.ts");
|
||||
const { orderTargetsByHeadroom, __setHeadroomSaturationFetcherForTests } = mod;
|
||||
|
||||
const silentLog = { warn: () => {} };
|
||||
|
||||
function target(connectionId: string, modelStr = "testprov/model-a") {
|
||||
return {
|
||||
kind: "model" as const,
|
||||
stepId: connectionId,
|
||||
executionKey: `key-${connectionId}`,
|
||||
modelStr,
|
||||
provider: "testprov",
|
||||
providerId: "testprov",
|
||||
connectionId,
|
||||
weight: 1,
|
||||
label: null,
|
||||
};
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
__setHeadroomSaturationFetcherForTests(null);
|
||||
});
|
||||
|
||||
test("orderTargetsByHeadroom: prefers the connection with the most free capacity", async () => {
|
||||
// conn-busy is at 0.8 util (5h), conn-free at 0.2 → free should come first.
|
||||
__setHeadroomSaturationFetcherForTests(async (connectionId, _provider, dim) => {
|
||||
const table: Record<string, { "5h": number; weekly: number }> = {
|
||||
"conn-busy": { "5h": 0.8, weekly: 0.1 },
|
||||
"conn-free": { "5h": 0.2, weekly: 0.1 },
|
||||
};
|
||||
return table[connectionId]?.[dim.window as "5h" | "weekly"] ?? 0;
|
||||
});
|
||||
|
||||
const ordered = await orderTargetsByHeadroom(
|
||||
[target("conn-busy"), target("conn-free")],
|
||||
"combo-x",
|
||||
silentLog
|
||||
);
|
||||
assert.deepEqual(
|
||||
ordered.map((t) => t.connectionId),
|
||||
["conn-free", "conn-busy"]
|
||||
);
|
||||
});
|
||||
|
||||
test("orderTargetsByHeadroom: weekly window can bind even when 5h is low", async () => {
|
||||
// conn-a: balanced 0.5/0.5 → headroom 0.5
|
||||
// conn-b: 5h great (0.1) but weekly nearly exhausted (0.95) → headroom 0.05
|
||||
__setHeadroomSaturationFetcherForTests(async (connectionId, _provider, dim) => {
|
||||
const table: Record<string, { "5h": number; weekly: number }> = {
|
||||
"conn-a": { "5h": 0.5, weekly: 0.5 },
|
||||
"conn-b": { "5h": 0.1, weekly: 0.95 },
|
||||
};
|
||||
return table[connectionId]?.[dim.window as "5h" | "weekly"] ?? 0;
|
||||
});
|
||||
|
||||
const ordered = await orderTargetsByHeadroom(
|
||||
[target("conn-a"), target("conn-b")],
|
||||
"combo-x",
|
||||
silentLog
|
||||
);
|
||||
assert.deepEqual(
|
||||
ordered.map((t) => t.connectionId),
|
||||
["conn-a", "conn-b"]
|
||||
);
|
||||
});
|
||||
|
||||
test("orderTargetsByHeadroom: equal saturation preserves priority order (stable)", async () => {
|
||||
__setHeadroomSaturationFetcherForTests(async () => 0.3);
|
||||
const ordered = await orderTargetsByHeadroom(
|
||||
[target("c1"), target("c2"), target("c3")],
|
||||
"combo-x",
|
||||
silentLog
|
||||
);
|
||||
assert.deepEqual(
|
||||
ordered.map((t) => t.connectionId),
|
||||
["c1", "c2", "c3"]
|
||||
);
|
||||
});
|
||||
|
||||
test("orderTargetsByHeadroom: saturation fetcher throwing fails open (keeps order)", async () => {
|
||||
__setHeadroomSaturationFetcherForTests(async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const ordered = await orderTargetsByHeadroom(
|
||||
[target("c1"), target("c2")],
|
||||
"combo-x",
|
||||
silentLog
|
||||
);
|
||||
// Fail-open: the orderer catches and returns the original target order.
|
||||
assert.deepEqual(
|
||||
ordered.map((t) => t.connectionId),
|
||||
["c1", "c2"]
|
||||
);
|
||||
});
|
||||
|
||||
test("orderTargetsByHeadroom: single / empty target is a no-op without calling the fetcher", async () => {
|
||||
let called = 0;
|
||||
__setHeadroomSaturationFetcherForTests(async () => {
|
||||
called++;
|
||||
return 0;
|
||||
});
|
||||
assert.deepEqual(await orderTargetsByHeadroom([], "combo-x", silentLog), []);
|
||||
const one = [target("solo")];
|
||||
const orderedOne = await orderTargetsByHeadroom(one, "combo-x", silentLog);
|
||||
assert.deepEqual(
|
||||
orderedOne.map((t) => t.connectionId),
|
||||
["solo"]
|
||||
);
|
||||
assert.equal(called, 0, "fetcher must not be called for <=1 target");
|
||||
});
|
||||
|
||||
test("orderTargetsByHeadroom: fetches each unique connection's saturation once (5h+weekly)", async () => {
|
||||
const calls: string[] = [];
|
||||
__setHeadroomSaturationFetcherForTests(async (connectionId, _provider, dim) => {
|
||||
calls.push(`${connectionId}:${dim.window}`);
|
||||
return connectionId === "hot" ? 0.9 : 0.1;
|
||||
});
|
||||
|
||||
const ordered = await orderTargetsByHeadroom(
|
||||
[target("hot"), target("cool")],
|
||||
"combo-x",
|
||||
silentLog
|
||||
);
|
||||
assert.deepEqual(
|
||||
ordered.map((t) => t.connectionId),
|
||||
["cool", "hot"]
|
||||
);
|
||||
// Exactly one 5h + one weekly probe per unique connection (2 conns → 4 calls).
|
||||
calls.sort();
|
||||
assert.deepEqual(calls, ["cool:5h", "cool:weekly", "hot:5h", "hot:weekly"]);
|
||||
});
|
||||
77
tests/unit/combo-pin-health-gate.test.ts
Normal file
77
tests/unit/combo-pin-health-gate.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* ccp pin health gate — drop a context-cache pin when its provider is DURABLY
|
||||
* down so the session fails over instead of pounding a dead account, while
|
||||
* tolerating transient cooldowns so an unstable provider does not churn the pin.
|
||||
*
|
||||
* Incident 2026-06-22: a session pinned to a throttled/credits-exhausted account
|
||||
* stayed pinned forever (strategy bypassed, no failover) because the pin was only
|
||||
* dropped when the model LEFT the combo, never on connection health.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pinIsDurablyUnhealthy } from "../../open-sse/services/combo.ts";
|
||||
|
||||
const NOW = 1_900_000_000_000;
|
||||
const opts = { backoffLevel: 2, graceMs: 20_000 };
|
||||
const healthy = { testStatus: "active", backoffLevel: 0, rateLimitedUntil: null };
|
||||
|
||||
test("keeps pin when a healthy connection exists", () => {
|
||||
assert.equal(pinIsDurablyUnhealthy("CLOSED", [healthy], NOW, opts), false);
|
||||
});
|
||||
|
||||
test("drops pin when circuit is OPEN", () => {
|
||||
assert.equal(pinIsDurablyUnhealthy("OPEN", [healthy], NOW, opts), true);
|
||||
});
|
||||
|
||||
test("drops pin when there are no active connections", () => {
|
||||
assert.equal(pinIsDurablyUnhealthy("CLOSED", [], NOW, opts), true);
|
||||
});
|
||||
|
||||
test("drops pin when the only connection has credits exhausted (terminal)", () => {
|
||||
const conn = { testStatus: "credits_exhausted", backoffLevel: 0, rateLimitedUntil: null };
|
||||
assert.equal(pinIsDurablyUnhealthy("CLOSED", [conn], NOW, opts), true);
|
||||
});
|
||||
|
||||
test("drops pin on banned/expired terminal status", () => {
|
||||
assert.equal(
|
||||
pinIsDurablyUnhealthy("CLOSED", [{ testStatus: "banned", backoffLevel: 0 }], NOW, opts),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
pinIsDurablyUnhealthy("CLOSED", [{ testStatus: "expired", backoffLevel: 0 }], NOW, opts),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("drops pin once backoffLevel reaches the threshold (repeated failures)", () => {
|
||||
assert.equal(
|
||||
pinIsDurablyUnhealthy("CLOSED", [{ testStatus: "active", backoffLevel: 2 }], NOW, opts),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("anti-flap: keeps pin on a brief transient cooldown (low backoff, short rate-limit)", () => {
|
||||
const conn = {
|
||||
testStatus: "active",
|
||||
backoffLevel: 1,
|
||||
rateLimitedUntil: new Date(NOW + 4_000).toISOString(), // 4s out — within grace
|
||||
};
|
||||
assert.equal(pinIsDurablyUnhealthy("CLOSED", [conn], NOW, opts), false);
|
||||
});
|
||||
|
||||
test("drops pin on a long rate-limit window (beyond grace)", () => {
|
||||
const conn = {
|
||||
testStatus: "active",
|
||||
backoffLevel: 0,
|
||||
rateLimitedUntil: new Date(NOW + 60_000).toISOString(), // 60s out — durable
|
||||
};
|
||||
assert.equal(pinIsDurablyUnhealthy("CLOSED", [conn], NOW, opts), true);
|
||||
});
|
||||
|
||||
test("keeps pin if ANY connection is usable (terminal + healthy mix)", () => {
|
||||
const conns = [
|
||||
{ testStatus: "credits_exhausted", backoffLevel: 0 },
|
||||
healthy,
|
||||
];
|
||||
assert.equal(pinIsDurablyUnhealthy("CLOSED", conns, NOW, opts), false);
|
||||
});
|
||||
234
tests/unit/combo-quota-share-cooldown-wait.test.ts
Normal file
234
tests/unit/combo-quota-share-cooldown-wait.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* TDD (integration) — quota-share combo cooldown-aware retry (Variante A),
|
||||
* exercised through handleComboChat.
|
||||
*
|
||||
* Scenarios (modelled on model-lockout-max-cooldown.test.ts):
|
||||
* 1. strategy="quota-share", single connection, model hits a 429 with a SHORT
|
||||
* retry-after → the combo WAITS out the cooldown and re-dispatches; the 2nd
|
||||
* pass (lock expired) returns 200 instead of propagating the 429.
|
||||
* 2. A 403 (quota_exhausted, locked until midnight) → NO wait, the 403/429 is
|
||||
* propagated immediately (the helper's critical exclusion).
|
||||
* 3. Client abort DURING the wait → 499 "Request aborted".
|
||||
* 4. strategy="priority" (non quota-share) → unchanged: the 429 is propagated
|
||||
* immediately with NO wait.
|
||||
* 5. comboCooldownWait disabled in settings → unchanged: 429 propagated, no wait.
|
||||
*
|
||||
* The waits use a real (short) cooldown so the real setTimeout in
|
||||
* waitForCooldownAwareRetry elapses fast and the model lock expires naturally.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-combo-cooldown-wait-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-combo-cooldown-wait-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
|
||||
const { clearAllModelLockouts } = await import("../../open-sse/services/accountFallback.ts");
|
||||
|
||||
function createLog() {
|
||||
return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
|
||||
}
|
||||
|
||||
// A short transient cooldown so the real wait is fast but the lock genuinely
|
||||
// expires between passes. The 429 carries a retry-after hint slightly LONGER
|
||||
// than baseCooldownMs so waiting it out guarantees the lock has cleared.
|
||||
const BASE_COOLDOWN_MS = 150;
|
||||
const RETRY_AFTER_MS = 250;
|
||||
|
||||
function shortModelLockoutSettings() {
|
||||
return {
|
||||
modelLockout: {
|
||||
enabled: true,
|
||||
errorCodes: [403, 429],
|
||||
baseCooldownMs: BASE_COOLDOWN_MS,
|
||||
maxCooldownMs: 5000,
|
||||
maxBackoffSteps: 0,
|
||||
useExponentialBackoff: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: Record<string, unknown>) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function okResponse() {
|
||||
return jsonResponse(200, { id: "ok", choices: [{ message: { content: "recovered" } }] });
|
||||
}
|
||||
|
||||
function rateLimitResponse(status: number) {
|
||||
return jsonResponse(status, {
|
||||
error: { message: `rate limited (${status})` },
|
||||
// string ISO retry-after → computeClosestRetryAfter yields ~RETRY_AFTER_MS
|
||||
retryAfter: new Date(Date.now() + RETRY_AFTER_MS).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function comboOf(strategy: string) {
|
||||
return {
|
||||
name: `qtSd/${strategy}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
strategy,
|
||||
models: ["openai/gpt-4"],
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0, maxSetRetries: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
clearAllModelLockouts();
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
clearAllModelLockouts();
|
||||
try {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
});
|
||||
|
||||
test("quota-share: short 429 cooldown → waits and re-dispatches (2nd pass 200)", async () => {
|
||||
let calls = 0;
|
||||
const handleSingleModel = async () => {
|
||||
calls += 1;
|
||||
// 1st dispatch: transient 429 (records a rate_limit lock). 2nd dispatch
|
||||
// (after the wait, lock expired): success.
|
||||
return calls === 1 ? rateLimitResponse(429) : okResponse();
|
||||
};
|
||||
|
||||
const startedAt = Date.now();
|
||||
const res = await handleComboChat({
|
||||
body: { model: "openai/gpt-4" },
|
||||
combo: comboOf("quota-share"),
|
||||
handleSingleModel,
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog() as never,
|
||||
settings: shortModelLockoutSettings(),
|
||||
allCombos: null,
|
||||
});
|
||||
const elapsed = Date.now() - startedAt;
|
||||
|
||||
assert.equal(res.status, 200, "expected the retried dispatch to succeed with 200");
|
||||
assert.equal(calls, 2, "expected exactly one wait+redispatch (2 upstream calls)");
|
||||
assert.ok(
|
||||
elapsed >= BASE_COOLDOWN_MS,
|
||||
`expected to have waited out the cooldown, only ${elapsed}ms elapsed`
|
||||
);
|
||||
});
|
||||
|
||||
test("quota-share: 403 quota_exhausted → NO wait, error propagated immediately", async () => {
|
||||
let calls = 0;
|
||||
const handleSingleModel = async () => {
|
||||
calls += 1;
|
||||
return rateLimitResponse(403);
|
||||
};
|
||||
|
||||
const startedAt = Date.now();
|
||||
const res = await handleComboChat({
|
||||
body: { model: "openai/gpt-4" },
|
||||
combo: comboOf("quota-share"),
|
||||
handleSingleModel,
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog() as never,
|
||||
settings: shortModelLockoutSettings(),
|
||||
allCombos: null,
|
||||
});
|
||||
const elapsed = Date.now() - startedAt;
|
||||
|
||||
assert.notEqual(res.status, 200, "quota_exhausted must not be retried into a success");
|
||||
// The real signal that the cooldown wait did NOT fire: a single upstream
|
||||
// dispatch (no redispatch). The 403 lock cooldown is multi-second, so the
|
||||
// wait — had it fired — would dominate the elapsed time; assert we stayed far
|
||||
// below that (loose bound; the first combo dispatch pays DB/import overhead).
|
||||
assert.equal(calls, 1, "quota_exhausted must NOT trigger a wait+redispatch");
|
||||
assert.ok(elapsed < 1500, `quota_exhausted must not wait out a cooldown, but ${elapsed}ms elapsed`);
|
||||
});
|
||||
|
||||
test("quota-share: client abort during the wait → 499", async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const handleSingleModel = async () => {
|
||||
calls += 1;
|
||||
// Always 429 so the loop reaches the wait; abort fires during the wait.
|
||||
return rateLimitResponse(429);
|
||||
};
|
||||
|
||||
// Abort shortly after the request starts — within the cooldown wait window.
|
||||
setTimeout(() => controller.abort(), 50);
|
||||
|
||||
const res = await handleComboChat({
|
||||
body: { model: "openai/gpt-4" },
|
||||
combo: comboOf("quota-share"),
|
||||
handleSingleModel,
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog() as never,
|
||||
settings: shortModelLockoutSettings(),
|
||||
allCombos: null,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
assert.equal(res.status, 499, "abort during the cooldown wait must return 499");
|
||||
});
|
||||
|
||||
test("non quota-share (priority): 429 propagated immediately, NO wait", async () => {
|
||||
let calls = 0;
|
||||
const handleSingleModel = async () => {
|
||||
calls += 1;
|
||||
return rateLimitResponse(429);
|
||||
};
|
||||
|
||||
const startedAt = Date.now();
|
||||
const res = await handleComboChat({
|
||||
body: { model: "openai/gpt-4" },
|
||||
combo: { ...comboOf("priority"), name: "priority-combo" },
|
||||
handleSingleModel,
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog() as never,
|
||||
settings: shortModelLockoutSettings(),
|
||||
allCombos: null,
|
||||
});
|
||||
const elapsed = Date.now() - startedAt;
|
||||
|
||||
assert.equal(res.status, 429, "priority combo must propagate the 429 unchanged");
|
||||
assert.equal(calls, 1, "priority combo must NOT wait+redispatch");
|
||||
assert.ok(elapsed < 1500, `priority combo must not wait out a cooldown, but ${elapsed}ms elapsed`);
|
||||
});
|
||||
|
||||
test("quota-share with comboCooldownWait disabled → 429 propagated, NO wait", async () => {
|
||||
let calls = 0;
|
||||
const handleSingleModel = async () => {
|
||||
calls += 1;
|
||||
return rateLimitResponse(429);
|
||||
};
|
||||
|
||||
const res = await handleComboChat({
|
||||
body: { model: "openai/gpt-4" },
|
||||
combo: comboOf("quota-share"),
|
||||
handleSingleModel,
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog() as never,
|
||||
settings: {
|
||||
...shortModelLockoutSettings(),
|
||||
resilienceSettings: { comboCooldownWait: { enabled: false } },
|
||||
},
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(res.status, 429, "disabled feature must propagate the 429 unchanged");
|
||||
assert.equal(calls, 1, "disabled feature must NOT wait+redispatch");
|
||||
});
|
||||
@@ -154,13 +154,18 @@ test("POST /api/combos rejects composite tiers that point to unknown steps", asy
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(body, {
|
||||
error: {
|
||||
message: "Invalid composite tiers",
|
||||
details: [
|
||||
{
|
||||
field: "config.compositeTiers.tiers.primary.stepId",
|
||||
message: 'stepId "step-missing" does not exist in combo.models',
|
||||
},
|
||||
],
|
||||
code: "COMBO_003",
|
||||
message: "Composite tier configuration is invalid",
|
||||
category: "COMBO",
|
||||
details: {
|
||||
reason: "Invalid composite tiers",
|
||||
details: [
|
||||
{
|
||||
field: "config.compositeTiers.tiers.primary.stepId",
|
||||
message: 'stepId "step-missing" does not exist in combo.models',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -222,13 +227,18 @@ test("PUT /api/combos rejects updates that orphan an existing composite tier ste
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(body, {
|
||||
error: {
|
||||
message: "Invalid composite tiers",
|
||||
details: [
|
||||
{
|
||||
field: "config.compositeTiers.tiers.primary.stepId",
|
||||
message: 'stepId "step-primary" does not exist in combo.models',
|
||||
},
|
||||
],
|
||||
code: "COMBO_003",
|
||||
message: "Composite tier configuration is invalid",
|
||||
category: "COMBO",
|
||||
details: {
|
||||
reason: "Invalid composite tiers",
|
||||
details: [
|
||||
{
|
||||
field: "config.compositeTiers.tiers.primary.stepId",
|
||||
message: 'stepId "step-primary" does not exist in combo.models',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
322
tests/unit/combo-session-stickiness.test.ts
Normal file
322
tests/unit/combo-session-stickiness.test.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* tests/unit/combo-session-stickiness.test.ts
|
||||
*
|
||||
* Unit tests for open-sse/services/combo/sessionStickiness.ts
|
||||
*
|
||||
* Design: saturation is injected via __setStickinessHeadroomFetcherForTests —
|
||||
* no network, no DB, fully deterministic.
|
||||
*
|
||||
* Coverage:
|
||||
* - deriveMessageHash: stable key derivation from first user message
|
||||
* - applySessionStickiness: same hash → same connection while healthy
|
||||
* - applySessionStickiness: saturated connection → rebind (clear + fall through)
|
||||
* - applySessionStickiness: no user message → normal ordering, no crash
|
||||
* - applySessionStickiness: different hashes → can map to different connections
|
||||
* - applySessionStickiness: saturation fetch error → fail-open
|
||||
* - recordStickyBinding / clearStickyBinding lifecycle
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { HeadroomSaturation } from "../../open-sse/services/combo/headroomRanking.ts";
|
||||
|
||||
const mod = await import("../../open-sse/services/combo/sessionStickiness.ts");
|
||||
const {
|
||||
deriveMessageHash,
|
||||
applySessionStickiness,
|
||||
recordStickyBinding,
|
||||
clearStickyBinding,
|
||||
clearAllStickyBindings,
|
||||
__setStickinessHeadroomFetcherForTests,
|
||||
STICKINESS_HEADROOM_THRESHOLD,
|
||||
} = mod;
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTarget(connectionId: string): import("../../open-sse/services/combo/types.ts").ResolvedComboTarget {
|
||||
return {
|
||||
kind: "model",
|
||||
stepId: `step-${connectionId}`,
|
||||
executionKey: `key-${connectionId}`,
|
||||
modelStr: `gpt-4/${connectionId}`,
|
||||
provider: "openai",
|
||||
providerId: null,
|
||||
connectionId,
|
||||
weight: 1,
|
||||
label: null,
|
||||
};
|
||||
}
|
||||
|
||||
function injectSat(sat: HeadroomSaturation | undefined): void {
|
||||
__setStickinessHeadroomFetcherForTests(async (_id: string) => sat);
|
||||
}
|
||||
|
||||
// ─── Test lifecycle ───────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearAllStickyBindings();
|
||||
// Default: healthy connection (headroom = 0.5)
|
||||
injectSat({ util5h: 0.3, util7d: 0.2 });
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
__setStickinessHeadroomFetcherForTests(null);
|
||||
});
|
||||
|
||||
// ─── deriveMessageHash ───────────────────────────────────────────────────────
|
||||
|
||||
test("deriveMessageHash: returns 16-char hex for a plain user message", () => {
|
||||
const hash = deriveMessageHash([{ role: "user", content: "Hello world" }]);
|
||||
assert.ok(hash !== null, "hash should not be null");
|
||||
assert.match(hash!, /^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
test("deriveMessageHash: same content → same hash (stable)", () => {
|
||||
const msgs = [{ role: "user", content: "How are you?" }];
|
||||
assert.equal(deriveMessageHash(msgs), deriveMessageHash(msgs));
|
||||
});
|
||||
|
||||
test("deriveMessageHash: different first messages → different hashes", () => {
|
||||
const h1 = deriveMessageHash([{ role: "user", content: "AAA" }]);
|
||||
const h2 = deriveMessageHash([{ role: "user", content: "BBB" }]);
|
||||
assert.notEqual(h1, h2);
|
||||
});
|
||||
|
||||
test("deriveMessageHash: skips assistant messages, uses first user", () => {
|
||||
const msgs = [
|
||||
{ role: "assistant", content: "Hi" },
|
||||
{ role: "user", content: "My real first message" },
|
||||
];
|
||||
const direct = [{ role: "user", content: "My real first message" }];
|
||||
assert.equal(deriveMessageHash(msgs), deriveMessageHash(direct));
|
||||
});
|
||||
|
||||
test("deriveMessageHash: multi-part content array is hashed on text parts", () => {
|
||||
const msgs = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "image_url", url: "http://example.com/img.png" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const hash = deriveMessageHash(msgs);
|
||||
assert.ok(hash !== null);
|
||||
assert.match(hash!, /^[a-f0-9]{16}$/);
|
||||
// Stability
|
||||
assert.equal(deriveMessageHash(msgs), hash);
|
||||
});
|
||||
|
||||
test("deriveMessageHash: null/empty → null (fail-open)", () => {
|
||||
assert.equal(deriveMessageHash(null), null);
|
||||
assert.equal(deriveMessageHash([]), null);
|
||||
assert.equal(deriveMessageHash(undefined), null);
|
||||
// No user message
|
||||
assert.equal(deriveMessageHash([{ role: "assistant", content: "Hi" }]), null);
|
||||
});
|
||||
|
||||
// ─── applySessionStickiness — main scenarios ──────────────────────────────────
|
||||
|
||||
test("same message hash → same connection in repeated calls while healthy", async () => {
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B"), makeTarget("conn-C")];
|
||||
const messages = [{ role: "user", content: "First turn of conversation" }];
|
||||
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
assert.ok(hash, "hash must be derivable");
|
||||
|
||||
// Simulate first successful request: record sticky to conn-B
|
||||
recordStickyBinding(hash, "conn-B");
|
||||
|
||||
// Call 1
|
||||
const r1 = await applySessionStickiness(targets, messages);
|
||||
assert.ok(r1.stuck, "should be stuck on first call");
|
||||
assert.equal(r1.targets[0].connectionId, "conn-B", "conn-B should be first");
|
||||
|
||||
// Call 2 (same conversation)
|
||||
const r2 = await applySessionStickiness(targets, messages);
|
||||
assert.ok(r2.stuck, "should remain stuck on second call");
|
||||
assert.equal(r2.targets[0].connectionId, "conn-B");
|
||||
});
|
||||
|
||||
test("saturated connection → rebind (clear sticky, return normal ordering)", async () => {
|
||||
injectSat({ util5h: 0.9, util7d: 0.9 }); // headroom = 0.1, below threshold
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B")];
|
||||
const messages = [{ role: "user", content: "Saturation test" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
|
||||
recordStickyBinding(hash, "conn-A");
|
||||
|
||||
const result = await applySessionStickiness(targets, messages);
|
||||
|
||||
assert.equal(result.stuck, false, "should NOT be stuck when saturated");
|
||||
// Normal order preserved (conn-A stays at index 0 — just not "stuck")
|
||||
assert.equal(result.targets[0].connectionId, "conn-A");
|
||||
|
||||
// Binding must have been cleared — subsequent call also returns not stuck
|
||||
// (even with healthy fetcher since the binding is gone)
|
||||
injectSat({ util5h: 0.1, util7d: 0.1 }); // now healthy
|
||||
const result2 = await applySessionStickiness(targets, messages);
|
||||
assert.equal(result2.stuck, false, "binding was cleared, no stickiness on follow-up");
|
||||
});
|
||||
|
||||
test("no user message in body → normal ordering, no crash", async () => {
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B")];
|
||||
|
||||
const r1 = await applySessionStickiness(targets, null);
|
||||
assert.equal(r1.stuck, false);
|
||||
assert.deepEqual(
|
||||
r1.targets.map((t) => t.connectionId),
|
||||
["conn-A", "conn-B"]
|
||||
);
|
||||
|
||||
const r2 = await applySessionStickiness(targets, []);
|
||||
assert.equal(r2.stuck, false);
|
||||
|
||||
const r3 = await applySessionStickiness(targets, undefined);
|
||||
assert.equal(r3.stuck, false);
|
||||
});
|
||||
|
||||
test("different message hashes can map to different connections", async () => {
|
||||
injectSat({ util5h: 0.0, util7d: 0.0 }); // full headroom
|
||||
const targets = [makeTarget("conn-X"), makeTarget("conn-Y")];
|
||||
|
||||
const msgs1 = [{ role: "user", content: "Conversation Alpha" }];
|
||||
const msgs2 = [{ role: "user", content: "Conversation Beta" }];
|
||||
|
||||
const hash1 = deriveMessageHash(msgs1)!;
|
||||
const hash2 = deriveMessageHash(msgs2)!;
|
||||
assert.notEqual(hash1, hash2, "hashes must differ");
|
||||
|
||||
recordStickyBinding(hash1, "conn-X");
|
||||
recordStickyBinding(hash2, "conn-Y");
|
||||
|
||||
const r1 = await applySessionStickiness(targets, msgs1);
|
||||
const r2 = await applySessionStickiness(targets, msgs2);
|
||||
|
||||
assert.ok(r1.stuck);
|
||||
assert.ok(r2.stuck);
|
||||
assert.equal(r1.targets[0].connectionId, "conn-X");
|
||||
assert.equal(r2.targets[0].connectionId, "conn-Y");
|
||||
});
|
||||
|
||||
test("saturation fetch error → fail-open (original order, no crash)", async () => {
|
||||
__setStickinessHeadroomFetcherForTests(async (_id: string) => {
|
||||
throw new Error("network failure");
|
||||
});
|
||||
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B")];
|
||||
const messages = [{ role: "user", content: "Error path" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
recordStickyBinding(hash, "conn-A");
|
||||
|
||||
const result = await applySessionStickiness(targets, messages);
|
||||
assert.equal(result.stuck, false, "must not crash on fetcher error");
|
||||
assert.deepEqual(
|
||||
result.targets.map((t) => t.connectionId),
|
||||
["conn-A", "conn-B"],
|
||||
"original order preserved"
|
||||
);
|
||||
});
|
||||
|
||||
test("single target list → no-op, no sticky lookup performed", async () => {
|
||||
const targets = [makeTarget("conn-solo")];
|
||||
const messages = [{ role: "user", content: "Only one target" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
recordStickyBinding(hash, "conn-solo");
|
||||
|
||||
const result = await applySessionStickiness(targets, messages);
|
||||
assert.equal(result.stuck, false, "no stickiness applied for single target");
|
||||
assert.deepEqual(result.targets, targets);
|
||||
});
|
||||
|
||||
test("STICKINESS_HEADROOM_THRESHOLD: connection below threshold is NOT reused", async () => {
|
||||
// headroom = 1 − 0.9 = 0.1, clearly below threshold 0.15
|
||||
injectSat({ util5h: 0.9, util7d: 0.0 });
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B")];
|
||||
const messages = [{ role: "user", content: "Below threshold" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
recordStickyBinding(hash, "conn-A");
|
||||
|
||||
const result = await applySessionStickiness(targets, messages);
|
||||
assert.equal(result.stuck, false, "connection below threshold must not be reused");
|
||||
});
|
||||
|
||||
test("connection just above threshold IS reused", async () => {
|
||||
// headroom = 1 − 0.84 = 0.16 > 0.15
|
||||
injectSat({ util5h: 0.84, util7d: 0.0 });
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B")];
|
||||
const messages = [{ role: "user", content: "Above threshold" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
recordStickyBinding(hash, "conn-A");
|
||||
|
||||
const result = await applySessionStickiness(targets, messages);
|
||||
assert.ok(result.stuck, "connection just above threshold should be reused");
|
||||
assert.equal(result.targets[0].connectionId, "conn-A");
|
||||
});
|
||||
|
||||
test("sticky connection no longer in target list → clear binding, normal order", async () => {
|
||||
const targets = [makeTarget("conn-X"), makeTarget("conn-Y")];
|
||||
const messages = [{ role: "user", content: "Removed connection" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
|
||||
// Sticky to conn-GONE which is NOT in targets
|
||||
recordStickyBinding(hash, "conn-GONE");
|
||||
|
||||
const result = await applySessionStickiness(targets, messages);
|
||||
assert.equal(result.stuck, false, "should not be stuck when connection is gone");
|
||||
assert.deepEqual(
|
||||
result.targets.map((t) => t.connectionId),
|
||||
["conn-X", "conn-Y"]
|
||||
);
|
||||
});
|
||||
|
||||
// ─── recordStickyBinding / clearStickyBinding lifecycle ──────────────────────
|
||||
|
||||
test("recordStickyBinding: creates a new binding and stickiness is applied", async () => {
|
||||
const targets = [makeTarget("conn-1"), makeTarget("conn-2")];
|
||||
const messages = [{ role: "user", content: "Lifecycle test" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
|
||||
recordStickyBinding(hash, "conn-1");
|
||||
|
||||
const r = await applySessionStickiness(targets, messages);
|
||||
assert.ok(r.stuck);
|
||||
assert.equal(r.targets[0].connectionId, "conn-1");
|
||||
});
|
||||
|
||||
test("clearStickyBinding: removes the binding so next call is normal ordering", async () => {
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B")];
|
||||
const messages = [{ role: "user", content: "Clear test" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
|
||||
recordStickyBinding(hash, "conn-B");
|
||||
clearStickyBinding(hash);
|
||||
|
||||
const r = await applySessionStickiness(targets, messages);
|
||||
assert.equal(r.stuck, false, "binding was cleared, no stickiness");
|
||||
});
|
||||
|
||||
test("recordStickyBinding: updating to a new connectionId rebinds correctly", async () => {
|
||||
const targets = [makeTarget("conn-OLD"), makeTarget("conn-NEW")];
|
||||
const messages = [{ role: "user", content: "Rebind test" }];
|
||||
const hash = deriveMessageHash(messages)!;
|
||||
|
||||
recordStickyBinding(hash, "conn-OLD");
|
||||
// Rebind to conn-NEW
|
||||
recordStickyBinding(hash, "conn-NEW");
|
||||
|
||||
const r = await applySessionStickiness(targets, messages);
|
||||
assert.ok(r.stuck);
|
||||
assert.equal(r.targets[0].connectionId, "conn-NEW");
|
||||
});
|
||||
|
||||
test("messageHash is returned in result even when no binding exists", async () => {
|
||||
const targets = [makeTarget("conn-A"), makeTarget("conn-B")];
|
||||
const messages = [{ role: "user", content: "No binding yet" }];
|
||||
|
||||
const r = await applySessionStickiness(targets, messages);
|
||||
assert.equal(r.stuck, false);
|
||||
assert.ok(r.messageHash !== null, "hash should be derivable and returned");
|
||||
assert.match(r.messageHash!, /^[a-f0-9]{16}$/);
|
||||
});
|
||||
@@ -12,6 +12,8 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const dbCore = await import("../../src/lib/db/core.ts");
|
||||
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
|
||||
const { clearAllStickyBindings } =
|
||||
await import("../../open-sse/services/combo/sessionStickiness.ts");
|
||||
const { invalidateCodexQuotaCache, registerCodexConnection, registerCodexQuotaFetcher } =
|
||||
await import("../../open-sse/services/codexQuotaFetcher.ts");
|
||||
const { registerQuotaFetcher } = await import("../../open-sse/services/quotaPreflight.ts");
|
||||
@@ -171,6 +173,10 @@ async function selectedConnectionFor(
|
||||
combo: Record<string, unknown>,
|
||||
options: { apiKeyAllowedConnections?: string[] | null } = {}
|
||||
) {
|
||||
// Isolate strategy/round-robin assertions from session stickiness (#5): this helper
|
||||
// reuses the same body, so a sticky binding from a prior call would pin the connection
|
||||
// and break tie-break rotation. Stickiness has its own suite (combo-session-stickiness).
|
||||
clearAllStickyBindings();
|
||||
const calls: Array<string | null> = [];
|
||||
const response = await handleComboChat({
|
||||
body: reqBodyTextArray,
|
||||
|
||||
429
tests/unit/combo-task-aware.test.ts
Normal file
429
tests/unit/combo-task-aware.test.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Tests for task-aware combo routing (port of upstream PR #2045).
|
||||
*
|
||||
* Coverage:
|
||||
* - classifyTask: light / standard / heavy / critical levels
|
||||
* - reorderByTaskWeight: floats higher-power models for heavy tasks
|
||||
* - getConversationCacheKey: stable per conversation, distinct per thread
|
||||
* - getOrSetConversationAffinityIndex: affinity pin + TTL behaviour
|
||||
* - isTaskRoutingStrategy: gate for task-aware strategies
|
||||
* - Guard: non-task-aware strategies are unaffected (identity path)
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
classifyTask,
|
||||
getConversationCacheKey,
|
||||
getOrSetConversationAffinityIndex,
|
||||
isTaskRoutingStrategy,
|
||||
reorderByTaskWeight,
|
||||
scoreModelForTask,
|
||||
TASK_LEVEL_WEIGHT,
|
||||
comboConversationAffinity,
|
||||
clearConversationAffinity,
|
||||
} from "../../open-sse/services/taskAwareRouting.ts";
|
||||
import type { ResolvedComboTarget } from "../../open-sse/services/combo/types.ts";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTarget(modelStr: string, idx = 0): ResolvedComboTarget {
|
||||
const [provider = "", model = modelStr] = modelStr.includes("/")
|
||||
? modelStr.split("/")
|
||||
: ["", modelStr];
|
||||
return {
|
||||
kind: "model" as const,
|
||||
stepId: `step-${idx}`,
|
||||
executionKey: `exec-${idx}`,
|
||||
modelStr,
|
||||
provider,
|
||||
providerId: null,
|
||||
connectionId: null,
|
||||
weight: 1,
|
||||
label: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { messages: [{ role: "user", content: "Hello" }], ...overrides };
|
||||
}
|
||||
|
||||
// ── classifyTask ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("classifyTask", () => {
|
||||
it("classifies empty body as light (all signals tiny → default-light path)", () => {
|
||||
const t = classifyTask({});
|
||||
// Empty body: 0 chars, 0 messages, 0 tools, 0 output tokens → light
|
||||
assert.equal(t.level, "light");
|
||||
assert.equal(t.weight, TASK_LEVEL_WEIGHT.light);
|
||||
});
|
||||
|
||||
it("classifies a simple short greeting as light", () => {
|
||||
const t = classifyTask({ messages: [{ role: "user", content: "hi there" }], max_tokens: 100 });
|
||||
assert.equal(t.level, "light");
|
||||
assert.equal(t.weight, TASK_LEVEL_WEIGHT.light);
|
||||
});
|
||||
|
||||
it("classifies a translate request as light", () => {
|
||||
const t = classifyTask({
|
||||
messages: [{ role: "user", content: "translate this short sentence" }],
|
||||
max_tokens: 256,
|
||||
});
|
||||
assert.equal(t.level, "light");
|
||||
});
|
||||
|
||||
it("classifies a quick rewrite request as light", () => {
|
||||
const t = classifyTask({
|
||||
messages: [{ role: "user", content: "quick rewrite this sentence to be more concise" }],
|
||||
max_tokens: 300,
|
||||
});
|
||||
assert.equal(t.level, "light");
|
||||
});
|
||||
|
||||
it("classifies long conversation + big prompt as heavy (two signals)", () => {
|
||||
// 20 messages (1 signal) + prompt >= 24000 chars (1 signal) = 2 → heavy
|
||||
const bigContent = "context ".repeat(3500); // ~28000 chars
|
||||
const messages = [
|
||||
{ role: "user", content: bigContent },
|
||||
...Array.from({ length: 20 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? "assistant" : "user",
|
||||
content: "turn " + i,
|
||||
})),
|
||||
];
|
||||
const t = classifyTask({ messages });
|
||||
assert.equal(t.level, "heavy");
|
||||
assert.equal(t.weight, TASK_LEVEL_WEIGHT.heavy);
|
||||
});
|
||||
|
||||
it("classifies high reasoning effort as heavy", () => {
|
||||
const t = classifyTask({
|
||||
messages: [{ role: "user", content: "analyze this code" }],
|
||||
reasoning_effort: "high",
|
||||
});
|
||||
assert.equal(t.level, "heavy");
|
||||
});
|
||||
|
||||
it("classifies huge context (>= 100k chars) as critical", () => {
|
||||
const t = classifyTask({
|
||||
messages: [{ role: "user", content: "x".repeat(120_000) }],
|
||||
});
|
||||
assert.equal(t.level, "critical");
|
||||
assert.equal(t.weight, TASK_LEVEL_WEIGHT.critical);
|
||||
});
|
||||
|
||||
it("classifies huge output request (>= 32768 tokens) as critical", () => {
|
||||
const t = classifyTask({
|
||||
messages: [{ role: "user", content: "write a comprehensive report" }],
|
||||
max_tokens: 40_000,
|
||||
});
|
||||
assert.equal(t.level, "critical");
|
||||
});
|
||||
|
||||
it("classifies security+tools+effort as critical", () => {
|
||||
const t = classifyTask({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Find a critical bug bounty attack chain for RCE or supply chain impact.\n${"context ".repeat(2000)}`,
|
||||
},
|
||||
],
|
||||
tools: [{ name: "read" }, { name: "grep" }, { name: "web" }, { name: "bash" }],
|
||||
reasoning_effort: "high",
|
||||
max_tokens: 12_000,
|
||||
});
|
||||
assert.equal(t.level, "critical");
|
||||
});
|
||||
|
||||
it("classifies moderate prompt without signals as standard (must exceed light thresholds)", () => {
|
||||
// To be classified standard (not light), prompt must either: be > 4000 chars,
|
||||
// have > 3 messages, tools, or not match a light keyword — "explain briefly" would be light.
|
||||
// A prompt with "analyze" keyword + 5000 chars crosses into standard.
|
||||
const t = classifyTask({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Please analyze how TCP works in detail.\n" + "context ".repeat(500),
|
||||
},
|
||||
{ role: "assistant", content: "TCP uses..." },
|
||||
{ role: "user", content: "And what about UDP?" },
|
||||
{ role: "assistant", content: "UDP is..." },
|
||||
],
|
||||
});
|
||||
assert.equal(t.level, "standard");
|
||||
});
|
||||
|
||||
it("returns reasons array for each classification", () => {
|
||||
const t = classifyTask({
|
||||
messages: [{ role: "user", content: "x".repeat(120_000) }],
|
||||
});
|
||||
assert.ok(Array.isArray(t.reasons), "reasons should be an array");
|
||||
assert.ok(t.reasons.length > 0, "critical task should have at least one reason");
|
||||
});
|
||||
});
|
||||
|
||||
// ── reorderByTaskWeight ───────────────────────────────────────────────────────
|
||||
|
||||
describe("reorderByTaskWeight", () => {
|
||||
it("returns same reference for single-target list", () => {
|
||||
const targets = [makeTarget("anthropic/claude-haiku-4.5", 0)];
|
||||
const task = classifyTask({ messages: [{ role: "user", content: "hello" }] });
|
||||
const out = reorderByTaskWeight(targets, task, new Set());
|
||||
assert.strictEqual(out, targets);
|
||||
});
|
||||
|
||||
it("returns same reference for empty list", () => {
|
||||
const targets: ResolvedComboTarget[] = [];
|
||||
const task = classifyTask({});
|
||||
const out = reorderByTaskWeight(targets, task, new Set());
|
||||
assert.strictEqual(out, targets);
|
||||
});
|
||||
|
||||
it("routes light tasks to lighter models (haiku before opus)", () => {
|
||||
const targets = [
|
||||
makeTarget("anthropic/claude-opus-4.6", 0),
|
||||
makeTarget("anthropic/claude-haiku-4.5", 1),
|
||||
];
|
||||
const task = classifyTask({
|
||||
messages: [{ role: "user", content: "quick rewrite this sentence" }],
|
||||
max_tokens: 300,
|
||||
});
|
||||
const out = reorderByTaskWeight(targets, task, new Set());
|
||||
assert.equal(out[0].modelStr, "anthropic/claude-haiku-4.5");
|
||||
});
|
||||
|
||||
it("routes critical tasks to stronger models (opus before haiku)", () => {
|
||||
const targets = [
|
||||
makeTarget("anthropic/claude-haiku-4.5", 0),
|
||||
makeTarget("anthropic/claude-opus-4.6", 1),
|
||||
];
|
||||
const task = classifyTask({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Security review for account takeover and cross-tenant RCE impact.\n${"code ".repeat(3000)}`,
|
||||
},
|
||||
],
|
||||
tools: [{ name: "read" }, { name: "grep" }, { name: "bash" }, { name: "web" }],
|
||||
reasoning_effort: "high",
|
||||
});
|
||||
const required = new Set(["reasoning"]);
|
||||
const out = reorderByTaskWeight(targets, task, required);
|
||||
assert.equal(out[0].modelStr, "anthropic/claude-opus-4.6");
|
||||
assert.ok(
|
||||
scoreModelForTask(out[0].modelStr, task, required) >=
|
||||
scoreModelForTask(out[1].modelStr, task, required),
|
||||
"first model should score >= second"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps hard-cap vision model first even for light tasks", () => {
|
||||
// anthropic/claude-haiku-4.5 has vision; deepseek-chat does not
|
||||
const targets = [
|
||||
makeTarget("deepseek/deepseek-chat", 0),
|
||||
makeTarget("anthropic/claude-haiku-4.5", 1),
|
||||
];
|
||||
const task = classifyTask({ messages: [{ role: "user", content: "what is in this image?" }] });
|
||||
const out = reorderByTaskWeight(targets, task, new Set(["vision"]));
|
||||
assert.equal(out[0].modelStr, "anthropic/claude-haiku-4.5");
|
||||
});
|
||||
|
||||
it("never drops any targets", () => {
|
||||
const targets = [
|
||||
makeTarget("anthropic/claude-opus-4.6", 0),
|
||||
makeTarget("anthropic/claude-haiku-4.5", 1),
|
||||
makeTarget("deepseek/deepseek-chat", 2),
|
||||
];
|
||||
const task = classifyTask({ messages: [{ role: "user", content: "x".repeat(120_000) }] });
|
||||
const out = reorderByTaskWeight(targets, task, new Set());
|
||||
assert.equal(out.length, 3);
|
||||
});
|
||||
|
||||
it("is stable: ties preserve original order", () => {
|
||||
// Two identical models should not swap positions
|
||||
const targets = [
|
||||
makeTarget("anthropic/claude-haiku-4.5", 0),
|
||||
makeTarget("anthropic/claude-haiku-4.5", 1),
|
||||
];
|
||||
const task = classifyTask({});
|
||||
const out = reorderByTaskWeight(targets, task, new Set());
|
||||
assert.equal(out[0].stepId, "step-0");
|
||||
assert.equal(out[1].stepId, "step-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── isTaskRoutingStrategy ─────────────────────────────────────────────────────
|
||||
|
||||
describe("isTaskRoutingStrategy", () => {
|
||||
it("returns true for task-aware strategy names", () => {
|
||||
for (const name of ["smart", "task", "task-aware", "task_aware", "auto"]) {
|
||||
assert.ok(isTaskRoutingStrategy(name), `Expected ${name} to be task-routing`);
|
||||
}
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
assert.ok(isTaskRoutingStrategy("SMART"));
|
||||
assert.ok(isTaskRoutingStrategy("Task-Aware"));
|
||||
});
|
||||
|
||||
it("returns false for standard strategy names", () => {
|
||||
for (const name of [
|
||||
"priority",
|
||||
"fallback",
|
||||
"round-robin",
|
||||
"weighted",
|
||||
"random",
|
||||
"fill-first",
|
||||
"p2c",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
"reset-aware",
|
||||
"reset-window",
|
||||
"context-optimized",
|
||||
"headroom",
|
||||
"lkgp",
|
||||
"context-relay",
|
||||
]) {
|
||||
assert.ok(!isTaskRoutingStrategy(name), `Expected ${name} to NOT be task-routing`);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for null/undefined/empty", () => {
|
||||
assert.ok(!isTaskRoutingStrategy(null));
|
||||
assert.ok(!isTaskRoutingStrategy(undefined));
|
||||
assert.ok(!isTaskRoutingStrategy(""));
|
||||
});
|
||||
});
|
||||
|
||||
// ── getConversationCacheKey ───────────────────────────────────────────────────
|
||||
|
||||
describe("getConversationCacheKey", () => {
|
||||
it("returns null for null input", () => {
|
||||
assert.equal(getConversationCacheKey(null as unknown as Record<string, unknown>), null);
|
||||
});
|
||||
|
||||
it("returns a string for a body with messages", () => {
|
||||
const key = getConversationCacheKey({
|
||||
messages: [
|
||||
{ role: "system", content: "You are helpful" },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
});
|
||||
assert.equal(typeof key, "string");
|
||||
assert.ok((key as string).length > 0);
|
||||
});
|
||||
|
||||
it("is stable across calls for the same conversation start", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant" },
|
||||
{ role: "user", content: "Hello, how are you?" },
|
||||
],
|
||||
};
|
||||
const k1 = getConversationCacheKey(body);
|
||||
const k2 = getConversationCacheKey(body);
|
||||
assert.equal(k1, k2);
|
||||
});
|
||||
|
||||
it("differs for different first messages", () => {
|
||||
const k1 = getConversationCacheKey({
|
||||
messages: [{ role: "user", content: "Explain TCP" }],
|
||||
});
|
||||
const k2 = getConversationCacheKey({
|
||||
messages: [{ role: "user", content: "Explain UDP" }],
|
||||
});
|
||||
assert.notEqual(k1, k2);
|
||||
});
|
||||
|
||||
it("same key even when later messages are appended (conversation affinity)", () => {
|
||||
const systemMsg = { role: "system", content: "You are a coding assistant" };
|
||||
const firstUser = { role: "user", content: "What is a pointer?" };
|
||||
const k1 = getConversationCacheKey({ messages: [systemMsg, firstUser] });
|
||||
const k2 = getConversationCacheKey({
|
||||
messages: [
|
||||
systemMsg,
|
||||
firstUser,
|
||||
{ role: "assistant", content: "A pointer stores an address..." },
|
||||
{ role: "user", content: "Can you give an example?" },
|
||||
],
|
||||
});
|
||||
assert.equal(k1, k2);
|
||||
});
|
||||
|
||||
it("prefers explicit conversation_id when present", () => {
|
||||
const k1 = getConversationCacheKey({
|
||||
conversation_id: "thread-abc-123",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
});
|
||||
const k2 = getConversationCacheKey({ conversation_id: "thread-abc-123" });
|
||||
assert.equal(k1, k2);
|
||||
assert.notEqual(k1, null);
|
||||
});
|
||||
|
||||
it("different conversation_ids produce different keys", () => {
|
||||
const k1 = getConversationCacheKey({ conversation_id: "thread-1" });
|
||||
const k2 = getConversationCacheKey({ conversation_id: "thread-2" });
|
||||
assert.notEqual(k1, k2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Conversation affinity (getOrSetConversationAffinityIndex) ─────────────────
|
||||
|
||||
describe("conversation affinity", () => {
|
||||
before(() => clearConversationAffinity());
|
||||
after(() => clearConversationAffinity());
|
||||
|
||||
it("returns currentIndex for a new conversation key", () => {
|
||||
const idx = getOrSetConversationAffinityIndex("combo-x", "conv-aaa", 2);
|
||||
assert.equal(idx, 2);
|
||||
});
|
||||
|
||||
it("returns the pinned index for the same conversation key", () => {
|
||||
clearConversationAffinity();
|
||||
getOrSetConversationAffinityIndex("combo-y", "conv-bbb", 0);
|
||||
// Second call should return the pinned index (0), not a new currentIndex (1)
|
||||
const idx = getOrSetConversationAffinityIndex("combo-y", "conv-bbb", 1);
|
||||
assert.equal(idx, 0);
|
||||
});
|
||||
|
||||
it("different conversation keys get independent pins", () => {
|
||||
clearConversationAffinity();
|
||||
const i1 = getOrSetConversationAffinityIndex("combo-z", "conv-c1", 0);
|
||||
const i2 = getOrSetConversationAffinityIndex("combo-z", "conv-c2", 1);
|
||||
assert.equal(i1, 0);
|
||||
assert.equal(i2, 1);
|
||||
// Both pins should stay
|
||||
assert.equal(getOrSetConversationAffinityIndex("combo-z", "conv-c1", 99), 0);
|
||||
assert.equal(getOrSetConversationAffinityIndex("combo-z", "conv-c2", 99), 1);
|
||||
});
|
||||
|
||||
it("clearConversationAffinity clears only the named combo prefix", () => {
|
||||
clearConversationAffinity();
|
||||
getOrSetConversationAffinityIndex("combo-a", "conv-1", 0);
|
||||
getOrSetConversationAffinityIndex("combo-b", "conv-1", 1);
|
||||
clearConversationAffinity("combo-a");
|
||||
// combo-a entry should be gone
|
||||
const sizeAfter = comboConversationAffinity.size;
|
||||
assert.equal(sizeAfter, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Guard: non-task-aware strategy leaves targets unchanged ───────────────────
|
||||
|
||||
describe("non-task-aware strategy guard", () => {
|
||||
it("reorderByTaskWeight is still safe to call externally but only wired for task strategies", () => {
|
||||
// The function itself is pure — what matters is that combo.ts only calls it
|
||||
// when isTaskRoutingStrategy() is true. Verify that explicitly.
|
||||
const nonTaskStrategies = ["priority", "fallback", "round-robin", "cost-optimized"];
|
||||
for (const strategy of nonTaskStrategies) {
|
||||
assert.ok(!isTaskRoutingStrategy(strategy), `${strategy} should not trigger task routing`);
|
||||
}
|
||||
});
|
||||
|
||||
it("reorderByTaskWeight on standard task with equal-power models returns same reference", () => {
|
||||
// If all scores are equal, no reordering → returns same array reference
|
||||
const targets = [makeTarget("anthropic/claude-haiku-4.5", 0)];
|
||||
const task = classifyTask({});
|
||||
const out = reorderByTaskWeight(targets, task, new Set());
|
||||
assert.strictEqual(out, targets);
|
||||
});
|
||||
});
|
||||
101
tests/unit/combo/auto-status-penalty-4540.test.ts
Normal file
101
tests/unit/combo/auto-status-penalty-4540.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// tests/unit/combo/auto-status-penalty-4540.test.ts
|
||||
// Regression for #4540: an exhausted provider connection (e.g. credits_exhausted /
|
||||
// rate_limited with no numeric quota fetcher) used to score IDENTICALLY to a healthy
|
||||
// one — quotaRemaining defaulted to 100 and testStatus never entered scoring — so auto
|
||||
// routing kept picking dead providers. With the quota-preflight hard cutoff DISABLED
|
||||
// (the default), we must NOT hard-block such a candidate (that would surface a misleading
|
||||
// "below quota cutoff" 429); instead a SOFT status penalty must rank it strictly BELOW
|
||||
// an otherwise-identical healthy candidate.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { scoreAutoTargets } from "../../../open-sse/services/combo/autoStrategy.ts";
|
||||
import type {
|
||||
AutoProviderCandidate,
|
||||
ResolvedComboTarget,
|
||||
} from "../../../open-sse/services/combo/types.ts";
|
||||
import type { ScoringWeights } from "../../../open-sse/services/autoCombo/scoring.ts";
|
||||
|
||||
// Quota-weighted only so the two candidates would otherwise tie at quotaRemaining=100.
|
||||
const quotaOnlyWeights: ScoringWeights = {
|
||||
quota: 1,
|
||||
health: 0,
|
||||
costInv: 0,
|
||||
latencyInv: 0,
|
||||
taskFit: 0,
|
||||
stability: 0,
|
||||
tierPriority: 0,
|
||||
tierAffinity: 0,
|
||||
specificityMatch: 0,
|
||||
contextAffinity: 0,
|
||||
resetWindowAffinity: 0,
|
||||
connectionDensity: 0,
|
||||
};
|
||||
|
||||
function target(provider: string, model: string, connectionId: string): ResolvedComboTarget {
|
||||
return {
|
||||
kind: "model",
|
||||
stepId: `${provider}-${model}-${connectionId}`,
|
||||
executionKey: `${provider}/${model}@${connectionId}`,
|
||||
modelStr: `${provider}/${model}`,
|
||||
provider,
|
||||
providerId: null,
|
||||
connectionId,
|
||||
} as ResolvedComboTarget;
|
||||
}
|
||||
|
||||
function candidate(
|
||||
provider: string,
|
||||
model: string,
|
||||
connectionId: string,
|
||||
overrides: Partial<AutoProviderCandidate> = {}
|
||||
): AutoProviderCandidate {
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
stepId: `${provider}-${model}-${connectionId}`,
|
||||
executionKey: `${provider}/${model}@${connectionId}`,
|
||||
modelStr: `${provider}/${model}`,
|
||||
connectionId,
|
||||
quotaRemaining: 100,
|
||||
quotaTotal: 100,
|
||||
circuitBreakerState: "CLOSED",
|
||||
costPer1MTokens: 1,
|
||||
p95LatencyMs: 1000,
|
||||
latencyStdDev: 10,
|
||||
errorRate: 0,
|
||||
resetWindowAffinity: 0.5,
|
||||
connectionPoolSize: 1,
|
||||
...overrides,
|
||||
} as AutoProviderCandidate;
|
||||
}
|
||||
|
||||
test("#4540: exhausted (statusPenalty) candidate scores strictly BELOW an identical healthy one without being hard-blocked", () => {
|
||||
const targets = [target("dead", "m", "dead-conn"), target("healthy", "m", "healthy-conn")];
|
||||
const ranked = scoreAutoTargets(
|
||||
targets,
|
||||
[
|
||||
// Exhausted connection with no numeric quota fetcher: quotaRemaining stays 100,
|
||||
// but the connection terminal status flags a soft penalty (NOT a hard block).
|
||||
candidate("dead", "m", "dead-conn", { statusPenalty: true }),
|
||||
candidate("healthy", "m", "healthy-conn"),
|
||||
],
|
||||
"coding",
|
||||
quotaOnlyWeights
|
||||
);
|
||||
|
||||
// Soft penalty: NOT hard-blocked — both candidates are still in the pool.
|
||||
assert.equal(ranked.length, 2, "soft penalty must not drop the exhausted candidate");
|
||||
|
||||
const dead = ranked.find((e) => e.target.provider === "dead");
|
||||
const healthy = ranked.find((e) => e.target.provider === "healthy");
|
||||
assert.ok(dead && healthy, "both candidates present");
|
||||
|
||||
// The fix: exhausted scores STRICTLY less than the identical healthy one (was a tie).
|
||||
assert.ok(
|
||||
(dead!.score as number) < (healthy!.score as number),
|
||||
`exhausted score (${dead!.score}) must be strictly < healthy score (${healthy!.score})`
|
||||
);
|
||||
|
||||
// And ranking puts healthy first.
|
||||
assert.equal(ranked[0]?.target.provider, "healthy");
|
||||
});
|
||||
46
tests/unit/combo/effective-max-concurrency.test.ts
Normal file
46
tests/unit/combo/effective-max-concurrency.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// tests/unit/combo/effective-max-concurrency.test.ts
|
||||
// Unit test for effectiveMaxConcurrency — the pure resolver that turns a
|
||||
// connection's per-account concurrency cap (provider_connections.max_concurrent)
|
||||
// into the semaphore's maxConcurrency for a round-robin combo target, falling
|
||||
// back to the combo-level default when the connection has no positive cap.
|
||||
//
|
||||
// Runner: node:test + assert/strict (matches the sibling combo predicate tests).
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { effectiveMaxConcurrency } from "../../../open-sse/services/combo/comboPredicates.ts";
|
||||
|
||||
describe("effectiveMaxConcurrency", () => {
|
||||
test("a positive per-connection cap wins over the fallback", () => {
|
||||
assert.equal(effectiveMaxConcurrency(1, 3), 1, "GLM/MiniMax-style cap of 1 must be honored");
|
||||
assert.equal(effectiveMaxConcurrency(5, 3), 5, "a cap higher than the default is still used");
|
||||
});
|
||||
|
||||
test("null cap → fallback (no per-connection limit configured)", () => {
|
||||
assert.equal(effectiveMaxConcurrency(null, 3), 3);
|
||||
});
|
||||
|
||||
test("undefined cap → fallback", () => {
|
||||
assert.equal(effectiveMaxConcurrency(undefined, 7), 7);
|
||||
});
|
||||
|
||||
test("zero cap → fallback (0 means 'no limit', not 'block everything')", () => {
|
||||
assert.equal(effectiveMaxConcurrency(0, 3), 3);
|
||||
});
|
||||
|
||||
test("negative cap → fallback (defensive)", () => {
|
||||
assert.equal(effectiveMaxConcurrency(-2, 4), 4);
|
||||
});
|
||||
|
||||
test("non-finite cap → fallback (defensive)", () => {
|
||||
assert.equal(effectiveMaxConcurrency(Number.NaN, 4), 4);
|
||||
assert.equal(effectiveMaxConcurrency(Number.POSITIVE_INFINITY, 4), 4);
|
||||
});
|
||||
|
||||
test("a non-integer positive cap is floored to a whole slot count", () => {
|
||||
assert.equal(
|
||||
effectiveMaxConcurrency(2.9, 3),
|
||||
2,
|
||||
"fractional caps floor to whole concurrency slots"
|
||||
);
|
||||
});
|
||||
});
|
||||
132
tests/unit/combo/quota-share-concurrency.test.ts
Normal file
132
tests/unit/combo/quota-share-concurrency.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* tests/unit/combo/quota-share-concurrency.test.ts
|
||||
*
|
||||
* FASE 2.1: the per-connection concurrency slot for quota-share combos. These
|
||||
* tests pin the contract of acquireQuotaShareConcurrencySlot against the real
|
||||
* semaphore module: no limit when there is no cap, a stable connection-scoped
|
||||
* key, genuine serialization (a second request WAITS until the first releases),
|
||||
* and fail-open behavior when the queue is saturated.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as semaphore from "../../../open-sse/services/rateLimitSemaphore.ts";
|
||||
import {
|
||||
quotaShareConcurrencyKey,
|
||||
acquireQuotaShareConcurrencySlot,
|
||||
} from "../../../open-sse/services/combo/quotaShareConcurrency.ts";
|
||||
|
||||
const noopLog = { warn: () => {} };
|
||||
|
||||
function target(connectionId: string) {
|
||||
return {
|
||||
connectionId,
|
||||
modelStr: "p/m",
|
||||
executionKey: "p/m",
|
||||
provider: "p",
|
||||
stepId: "s",
|
||||
label: "p/m",
|
||||
} as never;
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
test("quotaShareConcurrencyKey is stable and connection-scoped", () => {
|
||||
assert.equal(quotaShareConcurrencyKey("abc"), "qsconn:abc");
|
||||
assert.equal(quotaShareConcurrencyKey("abc"), quotaShareConcurrencyKey("abc"));
|
||||
assert.notEqual(quotaShareConcurrencyKey("a"), quotaShareConcurrencyKey("b"));
|
||||
});
|
||||
|
||||
test("no slot when cap is null (no per-connection limit → unchanged behavior)", async () => {
|
||||
semaphore.resetAll();
|
||||
const release = await acquireQuotaShareConcurrencySlot(
|
||||
target("c1"),
|
||||
null,
|
||||
{ queueTimeoutMs: 50, maxQueueSize: 10 },
|
||||
noopLog
|
||||
);
|
||||
assert.equal(release, null);
|
||||
});
|
||||
|
||||
test("no slot when cap <= 0", async () => {
|
||||
semaphore.resetAll();
|
||||
assert.equal(
|
||||
await acquireQuotaShareConcurrencySlot(
|
||||
target("c1"),
|
||||
0,
|
||||
{ queueTimeoutMs: 50, maxQueueSize: 10 },
|
||||
noopLog
|
||||
),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("no slot when connectionId is empty", async () => {
|
||||
semaphore.resetAll();
|
||||
const release = await acquireQuotaShareConcurrencySlot(
|
||||
target(""),
|
||||
1,
|
||||
{ queueTimeoutMs: 50, maxQueueSize: 10 },
|
||||
noopLog
|
||||
);
|
||||
assert.equal(release, null);
|
||||
});
|
||||
|
||||
test("acquires a slot when a positive cap is set", async () => {
|
||||
semaphore.resetAll();
|
||||
const release = await acquireQuotaShareConcurrencySlot(
|
||||
target("c1"),
|
||||
1,
|
||||
{ queueTimeoutMs: 50, maxQueueSize: 10 },
|
||||
noopLog
|
||||
);
|
||||
assert.equal(typeof release, "function");
|
||||
release!();
|
||||
});
|
||||
|
||||
test("cap=1 serializes: a second concurrent request WAITS until the first releases", async () => {
|
||||
semaphore.resetAll();
|
||||
const opts = { queueTimeoutMs: 1000, maxQueueSize: 10 };
|
||||
const r1 = await acquireQuotaShareConcurrencySlot(target("c1"), 1, opts, noopLog);
|
||||
assert.equal(typeof r1, "function", "first request acquires the only slot");
|
||||
|
||||
let secondResolved = false;
|
||||
const p2 = acquireQuotaShareConcurrencySlot(target("c1"), 1, opts, noopLog).then((r) => {
|
||||
secondResolved = true;
|
||||
return r;
|
||||
});
|
||||
|
||||
await wait(60);
|
||||
assert.equal(
|
||||
secondResolved,
|
||||
false,
|
||||
"second request is still queued while the first holds the slot"
|
||||
);
|
||||
|
||||
r1!(); // release the first
|
||||
const r2 = await p2;
|
||||
assert.equal(secondResolved, true, "second request resolves only after the first releases");
|
||||
assert.equal(typeof r2, "function", "second request then acquires the freed slot");
|
||||
r2!();
|
||||
});
|
||||
|
||||
test("fail-open: a saturated queue proceeds without a slot (null), never blocks", async () => {
|
||||
semaphore.resetAll();
|
||||
const opts = { queueTimeoutMs: 1000, maxQueueSize: 0 };
|
||||
const r1 = await acquireQuotaShareConcurrencySlot(target("c1"), 1, opts, noopLog);
|
||||
assert.equal(typeof r1, "function", "first request acquires");
|
||||
const r2 = await acquireQuotaShareConcurrencySlot(target("c1"), 1, opts, noopLog);
|
||||
assert.equal(r2, null, "queue full → fail-open null (availability never worsened)");
|
||||
r1!();
|
||||
});
|
||||
|
||||
test("different connections have independent gates (no cross-contention)", async () => {
|
||||
semaphore.resetAll();
|
||||
const opts = { queueTimeoutMs: 50, maxQueueSize: 0 };
|
||||
const r1 = await acquireQuotaShareConcurrencySlot(target("c1"), 1, opts, noopLog);
|
||||
const r2 = await acquireQuotaShareConcurrencySlot(target("c2"), 1, opts, noopLog);
|
||||
assert.equal(typeof r1, "function");
|
||||
assert.equal(typeof r2, "function", "a different connection has its own slot");
|
||||
r1!();
|
||||
r2!();
|
||||
});
|
||||
45
tests/unit/compression-run-telemetry-auth.test.ts
Normal file
45
tests/unit/compression-run-telemetry-auth.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Security regression (#4694 sibling-path gate parity): the compression
|
||||
* run-telemetry route MUST authenticate before disclosing telemetry, mirroring
|
||||
* its sibling `settings/compression/route.ts`. A behavioral 401 test would need
|
||||
* full DB/settings bootstrap (isAuthenticated reads settings), so this is a
|
||||
* source guard that fails if the auth gate is removed or reordered after the
|
||||
* data call — the same wiring-guard pattern used elsewhere in the suite.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROUTE = join(
|
||||
__dirname,
|
||||
"../../src/app/api/settings/compression/run-telemetry/route.ts"
|
||||
);
|
||||
|
||||
test("run-telemetry GET imports isAuthenticated from the shared auth util", () => {
|
||||
const src = readFileSync(ROUTE, "utf8");
|
||||
assert.match(src, /import\s*\{[^}]*\bisAuthenticated\b[^}]*\}\s*from\s*["']@\/shared\/utils\/apiAuth["']/);
|
||||
});
|
||||
|
||||
test("run-telemetry GET gates on auth and returns 401 before reading telemetry", () => {
|
||||
const src = readFileSync(ROUTE, "utf8");
|
||||
|
||||
const authIdx = src.indexOf("isAuthenticated(request)");
|
||||
const unauthorizedIdx = src.search(/status:\s*401/);
|
||||
const dataIdx = src.indexOf("getCompressionRunTelemetrySummary(");
|
||||
|
||||
assert.ok(authIdx > 0, "must call isAuthenticated(request)");
|
||||
assert.ok(unauthorizedIdx > 0, "must return a 401 on failed auth");
|
||||
assert.ok(dataIdx > 0, "must call getCompressionRunTelemetrySummary");
|
||||
|
||||
// The auth check and its 401 must come BEFORE the telemetry read.
|
||||
assert.ok(authIdx < dataIdx, "auth check must precede the telemetry read");
|
||||
assert.ok(unauthorizedIdx < dataIdx, "401 response must precede the telemetry read");
|
||||
});
|
||||
|
||||
test("run-telemetry GET receives the request object (so auth can read it)", () => {
|
||||
const src = readFileSync(ROUTE, "utf8");
|
||||
assert.match(src, /export\s+async\s+function\s+GET\s*\(\s*request\s*:/);
|
||||
});
|
||||
59
tests/unit/compression/preservation-redos-4795.test.ts
Normal file
59
tests/unit/compression/preservation-redos-4795.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
extractPreservedBlocks,
|
||||
restorePreservedBlocks,
|
||||
} from "../../../open-sse/services/compression/preservation.ts";
|
||||
|
||||
// Regression guard for #4795: a single unmatched `$` followed by a run of
|
||||
// consecutive backslashes (e.g. Windows paths pasted into a prompt) triggered
|
||||
// catastrophic backtracking in the `math_inline` built-in pattern, pinning one
|
||||
// CPU core at 100% and freezing the event loop. The alternation
|
||||
// `(?:\\.|[^$\n])` was ambiguous: a backslash could be consumed either by the
|
||||
// escape branch `\\.` or by the catch-all `[^$\n]`, so a run of N backslashes
|
||||
// could be tiled exponentially many ways before the trailing `$` anchor failed.
|
||||
describe("preservation ReDoS guard (#4795)", () => {
|
||||
it("does not hang on a `$` followed by many consecutive backslashes (no closing `$`)", () => {
|
||||
// Without the fix this blows up exponentially (N=38 already exceeds 8s).
|
||||
const evil = "$x" + "\\".repeat(200) + "y";
|
||||
const start = Date.now();
|
||||
const { text } = extractPreservedBlocks(evil);
|
||||
const elapsed = Date.now() - start;
|
||||
assert.ok(
|
||||
elapsed < 1000,
|
||||
`extractPreservedBlocks must not backtrack catastrophically (took ${elapsed}ms)`
|
||||
);
|
||||
// The pathological input is not valid inline math, so it stays untouched.
|
||||
assert.equal(text, evil, "non-math text must be returned unchanged");
|
||||
});
|
||||
|
||||
it("does not hang on Windows-path-style payloads with backslashes after a `$`", () => {
|
||||
const evil =
|
||||
"$C:\\Users\\Alpha\\Net\\DESKTOP\\" + "sub\\".repeat(120) + "no-closing-dollar";
|
||||
const start = Date.now();
|
||||
extractPreservedBlocks(evil);
|
||||
assert.ok(Date.now() - start < 1000, "Windows-path payload must resolve quickly");
|
||||
});
|
||||
|
||||
it("still preserves legitimate inline math", () => {
|
||||
const text = "The identity $E=mc^2$ and $\\alpha + \\beta$ are classics.";
|
||||
const { text: extracted, blocks } = extractPreservedBlocks(text);
|
||||
const mathBlocks = blocks.filter((b) => b.kind === "math_inline");
|
||||
assert.equal(mathBlocks.length, 2, "both inline-math spans must be preserved");
|
||||
assert.ok(!extracted.includes("E=mc^2"), "math content must be replaced by a placeholder");
|
||||
assert.equal(
|
||||
restorePreservedBlocks(extracted, blocks),
|
||||
text,
|
||||
"round-trip must reproduce the original text"
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves inline math that contains an escaped dollar", () => {
|
||||
const text = "Price math: $a \\$ b$ end.";
|
||||
const { blocks } = extractPreservedBlocks(text);
|
||||
assert.ok(
|
||||
blocks.some((b) => b.kind === "math_inline" && b.content === "$a \\$ b$"),
|
||||
"inline math with an escaped dollar must still be captured via the `\\.` branch"
|
||||
);
|
||||
});
|
||||
});
|
||||
108
tests/unit/compression/rtk-file-read-no-truncate-4559.test.ts
Normal file
108
tests/unit/compression/rtk-file-read-no-truncate-4559.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* TDD regression for #4559: RTK compression over-truncates tool-result content.
|
||||
*
|
||||
* A user reported that when a tool returns a file's contents (e.g. a ~147-line
|
||||
* text/code file via a Read tool), RTK's default caps (maxLinesPerResult: 120,
|
||||
* maxCharsPerResult: 12000) drop the middle and most lines are silently
|
||||
* suppressed. Disabling compression OR the RTK "tool results" toggle fixes it;
|
||||
* OpenRouter (no OmniRoute) is unaffected.
|
||||
*
|
||||
* Root cause: the final `smartTruncate` hard-cap in processRtkText fires for ANY
|
||||
* tool result over 120 lines — including document/file reads — even though RTK's
|
||||
* intent is repetitive *command output* (npm install logs, make, docker logs),
|
||||
* NOT prose/code file reads.
|
||||
*
|
||||
* Fix: when the content is NOT a recognized repetitive command/log output
|
||||
* (detection.type === "unknown" with no detected command — i.e. a document-style
|
||||
* read), RTK must NOT apply the line/char truncation that drops the middle.
|
||||
* Genuine repetitive logs (which detect as a known command type) are unaffected,
|
||||
* preserving RTK's value there.
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { processRtkText } from "../../../open-sse/services/compression/engines/rtk/index.ts";
|
||||
|
||||
// A ~147-line mixed prose/code file read — every line is distinct content, NOT
|
||||
// repetitive log noise. No shell-command prefix, so RTK detects it as "unknown".
|
||||
function buildFileRead(): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# Module: payment processor");
|
||||
lines.push("");
|
||||
lines.push("This module reconciles charges against the ledger.");
|
||||
lines.push("");
|
||||
lines.push("import { Decimal } from './decimal';");
|
||||
lines.push("import { Ledger } from './ledger';");
|
||||
lines.push("");
|
||||
for (let i = 0; i < 70; i++) {
|
||||
lines.push(`export function step${i}(input: Input${i}): Result${i} {`);
|
||||
lines.push(` // reconcile bucket ${i} against the prior settlement window`);
|
||||
lines.push(` const value = computeBucket${i}(input.amount, input.currency);`);
|
||||
lines.push(` const settled = applyDiscount${i}(value, input.coupon);`);
|
||||
lines.push(` return { id: ${i}, value: settled, settledAt: input.timestamp${i} };`);
|
||||
lines.push("}");
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
describe("RTK file-read tool result — #4559 no over-truncation", () => {
|
||||
it("does not drop the middle of a 147-line document/file read", () => {
|
||||
const fileRead = buildFileRead();
|
||||
const totalLines = fileRead.split("\n").length;
|
||||
assert.ok(totalLines > 120, `fixture should exceed default cap; got ${totalLines}`);
|
||||
|
||||
// Default RTK tool-result path (applyToToolResults defaults true; this is the
|
||||
// text path the Anthropic/OpenAI tool_result blocks feed into).
|
||||
const result = processRtkText(fileRead, {
|
||||
config: { maxLinesPerResult: 120, maxCharsPerResult: 12000 },
|
||||
});
|
||||
|
||||
// The hard-cap truncation must NOT have fired for a document/file read.
|
||||
assert.ok(
|
||||
!result.techniquesUsed.includes("rtk-truncate"),
|
||||
`file-read content must not be hard-cap truncated; techniquesUsed: ${result.techniquesUsed.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
|
||||
// The truncation marker must be absent and the middle content retained.
|
||||
assert.ok(
|
||||
!result.text.includes("[rtk:truncated"),
|
||||
"no truncation marker should be injected into a file read"
|
||||
);
|
||||
|
||||
// Sample lines from the head, middle, and tail must all survive.
|
||||
assert.ok(result.text.includes("step0("), "head content survives");
|
||||
assert.ok(result.text.includes("step35("), "middle content survives");
|
||||
assert.ok(result.text.includes("step69("), "tail content survives");
|
||||
|
||||
// Output retains the vast majority of the lines (not dropped below a
|
||||
// reasonable threshold).
|
||||
const outLines = result.text.split("\n").length;
|
||||
assert.ok(
|
||||
outLines >= totalLines * 0.9,
|
||||
`expected to retain >=90% of lines; kept ${outLines}/${totalLines}`
|
||||
);
|
||||
});
|
||||
|
||||
it("still truncates genuine repetitive command output (RTK value preserved)", () => {
|
||||
// npm install-style repetitive log lines, with a detected command so RTK
|
||||
// recognizes it as repetitive output worth truncating.
|
||||
const noise = Array.from(
|
||||
{ length: 300 },
|
||||
(_, i) => `added package-${i}@1.0.0 to node_modules`
|
||||
).join("\n");
|
||||
const input = `$ npm install\n${noise}`;
|
||||
|
||||
const result = processRtkText(input, {
|
||||
command: "npm install",
|
||||
config: { maxLinesPerResult: 120, maxCharsPerResult: 12000 },
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
result.compressed,
|
||||
"repetitive npm install output should still be compressed"
|
||||
);
|
||||
});
|
||||
});
|
||||
96
tests/unit/copilot-agent-antigravity-parity.test.ts
Normal file
96
tests/unit/copilot-agent-antigravity-parity.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Port of decolua/9router#779: GitHub Copilot Agent Mode & Antigravity "Invalid Argument" parity.
|
||||
//
|
||||
// Upstream symptom: VS Code GitHub Copilot Chat (in Agent mode) requests
|
||||
// `maxOutputTokens` values well above what the Antigravity Cloud Code backend
|
||||
// will accept — the backend rejects the call with HTTP 400 "Invalid Argument"
|
||||
// even though the request envelope is otherwise valid. The same trigger is
|
||||
// repeatedly observed in upstream issue reports and in OmniRoute connection
|
||||
// cooldowns spiking on Copilot traffic.
|
||||
//
|
||||
// Fix: hard-cap `generationConfig.maxOutputTokens` to MAX_ANTIGRAVITY_OUTPUT_TOKENS
|
||||
// (16384) so any oversized client request is silently shrunk to a value the
|
||||
// upstream accepts. Smaller values are left untouched, and the cap applies
|
||||
// independently of the thinkingBudget bump logic that already lives in
|
||||
// `applyAntigravityGenerationDefaults`.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
MAX_ANTIGRAVITY_OUTPUT_TOKENS,
|
||||
__test_applyAntigravityGenerationDefaults as applyAntigravityGenerationDefaults,
|
||||
} from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
test("MAX_ANTIGRAVITY_OUTPUT_TOKENS is fixed at 16384 (upstream-accepted ceiling)", () => {
|
||||
assert.equal(MAX_ANTIGRAVITY_OUTPUT_TOKENS, 16384);
|
||||
});
|
||||
|
||||
test("Copilot-style oversized maxOutputTokens is clamped to 16384 to avoid Antigravity 400 'Invalid Argument'", () => {
|
||||
// Reproduces the upstream-rejected envelope: VS Code GitHub Copilot Chat in
|
||||
// Agent mode commonly requests 32K–65K output tokens.
|
||||
const request: Record<string, unknown> = {
|
||||
generationConfig: {
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
};
|
||||
|
||||
applyAntigravityGenerationDefaults(request);
|
||||
|
||||
const gc = request.generationConfig as Record<string, unknown>;
|
||||
assert.equal(gc.maxOutputTokens, MAX_ANTIGRAVITY_OUTPUT_TOKENS);
|
||||
});
|
||||
|
||||
test("maxOutputTokens at or below the cap is left untouched", () => {
|
||||
const request: Record<string, unknown> = {
|
||||
generationConfig: {
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
};
|
||||
|
||||
applyAntigravityGenerationDefaults(request);
|
||||
|
||||
const gc = request.generationConfig as Record<string, unknown>;
|
||||
assert.equal(gc.maxOutputTokens, 8192);
|
||||
});
|
||||
|
||||
test("maxOutputTokens exactly at the cap is left untouched (boundary)", () => {
|
||||
const request: Record<string, unknown> = {
|
||||
generationConfig: {
|
||||
maxOutputTokens: MAX_ANTIGRAVITY_OUTPUT_TOKENS,
|
||||
},
|
||||
};
|
||||
|
||||
applyAntigravityGenerationDefaults(request);
|
||||
|
||||
const gc = request.generationConfig as Record<string, unknown>;
|
||||
assert.equal(gc.maxOutputTokens, MAX_ANTIGRAVITY_OUTPUT_TOKENS);
|
||||
});
|
||||
|
||||
test("Cap is applied even when no generationConfig is provided initially", () => {
|
||||
// The defaults helper synthesises generationConfig — the cap must not crash
|
||||
// and must leave a well-formed object behind.
|
||||
const request: Record<string, unknown> = {};
|
||||
applyAntigravityGenerationDefaults(request);
|
||||
|
||||
const gc = request.generationConfig as Record<string, unknown>;
|
||||
assert.equal(typeof gc, "object");
|
||||
// No maxOutputTokens was requested — the cap must not invent one.
|
||||
assert.equal(gc.maxOutputTokens, undefined);
|
||||
});
|
||||
|
||||
test("Cap interacts safely with thinkingBudget bump: bump still wins when budget exceeds tokens, then the cap still clamps the bumped value", () => {
|
||||
// thinkingBudget bumps maxOutputTokens to floor(budget)+1 when it exceeds
|
||||
// the requested ceiling; if the bump itself overshoots the AG cap, the cap
|
||||
// must still apply.
|
||||
const request: Record<string, unknown> = {
|
||||
generationConfig: {
|
||||
maxOutputTokens: 1000,
|
||||
thinkingConfig: { thinkingBudget: 20000 },
|
||||
},
|
||||
};
|
||||
|
||||
applyAntigravityGenerationDefaults(request);
|
||||
|
||||
const gc = request.generationConfig as Record<string, unknown>;
|
||||
assert.equal(gc.maxOutputTokens, MAX_ANTIGRAVITY_OUTPUT_TOKENS);
|
||||
});
|
||||
93
tests/unit/copilot-gemini-claude-route-no-responses.test.ts
Normal file
93
tests/unit/copilot-gemini-claude-route-no-responses.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Regression test ported from decolua/9router#1536 (follow-up to #663).
|
||||
*
|
||||
* GitHub Copilot's `/responses` endpoint only serves OpenAI (gpt/codex) models —
|
||||
* Gemini and Claude models reject with HTTP 400
|
||||
* "[github/<model>] [400]: model <model> does not support Responses API."
|
||||
* (unsupported_api_for_model). They must therefore never be routed to
|
||||
* `/responses`, no matter what `targetFormat` a registry entry (or any future
|
||||
* misconfiguration) declares for them.
|
||||
*
|
||||
* OmniRoute's GithubExecutor decides the endpoint inside `buildUrl()` purely
|
||||
* from `getModelTargetFormat("gh", model)`. The registry today (`open-sse/
|
||||
* config/providers/registry/github/index.ts`) correctly omits
|
||||
* `targetFormat: "openai-responses"` on every Claude/Gemini entry — but that is
|
||||
* a single-line away from regressing (the file's own comments on lines 60, 74,
|
||||
* 80 warn against it). This test pins the defensive invariant: even if a
|
||||
* Claude/Gemini variant ends up tagged `openai-responses`, the executor must
|
||||
* still build the chat/completions URL.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { GithubExecutor } from "../../open-sse/executors/github.ts";
|
||||
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.ts";
|
||||
|
||||
const CHAT_URL = "https://api.githubcopilot.com/chat/completions";
|
||||
const RESPONSES_URL = "https://api.githubcopilot.com/responses";
|
||||
|
||||
describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9router#1536)", () => {
|
||||
it("routes registered Claude/Gemini Copilot models to chat/completions", () => {
|
||||
const exec = new GithubExecutor();
|
||||
for (const id of [
|
||||
"claude-haiku-4.5",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-sonnet-4.6",
|
||||
"claude-opus-4.6",
|
||||
"claude-opus-4.7",
|
||||
"claude-opus-4-5-20251101",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
]) {
|
||||
assert.equal(exec.buildUrl(id, false), CHAT_URL, `${id} must route to chat/completions`);
|
||||
}
|
||||
});
|
||||
|
||||
it("still uses chat/completions if a Claude/Gemini model is wrongly tagged openai-responses", () => {
|
||||
const exec = new GithubExecutor();
|
||||
const ghModels = PROVIDER_MODELS["gh"];
|
||||
assert.ok(Array.isArray(ghModels), "gh provider models registry must exist");
|
||||
|
||||
const claude = ghModels.find((m: any) => m.id === "claude-sonnet-4.6");
|
||||
const gemini = ghModels.find((m: any) => m.id === "gemini-3.1-pro-preview");
|
||||
assert.ok(claude && gemini, "claude-sonnet-4.6 and gemini-3.1-pro-preview must be registered");
|
||||
|
||||
const originalClaude = claude.targetFormat;
|
||||
const originalGemini = gemini.targetFormat;
|
||||
try {
|
||||
// Simulate a future misconfiguration. The guard must still hold.
|
||||
(claude as any).targetFormat = "openai-responses";
|
||||
(gemini as any).targetFormat = "openai-responses";
|
||||
|
||||
assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL);
|
||||
assert.equal(exec.buildUrl("gemini-3.1-pro-preview", false), CHAT_URL);
|
||||
} finally {
|
||||
(claude as any).targetFormat = originalClaude;
|
||||
(gemini as any).targetFormat = originalGemini;
|
||||
}
|
||||
});
|
||||
|
||||
it("still routes registered OpenAI/codex Copilot models to /responses", () => {
|
||||
const exec = new GithubExecutor();
|
||||
for (const id of ["gpt-5-mini", "gpt-5.3-codex", "gpt-5.4-mini", "gpt-5.4"]) {
|
||||
assert.equal(exec.buildUrl(id, false), RESPONSES_URL, `${id} must route to /responses`);
|
||||
}
|
||||
});
|
||||
|
||||
it("is case-insensitive when guarding (defensive against upper/mixed-case ids)", () => {
|
||||
const exec = new GithubExecutor();
|
||||
const ghModels = PROVIDER_MODELS["gh"];
|
||||
const claude = ghModels.find((m: any) => m.id === "claude-sonnet-4.6");
|
||||
const original = claude.targetFormat;
|
||||
try {
|
||||
(claude as any).targetFormat = "openai-responses";
|
||||
// Look up by the same id (registry is case-sensitive on lookup) but with a
|
||||
// mixed-case path through the guard. We rebuild with the registered id;
|
||||
// the guard normalizes before substring check, so it must still detect.
|
||||
assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL);
|
||||
} finally {
|
||||
(claude as any).targetFormat = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
29
tests/unit/copilot-runOmniRouteCli.test.ts
Normal file
29
tests/unit/copilot-runOmniRouteCli.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
test("runOmniRouteCli: missing command returns error", async () => {
|
||||
const { getCopilotTool } = await import("../../src/lib/copilot/tools.ts");
|
||||
const tool = getCopilotTool("runOmniRouteCli");
|
||||
assert.ok(tool);
|
||||
const result = await tool.handler({});
|
||||
assert.equal(result, "Please provide a command to execute.");
|
||||
});
|
||||
|
||||
test("runOmniRouteCli: empty command returns error", async () => {
|
||||
const { getCopilotTool } = await import("../../src/lib/copilot/tools.ts");
|
||||
const tool = getCopilotTool("runOmniRouteCli");
|
||||
assert.ok(tool);
|
||||
const result = await tool.handler({ command: "" });
|
||||
assert.equal(result, "Please provide a command to execute.");
|
||||
});
|
||||
|
||||
test("runOmniRouteCli: returns CLI-not-found when omniroute unavailable", async () => {
|
||||
const { getCopilotTool } = await import("../../src/lib/copilot/tools.ts");
|
||||
const tool = getCopilotTool("runOmniRouteCli");
|
||||
assert.ok(tool);
|
||||
const result = await tool.handler({ command: "health" });
|
||||
assert.ok(
|
||||
result.includes("omniroute CLI not found in PATH"),
|
||||
`Expected CLI-not-found message, got: ${result}`
|
||||
);
|
||||
});
|
||||
156
tests/unit/custom-endpoint-system-prompt.test.ts
Normal file
156
tests/unit/custom-endpoint-system-prompt.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* TDD test for per-endpoint custom system prompt injection (port of upstream #2063).
|
||||
*
|
||||
* Validates that injectCustomSystemPrompt():
|
||||
* - Appends the custom prompt to an existing system message (string content)
|
||||
* - Appends the custom prompt to an existing system message (array content)
|
||||
* - Creates a new system message when none exists
|
||||
* - Injects into Claude-style `system` field (string)
|
||||
* - Injects into Claude-style `system` field (array)
|
||||
* - Does NOT inject when prompt is empty string
|
||||
* - Does NOT inject when body has _skipSystemPrompt flag
|
||||
* - Leaves body unchanged when prompt is falsy
|
||||
*
|
||||
* Also validates settings defaults include customSystemPromptEnabled / customSystemPrompt.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { injectCustomSystemPrompt } = await import(
|
||||
"../../open-sse/services/systemPrompt.ts"
|
||||
);
|
||||
|
||||
// ─── injectCustomSystemPrompt ────────────────────────────────────────────────
|
||||
|
||||
test("injectCustomSystemPrompt: appends to existing string system message", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello" },
|
||||
],
|
||||
};
|
||||
const result = injectCustomSystemPrompt(body, "Always respond formally.");
|
||||
const sysMsg = result.messages.find((m) => m.role === "system");
|
||||
assert.ok(sysMsg, "system message must exist");
|
||||
assert.ok(
|
||||
typeof sysMsg.content === "string" && sysMsg.content.includes("You are a helpful assistant."),
|
||||
"original content preserved"
|
||||
);
|
||||
assert.ok(
|
||||
typeof sysMsg.content === "string" && sysMsg.content.includes("Always respond formally."),
|
||||
"custom prompt appended"
|
||||
);
|
||||
});
|
||||
|
||||
test("injectCustomSystemPrompt: appends to existing array-content system message", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "system", content: [{ type: "text", text: "Base instructions." }] },
|
||||
{ role: "user", content: "Hi" },
|
||||
],
|
||||
};
|
||||
const result = injectCustomSystemPrompt(body, "Speak like a pirate.");
|
||||
const sysMsg = result.messages.find((m) => m.role === "system");
|
||||
assert.ok(sysMsg, "system message must exist");
|
||||
assert.ok(Array.isArray(sysMsg.content), "content must remain array");
|
||||
const texts = (sysMsg.content as Array<{ type: string; text: string }>).map((c) => c.text);
|
||||
assert.ok(texts.includes("Base instructions."), "original block preserved");
|
||||
assert.ok(texts.includes("Speak like a pirate."), "custom prompt block appended");
|
||||
});
|
||||
|
||||
test("injectCustomSystemPrompt: creates system message when none exists", () => {
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "What is 2+2?" }],
|
||||
};
|
||||
const result = injectCustomSystemPrompt(body, "Think step by step.");
|
||||
assert.equal(result.messages[0].role, "system", "system message prepended");
|
||||
assert.equal(result.messages[0].content, "Think step by step.", "prompt is the content");
|
||||
assert.equal(result.messages[1].role, "user", "user message preserved after system");
|
||||
});
|
||||
|
||||
test("injectCustomSystemPrompt: injects into Claude-style string system field", () => {
|
||||
const body = {
|
||||
system: "You are Claude.",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
};
|
||||
const result = injectCustomSystemPrompt(body, "Be concise.");
|
||||
assert.ok(typeof result.system === "string", "system field still a string");
|
||||
assert.ok(result.system.includes("You are Claude."), "original system preserved");
|
||||
assert.ok(result.system.includes("Be concise."), "custom prompt appended to system");
|
||||
});
|
||||
|
||||
test("injectCustomSystemPrompt: injects into Claude-style array system field", () => {
|
||||
const body = {
|
||||
system: [{ type: "text", text: "You are Claude." }],
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
};
|
||||
const result = injectCustomSystemPrompt(body, "Be concise.");
|
||||
assert.ok(Array.isArray(result.system), "system field still an array");
|
||||
const texts = (result.system as Array<{ type: string; text: string }>).map((c) => c.text);
|
||||
assert.ok(texts.includes("You are Claude."), "original system block preserved");
|
||||
assert.ok(texts.includes("Be concise."), "custom prompt block appended");
|
||||
});
|
||||
|
||||
test("injectCustomSystemPrompt: no-op when prompt is empty string", () => {
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
};
|
||||
const result = injectCustomSystemPrompt(body, "");
|
||||
assert.deepEqual(result, body, "body unchanged when prompt is empty");
|
||||
});
|
||||
|
||||
test("injectCustomSystemPrompt: no-op when body has _skipSystemPrompt flag", () => {
|
||||
const body = {
|
||||
_skipSystemPrompt: true,
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
};
|
||||
const result = injectCustomSystemPrompt(body as Record<string, unknown>, "Be formal.");
|
||||
assert.deepEqual(result, body, "body unchanged when _skipSystemPrompt is set");
|
||||
});
|
||||
|
||||
test("injectCustomSystemPrompt: does not mutate input body (immutable)", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "system", content: "Original." },
|
||||
{ role: "user", content: "Hi" },
|
||||
],
|
||||
};
|
||||
const original = JSON.stringify(body);
|
||||
injectCustomSystemPrompt(body, "New instruction.");
|
||||
assert.equal(JSON.stringify(body), original, "input body not mutated");
|
||||
});
|
||||
|
||||
// ─── Settings defaults ───────────────────────────────────────────────────────
|
||||
// Verify that getSettings() returns the expected defaults for the new keys.
|
||||
// Uses an in-memory DB (DATA_DIR set to a temp path so no production DB is touched).
|
||||
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const tmpDir = join(tmpdir(), `omniroute-test-${Date.now()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
process.env["DATA_DIR"] = tmpDir;
|
||||
|
||||
test("settings defaults include customSystemPromptEnabled=false and customSystemPrompt=''", async (t) => {
|
||||
// Dynamic import after setting DATA_DIR to avoid polluting other tests
|
||||
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
const { getSettings } = await import("../../src/lib/db/settings.ts");
|
||||
|
||||
const settings = await getSettings();
|
||||
assert.equal(
|
||||
settings.customSystemPromptEnabled,
|
||||
false,
|
||||
"customSystemPromptEnabled default is false"
|
||||
);
|
||||
assert.equal(
|
||||
settings.customSystemPrompt,
|
||||
"",
|
||||
"customSystemPrompt default is empty string"
|
||||
);
|
||||
|
||||
t.after(() => {
|
||||
resetDbInstance();
|
||||
});
|
||||
});
|
||||
109
tests/unit/custom-provider-combo-models.test.ts
Normal file
109
tests/unit/custom-provider-combo-models.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { getModelCatalogSourceLabel, normalizeModelCatalogSource } = await import(
|
||||
"../../src/shared/utils/modelCatalogSearch.ts"
|
||||
);
|
||||
|
||||
// Ported from upstream PR decolua/9router#2018 (Hamsa_M):
|
||||
// custom (openai-/anthropic-compatible) providers in the combo model-select modal
|
||||
// now dynamically fetch their model catalog from the provider's upstream `/models`
|
||||
// endpoint, merge it with alias/fallback/custom models (deduped by id) and tag the
|
||||
// fetched entries with an "auto" source badge.
|
||||
|
||||
test('the "auto" source normalizes to itself and renders the "Auto" badge', () => {
|
||||
// Before the port the modal had no way to tag dynamically-fetched models, so an
|
||||
// unknown source fell through to "system" / "Built-in", which is misleading.
|
||||
assert.equal(normalizeModelCatalogSource("auto"), "auto");
|
||||
assert.equal(getModelCatalogSourceLabel("auto"), "Auto");
|
||||
});
|
||||
|
||||
test('"auto" must not collide with the "auto-sync" import alias', () => {
|
||||
// `auto-sync` is an existing synced-import alias and must keep mapping to imported.
|
||||
assert.equal(normalizeModelCatalogSource("auto-sync"), "imported");
|
||||
assert.equal(getModelCatalogSourceLabel("auto-sync"), "Imported");
|
||||
});
|
||||
|
||||
// Mirrors the modal's merge step: alias models win, fetched ("auto") models fill the
|
||||
// gaps, deduped by id against alias + fallback + custom entries.
|
||||
function mergeFetchedModels(
|
||||
nodePrefix: string,
|
||||
nodeModels: Array<{ id: string }>,
|
||||
fallbackEntries: Array<{ id: string }>,
|
||||
customEntries: Array<{ id: string }>,
|
||||
fetched: Array<Record<string, string>>
|
||||
) {
|
||||
const fetchedEntries = fetched
|
||||
.map((m) => {
|
||||
const id = m.id || m.slug || m.model || m.name;
|
||||
return {
|
||||
id,
|
||||
name: m.name || m.displayName || id,
|
||||
value: `${nodePrefix}/${id}`,
|
||||
isFetched: true,
|
||||
source: "auto",
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(fm) =>
|
||||
fm.id &&
|
||||
!nodeModels.some((nm) => nm.id === fm.id) &&
|
||||
!fallbackEntries.some((fbm) => fbm.id === fm.id) &&
|
||||
!customEntries.some((cm) => cm.id === fm.id)
|
||||
);
|
||||
return [...nodeModels, ...fallbackEntries, ...customEntries, ...fetchedEntries];
|
||||
}
|
||||
|
||||
test("fetched models merge with alias models, deduping by id", () => {
|
||||
const nodeModels = [{ id: "gpt-4", name: "GPT-4", value: "p/gpt-4" }];
|
||||
const fetched = [
|
||||
{ id: "gpt-4", name: "GPT-4 Turbo" }, // duplicate of an alias → dropped
|
||||
{ id: "gpt-3.5", name: "GPT-3.5" }, // new → kept, tagged auto
|
||||
];
|
||||
|
||||
const merged = mergeFetchedModels("p", nodeModels, [], [], fetched) as Array<{
|
||||
id: string;
|
||||
source?: string;
|
||||
}>;
|
||||
|
||||
assert.equal(merged.length, 2);
|
||||
assert.deepEqual(
|
||||
merged.map((m) => m.id).sort(),
|
||||
["gpt-3.5", "gpt-4"]
|
||||
);
|
||||
const auto = merged.find((m) => m.id === "gpt-3.5");
|
||||
assert.equal(auto?.source, "auto");
|
||||
// The pre-existing alias entry keeps its original (non-auto) identity.
|
||||
assert.equal(merged.find((m) => m.id === "gpt-4")?.source, undefined);
|
||||
});
|
||||
|
||||
test("fetched ids fall back across id/slug/model/name keys", () => {
|
||||
const merged = mergeFetchedModels(
|
||||
"p",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{ slug: "llama-3" }, { model: "mixtral" }, { name: "qwen" }]
|
||||
) as Array<{ id: string; value: string }>;
|
||||
assert.deepEqual(
|
||||
merged.map((m) => m.id).sort(),
|
||||
["llama-3", "mixtral", "qwen"]
|
||||
);
|
||||
assert.equal(merged.find((m) => m.id === "llama-3")?.value, "p/llama-3");
|
||||
});
|
||||
|
||||
test("fetched entries are deduped against fallback and custom models too", () => {
|
||||
const merged = mergeFetchedModels(
|
||||
"p",
|
||||
[],
|
||||
[{ id: "fb-model" }],
|
||||
[{ id: "custom-model" }],
|
||||
[{ id: "fb-model" }, { id: "custom-model" }, { id: "brand-new" }]
|
||||
) as Array<{ id: string; source?: string }>;
|
||||
// Only the genuinely-new model survives as an auto entry.
|
||||
const autoEntries = merged.filter((m) => m.source === "auto");
|
||||
assert.deepEqual(
|
||||
autoEntries.map((m) => m.id),
|
||||
["brand-new"]
|
||||
);
|
||||
});
|
||||
@@ -146,6 +146,81 @@ test("database log settings mirror the runtime pipeline toggle", async () => {
|
||||
assert.equal(databaseSettings.getUserDatabaseSettings().logs.callLogPipelineEnabled, true);
|
||||
});
|
||||
|
||||
test("database optimization settings apply SQLite cache size immediately", () => {
|
||||
const current = databaseSettings.getUserDatabaseSettings();
|
||||
|
||||
databaseSettings.updateDatabaseSettings({
|
||||
optimization: {
|
||||
...current.optimization,
|
||||
autoVacuumMode: core.getAutoVacuumMode(),
|
||||
pageSize: 4096,
|
||||
cacheSize: 16384,
|
||||
},
|
||||
});
|
||||
|
||||
const db = core.getDbInstance();
|
||||
const stored = db
|
||||
.prepare(
|
||||
"SELECT value FROM key_value WHERE namespace = 'databaseSettings' AND key = 'optimization.cacheSize'"
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
assert.equal(db.pragma("cache_size", { simple: true }), -16384);
|
||||
assert.equal(JSON.parse(stored?.value ?? "null"), 16384);
|
||||
assert.equal(databaseSettings.getUserDatabaseSettings().optimization.cacheSize, 16384);
|
||||
});
|
||||
|
||||
test("database optimization settings apply SQLite page size immediately", () => {
|
||||
const current = databaseSettings.getUserDatabaseSettings();
|
||||
|
||||
databaseSettings.updateDatabaseSettings({
|
||||
optimization: {
|
||||
...current.optimization,
|
||||
autoVacuumMode: core.getAutoVacuumMode(),
|
||||
pageSize: 8192,
|
||||
cacheSize: 16384,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(core.getDbInstance().pragma("page_size", { simple: true }), 8192);
|
||||
assert.equal(databaseSettings.getUserDatabaseSettings().optimization.pageSize, 8192);
|
||||
});
|
||||
|
||||
test("database optimization cache size is applied when the DB is reopened", () => {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('databaseSettings', ?, ?)"
|
||||
).run("optimization.cacheSize", JSON.stringify(32768));
|
||||
|
||||
core.resetDbInstance();
|
||||
const reopened = core.getDbInstance();
|
||||
|
||||
assert.equal(reopened.pragma("cache_size", { simple: true }), -32768);
|
||||
});
|
||||
|
||||
test("database optimization rejects negative cache size through the API", async () => {
|
||||
const current = databaseSettings.getUserDatabaseSettings();
|
||||
const response = await databaseSettingsRoute.PATCH(
|
||||
makeJsonRequest("PATCH", {
|
||||
optimization: {
|
||||
...current.optimization,
|
||||
cacheSize: -2000,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
});
|
||||
|
||||
test("database settings reader normalizes legacy negative cache size to the positive default", () => {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('databaseSettings', ?, ?)"
|
||||
).run("optimization.cacheSize", JSON.stringify(-2000));
|
||||
|
||||
assert.equal(databaseSettings.getUserDatabaseSettings().optimization.cacheSize, 16384);
|
||||
});
|
||||
|
||||
test("purgeDetailedLogs deletes request_detail_logs", async () => {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare("INSERT INTO request_detail_logs (id, timestamp, duration_ms) VALUES (?, ?, ?)").run(
|
||||
|
||||
70
tests/unit/db-case-mapping-split.test.ts
Normal file
70
tests/unit/db-case-mapping-split.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// Characterization of the db/core.ts column-mapping split (god-file decomposition): the pure
|
||||
// snake_case ↔ camelCase row helpers moved into db/caseMapping.ts. Behavior-preserving move — these
|
||||
// locks pin the conversion semantics (boolean coercion for known flag columns, JSON parsing for
|
||||
// providerSpecificData + `_json` TEXT columns, null-stripping) and that core.ts still re-exports them
|
||||
// for the historical `from "./core"` import surface.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const M = await import("../../src/lib/db/caseMapping.ts");
|
||||
const CORE = await import("../../src/lib/db/core.ts");
|
||||
|
||||
test("module exposes the five mapping helpers", () => {
|
||||
for (const name of ["toSnakeCase", "toCamelCase", "objToSnake", "rowToCamel", "cleanNulls"]) {
|
||||
assert.equal(typeof (M as Record<string, unknown>)[name], "function", `missing ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("core.ts re-exports the same helpers (historical surface preserved)", () => {
|
||||
for (const name of ["toSnakeCase", "toCamelCase", "objToSnake", "rowToCamel", "cleanNulls"]) {
|
||||
assert.equal((CORE as Record<string, unknown>)[name], (M as Record<string, unknown>)[name]);
|
||||
}
|
||||
});
|
||||
|
||||
test("toSnakeCase / toCamelCase round-trip column names", () => {
|
||||
assert.equal(M.toSnakeCase("maxRequestsPerDay"), "max_requests_per_day");
|
||||
assert.equal(M.toCamelCase("max_requests_per_day"), "maxRequestsPerDay");
|
||||
});
|
||||
|
||||
test("objToSnake converts keys, passes non-objects through", () => {
|
||||
assert.deepEqual(M.objToSnake({ providerId: "x", isActive: 1 }), {
|
||||
provider_id: "x",
|
||||
is_active: 1,
|
||||
});
|
||||
assert.equal(M.objToSnake(null), null);
|
||||
assert.equal(M.objToSnake("str"), "str");
|
||||
});
|
||||
|
||||
test("rowToCamel coerces known boolean flags from 0/1", () => {
|
||||
const out = M.rowToCamel({ is_active: 1, proxy_enabled: 0, per_key_proxy_enabled: 1 });
|
||||
assert.equal(out?.isActive, true);
|
||||
assert.equal(out?.proxyEnabled, false);
|
||||
assert.equal(out?.perKeyProxyEnabled, true);
|
||||
assert.equal(M.rowToCamel(null), null);
|
||||
});
|
||||
|
||||
test("rowToCamel parses providerSpecificData JSON string", () => {
|
||||
const out = M.rowToCamel({ provider_specific_data: '{"a":1}' });
|
||||
assert.deepEqual(out?.providerSpecificData, { a: 1 });
|
||||
// invalid JSON falls back to the raw string
|
||||
const bad = M.rowToCamel({ provider_specific_data: "not json" });
|
||||
assert.equal(bad?.providerSpecificData, "not json");
|
||||
});
|
||||
|
||||
test("rowToCamel unwraps `_json` columns to the base key", () => {
|
||||
const out = M.rowToCamel({ quota_window_thresholds_json: '{"hi":80}' });
|
||||
assert.deepEqual(out?.quotaWindowThresholds, { hi: 80 });
|
||||
assert.equal("quotaWindowThresholdsJson" in (out ?? {}), false);
|
||||
// NULL `_json` column normalizes the base key to null
|
||||
const nul = M.rowToCamel({ quota_window_thresholds_json: null });
|
||||
assert.equal(nul?.quotaWindowThresholds, null);
|
||||
});
|
||||
|
||||
test("cleanNulls drops null/undefined, keeps falsy-but-present values", () => {
|
||||
assert.deepEqual(M.cleanNulls({ a: 0, b: "", c: null, d: undefined, e: false }), {
|
||||
a: 0,
|
||||
b: "",
|
||||
e: false,
|
||||
});
|
||||
assert.deepEqual(M.cleanNulls(null), {});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const poolsDb = await import("../../src/lib/db/quotaPools.ts");
|
||||
const { getDbInstance } = core;
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
@@ -260,3 +261,61 @@ test("allocation stores optional capValue and capUnit correctly", () => {
|
||||
assert.equal(alloc.capValue, 1000);
|
||||
assert.equal(alloc.capUnit, "requests");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guard A (issue #10): corrupted/unknown policy in the DB must be normalized to
|
||||
// the most restrictive policy ('hard') at the read boundary — never trusted via
|
||||
// `row.policy as Policy`. A garbage policy reaching the fair-share engine would
|
||||
// fall through every switch case and silently ALLOW (fail-OPEN).
|
||||
//
|
||||
// The schema has CHECK (policy IN ('hard','soft','burst')), so we bypass it with
|
||||
// PRAGMA ignore_check_constraints to simulate a legacy/corrupted row.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("rowToAllocation normalizes an unknown DB policy to 'hard' (Guard A)", () => {
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: "c-guardA",
|
||||
name: "Corrupt Policy Pool",
|
||||
allocations: [{ apiKeyId: "k-corrupt", weight: 100, policy: "soft" }],
|
||||
});
|
||||
|
||||
// Inject a corrupted policy directly, bypassing the CHECK constraint.
|
||||
const db = getDbInstance() as unknown as {
|
||||
pragma: (s: string) => unknown;
|
||||
prepare: (sql: string) => { run: (...p: unknown[]) => unknown };
|
||||
};
|
||||
db.pragma("ignore_check_constraints = ON");
|
||||
db.prepare("UPDATE quota_allocations SET policy = ? WHERE pool_id = ? AND api_key_id = ?").run(
|
||||
"bogus-policy",
|
||||
pool.id,
|
||||
"k-corrupt"
|
||||
);
|
||||
db.pragma("ignore_check_constraints = OFF");
|
||||
|
||||
// Read through the domain module — the unknown policy must become 'hard'.
|
||||
const found = poolsDb.getPool(pool.id)!;
|
||||
const alloc = found.allocations.find((a) => a.apiKeyId === "k-corrupt")!;
|
||||
assert.equal(alloc.policy, "hard", "unknown DB policy must be normalized to 'hard'");
|
||||
|
||||
// Same expectation via listAllocationsForApiKey (the other read path).
|
||||
const list = poolsDb.listAllocationsForApiKey("k-corrupt");
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].allocation.policy, "hard");
|
||||
});
|
||||
|
||||
test("rowToAllocation preserves valid policies unchanged (Guard A regression)", () => {
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: "c-guardA-valid",
|
||||
name: "Valid Policy Pool",
|
||||
allocations: [
|
||||
{ apiKeyId: "k-hard", weight: 34, policy: "hard" },
|
||||
{ apiKeyId: "k-soft", weight: 33, policy: "soft" },
|
||||
{ apiKeyId: "k-burst", weight: 33, policy: "burst" },
|
||||
],
|
||||
});
|
||||
|
||||
const found = poolsDb.getPool(pool.id)!;
|
||||
assert.equal(found.allocations.find((a) => a.apiKeyId === "k-hard")!.policy, "hard");
|
||||
assert.equal(found.allocations.find((a) => a.apiKeyId === "k-soft")!.policy, "soft");
|
||||
assert.equal(found.allocations.find((a) => a.apiKeyId === "k-burst")!.policy, "burst");
|
||||
});
|
||||
|
||||
65
tests/unit/db-schema-columns-split.test.ts
Normal file
65
tests/unit/db-schema-columns-split.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Characterization of the db/core.ts schema-column split (god-file decomposition): the idempotent
|
||||
// ALTER-TABLE column reconcilers + table introspection helpers moved into db/schemaColumns.ts. These
|
||||
// run an in-memory SQLite db through the helpers to lock the observable behavior: ensure* adds missing
|
||||
// columns and is safe to re-run; hasTable/hasColumn/getTableColumns/quoteIdentifier introspect.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { tryOpenSync } from "../../src/lib/db/adapters/driverFactory.ts";
|
||||
import {
|
||||
ensureUsageHistoryColumns,
|
||||
hasColumn,
|
||||
hasTable,
|
||||
quoteIdentifier,
|
||||
getTableColumns,
|
||||
} from "../../src/lib/db/schemaColumns.ts";
|
||||
|
||||
function openMemoryDb() {
|
||||
// Synchronous in-memory adapter — no DATA_DIR / file handles to clean up.
|
||||
const db = tryOpenSync(":memory:");
|
||||
assert.ok(db, "expected a synchronous sqlite adapter for :memory:");
|
||||
return db!;
|
||||
}
|
||||
|
||||
test("quoteIdentifier escapes embedded double quotes", () => {
|
||||
assert.equal(quoteIdentifier("plain"), '"plain"');
|
||||
assert.equal(quoteIdentifier('we"ird'), '"we""ird"');
|
||||
});
|
||||
|
||||
test("hasTable / hasColumn / getTableColumns introspect a live table", () => {
|
||||
const db = openMemoryDb();
|
||||
try {
|
||||
db.exec("CREATE TABLE usage_history (id INTEGER PRIMARY KEY, model TEXT)");
|
||||
assert.equal(hasTable(db, "usage_history"), true);
|
||||
assert.equal(hasTable(db, "does_not_exist"), false);
|
||||
assert.equal(hasColumn(db, "usage_history", "model"), true);
|
||||
assert.equal(hasColumn(db, "usage_history", "nope"), false);
|
||||
assert.deepEqual(getTableColumns(db, "usage_history").sort(), ["id", "model"]);
|
||||
} finally {
|
||||
db.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
test("ensureUsageHistoryColumns adds missing columns and is idempotent", () => {
|
||||
const db = openMemoryDb();
|
||||
try {
|
||||
db.exec("CREATE TABLE usage_history (id INTEGER PRIMARY KEY, model TEXT)");
|
||||
assert.equal(hasColumn(db, "usage_history", "service_tier"), false);
|
||||
|
||||
ensureUsageHistoryColumns(db);
|
||||
for (const col of [
|
||||
"success",
|
||||
"latency_ms",
|
||||
"ttft_ms",
|
||||
"error_code",
|
||||
"service_tier",
|
||||
"combo_strategy",
|
||||
]) {
|
||||
assert.equal(hasColumn(db, "usage_history", col), true, `expected ${col} after ensure`);
|
||||
}
|
||||
|
||||
// Re-running must not throw (columns already present) — idempotency.
|
||||
assert.doesNotThrow(() => ensureUsageHistoryColumns(db));
|
||||
} finally {
|
||||
db.close?.();
|
||||
}
|
||||
});
|
||||
104
tests/unit/deno-deploy-relay-path-ssrf.test.ts
Normal file
104
tests/unit/deno-deploy-relay-path-ssrf.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// SSRF regression guard for the Deno Deploy relay worker (PR #4643 follow-up).
|
||||
//
|
||||
// The generated edge worker reads an attacker-controlled `x-relay-path` header
|
||||
// and previously appended it to the (validated) `x-relay-target` origin via
|
||||
// string concatenation: `fetch(target.replace(/\/$/, "") + relayPath)`. That
|
||||
// lets the caller smuggle a NEW host past the allow/SSRF check using userinfo
|
||||
// (`@`), a backslash, or a protocol-relative path:
|
||||
// - x-relay-target: https://api.anthropic.com (passes the host guard)
|
||||
// - x-relay-path: //evil.com/x → fetches https://evil.com/x
|
||||
// - x-relay-path: /x@evil.com → userinfo confusion
|
||||
// - x-relay-path: \evil.com → backslash host confusion
|
||||
//
|
||||
// The fix resolves the path against the validated target with `new URL()` and
|
||||
// re-checks that the resolved host/credentials still match the target. The
|
||||
// path-resolution decision is exported as the pure `resolveRelayTarget()` so it
|
||||
// can be unit-tested directly (the worker body — a Deno-runtime string — embeds
|
||||
// the SAME function source, asserted below by diffing the generated worker).
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
resolveRelayTarget,
|
||||
__buildRelayWorkerForTest,
|
||||
} from "../../src/app/api/settings/proxy/deno-deploy/route";
|
||||
|
||||
const TARGET = "https://api.anthropic.com";
|
||||
|
||||
describe("resolveRelayTarget — SSRF-safe path join", () => {
|
||||
it("accepts a legitimate absolute path and keeps the validated host", () => {
|
||||
const r = resolveRelayTarget(TARGET, "/v1/foo");
|
||||
assert.equal(r.ok, true, "a normal path must pass");
|
||||
if (r.ok) {
|
||||
assert.equal(r.url, "https://api.anthropic.com/v1/foo");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a path with a query string", () => {
|
||||
const r = resolveRelayTarget(TARGET, "/v1/messages?beta=true");
|
||||
assert.equal(r.ok, true);
|
||||
if (r.ok) {
|
||||
assert.equal(r.url, "https://api.anthropic.com/v1/messages?beta=true");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a protocol-relative path that swaps the host (//evil.com/x)", () => {
|
||||
const r = resolveRelayTarget(TARGET, "//evil.com/x");
|
||||
assert.equal(r.ok, false, "//evil.com must NOT be allowed to change the host");
|
||||
if (!r.ok) assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
it("rejects a path containing userinfo (/x@evil.com)", () => {
|
||||
const r = resolveRelayTarget(TARGET, "/x@evil.com");
|
||||
assert.equal(r.ok, false, "@ in the path must be rejected");
|
||||
if (!r.ok) assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
it("rejects a backslash host-confusion path (\\evil.com)", () => {
|
||||
const r = resolveRelayTarget(TARGET, "\\evil.com");
|
||||
assert.equal(r.ok, false, "backslash must be rejected");
|
||||
if (!r.ok) assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
it("rejects a value that does not start with '/'", () => {
|
||||
const r = resolveRelayTarget(TARGET, "evil.com/x");
|
||||
assert.equal(r.ok, false, "a path must start with '/'");
|
||||
if (!r.ok) assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
it("rejects an embedded-credentials path that resolves a different host", () => {
|
||||
// Classic userinfo trick: everything before '@' becomes userinfo, the real
|
||||
// host is after it.
|
||||
const r = resolveRelayTarget(TARGET, "/@evil.com/path");
|
||||
assert.equal(r.ok, false);
|
||||
if (!r.ok) assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
it("rejects backslash-prefixed protocol-relative (\\\\evil.com)", () => {
|
||||
const r = resolveRelayTarget(TARGET, "\\\\evil.com/x");
|
||||
assert.equal(r.ok, false);
|
||||
if (!r.ok) assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRelayWorker — generated worker has no string-concat SSRF hole", () => {
|
||||
it("does not concatenate the target with the raw relay path", () => {
|
||||
const worker = __buildRelayWorkerForTest("deadbeefcafe");
|
||||
// The vulnerable pattern was: target.replace(/\/$/, "") + relayPath
|
||||
assert.ok(
|
||||
!/\.replace\(\s*\/\\?\/\$\/\s*,\s*["']{2}\s*\)\s*\+\s*relayPath/.test(worker),
|
||||
"worker must not append relayPath to target by string concatenation"
|
||||
);
|
||||
assert.ok(
|
||||
!worker.includes('+ relayPath'),
|
||||
"worker must not contain `+ relayPath` string concatenation"
|
||||
);
|
||||
});
|
||||
|
||||
it("embeds the resolveRelayTarget guard into the worker body", () => {
|
||||
const worker = __buildRelayWorkerForTest("deadbeefcafe");
|
||||
assert.ok(
|
||||
worker.includes("resolveRelayTarget"),
|
||||
"the worker must call the shared resolveRelayTarget guard"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { Dispatcher } from "undici";
|
||||
import {
|
||||
__createRoundRobinDispatcherForTest,
|
||||
__getDefaultDispatcherOptionsForTest,
|
||||
__getProxyDispatcherOptionsForTest,
|
||||
getDefaultDispatcherConnectionLimit,
|
||||
@@ -39,7 +41,10 @@ describe("#4580 direct dispatcher options", () => {
|
||||
});
|
||||
|
||||
it("connection limit honors OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS", () => {
|
||||
assert.equal(getDefaultDispatcherConnectionLimit({ OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS: "8" }), 8);
|
||||
assert.equal(
|
||||
getDefaultDispatcherConnectionLimit({ OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS: "8" }),
|
||||
8
|
||||
);
|
||||
});
|
||||
|
||||
it("connection limit clamps invalid values to the default", () => {
|
||||
@@ -49,4 +54,30 @@ describe("#4580 direct dispatcher options", () => {
|
||||
);
|
||||
assert.equal(getDefaultDispatcherConnectionLimit({}), 32);
|
||||
});
|
||||
|
||||
it("fans out direct requests across independent dispatcher pools", () => {
|
||||
const calls: number[] = [];
|
||||
const dispatchers = [0, 1, 2].map(
|
||||
(index) =>
|
||||
({
|
||||
dispatch() {
|
||||
calls.push(index);
|
||||
return true;
|
||||
},
|
||||
close() {},
|
||||
destroy() {},
|
||||
}) as unknown as Dispatcher
|
||||
);
|
||||
const dispatcher = __createRoundRobinDispatcherForTest(dispatchers);
|
||||
const dispatchOptions = {
|
||||
origin: "https://chatgpt.com",
|
||||
path: "/backend-api/codex/responses",
|
||||
method: "POST",
|
||||
} as unknown as Parameters<Dispatcher["dispatch"]>[0];
|
||||
const handler = {} as Parameters<Dispatcher["dispatch"]>[1];
|
||||
|
||||
for (let i = 0; i < 7; i++) dispatcher.dispatch(dispatchOptions, handler);
|
||||
|
||||
assert.deepEqual(calls, [0, 1, 2, 0, 1, 2, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ const { createErrorResponse, createErrorResponseFromUnknown } =
|
||||
await import("../../src/lib/api/errorResponse.ts");
|
||||
const { getAccountDisplayName, getProviderDisplayName } =
|
||||
await import("../../src/lib/display/names.ts");
|
||||
const { resolveProviderName } = await import("../../src/lib/display/useProviderNodeMap.ts");
|
||||
|
||||
test("toJsonErrorPayload: preserves upstream error objects that already have error payloads", () => {
|
||||
const payload = {
|
||||
@@ -201,6 +202,32 @@ test("getAccountDisplayName: respects priority order and fallback", () => {
|
||||
assert.equal(getAccountDisplayName(null), "Unknown Account");
|
||||
});
|
||||
|
||||
test("resolveProviderName: returns user-given name from nodeMap for custom provider", () => {
|
||||
const nodeMap = new Map([
|
||||
["openai-compatible-chat-abc123", { name: "My LLM", prefix: "my-llm" }],
|
||||
]);
|
||||
assert.equal(
|
||||
resolveProviderName("openai-compatible-chat-abc123", nodeMap),
|
||||
"My LLM",
|
||||
"should return the user-given name when present in the map"
|
||||
);
|
||||
// Falls back to prefix when name is blank
|
||||
const mapNoName = new Map([
|
||||
["openai-compatible-chat-abc123", { name: null, prefix: "my-prefix" }],
|
||||
]);
|
||||
assert.equal(resolveProviderName("openai-compatible-chat-abc123", mapNoName), "my-prefix");
|
||||
// Falls back to de-UUIDed label when no node entry exists
|
||||
assert.equal(
|
||||
resolveProviderName("openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441", new Map()),
|
||||
"Compatible (openai)"
|
||||
);
|
||||
// Falls back gracefully for unknown ids not in the map
|
||||
assert.equal(resolveProviderName("plain-provider", nodeMap), "plain-provider");
|
||||
// Handles null/undefined id
|
||||
assert.equal(resolveProviderName(null, nodeMap), "Unknown Provider");
|
||||
assert.equal(resolveProviderName(undefined, null), "Unknown Provider");
|
||||
});
|
||||
|
||||
test("getProviderDisplayName: prefers node metadata and simplifies compatible IDs", () => {
|
||||
assert.equal(
|
||||
getProviderDisplayName("openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441", {
|
||||
|
||||
@@ -188,10 +188,10 @@ test("CodexExecutor.buildHeaders binds workspace ids and disables SSE accept for
|
||||
assert.equal(standardHeaders.Authorization, "Bearer codex-token");
|
||||
assert.equal(standardHeaders.Accept, "text/event-stream");
|
||||
assert.equal(standardHeaders["chatgpt-account-id"], "workspace-1");
|
||||
assert.equal(standardHeaders.Version, "0.132.0");
|
||||
assert.equal(standardHeaders.Version, "0.142.0");
|
||||
assert.equal(standardHeaders["Openai-Beta"], "responses=experimental");
|
||||
assert.equal(standardHeaders["X-Codex-Beta-Features"], "responses_websockets");
|
||||
assert.equal(standardHeaders["User-Agent"], "codex-cli/0.132.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(standardHeaders["User-Agent"], "codex-cli/0.142.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(compactHeaders.Accept, "application/json");
|
||||
});
|
||||
|
||||
@@ -200,13 +200,13 @@ test("CodexExecutor.buildHeaders honors safe env overrides for Version and User-
|
||||
|
||||
await withEnv(
|
||||
{
|
||||
CODEX_CLIENT_VERSION: "0.132.0",
|
||||
CODEX_CLIENT_VERSION: "0.142.0",
|
||||
CODEX_USER_AGENT: undefined,
|
||||
},
|
||||
() => {
|
||||
const headers = executor.buildHeaders({ accessToken: "codex-token" }, true);
|
||||
assert.equal(headers.Version, "0.132.0");
|
||||
assert.equal(headers["User-Agent"], "codex-cli/0.132.0 (Windows 10.0.26200; x64)");
|
||||
assert.equal(headers.Version, "0.142.0");
|
||||
assert.equal(headers["User-Agent"], "codex-cli/0.142.0 (Windows 10.0.26200; x64)");
|
||||
}
|
||||
);
|
||||
|
||||
@@ -217,7 +217,7 @@ test("CodexExecutor.buildHeaders honors safe env overrides for Version and User-
|
||||
},
|
||||
() => {
|
||||
const headers = executor.buildHeaders({ accessToken: "codex-token" }, true);
|
||||
assert.equal(headers.Version, "0.132.0");
|
||||
assert.equal(headers.Version, "0.142.0");
|
||||
assert.equal(headers["User-Agent"], "custom-codex/9.9.9");
|
||||
}
|
||||
);
|
||||
|
||||
@@ -71,6 +71,33 @@ test("GithubExecutor.buildUrl keeps GitHub Claude Opus 4.6 on /chat/completions"
|
||||
assert.equal(url, "https://api.githubcopilot.com/chat/completions");
|
||||
});
|
||||
|
||||
test("GithubExecutor.buildUrl routes unlisted Codex models to /responses (9router#102)", () => {
|
||||
// Copilot Codex models advertise supported_endpoints: ["/responses"]. When such
|
||||
// a model isn't in the curated gh registry, getModelTargetFormat returns null and
|
||||
// the request fell through to /chat/completions -> upstream 400 "model <id> is not
|
||||
// accessible via the /chat/completions endpoint". Any *-codex id must route to
|
||||
// /responses regardless of whether it's explicitly registered.
|
||||
const executor = new GithubExecutor();
|
||||
for (const model of [
|
||||
"gpt-5-codex",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.2-codex",
|
||||
]) {
|
||||
assert.equal(
|
||||
executor.buildUrl(model, true),
|
||||
"https://api.githubcopilot.com/responses",
|
||||
`${model} must route to /responses`
|
||||
);
|
||||
}
|
||||
// Non-codex unlisted models keep the chat/completions default.
|
||||
assert.equal(
|
||||
executor.buildUrl("some-random-chat-model", true),
|
||||
"https://api.githubcopilot.com/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
test("GithubExecutor.transformRequest injects JSON response instructions for Claude and strips reasoning fields", () => {
|
||||
const executor = new GithubExecutor();
|
||||
const body = {
|
||||
|
||||
121
tests/unit/executors-strip-unsupported-params.test.ts
Normal file
121
tests/unit/executors-strip-unsupported-params.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// Unit tests for stripUnsupportedParams: per-(provider,model) strip of params
|
||||
// the upstream rejects (HTTP 400). Port from 9router#7ae9fff6 (fixes #1748).
|
||||
//
|
||||
// Rule coverage:
|
||||
// 1. claude-opus-4 series: temperature deprecated → Anthropic 400.
|
||||
// 2. github + gpt-5.4: temperature unsupported.
|
||||
// 3. github + Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
stripUnsupportedParams,
|
||||
__STRIP_RULES_FOR_TEST,
|
||||
} from "../../open-sse/translator/paramSupport.ts";
|
||||
|
||||
test("stripUnsupportedParams: drops temperature for claude-opus-4 models (any provider)", () => {
|
||||
const body = { model: "claude-opus-4-20250514", temperature: 0.7, max_tokens: 100 };
|
||||
stripUnsupportedParams("anthropic", "claude-opus-4-20250514", body);
|
||||
assert.equal(body.temperature, undefined, "temperature must be stripped");
|
||||
assert.equal(body.max_tokens, 100, "other params must survive");
|
||||
assert.equal(body.model, "claude-opus-4-20250514", "model must not be touched");
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: drops temperature for claude-opus-4-1 (case insensitive)", () => {
|
||||
const body: Record<string, unknown> = { temperature: 0.5 };
|
||||
stripUnsupportedParams("anthropic", "CLAUDE-OPUS-4-1-20250805", body);
|
||||
assert.equal(body.temperature, undefined);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: keeps temperature for claude-sonnet-4 (only opus-4 affected)", () => {
|
||||
const body: Record<string, unknown> = { temperature: 0.7 };
|
||||
stripUnsupportedParams("anthropic", "claude-sonnet-4-20250514", body);
|
||||
assert.equal(body.temperature, 0.7, "sonnet-4 still accepts temperature");
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: keeps temperature for claude-opus-3 (regression guard)", () => {
|
||||
const body: Record<string, unknown> = { temperature: 0.7 };
|
||||
stripUnsupportedParams("anthropic", "claude-3-opus-20240229", body);
|
||||
assert.equal(body.temperature, 0.7);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: github + gpt-5.4 strips temperature", () => {
|
||||
const body: Record<string, unknown> = { temperature: 1, max_completion_tokens: 200 };
|
||||
stripUnsupportedParams("github", "gpt-5.4", body);
|
||||
assert.equal(body.temperature, undefined);
|
||||
assert.equal(body.max_completion_tokens, 200);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: github + gpt-5 (non-5.4) keeps temperature", () => {
|
||||
const body: Record<string, unknown> = { temperature: 1 };
|
||||
stripUnsupportedParams("github", "gpt-5", body);
|
||||
assert.equal(body.temperature, 1);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: github + Claude strips thinking + reasoning_effort", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
thinking: { type: "enabled" },
|
||||
reasoning_effort: "high",
|
||||
temperature: 0.5,
|
||||
};
|
||||
stripUnsupportedParams("github", "claude-3-5-sonnet", body);
|
||||
assert.equal(body.thinking, undefined, "Copilot rejects Claude-style thinking");
|
||||
assert.equal(body.reasoning_effort, undefined);
|
||||
assert.equal(body.temperature, 0.5, "non-targeted params survive");
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: github + claude opus 4.6 KEEPS thinking + reasoning_effort", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
thinking: { type: "enabled" },
|
||||
reasoning_effort: "high",
|
||||
};
|
||||
stripUnsupportedParams("github", "claude-opus-4.6", body);
|
||||
assert.equal(body.reasoning_effort, "high", "opus-4.6 supports reasoning_effort");
|
||||
// Note: thinking survives too on opus-4.6 per upstream rule.
|
||||
assert.deepEqual(body.thinking, { type: "enabled" });
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: github + claude sonnet 4.6 KEEPS reasoning_effort", () => {
|
||||
const body: Record<string, unknown> = { reasoning_effort: "low" };
|
||||
stripUnsupportedParams("github", "claude-sonnet-4.6", body);
|
||||
assert.equal(body.reasoning_effort, "low");
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: non-github provider + Claude does NOT strip thinking", () => {
|
||||
// The github-Claude rule is provider-scoped.
|
||||
const body: Record<string, unknown> = { thinking: { type: "enabled" } };
|
||||
stripUnsupportedParams("anthropic", "claude-3-5-sonnet", body);
|
||||
assert.deepEqual(body.thinking, { type: "enabled" });
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: only deletes when the key is present (never adds undefined)", () => {
|
||||
const body: Record<string, unknown> = { max_tokens: 50 };
|
||||
stripUnsupportedParams("anthropic", "claude-opus-4", body);
|
||||
// No `temperature` key should be introduced.
|
||||
assert.equal("temperature" in body, false);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: returns the same body reference (in-place mutation)", () => {
|
||||
const body: Record<string, unknown> = { temperature: 0.7 };
|
||||
const out = stripUnsupportedParams("anthropic", "claude-opus-4", body);
|
||||
assert.equal(out, body);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: null/undefined body is a no-op", () => {
|
||||
assert.equal(stripUnsupportedParams("anthropic", "claude-opus-4", null), null);
|
||||
assert.equal(stripUnsupportedParams("anthropic", "claude-opus-4", undefined), undefined);
|
||||
});
|
||||
|
||||
test("stripUnsupportedParams: missing model is a no-op", () => {
|
||||
const body: Record<string, unknown> = { temperature: 0.7 };
|
||||
stripUnsupportedParams("anthropic", "", body);
|
||||
assert.equal(body.temperature, 0.7);
|
||||
});
|
||||
|
||||
test("STRIP_RULES is non-empty and every rule has a drop list", () => {
|
||||
assert.ok(__STRIP_RULES_FOR_TEST.length > 0);
|
||||
for (const rule of __STRIP_RULES_FOR_TEST) {
|
||||
assert.ok(Array.isArray(rule.drop) && rule.drop.length > 0);
|
||||
assert.ok(typeof rule.match === "function" || rule.match instanceof RegExp);
|
||||
}
|
||||
});
|
||||
54
tests/unit/gemini-antigravity-pattern-preserved.test.ts
Normal file
54
tests/unit/gemini-antigravity-pattern-preserved.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Regression test: `pattern` must be preserved in antigravity tool schemas.
|
||||
//
|
||||
// Upstream: decolua/9router @ f6c2f7ca / Fixes decolua/9router#1368.
|
||||
//
|
||||
// Tools such as glob/grep/file-search rely on a `pattern` constraint on
|
||||
// string parameters. Antigravity (Gemini-derived) DOES accept `pattern`,
|
||||
// so removing it for that surface drops critical tool semantics and
|
||||
// produces upstream 400s / wrong-tool-call behavior.
|
||||
//
|
||||
// Two assertions, mirroring how the field is consumed downstream:
|
||||
// 1) `pattern` must NOT live in the unsupported keyword set used by the
|
||||
// antigravity schema cleaner.
|
||||
// 2) End-to-end, `cleanJSONSchemaForAntigravity` must preserve a `pattern`
|
||||
// constraint placed on a string property (typical glob/grep tool).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
GEMINI_UNSUPPORTED_SCHEMA_KEYS,
|
||||
cleanJSONSchemaForAntigravity,
|
||||
} from "../../open-sse/translator/helpers/geminiHelper.ts";
|
||||
|
||||
test("GEMINI_UNSUPPORTED_SCHEMA_KEYS does not strip `pattern` for antigravity", () => {
|
||||
assert.equal(
|
||||
GEMINI_UNSUPPORTED_SCHEMA_KEYS.has("pattern"),
|
||||
false,
|
||||
"`pattern` is supported by antigravity and must be preserved on tool schemas"
|
||||
);
|
||||
});
|
||||
|
||||
test("cleanJSONSchemaForAntigravity preserves `pattern` on glob/grep-style tool schema", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
glob: {
|
||||
type: "string",
|
||||
description: "Glob pattern to match files",
|
||||
pattern: "^[A-Za-z0-9_\\-/*.]+$",
|
||||
},
|
||||
},
|
||||
required: ["glob"],
|
||||
};
|
||||
|
||||
const cleaned = cleanJSONSchemaForAntigravity(schema) as {
|
||||
properties: { glob: { pattern?: string } };
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
cleaned.properties.glob.pattern,
|
||||
"^[A-Za-z0-9_\\-/*.]+$",
|
||||
"`pattern` constraint on a string property must survive antigravity cleaning"
|
||||
);
|
||||
});
|
||||
214
tests/unit/google-flow-video-4569.test.ts
Normal file
214
tests/unit/google-flow-video-4569.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* #4569 — Google Flow video generation helpers.
|
||||
*
|
||||
* Covers the pure transformation logic (param normalization, Veo submit-body
|
||||
* construction, operation-name parsing, LRO result parsing, credential
|
||||
* resolution) and a source guard asserting the wire endpoint stays isolated and
|
||||
* flagged for live validation (Hard Rule #18).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import {
|
||||
GOOGLE_FLOW_CREDENTIAL_PROVIDER,
|
||||
GOOGLE_FLOW_HOST,
|
||||
buildGoogleFlowSubmitBody,
|
||||
normalizeFlowVideoParams,
|
||||
parseFlowOperationName,
|
||||
parseFlowOperationResult,
|
||||
resolveFlowAccessToken,
|
||||
resolveFlowProjectId,
|
||||
resolveVideoCredentialProvider,
|
||||
} from "../../open-sse/handlers/videoGeneration/googleFlow.ts";
|
||||
|
||||
import { getVideoProvider, parseVideoModel } from "../../open-sse/config/videoRegistry.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
test("normalizeFlowVideoParams: defaults sampleCount to 1 and stringifies prompt", () => {
|
||||
const p = normalizeFlowVideoParams({ prompt: "a cat surfing" });
|
||||
assert.equal(p.prompt, "a cat surfing");
|
||||
assert.equal(p.sampleCount, 1);
|
||||
assert.equal(p.aspectRatio, undefined);
|
||||
assert.equal(p.durationSeconds, undefined);
|
||||
});
|
||||
|
||||
test("normalizeFlowVideoParams: reads snake_case OpenAI fields", () => {
|
||||
const p = normalizeFlowVideoParams({
|
||||
prompt: "x",
|
||||
aspect_ratio: "16:9",
|
||||
duration: 8,
|
||||
n: 2,
|
||||
negative_prompt: "blurry",
|
||||
resolution: "1080p",
|
||||
});
|
||||
assert.equal(p.aspectRatio, "16:9");
|
||||
assert.equal(p.durationSeconds, 8);
|
||||
assert.equal(p.sampleCount, 2);
|
||||
assert.equal(p.negativePrompt, "blurry");
|
||||
assert.equal(p.resolution, "1080p");
|
||||
});
|
||||
|
||||
test("normalizeFlowVideoParams: reads camelCase native fields", () => {
|
||||
const p = normalizeFlowVideoParams({ prompt: "x", aspectRatio: "9:16", durationSeconds: 4 });
|
||||
assert.equal(p.aspectRatio, "9:16");
|
||||
assert.equal(p.durationSeconds, 4);
|
||||
});
|
||||
|
||||
test("normalizeFlowVideoParams: treats ratio-shaped size as aspectRatio, ignores pixel size", () => {
|
||||
assert.equal(normalizeFlowVideoParams({ prompt: "x", size: "16:9" }).aspectRatio, "16:9");
|
||||
assert.equal(normalizeFlowVideoParams({ prompt: "x", size: "1024x1024" }).aspectRatio, undefined);
|
||||
});
|
||||
|
||||
test("normalizeFlowVideoParams: rejects non-positive / non-finite numbers", () => {
|
||||
const p = normalizeFlowVideoParams({ prompt: "x", n: 0, duration: -5 });
|
||||
assert.equal(p.sampleCount, 1);
|
||||
assert.equal(p.durationSeconds, undefined);
|
||||
});
|
||||
|
||||
test("buildGoogleFlowSubmitBody: emits documented Veo instances/parameters shape", () => {
|
||||
const body = buildGoogleFlowSubmitBody({
|
||||
prompt: "a dog",
|
||||
aspectRatio: "16:9",
|
||||
durationSeconds: 6,
|
||||
sampleCount: 1,
|
||||
negativePrompt: "ugly",
|
||||
resolution: "720p",
|
||||
});
|
||||
assert.deepEqual(body.instances, [{ prompt: "a dog" }]);
|
||||
assert.equal(body.parameters.sampleCount, 1);
|
||||
assert.equal(body.parameters.aspectRatio, "16:9");
|
||||
assert.equal(body.parameters.durationSeconds, 6);
|
||||
assert.equal(body.parameters.negativePrompt, "ugly");
|
||||
assert.equal(body.parameters.resolution, "720p");
|
||||
});
|
||||
|
||||
test("buildGoogleFlowSubmitBody: omits absent optional parameters", () => {
|
||||
const body = buildGoogleFlowSubmitBody({ prompt: "p", sampleCount: 3 });
|
||||
assert.deepEqual(body.parameters, { sampleCount: 3 });
|
||||
assert.ok(!("aspectRatio" in body.parameters));
|
||||
assert.ok(!("durationSeconds" in body.parameters));
|
||||
});
|
||||
|
||||
test("parseFlowOperationName: handles {name} and {operation:{name}} and rejects junk", () => {
|
||||
assert.equal(parseFlowOperationName({ name: "operations/abc" }), "operations/abc");
|
||||
assert.equal(parseFlowOperationName({ operation: { name: "operations/xyz" } }), "operations/xyz");
|
||||
assert.equal(parseFlowOperationName({}), null);
|
||||
assert.equal(parseFlowOperationName(null), null);
|
||||
assert.equal(parseFlowOperationName({ name: "" }), null);
|
||||
});
|
||||
|
||||
test("parseFlowOperationResult: not done → keep polling", () => {
|
||||
assert.deepEqual(parseFlowOperationResult({ done: false }), { done: false });
|
||||
assert.deepEqual(parseFlowOperationResult({}), { done: false });
|
||||
});
|
||||
|
||||
test("parseFlowOperationResult: done with base64 video (documented shape)", () => {
|
||||
const r = parseFlowOperationResult({
|
||||
done: true,
|
||||
response: { videos: [{ bytesBase64Encoded: "AAAA" }] },
|
||||
});
|
||||
assert.equal(r.done, true);
|
||||
assert.equal(r.base64, "AAAA");
|
||||
assert.equal(r.format, "mp4");
|
||||
assert.equal(r.error, undefined);
|
||||
});
|
||||
|
||||
test("parseFlowOperationResult: done with gcsUri/uri video", () => {
|
||||
assert.equal(
|
||||
parseFlowOperationResult({ done: true, response: { videos: [{ gcsUri: "gs://b/v.mp4" }] } }).url,
|
||||
"gs://b/v.mp4"
|
||||
);
|
||||
assert.equal(
|
||||
parseFlowOperationResult({ done: true, response: { videos: [{ uri: "https://x/v.mp4" }] } }).url,
|
||||
"https://x/v.mp4"
|
||||
);
|
||||
});
|
||||
|
||||
test("parseFlowOperationResult: done with alternate generateVideoResponse shape", () => {
|
||||
const r = parseFlowOperationResult({
|
||||
done: true,
|
||||
response: {
|
||||
generateVideoResponse: { generatedSamples: [{ video: { uri: "https://x/sample.mp4" } }] },
|
||||
},
|
||||
});
|
||||
assert.equal(r.url, "https://x/sample.mp4");
|
||||
});
|
||||
|
||||
test("parseFlowOperationResult: done with error surfaces the message", () => {
|
||||
const r = parseFlowOperationResult({ done: true, error: { message: "quota exceeded" } });
|
||||
assert.equal(r.done, true);
|
||||
assert.equal(r.error, "quota exceeded");
|
||||
});
|
||||
|
||||
test("parseFlowOperationResult: done but empty → explicit no-video error", () => {
|
||||
const r = parseFlowOperationResult({ done: true, response: {} });
|
||||
assert.equal(r.done, true);
|
||||
assert.match(r.error ?? "", /no video/i);
|
||||
});
|
||||
|
||||
test("parseFlowOperationResult: null/empty array elements do not crash (defensive)", () => {
|
||||
// Upstream returning a null element must not throw a TypeError (gemini-code-assist #4769).
|
||||
for (const response of [
|
||||
{ videos: [null] },
|
||||
{ videos: [undefined] },
|
||||
{ videos: [{}] },
|
||||
{ generateVideoResponse: { generatedSamples: [null] } },
|
||||
{ generateVideoResponse: { generatedSamples: [{ video: null }] } },
|
||||
{ generateVideoResponse: { generatedSamples: [{}] } },
|
||||
]) {
|
||||
const r = parseFlowOperationResult({ done: true, response });
|
||||
assert.equal(r.done, true);
|
||||
assert.match(r.error ?? "", /no video/i);
|
||||
assert.equal(r.base64, undefined);
|
||||
assert.equal(r.url, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveFlowProjectId: direct, providerSpecificData, and missing", () => {
|
||||
assert.equal(resolveFlowProjectId({ projectId: "proj-1" }), "proj-1");
|
||||
assert.equal(resolveFlowProjectId({ providerSpecificData: { projectId: "proj-2" } }), "proj-2");
|
||||
assert.equal(resolveFlowProjectId({}), null);
|
||||
assert.equal(resolveFlowProjectId(null), null);
|
||||
});
|
||||
|
||||
test("resolveFlowAccessToken: prefers accessToken, falls back to apiKey", () => {
|
||||
assert.equal(resolveFlowAccessToken({ accessToken: "tok" }), "tok");
|
||||
assert.equal(resolveFlowAccessToken({ apiKey: "key" }), "key");
|
||||
assert.equal(resolveFlowAccessToken({}), null);
|
||||
});
|
||||
|
||||
test("videoRegistry: googleflow provider is registered with oauth + google-flow format", () => {
|
||||
const provider = getVideoProvider("googleflow");
|
||||
assert.ok(provider, "googleflow provider must exist");
|
||||
assert.equal(provider.format, "google-flow");
|
||||
assert.equal(provider.authType, "oauth");
|
||||
assert.ok(provider.models.length > 0, "must expose at least one Veo model");
|
||||
});
|
||||
|
||||
test("videoRegistry: parseVideoModel resolves googleflow/<model> and its alias", () => {
|
||||
const parsed = parseVideoModel("googleflow/veo-3.1-generate");
|
||||
assert.equal(parsed.provider, "googleflow");
|
||||
assert.equal(parsed.model, "veo-3.1-generate");
|
||||
const aliased = parseVideoModel("flow/veo-3.1-generate");
|
||||
assert.equal(aliased.provider, "googleflow");
|
||||
});
|
||||
|
||||
test("resolveVideoCredentialProvider: googleflow reuses antigravity OAuth, others unchanged", () => {
|
||||
assert.equal(resolveVideoCredentialProvider("googleflow"), "antigravity");
|
||||
assert.equal(GOOGLE_FLOW_CREDENTIAL_PROVIDER, "antigravity");
|
||||
assert.equal(resolveVideoCredentialProvider("vertex"), "vertex");
|
||||
assert.equal(resolveVideoCredentialProvider("kie"), "kie");
|
||||
});
|
||||
|
||||
test("source guard: wire endpoint stays isolated + flagged for live validation (Rule #18)", () => {
|
||||
assert.match(GOOGLE_FLOW_HOST, /aisandbox-pa\.googleapis\.com/);
|
||||
const src = readFileSync(
|
||||
join(__dirname, "../../open-sse/handlers/videoGeneration/googleFlow.ts"),
|
||||
"utf8"
|
||||
);
|
||||
assert.match(src, /PENDING LIVE VALIDATION/, "wire format must be flagged for HAR validation");
|
||||
});
|
||||
@@ -4,11 +4,32 @@
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import dns from "node:dns";
|
||||
import { callVisionModel, type VisionModelConfig } from "@/lib/guardrails/visionBridgeHelpers";
|
||||
|
||||
// Store original fetch
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
|
||||
// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
|
||||
// These tests mock globalThis.fetch with example.com hosts that don't actually
|
||||
// resolve in CI; the call path (callVisionModel -> fetchRemoteImageAsDataUri)
|
||||
// does not expose a way to inject a `lookup` stub through to fetchRemoteImage,
|
||||
// so we monkey-patch dns.promises.lookup with a pass-through public-IP
|
||||
// resolver. Node --test runs each test file in its own process, so this
|
||||
// rebinding does not leak across files.
|
||||
const originalDnsLookup = dns.promises.lookup;
|
||||
(dns.promises as { lookup: unknown }).lookup = (async (
|
||||
_hostname: string,
|
||||
options?: { all?: boolean }
|
||||
) => {
|
||||
const record = { address: "203.0.113.1", family: 4 };
|
||||
return options && options.all ? [record] : record;
|
||||
}) as typeof dns.promises.lookup;
|
||||
process.on("exit", () => {
|
||||
(dns.promises as { lookup: unknown }).lookup = originalDnsLookup;
|
||||
});
|
||||
|
||||
test("callVisionModel returns description on success", async () => {
|
||||
// Mock global fetch
|
||||
const mockResponse = {
|
||||
|
||||
138
tests/unit/headroom-responses-format.test.ts
Normal file
138
tests/unit/headroom-responses-format.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// #1998 (upstream) — adaptBodyForCompression must use the full translator pair
|
||||
// (openaiResponsesToOpenAIRequest → compress → openaiToOpenAIResponsesRequest) when
|
||||
// body.input is in Responses format, so that:
|
||||
// (a) function_call_output items with non-string `output` (JSON objects) are properly
|
||||
// serialised and included in the compression pass (upstream bug: simple helper skips
|
||||
// them because hasTextContent() returns false for object content), and
|
||||
// (b) body.input stays Responses-shaped after the full round-trip.
|
||||
import { describe, it, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { adaptBodyForCompression } from "../../open-sse/services/compression/bodyAdapter.ts";
|
||||
|
||||
describe("adaptBodyForCompression openai-responses format (#1998)", () => {
|
||||
after(() => {
|
||||
// No DB touched.
|
||||
});
|
||||
|
||||
it("includes function_call_output with object output in the compression pass", () => {
|
||||
// The simple responsesItemToMessage() helper only sets content = item.output.
|
||||
// When output is a JSON object (not a string), hasTextContent() returns false
|
||||
// and the item is excluded from messages entirely — not compressable.
|
||||
// The fix: use openaiResponsesToOpenAIRequest which JSON.stringifies object output
|
||||
// so the text is available for compression engines.
|
||||
const body: Record<string, unknown> = {
|
||||
model: "gpt-5",
|
||||
input: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "c1",
|
||||
name: "bash",
|
||||
arguments: JSON.stringify({ command: "ls -la" }),
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "c1",
|
||||
// Object output — NOT a string. Simple helper loses this.
|
||||
output: { result: "file1.ts file2.ts ".repeat(50) },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const adapter = adaptBodyForCompression(body);
|
||||
|
||||
// After the fix, the adapter must recognise the function_call_output as
|
||||
// compressable and include it in the messages array.
|
||||
assert.ok(adapter.adapted, "adapter must be adapted (not a pass-through)");
|
||||
|
||||
const messages = (adapter.body as Record<string, unknown>).messages as Array<Record<string, unknown>>;
|
||||
assert.ok(Array.isArray(messages) && messages.length > 0, "messages must be non-empty");
|
||||
|
||||
// At least one tool/assistant message must be present for compression engines.
|
||||
const hasToolMsg = messages.some((m) => m.role === "tool" || m.role === "assistant");
|
||||
assert.ok(hasToolMsg, "messages must include a tool/assistant entry from the function_call pair");
|
||||
});
|
||||
|
||||
it("keeps body.input Responses-shaped after compress/restore round-trip for type:message items", () => {
|
||||
// Verify the canonical behaviour: a Responses body.input with type:message items
|
||||
// is correctly round-tripped through the adapter and restore.
|
||||
const body: Record<string, unknown> = {
|
||||
model: "gpt-5",
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "a long original message ".repeat(20) }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const adapter = adaptBodyForCompression(body);
|
||||
assert.ok(adapter.adapted, "adapter must be adapted for Responses body.input");
|
||||
|
||||
// Simulate compression: keep messages unchanged (no-op compression).
|
||||
const compressedBody = { ...(adapter.body as Record<string, unknown>) };
|
||||
const restored = adapter.restore(compressedBody);
|
||||
|
||||
// body.input must be an array of Responses items (not raw OpenAI messages).
|
||||
assert.ok(Array.isArray(restored.input), "restored.input must be an array");
|
||||
assert.equal((restored.input as unknown[]).length, 1, "restored.input must have one item");
|
||||
|
||||
const item = (restored.input as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(item.type, "message", "restored item must keep type:message");
|
||||
assert.equal(item.role, "user", "restored item must keep role:user");
|
||||
assert.ok(
|
||||
Array.isArray(item.content),
|
||||
"restored item.content must stay as an array (Responses format), not a string"
|
||||
);
|
||||
// Content must still carry the Responses input_text structure.
|
||||
const firstContentPart = (item.content as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(
|
||||
firstContentPart.type,
|
||||
"input_text",
|
||||
"content part must preserve type:input_text"
|
||||
);
|
||||
assert.ok(
|
||||
typeof firstContentPart.text === "string" && firstContentPart.text.length > 0,
|
||||
"content part must preserve the text value"
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips function_call_output with string output (preserves call_id and output field)", () => {
|
||||
// Ensure string output is correctly restored in output field (not content field).
|
||||
const body: Record<string, unknown> = {
|
||||
model: "gpt-5",
|
||||
input: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "c2",
|
||||
name: "read_file",
|
||||
arguments: JSON.stringify({ path: "/foo.ts" }),
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "c2",
|
||||
output: "large file contents ".repeat(30),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const adapter = adaptBodyForCompression(body);
|
||||
assert.ok(adapter.adapted, "adapter must be adapted");
|
||||
|
||||
// Simulate no-op compression.
|
||||
const compressedBody = { ...(adapter.body as Record<string, unknown>) };
|
||||
const restored = adapter.restore(compressedBody);
|
||||
|
||||
assert.ok(Array.isArray(restored.input), "restored.input must be an array");
|
||||
const items = restored.input as Array<Record<string, unknown>>;
|
||||
assert.equal(items.length, 2, "both function_call and function_call_output must be present");
|
||||
|
||||
const outputItem = items.find((i) => i.type === "function_call_output") as Record<string, unknown>;
|
||||
assert.ok(outputItem, "function_call_output item must be in restored.input");
|
||||
assert.equal(outputItem.call_id, "c2", "call_id must be preserved");
|
||||
assert.ok(
|
||||
typeof outputItem.output === "string" && outputItem.output.length > 0,
|
||||
"output field must be present and non-empty"
|
||||
);
|
||||
});
|
||||
});
|
||||
27
tests/unit/home-provider-topology-default-4596.test.ts
Normal file
27
tests/unit/home-provider-topology-default-4596.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Regression guard for #4596: the home provider-topology card (ReactFlow connection
|
||||
// map) vanished for installs that never persisted `showProviderTopologyOnHome`.
|
||||
// HomePageClient initialized the flag to `false` and only flipped it on an explicit
|
||||
// boolean from /api/settings, so an absent (undefined) setting kept the card hidden
|
||||
// forever — while AppearanceTab still showed the toggle as ON (`!== false`).
|
||||
// The card defaults ON; only an explicit `false` hides it.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { shouldShowProviderTopologyOnHome } from "../../src/app/(dashboard)/dashboard/homeAppearance.ts";
|
||||
|
||||
test("absent setting (undefined) shows the topology card — the #4596 regression", () => {
|
||||
assert.equal(shouldShowProviderTopologyOnHome(undefined), true);
|
||||
});
|
||||
|
||||
test("explicit false hides the topology card (respects opt-out)", () => {
|
||||
assert.equal(shouldShowProviderTopologyOnHome(false), false);
|
||||
});
|
||||
|
||||
test("explicit true shows the topology card", () => {
|
||||
assert.equal(shouldShowProviderTopologyOnHome(true), true);
|
||||
});
|
||||
|
||||
test("non-boolean truthy/absent values keep the default-on behavior", () => {
|
||||
// null (e.g. JSON null) and missing keys must not hide the card — only `false` does.
|
||||
assert.equal(shouldShowProviderTopologyOnHome(null), true);
|
||||
});
|
||||
80
tests/unit/image-compat-node-alias-shadow.test.ts
Normal file
80
tests/unit/image-compat-node-alias-shadow.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Regression test — ported from decolua/9router commit 047fdc89:
|
||||
* "fix(image): prevent compatible nodes from shadowing provider aliases"
|
||||
*
|
||||
* A user-defined openai-compatible provider node with `prefix: "cf"` must NOT
|
||||
* shadow the built-in `cloudflare-ai` provider (alias `cf`). Cloudflare image
|
||||
* routes like `cf/@cf/black-forest-labs/...` must keep resolving to the
|
||||
* built-in cloudflare-ai provider, regardless of any compatible-node prefix
|
||||
* collision.
|
||||
*
|
||||
* Inverse case: a non-reserved compatible-node prefix (e.g. `oct`) must still
|
||||
* resolve to the compatible node. The fix is a reserved-prefix guard — it must
|
||||
* not break user-defined prefixes that don't collide with built-in aliases.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-image-compat-shadow-")
|
||||
);
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const { getModelInfo } = await import("../../src/sse/services/model.ts");
|
||||
|
||||
test.before(async () => {
|
||||
await providersDb.createProviderNode({
|
||||
id: "openai-compatible-cf-collision",
|
||||
type: "openai-compatible",
|
||||
name: "Compatible CF Collision",
|
||||
prefix: "cf",
|
||||
baseUrl: "https://compatible.test/v1",
|
||||
chatPath: "/v1/chat/completions",
|
||||
modelsPath: "/v1/models",
|
||||
});
|
||||
await providersDb.createProviderNode({
|
||||
id: "openai-compatible-oct-passthrough",
|
||||
type: "openai-compatible",
|
||||
name: "Compatible OCT",
|
||||
prefix: "oct",
|
||||
baseUrl: "https://compatible-oct.test/v1",
|
||||
chatPath: "/v1/chat/completions",
|
||||
modelsPath: "/v1/models",
|
||||
});
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("compatible node with prefix=cf must NOT shadow the built-in cloudflare-ai alias", async () => {
|
||||
const info = (await getModelInfo("cf/@cf/black-forest-labs/flux-2-klein-9b")) as {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
assert.equal(
|
||||
info.provider,
|
||||
"cloudflare-ai",
|
||||
"cf/ must keep routing to the built-in cloudflare-ai provider (not the compatible node)"
|
||||
);
|
||||
assert.equal(info.model, "@cf/black-forest-labs/flux-2-klein-9b");
|
||||
});
|
||||
|
||||
test("non-reserved compatible-node prefix (oct) still routes to the compatible node", async () => {
|
||||
const info = (await getModelInfo("oct/gpt-image-1")) as {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
assert.equal(
|
||||
info.provider,
|
||||
"openai-compatible-oct-passthrough",
|
||||
"user-defined prefixes that don't collide with built-in aliases must still resolve to the compatible node"
|
||||
);
|
||||
assert.equal(info.model, "gpt-image-1");
|
||||
});
|
||||
@@ -1,11 +1,34 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import dns from "node:dns";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-images-"));
|
||||
|
||||
// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
|
||||
// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
|
||||
// Several image-handler tests (Fal AI URL->b64 normalization, BFL polling
|
||||
// with base64 input images, NanoBanana polling with URL->b64 conversion)
|
||||
// mock globalThis.fetch with example.com URLs that don't resolve in CI; the
|
||||
// handler invokes fetchRemoteImage without exposing a `lookup` injection
|
||||
// point, so we monkey-patch dns.promises.lookup to always return a public IP
|
||||
// so the rebinding guard passes and the test exercises the mocked fetch
|
||||
// behaviour as intended. Node --test runs each file in its own process, so
|
||||
// this rebinding does not leak across files.
|
||||
const originalDnsLookup = dns.promises.lookup;
|
||||
(dns.promises as { lookup: unknown }).lookup = (async (
|
||||
_hostname: string,
|
||||
options?: { all?: boolean }
|
||||
) => {
|
||||
const record = { address: "203.0.113.1", family: 4 };
|
||||
return options && options.all ? [record] : record;
|
||||
}) as typeof dns.promises.lookup;
|
||||
process.on("exit", () => {
|
||||
(dns.promises as { lookup: unknown }).lookup = originalDnsLookup;
|
||||
});
|
||||
|
||||
const { IMAGE_PROVIDERS, parseImageModel, getAllImageModels } =
|
||||
await import("../../open-sse/config/imageRegistry.ts");
|
||||
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
|
||||
|
||||
96
tests/unit/kiro-region-ssrf-guard.test.ts
Normal file
96
tests/unit/kiro-region-ssrf-guard.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// SSRF regression guard for kiro region — GHSA-6mwv-4mrm-5p3m.
|
||||
// Without the assertValidAwsRegion guard, a malicious region value
|
||||
// would be interpolated directly into upstream URLs like
|
||||
// `https://oidc.${region}.amazonaws.com/token`, allowing the caller to
|
||||
// redirect the proxy to arbitrary hosts (file://, 127.0.0.1, EC2 metadata, etc).
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
AWS_REGION_PATTERN,
|
||||
assertValidAwsRegion,
|
||||
} from "../../src/lib/oauth/constants/oauth";
|
||||
import { KiroService } from "../../src/lib/oauth/services/kiro";
|
||||
|
||||
describe("AWS_REGION_PATTERN", () => {
|
||||
it("accepts canonical AWS regions", () => {
|
||||
for (const r of [
|
||||
"us-east-1",
|
||||
"us-west-2",
|
||||
"eu-west-1",
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"sa-east-1",
|
||||
"me-south-1",
|
||||
"af-south-1",
|
||||
"eu-central-2",
|
||||
]) {
|
||||
assert.ok(AWS_REGION_PATTERN.test(r), `expected ${r} to match`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects SSRF-shaped values", () => {
|
||||
for (const bad of [
|
||||
"127.0.0.1",
|
||||
"169.254.169.254",
|
||||
"localhost",
|
||||
"evil.example.com",
|
||||
"us-east-1.evil.com",
|
||||
"us-east-1/../foo",
|
||||
"us-east-1#frag",
|
||||
"us-east-1?x=1",
|
||||
"file:///etc/passwd",
|
||||
"http://internal",
|
||||
"",
|
||||
"US-EAST-1", // wrong case
|
||||
"us_east_1",
|
||||
"us-east-",
|
||||
"-east-1",
|
||||
"us--east-1",
|
||||
]) {
|
||||
assert.ok(!AWS_REGION_PATTERN.test(bad), `expected ${bad!} to be rejected`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertValidAwsRegion", () => {
|
||||
it("returns the region when valid", () => {
|
||||
assert.equal(assertValidAwsRegion("us-east-1"), "us-east-1");
|
||||
});
|
||||
|
||||
it("throws on non-string", () => {
|
||||
assert.throws(() => assertValidAwsRegion(undefined as unknown as string));
|
||||
assert.throws(() => assertValidAwsRegion(null as unknown as string));
|
||||
assert.throws(() => assertValidAwsRegion(123 as unknown as string));
|
||||
});
|
||||
|
||||
it("throws on invalid region", () => {
|
||||
assert.throws(() => assertValidAwsRegion("127.0.0.1"));
|
||||
assert.throws(() => assertValidAwsRegion("evil.com"));
|
||||
assert.throws(() => assertValidAwsRegion(""));
|
||||
});
|
||||
});
|
||||
|
||||
describe("KiroService SSRF guard", () => {
|
||||
const svc = new KiroService();
|
||||
|
||||
it("registerClient rejects malicious region", async () => {
|
||||
await assert.rejects(() => svc.registerClient("127.0.0.1"));
|
||||
await assert.rejects(() => svc.registerClient("evil.example.com"));
|
||||
});
|
||||
|
||||
it("startDeviceAuthorization rejects malicious region", async () => {
|
||||
await assert.rejects(() =>
|
||||
svc.startDeviceAuthorization("cid", "csec", "https://x", "169.254.169.254")
|
||||
);
|
||||
});
|
||||
|
||||
it("pollDeviceToken rejects malicious region", async () => {
|
||||
await assert.rejects(() =>
|
||||
svc.pollDeviceToken("cid", "csec", "dc", "127.0.0.1")
|
||||
);
|
||||
});
|
||||
|
||||
it("validateImportToken rejects malicious region before fetching", async () => {
|
||||
await assert.rejects(() => svc.validateImportToken("aorAAAAAGfoo", "127.0.0.1"));
|
||||
});
|
||||
});
|
||||
238
tests/unit/kiro-toolresult-compression.test.ts
Normal file
238
tests/unit/kiro-toolresult-compression.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Kiro (AWS CodeWhisperer) tool-result compression — ported from upstream
|
||||
* decolua/9router#1194 (zanuartri).
|
||||
*
|
||||
* The upstream PR added Kiro-format support directly inside RTK. OmniRoute
|
||||
* lifts that capability to `bodyAdapter`, so the Kiro envelope
|
||||
* (`conversationState.history[].userInputMessage.userInputMessageContext.toolResults`)
|
||||
* flattens to OpenAI-shape `role:"tool"` messages — every compression engine
|
||||
* (RTK, lite, aggressive, etc.) automatically benefits, not just RTK.
|
||||
*
|
||||
* Error tool results (status === "error") are skipped to preserve diagnostics
|
||||
* (matches upstream behavior).
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { applyRtkCompression } from "../../open-sse/services/compression/engines/rtk/index.ts";
|
||||
import { adaptBodyForCompression } from "../../open-sse/services/compression/bodyAdapter.ts";
|
||||
|
||||
function buildKiroBodyWithBuildOutput(): Record<string, unknown> {
|
||||
const noisyNpmOutput = [
|
||||
"npm warn deprecated har-validator@5.1.5: this library is no longer supported",
|
||||
"npm warn deprecated uuid@3.4.0: uuid@10 and below is no longer supported",
|
||||
"npm warn deprecated request@2.88.2: request has been deprecated",
|
||||
"npm warn deprecated inflight@1.0.6: This module is not supported",
|
||||
"npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported",
|
||||
"npm warn deprecated rimraf@2.7.1: Rimraf versions prior to v4 are no longer supported",
|
||||
"",
|
||||
"added 47 packages, and audited 48 packages in 13s",
|
||||
"",
|
||||
"3 packages are looking for funding",
|
||||
" run `npm fund` for details",
|
||||
"",
|
||||
"4 vulnerabilities (2 moderate, 2 critical)",
|
||||
"",
|
||||
"Some issues need review, and may require choosing",
|
||||
"a different dependency.",
|
||||
"",
|
||||
"Run `npm audit` for details.",
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
conversationState: {
|
||||
chatTriggerType: "MANUAL",
|
||||
conversationId: "test-port-1194",
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
content: "Install express",
|
||||
modelId: "claude-sonnet-4.5",
|
||||
userInputMessageContext: {
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: "tool_1",
|
||||
status: "success",
|
||||
content: [{ text: noisyNpmOutput }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
history: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Kiro tool-result compression (port of decolua/9router#1194)", () => {
|
||||
it("adapts a Kiro conversationState body to messages with the tool-result text exposed", () => {
|
||||
const body = buildKiroBodyWithBuildOutput();
|
||||
const adapter = adaptBodyForCompression(body);
|
||||
const messages = (adapter.body.messages ?? []) as Array<{
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
}>;
|
||||
assert.ok(adapter.adapted, "Kiro body must be flagged as adapted");
|
||||
assert.ok(
|
||||
messages.some((m) => m.role === "tool" && typeof m.content === "string"),
|
||||
"adapter must surface tool-result text as a role:tool message"
|
||||
);
|
||||
});
|
||||
|
||||
it("compresses tool-result text inside Kiro currentMessage and writes the rewritten text back", () => {
|
||||
const body = buildKiroBodyWithBuildOutput();
|
||||
const before = (
|
||||
(body.conversationState as Record<string, unknown>).currentMessage as Record<string, unknown>
|
||||
).userInputMessage as {
|
||||
userInputMessageContext: { toolResults: Array<{ content: Array<{ text: string }> }> };
|
||||
};
|
||||
const originalLen = before.userInputMessageContext.toolResults[0].content[0].text.length;
|
||||
|
||||
const result = applyRtkCompression(body);
|
||||
|
||||
assert.equal(result.compressed, true, "RTK must compress noisy build output");
|
||||
assert.ok(result.stats, "stats must be returned");
|
||||
assert.ok(
|
||||
result.stats!.compressedTokens < result.stats!.originalTokens,
|
||||
"compressedTokens must drop"
|
||||
);
|
||||
|
||||
const restored = (
|
||||
(result.body as Record<string, unknown>).conversationState as Record<string, unknown>
|
||||
).currentMessage as {
|
||||
userInputMessage: {
|
||||
userInputMessageContext: { toolResults: Array<{ content: Array<{ text: string }> }> };
|
||||
};
|
||||
};
|
||||
const afterLen = restored.userInputMessage.userInputMessageContext.toolResults[0].content[0]
|
||||
.text.length;
|
||||
assert.ok(
|
||||
afterLen < originalLen,
|
||||
`Kiro tool-result text must shrink (before=${originalLen}, after=${afterLen})`
|
||||
);
|
||||
});
|
||||
|
||||
it("compresses tool-result text inside Kiro history[] entries", () => {
|
||||
// Reuse the noisy npm-install fixture (already proven to trigger a build-output
|
||||
// filter in the currentMessage test) so this test exercises the history[] path
|
||||
// without depending on a separate filter (cargo, etc.) being registered.
|
||||
const compilingLines: string[] = [
|
||||
"npm warn deprecated har-validator@5.1.5: this library is no longer supported",
|
||||
"npm warn deprecated uuid@3.4.0: uuid@10 and below is no longer supported",
|
||||
"npm warn deprecated request@2.88.2: request has been deprecated",
|
||||
"npm warn deprecated inflight@1.0.6: This module is not supported",
|
||||
"npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported",
|
||||
"npm warn deprecated rimraf@2.7.1: Rimraf versions prior to v4 are no longer supported",
|
||||
"",
|
||||
"added 47 packages, and audited 48 packages in 13s",
|
||||
"",
|
||||
"3 packages are looking for funding",
|
||||
" run `npm fund` for details",
|
||||
"",
|
||||
"4 vulnerabilities (2 moderate, 2 critical)",
|
||||
"",
|
||||
"Run `npm audit` for details.",
|
||||
];
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
conversationState: {
|
||||
chatTriggerType: "MANUAL",
|
||||
conversationId: "test-history",
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
content: "What happened?",
|
||||
modelId: "claude-sonnet-4.5",
|
||||
},
|
||||
},
|
||||
history: [
|
||||
{
|
||||
userInputMessage: {
|
||||
content: "Run npm install",
|
||||
modelId: "claude-sonnet-4.5",
|
||||
userInputMessageContext: {
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: "tool_2",
|
||||
status: "success",
|
||||
content: [{ text: compilingLines.join("\n") }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const originalLen = compilingLines.join("\n").length;
|
||||
const result = applyRtkCompression(body);
|
||||
|
||||
assert.equal(result.compressed, true, "history tool result must compress");
|
||||
const state = (result.body as Record<string, unknown>).conversationState as {
|
||||
history: Array<{
|
||||
userInputMessage: {
|
||||
userInputMessageContext: { toolResults: Array<{ content: Array<{ text: string }> }> };
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const afterLen = state.history[0].userInputMessage.userInputMessageContext.toolResults[0]
|
||||
.content[0].text.length;
|
||||
assert.ok(
|
||||
afterLen < originalLen,
|
||||
`history tool-result text must shrink (before=${originalLen}, after=${afterLen})`
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves error tool results (status === 'error') without rewriting their text", () => {
|
||||
const errorText =
|
||||
"npm error code E404\nnpm error 404 Not Found - GET https://registry.npmjs.org/invalid-package";
|
||||
const body: Record<string, unknown> = {
|
||||
conversationState: {
|
||||
chatTriggerType: "MANUAL",
|
||||
conversationId: "test-error",
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
content: "Install invalid-package",
|
||||
modelId: "claude-sonnet-4.5",
|
||||
userInputMessageContext: {
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: "tool_err",
|
||||
status: "error",
|
||||
content: [{ text: errorText }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
history: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyRtkCompression(body);
|
||||
const after = (
|
||||
(result.body as Record<string, unknown>).conversationState as {
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
userInputMessageContext: { toolResults: Array<{ content: Array<{ text: string }> }> };
|
||||
};
|
||||
};
|
||||
}
|
||||
).currentMessage.userInputMessage.userInputMessageContext.toolResults[0].content[0].text;
|
||||
assert.equal(after, errorText, "error tool-result text must be preserved byte-for-byte");
|
||||
});
|
||||
|
||||
it("handles a malformed Kiro body without crashing", () => {
|
||||
const malformed: Array<Record<string, unknown>> = [
|
||||
{ conversationState: null },
|
||||
{ conversationState: {} },
|
||||
{ conversationState: { history: null, currentMessage: null } },
|
||||
{ conversationState: { history: "not-an-array" } },
|
||||
];
|
||||
for (const body of malformed) {
|
||||
const result = applyRtkCompression(body);
|
||||
assert.ok(result, "must not throw on malformed Kiro body");
|
||||
// No tool-result text → engine reports no compression
|
||||
assert.equal(result.compressed, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
74
tests/unit/llm-selector-custom-vision-models.test.ts
Normal file
74
tests/unit/llm-selector-custom-vision-models.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Custom vision models in the LLM selector / `/v1/models` catalog
|
||||
* (port of upstream decolua/9router 5e5e78d3 — "fix: show custom vision
|
||||
* models in LLM selector and model list").
|
||||
*
|
||||
* Upstream stored an `imageToText` kind on user-added custom models and the
|
||||
* selector hid them from the default LLM list. OmniRoute already keeps custom
|
||||
* chat models in the LLM list, but the equivalent gap is the `supportsVision`
|
||||
* flag on a custom model: the catalog used to only set `capabilities.vision`
|
||||
* when the model id matched the conservative `isVisionModelId` heuristic
|
||||
* (`pixtral`, `llava`, `qwen-vl`, …). A user who registered a custom chat
|
||||
* model called e.g. `my-vision-llm` and explicitly checked "vision-capable"
|
||||
* still saw no `capabilities.vision` in `/v1/models`, so the LLM selector and
|
||||
* downstream routing treated it as text-only.
|
||||
*
|
||||
* This file pins the OmniRoute-flavoured behaviour:
|
||||
* • a custom chat model with `supportsVision: true` MUST surface
|
||||
* `capabilities: { vision: true }` via the catalog helper, regardless of
|
||||
* whether the model id matches the static vision heuristic.
|
||||
* • the existing id-based heuristic (`isVisionModelId`) still works.
|
||||
* • when neither signal is present, no vision fields are emitted (no false
|
||||
* positives — same conservative guard #4071 / #4072).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const catalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
|
||||
type VisionFields = { capabilities: { vision: true } } | null;
|
||||
type Helper = (entry: unknown, ...candidateIds: Array<string | null | undefined>) => VisionFields;
|
||||
|
||||
const getCustomVisionCapabilityFields = (
|
||||
catalog as unknown as { getCustomVisionCapabilityFields?: Helper }
|
||||
).getCustomVisionCapabilityFields;
|
||||
|
||||
test("custom vision helper is exported from the catalog module", () => {
|
||||
assert.equal(
|
||||
typeof getCustomVisionCapabilityFields,
|
||||
"function",
|
||||
"expected getCustomVisionCapabilityFields to be exported from src/app/api/v1/models/catalog.ts"
|
||||
);
|
||||
});
|
||||
|
||||
test("supportsVision flag on a custom model surfaces capabilities.vision (LLM selector unblock)", () => {
|
||||
const helper = getCustomVisionCapabilityFields as Helper;
|
||||
const entry = { id: "my-vision-llm", supportsVision: true };
|
||||
const fields = helper(entry, "openai-compat/my-vision-llm", "my-vision-llm");
|
||||
assert.ok(fields, "supportsVision=true must yield vision fields");
|
||||
assert.equal(fields?.capabilities.vision, true);
|
||||
});
|
||||
|
||||
test("id-based isVisionModelId heuristic still works when supportsVision is unset", () => {
|
||||
const helper = getCustomVisionCapabilityFields as Helper;
|
||||
const entry = { id: "pixtral-12b" };
|
||||
const fields = helper(entry, "openai-compat/pixtral-12b", "pixtral-12b");
|
||||
assert.ok(fields, "well-known vision model id must yield vision fields");
|
||||
assert.equal(fields?.capabilities.vision, true);
|
||||
});
|
||||
|
||||
test("no signal → no vision fields (avoids #4071-style false positives)", () => {
|
||||
const helper = getCustomVisionCapabilityFields as Helper;
|
||||
const entry = { id: "plain-text-llm" };
|
||||
const fields = helper(entry, "openai-compat/plain-text-llm", "plain-text-llm");
|
||||
assert.equal(fields, null);
|
||||
});
|
||||
|
||||
test("explicit supportsVision=false is respected (text-only override)", () => {
|
||||
const helper = getCustomVisionCapabilityFields as Helper;
|
||||
// Even if the id matches the heuristic, an explicit false flag wins so the
|
||||
// user can downgrade a mis-classified model.
|
||||
const entry = { id: "pixtral-12b", supportsVision: false };
|
||||
const fields = helper(entry, "alias/pixtral-12b", "pixtral-12b");
|
||||
assert.equal(fields, null);
|
||||
});
|
||||
140
tests/unit/migration-107-quota-share-strategy.test.ts
Normal file
140
tests/unit/migration-107-quota-share-strategy.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* tests/unit/migration-107-quota-share-strategy.test.ts
|
||||
*
|
||||
* Migration 107 flips the routing strategy of the existing auto-minted qtSd/ combos
|
||||
* from the legacy "fill-first" to the dedicated "quota-share" engine (Fase 3 #9),
|
||||
* WITHOUT touching combos already on quota-share or any user-authored combo.
|
||||
*
|
||||
* The migration is applied in isolation against an in-memory DB seeded with a mix
|
||||
* of combos, so the assertions pin the exact WHERE scope (name LIKE 'qtSd/%' AND
|
||||
* strategy = 'fill-first') and prove the other JSON fields are preserved.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATION_107 = fs.readFileSync(
|
||||
path.join(__dirname, "../../src/lib/db/migrations/107_quota_combos_quota_share_strategy.sql"),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
interface TestDb {
|
||||
exec: (sql: string) => unknown;
|
||||
prepare: (sql: string) => {
|
||||
run: (...p: unknown[]) => unknown;
|
||||
get: (...p: unknown[]) => Record<string, unknown> | undefined;
|
||||
};
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function makeDb(): TestDb {
|
||||
const db = new Database(":memory:") as unknown as TestDb;
|
||||
db.exec(
|
||||
`CREATE TABLE combos (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`
|
||||
);
|
||||
return db;
|
||||
}
|
||||
|
||||
function insertCombo(db: TestDb, id: string, name: string, strategy: string): void {
|
||||
const data = JSON.stringify({
|
||||
name,
|
||||
strategy,
|
||||
models: [{ kind: "model", model: "p/m", weight: 100 }],
|
||||
isHidden: name.startsWith("qtSd/"),
|
||||
});
|
||||
db.prepare(
|
||||
"INSERT INTO combos (id, name, data, created_at, updated_at) VALUES (?, ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"
|
||||
).run(id, name, data);
|
||||
}
|
||||
|
||||
function strategyOf(db: TestDb, name: string): string | null {
|
||||
const row = db
|
||||
.prepare("SELECT json_extract(data, '$.strategy') AS s FROM combos WHERE name = ?")
|
||||
.get(name);
|
||||
return (row?.s as string | undefined) ?? null;
|
||||
}
|
||||
|
||||
function dataOf(db: TestDb, name: string): Record<string, unknown> {
|
||||
const row = db.prepare("SELECT data FROM combos WHERE name = ?").get(name);
|
||||
return JSON.parse(row!.data as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
test("107 flips stale qtSd/ fill-first combos to quota-share", () => {
|
||||
const db = makeDb();
|
||||
insertCombo(db, "c1", "qtSd/grp/deepseek/deepseek-v4-flash", "fill-first");
|
||||
insertCombo(db, "c2", "qtSd/grp/claude/claude-opus-4-8", "fill-first");
|
||||
|
||||
db.exec(MIGRATION_107);
|
||||
|
||||
assert.equal(strategyOf(db, "qtSd/grp/deepseek/deepseek-v4-flash"), "quota-share");
|
||||
assert.equal(strategyOf(db, "qtSd/grp/claude/claude-opus-4-8"), "quota-share");
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("107 preserves the other JSON fields (only strategy changes)", () => {
|
||||
const db = makeDb();
|
||||
insertCombo(db, "c1", "qtSd/grp/glm/glm-5", "fill-first");
|
||||
|
||||
db.exec(MIGRATION_107);
|
||||
|
||||
const data = dataOf(db, "qtSd/grp/glm/glm-5");
|
||||
assert.equal(data.strategy, "quota-share");
|
||||
assert.equal(data.isHidden, true, "isHidden must be preserved");
|
||||
assert.deepEqual(
|
||||
data.models,
|
||||
[{ kind: "model", model: "p/m", weight: 100 }],
|
||||
"models array must be preserved"
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("107 leaves qtSd/ combos already on quota-share untouched", () => {
|
||||
const db = makeDb();
|
||||
insertCombo(db, "c1", "qtSd/grp/glm/glm-4.6", "quota-share");
|
||||
|
||||
db.exec(MIGRATION_107);
|
||||
|
||||
assert.equal(strategyOf(db, "qtSd/grp/glm/glm-4.6"), "quota-share");
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("107 never touches user-authored (non-qtSd/) combos", () => {
|
||||
const db = makeDb();
|
||||
insertCombo(db, "c1", "my-coding-combo", "fill-first");
|
||||
insertCombo(db, "c2", "team/fast", "fill-first");
|
||||
|
||||
db.exec(MIGRATION_107);
|
||||
|
||||
assert.equal(
|
||||
strategyOf(db, "my-coding-combo"),
|
||||
"fill-first",
|
||||
"a user's fill-first combo must be preserved"
|
||||
);
|
||||
assert.equal(strategyOf(db, "team/fast"), "fill-first");
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("107 is idempotent (second run is a no-op)", () => {
|
||||
const db = makeDb();
|
||||
insertCombo(db, "c1", "qtSd/grp/minimax/MiniMax-M2.7", "fill-first");
|
||||
|
||||
db.exec(MIGRATION_107);
|
||||
const afterFirst = dataOf(db, "qtSd/grp/minimax/MiniMax-M2.7");
|
||||
db.exec(MIGRATION_107);
|
||||
const afterSecond = dataOf(db, "qtSd/grp/minimax/MiniMax-M2.7");
|
||||
|
||||
assert.equal(strategyOf(db, "qtSd/grp/minimax/MiniMax-M2.7"), "quota-share");
|
||||
assert.deepEqual(afterFirst, afterSecond, "second run must not change the row");
|
||||
db.close();
|
||||
});
|
||||
@@ -1,8 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import dns from "node:dns";
|
||||
|
||||
import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts";
|
||||
|
||||
// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
|
||||
// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
|
||||
// The b64_json test mocks globalThis.fetch with an example.com URL that
|
||||
// doesn't resolve in CI; the handler invokes fetchRemoteImage without
|
||||
// exposing a `lookup` injection point, so we monkey-patch dns.promises.lookup
|
||||
// to always return a public IP so the rebinding guard passes and the test
|
||||
// exercises the mocked fetch behaviour as intended.
|
||||
const originalDnsLookup = dns.promises.lookup;
|
||||
(dns.promises as { lookup: unknown }).lookup = (async (
|
||||
_hostname: string,
|
||||
options?: { all?: boolean }
|
||||
) => {
|
||||
const record = { address: "203.0.113.1", family: 4 };
|
||||
return options && options.all ? [record] : record;
|
||||
}) as typeof dns.promises.lookup;
|
||||
process.on("exit", () => {
|
||||
(dns.promises as { lookup: unknown }).lookup = originalDnsLookup;
|
||||
});
|
||||
|
||||
test("handleImageGeneration(nanobanana): async submit+poll returns URL payload", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let pollCount = 0;
|
||||
|
||||
@@ -232,3 +232,22 @@ test("next-intl webpack hook preserves caller config and filters known extractor
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("optimizePackageImports excludes the internal @omniroute/open-sse workspace (build-OOM guard)", async () => {
|
||||
// Regression guard: adding the internal `@omniroute/open-sse` workspace to
|
||||
// optimizePackageImports makes Next.js resolve its entire barrel at build
|
||||
// time, driving the webpack production pass into a heap runaway that OOM'd
|
||||
// even at 28 GB. optimizePackageImports is for EXTERNAL barrel libs only.
|
||||
const { default: nextConfig } = await loadNextConfig("optimize-pkg-imports");
|
||||
const list = nextConfig.experimental?.optimizePackageImports ?? [];
|
||||
|
||||
assert.ok(Array.isArray(list), "optimizePackageImports should be an array");
|
||||
assert.ok(
|
||||
!list.includes("@omniroute/open-sse"),
|
||||
"do NOT add the internal @omniroute/open-sse workspace to optimizePackageImports — it OOMs the production build"
|
||||
);
|
||||
// The intended external barrel libs must remain optimized.
|
||||
for (const lib of ["lucide-react", "date-fns", "next-intl"]) {
|
||||
assert.ok(list.includes(lib), `expected external barrel lib ${lib} to stay optimized`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
CLAUDE_CONFIG,
|
||||
CLINE_CONFIG,
|
||||
CODEX_CONFIG,
|
||||
CODEBUDDY_CN_CONFIG,
|
||||
CURSOR_CONFIG,
|
||||
GEMINI_CONFIG,
|
||||
GITHUB_CONFIG,
|
||||
@@ -66,6 +67,7 @@ const EXPECTED_PROVIDER_KEYS = [
|
||||
"cline",
|
||||
"windsurf",
|
||||
"devin-cli",
|
||||
"codebuddy-cn",
|
||||
];
|
||||
|
||||
const EXPECTED_CONFIG_BY_PROVIDER = {
|
||||
@@ -87,6 +89,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = {
|
||||
windsurf: WINDSURF_CONFIG,
|
||||
"devin-cli": WINDSURF_CONFIG,
|
||||
trae: TRAE_CONFIG,
|
||||
"codebuddy-cn": CODEBUDDY_CN_CONFIG,
|
||||
};
|
||||
|
||||
const REQUIRED_FIELDS_BY_PROVIDER = {
|
||||
@@ -399,29 +402,21 @@ test("custom Google OAuth callbacks preserve the requested callback path and que
|
||||
});
|
||||
|
||||
test("custom Google OAuth credentials switch IPv6 loopback callbacks to public base URL", () => {
|
||||
const redirectUri = resolveBrowserOAuthRedirectUri(
|
||||
"gemini-cli",
|
||||
"http://[::1]:20128/callback",
|
||||
{
|
||||
OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com",
|
||||
GEMINI_OAUTH_CLIENT_ID: "custom-gemini.apps.googleusercontent.com",
|
||||
GEMINI_OAUTH_CLIENT_SECRET: "custom-gemini-secret",
|
||||
}
|
||||
);
|
||||
const redirectUri = resolveBrowserOAuthRedirectUri("gemini-cli", "http://[::1]:20128/callback", {
|
||||
OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com",
|
||||
GEMINI_OAUTH_CLIENT_ID: "custom-gemini.apps.googleusercontent.com",
|
||||
GEMINI_OAUTH_CLIENT_SECRET: "custom-gemini-secret",
|
||||
});
|
||||
|
||||
assert.equal(redirectUri, "https://omniroute.example.com/callback");
|
||||
});
|
||||
|
||||
test("custom Google OAuth callbacks default root loopback paths to callback path", () => {
|
||||
const redirectUri = resolveBrowserOAuthRedirectUri(
|
||||
"antigravity",
|
||||
"http://127.0.0.1:20128",
|
||||
{
|
||||
NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com",
|
||||
ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-antigravity.apps.googleusercontent.com",
|
||||
ANTIGRAVITY_OAUTH_CLIENT_SECRET: "custom-antigravity-secret",
|
||||
}
|
||||
);
|
||||
const redirectUri = resolveBrowserOAuthRedirectUri("antigravity", "http://127.0.0.1:20128", {
|
||||
NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com",
|
||||
ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-antigravity.apps.googleusercontent.com",
|
||||
ANTIGRAVITY_OAUTH_CLIENT_SECRET: "custom-antigravity-secret",
|
||||
});
|
||||
|
||||
assert.equal(redirectUri, "https://omniroute.example.com/callback");
|
||||
});
|
||||
|
||||
38
tests/unit/openai-developer-role-normalize.test.ts
Normal file
38
tests/unit/openai-developer-role-normalize.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.ts";
|
||||
|
||||
// Ported from upstream decolua/9router#1011 — many OpenAI-compatible providers
|
||||
// reject the `developer` role (introduced by OpenAI Responses API). Normalize to
|
||||
// `system` so passthrough requests keep working downstream.
|
||||
test("filterToOpenAIFormat normalizes developer role to system", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "developer", content: "You are helpful" },
|
||||
{ role: "user", content: "Hi" },
|
||||
],
|
||||
};
|
||||
|
||||
const out = filterToOpenAIFormat(body);
|
||||
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.equal(out.messages[0].content, "You are helpful");
|
||||
assert.equal(out.messages[1].role, "user");
|
||||
});
|
||||
|
||||
test("filterToOpenAIFormat normalizes developer role with array content", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{
|
||||
role: "developer",
|
||||
content: [{ type: "text", text: "Be concise" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const out = filterToOpenAIFormat(body);
|
||||
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.deepEqual(out.messages[0].content, [{ type: "text", text: "Be concise" }]);
|
||||
});
|
||||
94
tests/unit/opencode-deepseek-reasoning-injected.test.ts
Normal file
94
tests/unit/opencode-deepseek-reasoning-injected.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts");
|
||||
|
||||
/**
|
||||
* Regression test for upstream decolua/9router#1099 (issue #1543):
|
||||
*
|
||||
* OpenCode is a meta-provider that proxies multi-turn chats to thinking-mode
|
||||
* upstreams (DeepSeek V4 Flash, Kimi, MiniMax, ...). Those upstreams require
|
||||
* `reasoning_content` to be echoed back on every assistant message in the
|
||||
* conversation history; otherwise they return:
|
||||
*
|
||||
* 400 Bad Request — reasoning_content must be passed back
|
||||
*
|
||||
* Standard OpenAI clients do not preserve `reasoning_content` across turns, so
|
||||
* OpencodeExecutor.transformRequest must inject a non-empty placeholder on any
|
||||
* assistant message that lacks it when routing to a thinking-mode model.
|
||||
*/
|
||||
describe("OpencodeExecutor — DeepSeek reasoning_content injection (#1099)", () => {
|
||||
const executor = new OpencodeExecutor("opencode-zen");
|
||||
|
||||
function buildBody(extraAssistant: Record<string, unknown>) {
|
||||
return {
|
||||
model: "oc/deepseek-v4-flash-free",
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "previous answer", ...extraAssistant },
|
||||
{ role: "user", content: "follow up" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("injects reasoning_content on an assistant message that lacks it (deepseek model)", () => {
|
||||
const out = executor.transformRequest(
|
||||
"oc/deepseek-v4-flash-free",
|
||||
buildBody({}),
|
||||
true,
|
||||
{ apiKey: "test" } as never
|
||||
);
|
||||
const assistant = out.messages.find((m: { role: string }) => m.role === "assistant");
|
||||
assert.equal(typeof assistant.reasoning_content, "string");
|
||||
assert.ok(
|
||||
assistant.reasoning_content.length > 0,
|
||||
"expected non-empty reasoning_content placeholder injected"
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves an existing non-empty reasoning_content instead of overwriting", () => {
|
||||
const original = "prior chain of thought";
|
||||
const out = executor.transformRequest(
|
||||
"oc/deepseek-v4-flash-free",
|
||||
buildBody({ reasoning_content: original }),
|
||||
true,
|
||||
{ apiKey: "test" } as never
|
||||
);
|
||||
const assistant = out.messages.find((m: { role: string }) => m.role === "assistant");
|
||||
assert.equal(assistant.reasoning_content, original);
|
||||
});
|
||||
|
||||
it("injects for kimi-family models routed through opencode", () => {
|
||||
const body = {
|
||||
model: "oc/kimi-k2-thinking",
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
],
|
||||
};
|
||||
const out = executor.transformRequest("oc/kimi-k2-thinking", body, false, {
|
||||
apiKey: "test",
|
||||
} as never);
|
||||
const assistant = out.messages.find((m: { role: string }) => m.role === "assistant");
|
||||
assert.equal(typeof assistant.reasoning_content, "string");
|
||||
assert.ok(assistant.reasoning_content.length > 0);
|
||||
});
|
||||
|
||||
it("does not inject for non-thinking opencode models (no false positives)", () => {
|
||||
const body = {
|
||||
model: "oc/gpt-5",
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
],
|
||||
};
|
||||
const out = executor.transformRequest("oc/gpt-5", body, false, {
|
||||
apiKey: "test",
|
||||
} as never);
|
||||
const assistant = out.messages.find((m: { role: string }) => m.role === "assistant");
|
||||
assert.equal(assistant.reasoning_content, undefined);
|
||||
});
|
||||
});
|
||||
45
tests/unit/opencode-go-catalog-alignment.test.ts
Normal file
45
tests/unit/opencode-go-catalog-alignment.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* OpenCode Go model catalog alignment with the official Go docs.
|
||||
*
|
||||
* Port of decolua/9router 8efacc114 (thanks @nguyenha935): the official Go API
|
||||
* advertises `glm-5.2` and routes Kimi chat traffic through `kimi-k2.7-code`
|
||||
* (the live API rejects the plain `kimi-k2.7` alias for `/chat/completions`
|
||||
* even though the public docs example uses it). OmniRoute previously shipped
|
||||
* the older registry without these IDs.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { opencode_goProvider } = await import(
|
||||
"../../open-sse/config/providers/registry/opencode/go/index.ts"
|
||||
);
|
||||
|
||||
function modelIds(): string[] {
|
||||
return (opencode_goProvider.models ?? []).map((m) => m.id);
|
||||
}
|
||||
|
||||
test("opencode-go advertises glm-5.2 (official Go endpoint addition)", () => {
|
||||
assert.ok(
|
||||
modelIds().includes("glm-5.2"),
|
||||
`expected glm-5.2 in opencode-go catalog, got: ${modelIds().join(", ")}`
|
||||
);
|
||||
});
|
||||
|
||||
test("opencode-go advertises kimi-k2.7-code (live API rejects plain kimi-k2.7 for chat)", () => {
|
||||
assert.ok(
|
||||
modelIds().includes("kimi-k2.7-code"),
|
||||
`expected kimi-k2.7-code in opencode-go catalog, got: ${modelIds().join(", ")}`
|
||||
);
|
||||
});
|
||||
|
||||
test("opencode-go preserves the pre-existing minimax-m3 and qwen routing via targetFormat=claude", () => {
|
||||
// Routing through the /messages endpoint is OmniRoute's declarative
|
||||
// equivalent of upstream's MESSAGES_FORMAT_MODELS set; the alignment
|
||||
// change must not regress this.
|
||||
const byId = new Map(
|
||||
(opencode_goProvider.models ?? []).map((m) => [m.id, m as Record<string, unknown>])
|
||||
);
|
||||
assert.equal(byId.get("minimax-m3")?.targetFormat, "claude");
|
||||
assert.equal(byId.get("qwen3.7-max")?.targetFormat, "claude");
|
||||
});
|
||||
168
tests/unit/opencode-proxy-rotation-4954.test.ts
Normal file
168
tests/unit/opencode-proxy-rotation-4954.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, beforeEach, afterEach, before, after } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import net from "node:net";
|
||||
import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts";
|
||||
import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts";
|
||||
|
||||
/**
|
||||
* #4954 — "OpenCode Free" exposes per-account proxy + multi-account rotation in
|
||||
* the UI (NoAuthAccountCard persists providerSpecificData.fingerprints +
|
||||
* providerSpecificData.accountProxies), but the executor ignored them entirely:
|
||||
* every request egressed direct and never rotated. These tests pin the wiring:
|
||||
*
|
||||
* 1. A request for an account that has a configured proxy must egress THROUGH
|
||||
* that proxy (resolveProxyForRequest reports source "context", not "direct").
|
||||
* 2. On a 429 the executor must rotate to the NEXT account (and its proxy).
|
||||
*
|
||||
* The dispatch layer is mocked by stubbing globalThis.fetch — exactly what the
|
||||
* proxy context wraps — and we observe the proxy that resolveProxyForRequest sees
|
||||
* for the in-flight request, mirroring the mimocode proxy integration test. Two
|
||||
* throwaway local TCP listeners stand in for the proxies so runWithProxyContext's
|
||||
* fast-fail reachability probe passes without a live SOCKS/HTTP proxy.
|
||||
*/
|
||||
|
||||
const log = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
|
||||
const ACCOUNT_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const ACCOUNT_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
// Two local listeners so the proxy fast-fail reachability check succeeds. The
|
||||
// proxy host/port are observable in the egress context — that is what asserts the
|
||||
// per-account proxy is honored (was always "direct" before #4954).
|
||||
let serverA: net.Server;
|
||||
let serverB: net.Server;
|
||||
let portA = 0;
|
||||
let portB = 0;
|
||||
|
||||
function listen(server: net.Server): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
resolve((server.address() as net.AddressInfo).port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
serverA = net.createServer((s) => s.destroy());
|
||||
serverB = net.createServer((s) => s.destroy());
|
||||
portA = await listen(serverA);
|
||||
portB = await listen(serverB);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
serverA?.close();
|
||||
serverB?.close();
|
||||
});
|
||||
|
||||
function credentialsWithProxies() {
|
||||
return {
|
||||
apiKey: null,
|
||||
accessToken: null,
|
||||
connectionId: "noauth",
|
||||
providerSpecificData: {
|
||||
fingerprints: [ACCOUNT_A, ACCOUNT_B],
|
||||
accountProxies: [
|
||||
{ fingerprint: ACCOUNT_A, proxy: { type: "http", host: "127.0.0.1", port: portA } },
|
||||
{ fingerprint: ACCOUNT_B, proxy: { type: "http", host: "127.0.0.1", port: portB } },
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("OpencodeExecutor per-account proxy + rotation (#4954)", () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
let observed: Array<{ source: string; host: string | null; port: string | null }>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
observed = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
/** Record the proxy context resolved for each dispatch, then return `status`. */
|
||||
function installFetchStub(statuses: number[]) {
|
||||
let call = 0;
|
||||
globalThis.fetch = (async (input: any) => {
|
||||
const url = typeof input === "string" ? input : input?.url || String(input);
|
||||
const resolved = resolveProxyForRequest(url);
|
||||
let host: string | null = null;
|
||||
let port: string | null = null;
|
||||
try {
|
||||
if (resolved.proxyUrl) {
|
||||
const u = new URL(resolved.proxyUrl);
|
||||
host = u.hostname;
|
||||
port = u.port;
|
||||
}
|
||||
} catch {
|
||||
host = resolved.proxyUrl;
|
||||
}
|
||||
observed.push({ source: resolved.source, host, port });
|
||||
const status = statuses[Math.min(call, statuses.length - 1)];
|
||||
call++;
|
||||
return new Response(JSON.stringify({ ok: status === 200 }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
it("dispatches through the selected account's proxy (not direct)", async () => {
|
||||
const exec = new OpencodeExecutor("opencode-zen");
|
||||
installFetchStub([200]);
|
||||
|
||||
const result = await exec.execute({
|
||||
model: "grok-code",
|
||||
body: { messages: [{ role: "user", content: "hi" }], stream: false },
|
||||
stream: false,
|
||||
signal: null,
|
||||
credentials: credentialsWithProxies(),
|
||||
log,
|
||||
});
|
||||
|
||||
assert.strictEqual((result as any).response.status, 200);
|
||||
assert.ok(observed.length >= 1, "at least one dispatch happened");
|
||||
const first = observed[0];
|
||||
assert.strictEqual(
|
||||
first.source,
|
||||
"context",
|
||||
`expected proxy-context egress, got source="${first.source}" (was always "direct" before #4954)`
|
||||
);
|
||||
assert.strictEqual(first.host, "127.0.0.1", "egress must use a configured proxy host");
|
||||
assert.ok(
|
||||
first.port === String(portA) || first.port === String(portB),
|
||||
`expected one of the configured proxy ports, got "${first.port}"`
|
||||
);
|
||||
});
|
||||
|
||||
it("rotates to the next account (and its proxy) on a 429", async () => {
|
||||
const exec = new OpencodeExecutor("opencode-zen");
|
||||
// first account → 429, second account → 200
|
||||
installFetchStub([429, 200]);
|
||||
|
||||
const result = await exec.execute({
|
||||
model: "grok-code",
|
||||
body: { messages: [{ role: "user", content: "hi" }], stream: false },
|
||||
stream: false,
|
||||
signal: null,
|
||||
credentials: credentialsWithProxies(),
|
||||
log,
|
||||
});
|
||||
|
||||
assert.strictEqual((result as any).response.status, 200, "final response should succeed");
|
||||
assert.ok(observed.length >= 2, "should have retried on a second account after 429");
|
||||
const ports = observed.map((p) => p.port);
|
||||
assert.ok(ports.includes(String(portA)), "first attempt should use account A's proxy");
|
||||
assert.ok(ports.includes(String(portB)), "rotated attempt should use account B's proxy");
|
||||
assert.notStrictEqual(
|
||||
observed[0].port,
|
||||
observed[1].port,
|
||||
"rotation must switch to a different account/proxy"
|
||||
);
|
||||
for (const p of observed) {
|
||||
assert.strictEqual(p.source, "context", "every dispatch must egress through a proxy context");
|
||||
}
|
||||
});
|
||||
});
|
||||
184
tests/unit/ops-scripts.test.ts
Normal file
184
tests/unit/ops-scripts.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* tests/unit/ops-scripts.test.ts
|
||||
*
|
||||
* Contract tests for the incident-runbook ops scripts under bin/ (referenced by
|
||||
* docs/INCIDENT_RESPONSE.md and docs/PERF_BUDGETS.md):
|
||||
* rollback.sh · snapshot-data.sh · restore-data.sh · restore-policies.sh ·
|
||||
* cold-start-bench.sh
|
||||
*
|
||||
* These scripts touch deploys and the SQLite store, so the suite pins down the
|
||||
* safety contract rather than full ops behavior: every script is executable
|
||||
* bash with strict mode, prints usage on --help, the restore commands refuse to
|
||||
* run without a snapshot id, and a snapshot→restore round-trip works while the
|
||||
* non-interactive TTY guard blocks an unattended destructive restore.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, "..", "..");
|
||||
const BIN = path.join(ROOT, "bin");
|
||||
const SCRIPTS = [
|
||||
"rollback.sh",
|
||||
"snapshot-data.sh",
|
||||
"restore-data.sh",
|
||||
"restore-policies.sh",
|
||||
"cold-start-bench.sh",
|
||||
];
|
||||
|
||||
const hasSqlite3 = spawnSync("sqlite3", ["--version"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
/** Run a bin/ script with a NON-tty stdin (so the TTY guard engages). */
|
||||
function runScript(script: string, args: string[], env: Record<string, string> = {}) {
|
||||
return spawnSync("bash", [path.join(BIN, script), ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
describe("ops runbook scripts (bin/*.sh)", () => {
|
||||
it("every script exists, is executable, and uses bash + strict mode", () => {
|
||||
for (const s of SCRIPTS) {
|
||||
const p = path.join(BIN, s);
|
||||
assert.ok(fs.existsSync(p), `${s} is missing`);
|
||||
assert.ok(fs.statSync(p).mode & 0o111, `${s} is not executable (chmod +x)`);
|
||||
const body = fs.readFileSync(p, "utf8");
|
||||
assert.ok(body.startsWith("#!/usr/bin/env bash"), `${s} missing bash shebang`);
|
||||
assert.ok(body.includes("set -euo pipefail"), `${s} missing 'set -euo pipefail'`);
|
||||
}
|
||||
});
|
||||
|
||||
it("the shared helper and every script pass `bash -n` (syntax check)", () => {
|
||||
for (const s of [...SCRIPTS, "_ops-common.sh"]) {
|
||||
const r = spawnSync("bash", ["-n", path.join(BIN, s)], { encoding: "utf8" });
|
||||
assert.equal(r.status, 0, `${s} has a syntax error: ${r.stderr}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("--help exits 0 with a usage banner for every script", () => {
|
||||
for (const s of SCRIPTS) {
|
||||
const r = runScript(s, ["--help"]);
|
||||
assert.equal(r.status, 0, `${s} --help exited ${r.status}: ${r.stderr}`);
|
||||
assert.match(r.stdout, /Usage:/, `${s} --help printed no usage banner`);
|
||||
}
|
||||
});
|
||||
|
||||
it("restore scripts refuse to run without a snapshot id", () => {
|
||||
for (const s of ["restore-data.sh", "restore-policies.sh"]) {
|
||||
const r = runScript(s, []);
|
||||
assert.notEqual(r.status, 0, `${s} should fail without an id`);
|
||||
assert.match(r.stderr, /snapshot id required/, `${s} wrong error: ${r.stderr}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("snapshot → restore-data round-trips, and the TTY guard blocks unattended restores", async () => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-ops-"));
|
||||
try {
|
||||
const Database = (await import("better-sqlite3")).default;
|
||||
const dbPath = path.join(dataDir, "storage.sqlite");
|
||||
let db = new Database(dbPath);
|
||||
db.exec(
|
||||
"CREATE TABLE api_keys (id TEXT PRIMARY KEY, name TEXT);" +
|
||||
"INSERT INTO api_keys VALUES ('k1','orig');"
|
||||
);
|
||||
db.close();
|
||||
|
||||
const env = { DATA_DIR: dataDir };
|
||||
const snap = runScript("snapshot-data.sh", ["--label", "test"], env);
|
||||
assert.equal(snap.status, 0, `snapshot failed: ${snap.stderr}`);
|
||||
const id = snap.stdout.trim();
|
||||
assert.ok(id, "snapshot id not printed on stdout");
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(dataDir, "db_backups", `snapshot_${id}`, "storage.sqlite")),
|
||||
"snapshot dir not created"
|
||||
);
|
||||
|
||||
// Mutate the live DB so a successful restore is observable.
|
||||
db = new Database(dbPath);
|
||||
db.exec("UPDATE api_keys SET name='changed' WHERE id='k1';");
|
||||
db.close();
|
||||
|
||||
// Guard: non-interactive restore WITHOUT --yes must refuse (nothing destroyed).
|
||||
const blocked = runScript("restore-data.sh", [id], env);
|
||||
assert.notEqual(blocked.status, 0, "restore without --yes should be blocked");
|
||||
assert.match(blocked.stderr, /TTY/, `expected TTY guard, got: ${blocked.stderr}`);
|
||||
db = new Database(dbPath);
|
||||
assert.equal(
|
||||
(db.prepare("SELECT name FROM api_keys WHERE id='k1'").get() as { name: string }).name,
|
||||
"changed",
|
||||
"blocked restore must not have changed the DB"
|
||||
);
|
||||
db.close();
|
||||
|
||||
// With --yes it reverts to the snapshot.
|
||||
const ok = runScript("restore-data.sh", [id, "--yes"], env);
|
||||
assert.equal(ok.status, 0, `restore failed: ${ok.stderr}`);
|
||||
db = new Database(dbPath);
|
||||
assert.equal(
|
||||
(db.prepare("SELECT name FROM api_keys WHERE id='k1'").get() as { name: string }).name,
|
||||
"orig",
|
||||
"restore did not revert the row"
|
||||
);
|
||||
db.close();
|
||||
} finally {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
"restore-policies replaces only api_key* tables, preserving other tables",
|
||||
{ skip: hasSqlite3 ? false : "sqlite3 CLI not installed" },
|
||||
async () => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-pol-"));
|
||||
try {
|
||||
const Database = (await import("better-sqlite3")).default;
|
||||
const dbPath = path.join(dataDir, "storage.sqlite");
|
||||
let db = new Database(dbPath);
|
||||
db.exec(
|
||||
"CREATE TABLE api_keys (id TEXT PRIMARY KEY, name TEXT);" +
|
||||
"INSERT INTO api_keys VALUES ('k1','orig');" +
|
||||
"CREATE TABLE sessions (id TEXT PRIMARY KEY);" +
|
||||
"INSERT INTO sessions VALUES ('s-old');"
|
||||
);
|
||||
db.close();
|
||||
|
||||
const env = { DATA_DIR: dataDir };
|
||||
const id = runScript("snapshot-data.sh", [], env).stdout.trim();
|
||||
assert.ok(id, "snapshot id not printed");
|
||||
|
||||
// Change BOTH a policy table and a non-policy table after the snapshot.
|
||||
db = new Database(dbPath);
|
||||
db.exec(
|
||||
"UPDATE api_keys SET name='changed' WHERE id='k1';" +
|
||||
"INSERT INTO sessions VALUES ('s-new');"
|
||||
);
|
||||
db.close();
|
||||
|
||||
const r = runScript("restore-policies.sh", [id, "--yes"], env);
|
||||
assert.equal(r.status, 0, `restore-policies failed: ${r.stderr}`);
|
||||
|
||||
db = new Database(dbPath);
|
||||
// Policy table reverted…
|
||||
assert.equal(
|
||||
(db.prepare("SELECT name FROM api_keys WHERE id='k1'").get() as { name: string }).name,
|
||||
"orig",
|
||||
"api_keys policy was not restored"
|
||||
);
|
||||
// …non-policy table left intact (live usage not rewound).
|
||||
assert.equal(
|
||||
(db.prepare("SELECT COUNT(*) c FROM sessions").get() as { c: number }).c,
|
||||
2,
|
||||
"non-policy table must not be touched by restore-policies"
|
||||
);
|
||||
db.close();
|
||||
} finally {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
50
tests/unit/perplexity-key-validation-models.test.ts
Normal file
50
tests/unit/perplexity-key-validation-models.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Regression test for perplexity API key validation.
|
||||
//
|
||||
// Perplexity deprecated the unversioned `/models` endpoint (returns 404), so
|
||||
// our default validation probe — which derives `<baseUrl>/models` from the
|
||||
// perplexity registry entry via `addModelsSuffix` — would always fail to
|
||||
// confirm a valid key, falling through to the chat-completions probe and
|
||||
// often misclassifying live keys as "Invalid". Inspired by upstream
|
||||
// 9router fix (see commit message); we port it OmniRoute-style by
|
||||
// declaring an explicit `modelsUrl` on the perplexity registry entry.
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("perplexity registry — key validation models endpoint", () => {
|
||||
it("declares a modelsUrl pointing at /v1/models (not the deprecated /models)", async () => {
|
||||
const { getRegistryEntry } = await import(
|
||||
"../../open-sse/config/providerRegistry.ts"
|
||||
);
|
||||
const entry = getRegistryEntry("perplexity");
|
||||
assert.ok(entry, "perplexity must be registered in the execution registry");
|
||||
assert.equal(entry.format, "openai");
|
||||
assert.ok(
|
||||
typeof entry.modelsUrl === "string" && entry.modelsUrl.length > 0,
|
||||
"perplexity registry entry must declare an explicit modelsUrl so key " +
|
||||
"validation does not hit the deprecated <baseUrl>/models endpoint"
|
||||
);
|
||||
assert.equal(
|
||||
entry.modelsUrl,
|
||||
"https://api.perplexity.ai/v1/models",
|
||||
"perplexity modelsUrl must point at the versioned /v1/models endpoint " +
|
||||
"(unversioned /models was deprecated and now returns 404)"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not derive a /models URL that ends in /chat/completions/models", async () => {
|
||||
const { getRegistryEntry } = await import(
|
||||
"../../open-sse/config/providerRegistry.ts"
|
||||
);
|
||||
const entry = getRegistryEntry("perplexity");
|
||||
assert.ok(entry?.modelsUrl, "modelsUrl required");
|
||||
assert.ok(
|
||||
!entry.modelsUrl.includes("/chat/completions"),
|
||||
"modelsUrl must not include /chat/completions"
|
||||
);
|
||||
assert.ok(
|
||||
entry.modelsUrl.endsWith("/v1/models"),
|
||||
"modelsUrl must end with /v1/models"
|
||||
);
|
||||
});
|
||||
});
|
||||
38
tests/unit/pollinations-jsonmode-3981.test.ts
Normal file
38
tests/unit/pollinations-jsonmode-3981.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { PollinationsExecutor } from "../../open-sse/executors/pollinations.ts";
|
||||
|
||||
// #3981: Pollinations rejects (HTTP 400 "messages must contain the word 'json'")
|
||||
// any request with jsonMode=true whose messages don't mention "json". Setting
|
||||
// jsonMode unconditionally broke every normal chat request. jsonMode must only
|
||||
// be enabled when the caller actually requested JSON output.
|
||||
|
||||
test("#3981 transformRequest does NOT force jsonMode for a normal request", () => {
|
||||
const executor = new PollinationsExecutor();
|
||||
const body: any = {
|
||||
messages: [{ role: "user", content: "Hello there" }],
|
||||
};
|
||||
const out = executor.transformRequest("openai", body, true, null);
|
||||
assert.notEqual(out.jsonMode, true);
|
||||
});
|
||||
|
||||
test("#3981 transformRequest enables jsonMode when response_format json_object is requested", () => {
|
||||
const executor = new PollinationsExecutor();
|
||||
const body: any = {
|
||||
messages: [{ role: "user", content: "Return json" }],
|
||||
response_format: { type: "json_object" },
|
||||
};
|
||||
const out = executor.transformRequest("openai", body, true, null);
|
||||
assert.equal(out.jsonMode, true);
|
||||
});
|
||||
|
||||
test("#3981 transformRequest enables jsonMode when response_format json_schema is requested", () => {
|
||||
const executor = new PollinationsExecutor();
|
||||
const body: any = {
|
||||
messages: [{ role: "user", content: "Return structured json" }],
|
||||
response_format: { type: "json_schema", json_schema: { name: "x", schema: {} } },
|
||||
};
|
||||
const out = executor.transformRequest("openai", body, true, null);
|
||||
assert.equal(out.jsonMode, true);
|
||||
});
|
||||
56
tests/unit/pricing-constants-split.test.ts
Normal file
56
tests/unit/pricing-constants-split.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Characterization of the pricing.ts split (god-file decomposition): the host became a barrel that
|
||||
// re-exports DEFAULT_PRICING (now merged from 4 semantic family files that import shared tier consts)
|
||||
// and keeps the 3 helper functions. Pure-data move → behavior identical. Locks: public surface, the
|
||||
// spread-merge integrity, and that lookups/cost math resolve unchanged.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const P = await import("../../src/shared/constants/pricing.ts");
|
||||
|
||||
test("barrel still exports DEFAULT_PRICING + the 3 helpers", () => {
|
||||
for (const name of [
|
||||
"DEFAULT_PRICING",
|
||||
"getPricingForModel",
|
||||
"getDefaultPricing",
|
||||
"calculateCostFromTokens",
|
||||
]) {
|
||||
assert.ok(name in P, `missing export: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("DEFAULT_PRICING merges the 4 family files; families partition all entries", async () => {
|
||||
const merged = Object.keys((P as Record<string, object>).DEFAULT_PRICING).length;
|
||||
const families: [string, string][] = [
|
||||
["oauth-subscriptions", "DEFAULT_PRICING_OAUTH"],
|
||||
["frontier-labs", "DEFAULT_PRICING_FRONTIER"],
|
||||
["inference-hosts", "DEFAULT_PRICING_INFERENCE"],
|
||||
["regional", "DEFAULT_PRICING_REGIONAL"],
|
||||
];
|
||||
let famTotal = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const [file, exportName] of families) {
|
||||
const mod = await import(`../../src/shared/constants/pricing/${file}.ts`);
|
||||
for (const k of Object.keys(mod[exportName])) {
|
||||
assert.ok(!seen.has(k), `pricing key ${k} appears in more than one family`);
|
||||
seen.add(k);
|
||||
famTotal++;
|
||||
}
|
||||
}
|
||||
assert.equal(merged, famTotal, "spread-merge lost/duplicated a top-level key");
|
||||
assert.ok(merged > 25);
|
||||
});
|
||||
|
||||
test("shared tier consts feed the parts (a known model resolves to a shared rate)", () => {
|
||||
const pricing = (P as Record<string, (p: string, m: string) => unknown>).getPricingForModel(
|
||||
"openai",
|
||||
"gpt-4o"
|
||||
);
|
||||
assert.ok(pricing && typeof pricing === "object");
|
||||
assert.equal(typeof (pricing as { input?: number }).input, "number");
|
||||
});
|
||||
|
||||
test("calculateCostFromTokens stays callable and numeric", () => {
|
||||
const fn = (P as Record<string, (...a: unknown[]) => unknown>).calculateCostFromTokens;
|
||||
const out = fn("openai", "gpt-4o", { prompt_tokens: 1000, completion_tokens: 1000 });
|
||||
assert.equal(typeof out, "number");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* #4091 — MCP tool dispatch broken in Claude Code ("No such tool available")
|
||||
* on native Claude OAuth, regression from v3.8.27.
|
||||
* #4091 — third-party tool-name cloak map must survive the request-capture
|
||||
* round-trip (native Claude OAuth; original symptom: "No such tool available").
|
||||
*
|
||||
* Root cause: the native-Claude tool-name cloak stores the per-request
|
||||
* alias→original map as a NON-ENUMERABLE `_toolNameMap` on the request body.
|
||||
@@ -10,8 +10,13 @@
|
||||
* `finalBody = providerRequestCapture.body(transformedBody)` then resolves to
|
||||
* that round-tripped copy, so the response-side un-cloak
|
||||
* (`mergeResponseToolNameMap` → `remapToolNamesInResponse`) sees an empty map
|
||||
* and the cloaked PascalCase name (`McpN8nMcpSearchWorkflows`) streams verbatim
|
||||
* to Claude Code, which rejects it client-side.
|
||||
* and the cloaked PascalCase name streams verbatim to Claude Code, which
|
||||
* rejects it client-side.
|
||||
*
|
||||
* The example tool is a generic third-party name (`search_workflows`), not an
|
||||
* `mcp__…` name: #4861 deliberately exempts the `mcp__` namespace from cloaking
|
||||
* (Anthropic accepts those natively), so only non-`mcp__` third-party tools are
|
||||
* cloaked and exercise this map-preservation path.
|
||||
*
|
||||
* These tests pin the lossy boundary: `createPreparedRequestLogger().body()`
|
||||
* must preserve the cloak map from the real (fallback) transformed body even
|
||||
@@ -40,8 +45,8 @@ function makeCapture() {
|
||||
return { capture: createPreparedRequestLogger(reqLogger, scope), logged };
|
||||
}
|
||||
|
||||
const ALIAS = "McpN8nMcpSearchWorkflows";
|
||||
const ORIGINAL = "mcp__n8n-mcp__search_workflows";
|
||||
const ALIAS = "SearchWorkflows";
|
||||
const ORIGINAL = "search_workflows";
|
||||
|
||||
function makeCloakedClaudeBody(): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
|
||||
@@ -168,7 +168,10 @@ test("anthropic-compatible validation requires a base URL", async () => {
|
||||
assert.match(result.error, /No base URL configured/i);
|
||||
});
|
||||
|
||||
test("anthropic-compatible validation rejects invalid keys from /models", async () => {
|
||||
test("anthropic-compatible validation rejects invalid keys (auth-fail on both /models and /messages)", async () => {
|
||||
// After the 584cf66a port, /models alone is not authoritative — many compatible
|
||||
// proxies 401/403 on /models even with a valid key. To prove the key is bad we
|
||||
// require an auth-shaped failure on POST /v1/messages too.
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
@@ -183,7 +186,10 @@ test("anthropic-compatible validation rejects invalid keys from /models", async
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Invalid API key");
|
||||
assert.deepEqual(calls, ["https://api.example.com/v1/models"]);
|
||||
assert.deepEqual(calls, [
|
||||
"https://api.example.com/v1/models",
|
||||
"https://api.example.com/v1/messages",
|
||||
]);
|
||||
});
|
||||
|
||||
test("anthropic-compatible validation falls back to /messages and treats 400 as auth success", async () => {
|
||||
|
||||
87
tests/unit/providers-constants-split.test.ts
Normal file
87
tests/unit/providers-constants-split.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Characterization of the providers.ts catalog split (god-file decomposition): the host became a
|
||||
// barrel that re-exports 10 data catalogs now living under constants/providers/*, and APIKEY is
|
||||
// merged from 6 semantic family files (apikey/<family>.ts). Locks: the public surface (every catalog
|
||||
// + helpers still exported), the spread-merge integrity (157 APIKEY entries, no loss/dup), and that
|
||||
// load-time Zod validation still runs. Pure-data move → behavior must be identical.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const P = await import("../../src/shared/constants/providers.ts");
|
||||
|
||||
test("barrel still exports every catalog + key helpers", () => {
|
||||
for (const name of [
|
||||
"NOAUTH_PROVIDERS",
|
||||
"OAUTH_PROVIDERS",
|
||||
"WEB_COOKIE_PROVIDERS",
|
||||
"APIKEY_PROVIDERS",
|
||||
"LOCAL_PROVIDERS",
|
||||
"SEARCH_PROVIDERS",
|
||||
"AUDIO_ONLY_PROVIDERS",
|
||||
"UPSTREAM_PROXY_PROVIDERS",
|
||||
"CLOUD_AGENT_PROVIDERS",
|
||||
"SYSTEM_PROVIDERS",
|
||||
"AI_PROVIDERS",
|
||||
"ALIAS_TO_ID",
|
||||
"ID_TO_ALIAS",
|
||||
"getProviderById",
|
||||
"getProviderByAlias",
|
||||
"resolveProviderId",
|
||||
]) {
|
||||
assert.ok(name in P, `missing export: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("APIKEY_PROVIDERS merges the 6 family files into 157 entries (no loss / no dup)", async () => {
|
||||
const keys = Object.keys((P as Record<string, object>).APIKEY_PROVIDERS);
|
||||
assert.equal(keys.length, 157);
|
||||
assert.equal(new Set(keys).size, 157, "duplicate keys after spread-merge");
|
||||
// the merged object's entry-count equals the sum of the 6 semantic family files; families are a
|
||||
// strict partition (every provider in exactly one), so the sum must be exactly 157.
|
||||
const families: [string, string][] = [
|
||||
["gateways", "APIKEY_PROVIDERS_GATEWAYS"],
|
||||
["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"],
|
||||
["inference-hosts", "APIKEY_PROVIDERS_INFERENCE"],
|
||||
["enterprise-cloud", "APIKEY_PROVIDERS_ENTERPRISE"],
|
||||
["regional", "APIKEY_PROVIDERS_REGIONAL"],
|
||||
["specialty-media", "APIKEY_PROVIDERS_SPECIALTY"],
|
||||
];
|
||||
let famTotal = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const [file, exportName] of families) {
|
||||
const mod = await import(`../../src/shared/constants/providers/apikey/${file}.ts`);
|
||||
const famKeys = Object.keys(mod[exportName]);
|
||||
famTotal += famKeys.length;
|
||||
for (const k of famKeys) {
|
||||
assert.ok(!seen.has(k), `provider ${k} appears in more than one family`);
|
||||
seen.add(k);
|
||||
}
|
||||
}
|
||||
assert.equal(famTotal, 157, "families must partition all 157 providers");
|
||||
});
|
||||
|
||||
test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {
|
||||
const ai = (P as Record<string, Record<string, unknown>>).AI_PROVIDERS;
|
||||
assert.ok(Object.keys(ai).length > 200);
|
||||
assert.ok((P as Record<string, (id: string) => unknown>).getProviderById("openai"));
|
||||
assert.ok((P as Record<string, (id: string) => unknown>).getProviderById("claude"));
|
||||
// a moved catalog is reachable through the barrel re-export
|
||||
assert.ok((P as Record<string, Record<string, unknown>>).APIKEY_PROVIDERS["openai"]);
|
||||
});
|
||||
|
||||
test("each extracted data module is importable on its own", async () => {
|
||||
const mods = [
|
||||
["noauth", "NOAUTH_PROVIDERS"],
|
||||
["oauth", "OAUTH_PROVIDERS"],
|
||||
["web-cookie", "WEB_COOKIE_PROVIDERS"],
|
||||
["local", "LOCAL_PROVIDERS"],
|
||||
["search", "SEARCH_PROVIDERS"],
|
||||
["audio", "AUDIO_ONLY_PROVIDERS"],
|
||||
["upstream-proxy", "UPSTREAM_PROXY_PROVIDERS"],
|
||||
["cloud-agent", "CLOUD_AGENT_PROVIDERS"],
|
||||
["system", "SYSTEM_PROVIDERS"],
|
||||
];
|
||||
for (const [file, name] of mods) {
|
||||
const m = await import(`../../src/shared/constants/providers/${file}.ts`);
|
||||
assert.ok(m[name] && typeof m[name] === "object", `${file}.ts must export ${name}`);
|
||||
}
|
||||
});
|
||||
@@ -18,14 +18,19 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("bypassProxyPatch is present in the NVIDIA validation path (#3226 documented exception)", () => {
|
||||
const validation = readFileSync("src/lib/providers/validation.ts", "utf8");
|
||||
// After the validation.ts godfile split (#4921–#4930), bypassProxyPatch moved to the
|
||||
// extracted validation/headers.ts. Assert the bypass lives in the validation LAYER
|
||||
// (validation.ts + its extracted modules), not that it stayed in one file.
|
||||
const validationLayer =
|
||||
readFileSync("src/lib/providers/validation.ts", "utf8") +
|
||||
readFileSync("src/lib/providers/validation/headers.ts", "utf8");
|
||||
assert.ok(
|
||||
validation.includes("bypassProxyPatch"),
|
||||
"expected bypassProxyPatch to be present in validation.ts (the documented NVIDIA exception)"
|
||||
validationLayer.includes("bypassProxyPatch"),
|
||||
"expected bypassProxyPatch in the validation layer (validation/headers.ts) — the documented NVIDIA exception"
|
||||
);
|
||||
assert.ok(
|
||||
validation.includes("directHttpsRequest"),
|
||||
"expected directHttpsRequest helper to be present in validation.ts"
|
||||
validationLayer.includes("directHttpsRequest"),
|
||||
"expected directHttpsRequest helper in the validation layer"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
183
tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts
Normal file
183
tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Port of upstream decolua/9router PR #1360: Cloudflare Workers as proxy relay.
|
||||
//
|
||||
// Architecture mirrors the existing Vercel relay (same x-relay-target /
|
||||
// x-relay-path / x-relay-auth header spec, same SSRF guard inlined into the
|
||||
// worker, same fail-closed missing-relayAuth check). Only the deployment
|
||||
// target changes: instead of POSTing to the Vercel /v13/deployments API,
|
||||
// we PUT a Worker script to Cloudflare's accounts/{accountId}/workers/scripts
|
||||
// API and enable the workers.dev subdomain.
|
||||
//
|
||||
// Coverage:
|
||||
// 1. buildCloudflareWorkerScript(relayAuth) returns an Edge-runtime worker
|
||||
// module whose source enforces the same x-relay-auth check as the
|
||||
// Vercel relay (so a leaked workers.dev URL cannot be used as an
|
||||
// open SSRF proxy by a third party).
|
||||
// 2. proxyFetch's relay short-circuit treats type "cloudflare" the same
|
||||
// way as "vercel" — uses buildVercelRelayHeaders + routes to the
|
||||
// workers.dev origin, never the upstream target.
|
||||
// 3. The proxyDispatcher / DB layer recognise "cloudflare" as a relay
|
||||
// type (extractRelayAuth fires, dispatcher returns the worker URL).
|
||||
|
||||
// --- Install the relay sink BEFORE importing the module under test. ---
|
||||
type FetchCall = { input: unknown; init: RequestInit & { headers?: HeadersInit } };
|
||||
const relayCalls: FetchCall[] = [];
|
||||
const realGlobalFetch = globalThis.fetch;
|
||||
|
||||
const relaySink = (async (input: unknown, init: RequestInit = {}) => {
|
||||
relayCalls.push({ input, init });
|
||||
return Response.json({ via: "cloudflare-relay" });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
globalThis.fetch = relaySink;
|
||||
|
||||
const proxyDispatcher = await import("../../open-sse/utils/proxyDispatcher.ts");
|
||||
const { buildVercelRelayHeaders, proxyConfigToUrl } = proxyDispatcher;
|
||||
const proxyFetchMod = await import("../../open-sse/utils/proxyFetch.ts");
|
||||
const { proxyFetch, runWithProxyContext } = proxyFetchMod;
|
||||
|
||||
const cfDeploy = await import("../../src/lib/proxyRelay/cloudflareWorkerScript.ts");
|
||||
const { buildCloudflareWorkerScript } = cfDeploy;
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = realGlobalFetch;
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
relayCalls.length = 0;
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 1. buildCloudflareWorkerScript — emitted worker source contract
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
test("buildCloudflareWorkerScript embeds the supplied relayAuth literal", () => {
|
||||
const src = buildCloudflareWorkerScript("a-very-specific-secret-token");
|
||||
assert.ok(
|
||||
src.includes('"a-very-specific-secret-token"'),
|
||||
"worker source must embed the relayAuth secret as a string literal"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCloudflareWorkerScript rejects requests without a valid x-relay-auth header", () => {
|
||||
// The worker source must enforce the same auth check as the Vercel relay:
|
||||
// a 401 short-circuit when x-relay-auth does not match the embedded token.
|
||||
// We don't run the worker here — we check the source contains the guard.
|
||||
const src = buildCloudflareWorkerScript("the-secret");
|
||||
assert.ok(
|
||||
/x-relay-auth/.test(src),
|
||||
"worker source must reference the x-relay-auth header"
|
||||
);
|
||||
assert.ok(
|
||||
/401|Unauthorized/.test(src),
|
||||
"worker source must short-circuit unauthorised requests with 401"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCloudflareWorkerScript blocks loopback / RFC1918 / link-local hosts (SSRF guard)", () => {
|
||||
// Mirrors the Vercel relay's inlined SSRF guard. A leaked workers.dev URL
|
||||
// must not be usable to scan internal networks.
|
||||
const src = buildCloudflareWorkerScript("tok");
|
||||
// The guard recognises private CIDRs / loopback by literal substrings in
|
||||
// the inline function. These specific tokens are load-bearing.
|
||||
assert.ok(/127\.0\.0\.1|localhost/.test(src), "blocks loopback hosts");
|
||||
assert.ok(/192\.168|10\.|172/.test(src), "blocks RFC1918 hosts");
|
||||
assert.ok(/169\.254|link-local|fe80/.test(src), "blocks link-local hosts");
|
||||
});
|
||||
|
||||
test("buildCloudflareWorkerScript uses ESM default-export fetch handler (Workers Modules format)", () => {
|
||||
// Cloudflare's PUT /workers/scripts API expects a module-format worker
|
||||
// (main_module = index.js, content-type application/javascript+module).
|
||||
// The handler must be exposed as `export default { fetch }`.
|
||||
const src = buildCloudflareWorkerScript("tok");
|
||||
assert.ok(/export\s+default/.test(src), "must be an ES module (export default)");
|
||||
assert.ok(/fetch\s*\(/.test(src), "must export a fetch handler");
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 2. proxyFetch — cloudflare type takes the relay short-circuit
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const CLOUDFLARE_CTX = {
|
||||
type: "cloudflare" as const,
|
||||
host: "omniroute-relay.acme.workers.dev",
|
||||
relayAuth: "live-cf-secret",
|
||||
};
|
||||
|
||||
test("proxyFetch routes a cloudflare-type context through the relay endpoint with relay headers", async () => {
|
||||
const response = await runWithProxyContext(CLOUDFLARE_CTX, () =>
|
||||
proxyFetch("https://api.anthropic.com/v1/messages?x=1", {
|
||||
method: "POST",
|
||||
headers: { "x-existing": "keep-me" },
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(await response.json(), { via: "cloudflare-relay" });
|
||||
assert.equal(relayCalls.length, 1, "exactly one relay dispatch");
|
||||
const call = relayCalls[0];
|
||||
|
||||
// Rewritten to the workers.dev origin, NOT the upstream target.
|
||||
assert.equal(call.input, "https://omniroute-relay.acme.workers.dev");
|
||||
|
||||
const sentHeaders = new Headers(call.init.headers);
|
||||
assert.equal(sentHeaders.get("x-relay-target"), "https://api.anthropic.com");
|
||||
assert.equal(sentHeaders.get("x-relay-path"), "/v1/messages?x=1");
|
||||
assert.equal(sentHeaders.get("x-relay-auth"), "live-cf-secret");
|
||||
assert.equal(sentHeaders.get("x-existing"), "keep-me");
|
||||
assert.equal(call.init.method, "POST");
|
||||
assert.equal((call.init as { duplex?: string }).duplex, "half");
|
||||
});
|
||||
|
||||
test("proxyFetch throws (without dispatching) when a cloudflare context is missing relayAuth", async () => {
|
||||
await assert.rejects(
|
||||
runWithProxyContext({ type: "cloudflare", host: "x.workers.dev" }, () =>
|
||||
proxyFetch("https://api.anthropic.com/v1/messages", { method: "POST" })
|
||||
),
|
||||
/relay configuration error: missing relayAuth/
|
||||
);
|
||||
assert.equal(relayCalls.length, 0, "no relay dispatch when relayAuth is missing");
|
||||
});
|
||||
|
||||
test("the missing-relayAuth error message does not leak internal [ProxyFetch] diagnostics", async () => {
|
||||
await runWithProxyContext({ type: "cloudflare", host: "x.workers.dev" }, async () => {
|
||||
try {
|
||||
await proxyFetch("https://api.anthropic.com/v1/messages", { method: "POST" });
|
||||
assert.fail("expected the relay branch to throw on missing relayAuth");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
assert.ok(!message.includes("[ProxyFetch]"), "no internal [ProxyFetch] label");
|
||||
assert.ok(!message.includes("at /"), "no stack-trace path leaked");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 3. proxyConfigToUrl — cloudflare type yields the worker https origin
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
test("proxyConfigToUrl returns the cloudflare worker URL (no HTTP-proxy dispatcher needed)", () => {
|
||||
const url = proxyConfigToUrl({
|
||||
type: "cloudflare",
|
||||
host: "omniroute-relay.acme.workers.dev",
|
||||
});
|
||||
assert.equal(url, "https://omniroute-relay.acme.workers.dev");
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 4. buildVercelRelayHeaders is shared (renamed-or-aliased? at minimum still
|
||||
// works for cloudflare — same header spec).
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
test("buildVercelRelayHeaders is the shared relay-header builder used for cloudflare too", () => {
|
||||
const headers = buildVercelRelayHeaders(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"cf-tok"
|
||||
);
|
||||
assert.deepEqual(headers, {
|
||||
"x-relay-target": "https://api.openai.com",
|
||||
"x-relay-path": "/v1/chat/completions",
|
||||
"x-relay-auth": "cf-tok",
|
||||
});
|
||||
});
|
||||
100
tests/unit/proxy-pool-deno-deploy-relay.test.ts
Normal file
100
tests/unit/proxy-pool-deno-deploy-relay.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Port of decolua/9router PR #1437 — Deno Deploy relays.
|
||||
// OmniRoute already supports Vercel-typed relays (#2743 test). Deno Deploy is a
|
||||
// second cloud edge runtime that ships the same x-relay-{target,path,auth}
|
||||
// envelope: from the dispatch path's perspective the two are interchangeable.
|
||||
//
|
||||
// This test pins three contracts the port must respect:
|
||||
// 1. The deny-list of "this is a relay, not a TCP proxy" types is shared
|
||||
// between vercel and deno via a single RELAY_TYPES set in proxyDispatcher
|
||||
// (no copy-pasted `type === "vercel"` branches that would diverge).
|
||||
// 2. proxyConfigToUrl returns the relay URL verbatim for type:"deno" (callers
|
||||
// use buildRelayHeaders() + fetch directly, just like vercel).
|
||||
// 3. proxyFetch routes a type:"deno" context through the relay branch with
|
||||
// the same x-relay-* headers and never touches the TCP dispatcher path
|
||||
// (which would fail loudly against the fake hostname).
|
||||
|
||||
// --- Install the relay sink BEFORE importing the module under test. ---
|
||||
type FetchCall = { input: unknown; init: RequestInit & { headers?: HeadersInit } };
|
||||
const relayCalls: FetchCall[] = [];
|
||||
const realGlobalFetch = globalThis.fetch;
|
||||
|
||||
const relaySink = (async (input: unknown, init: RequestInit = {}) => {
|
||||
relayCalls.push({ input, init });
|
||||
return Response.json({ via: "deno-relay" });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
globalThis.fetch = relaySink;
|
||||
|
||||
const proxyDispatcher = await import("../../open-sse/utils/proxyDispatcher.ts");
|
||||
const { proxyConfigToUrl, RELAY_TYPES } = proxyDispatcher;
|
||||
const proxyFetchMod = await import("../../open-sse/utils/proxyFetch.ts");
|
||||
const { proxyFetch, runWithProxyContext } = proxyFetchMod;
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = realGlobalFetch;
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
relayCalls.length = 0;
|
||||
});
|
||||
|
||||
test("RELAY_TYPES is the single source of truth for relay-typed proxies", () => {
|
||||
// Both edge-relay types route through the x-relay-* envelope, not a TCP
|
||||
// dispatcher. Centralising the set prevents the upstream PR-1437 mistake of
|
||||
// adding `|| type === "deno"` at every callsite (one is bound to be missed).
|
||||
assert.ok(RELAY_TYPES instanceof Set, "RELAY_TYPES must be a Set");
|
||||
assert.ok(RELAY_TYPES.has("vercel"), "vercel is a relay type");
|
||||
assert.ok(RELAY_TYPES.has("deno"), "deno is a relay type");
|
||||
assert.ok(!RELAY_TYPES.has("http"), "http is NOT a relay type");
|
||||
assert.ok(!RELAY_TYPES.has("socks5"), "socks5 is NOT a relay type");
|
||||
});
|
||||
|
||||
test("proxyConfigToUrl returns the relay URL verbatim for type:'deno'", () => {
|
||||
const url = proxyConfigToUrl({
|
||||
type: "deno",
|
||||
host: "my-relay.example-org.deno.net",
|
||||
});
|
||||
assert.equal(url, "https://my-relay.example-org.deno.net");
|
||||
});
|
||||
|
||||
test("proxyFetch routes a deno-type context through the relay endpoint with relay headers", async () => {
|
||||
const DENO_CTX = {
|
||||
type: "deno" as const,
|
||||
host: "my-relay.example-org.deno.net",
|
||||
relayAuth: "deno-relay-secret",
|
||||
};
|
||||
|
||||
const response = await runWithProxyContext(DENO_CTX, () =>
|
||||
proxyFetch("https://api.anthropic.com/v1/messages?x=1", {
|
||||
method: "POST",
|
||||
headers: { "x-existing": "keep-me" },
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(await response.json(), { via: "deno-relay" });
|
||||
assert.equal(relayCalls.length, 1, "exactly one relay dispatch");
|
||||
|
||||
const call = relayCalls[0];
|
||||
assert.equal(call.input, "https://my-relay.example-org.deno.net");
|
||||
|
||||
const sentHeaders = new Headers(call.init.headers);
|
||||
assert.equal(sentHeaders.get("x-relay-target"), "https://api.anthropic.com");
|
||||
assert.equal(sentHeaders.get("x-relay-path"), "/v1/messages?x=1");
|
||||
assert.equal(sentHeaders.get("x-relay-auth"), "deno-relay-secret");
|
||||
assert.equal(sentHeaders.get("x-existing"), "keep-me");
|
||||
assert.equal(call.init.method, "POST");
|
||||
assert.equal((call.init as { duplex?: string }).duplex, "half");
|
||||
});
|
||||
|
||||
test("proxyFetch fails closed when a deno context is missing relayAuth", async () => {
|
||||
await assert.rejects(
|
||||
runWithProxyContext({ type: "deno", host: "relay.example-org.deno.net" }, () =>
|
||||
proxyFetch("https://api.anthropic.com/v1/messages", { method: "POST" })
|
||||
),
|
||||
/relay configuration error: missing relayAuth/i
|
||||
);
|
||||
assert.equal(relayCalls.length, 0, "no relay dispatch when relayAuth is missing");
|
||||
});
|
||||
140
tests/unit/proxy-pool-sync-4878.test.ts
Normal file
140
tests/unit/proxy-pool-sync-4878.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import "../../open-sse/utils/setupPolyfill.ts";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-sync-4878-"));
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const freeProxiesDb = await import("../../src/lib/db/freeProxies.ts");
|
||||
const addToPoolRoute = await import(
|
||||
"../../src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts"
|
||||
);
|
||||
const rateLimiter = await import("../../src/shared/utils/rateLimiter.ts");
|
||||
|
||||
function reset() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function makeReq(): Request {
|
||||
return new Request("http://localhost/test", { method: "POST" });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
reset();
|
||||
addToPoolRoute._resetConnectivityTesterForTests();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_DATA_DIR === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
} else {
|
||||
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
// ── SUB-FIX 1: non-2xx status when the add fails ─────────────────────────────
|
||||
|
||||
test("#4878 add-to-pool returns a non-2xx status when connectivity test fails", async () => {
|
||||
const { id } = await freeProxiesDb.upsertFreeProxy({
|
||||
source: "1proxy",
|
||||
host: "10.9.0.1",
|
||||
port: 8080,
|
||||
type: "http",
|
||||
countryCode: null,
|
||||
qualityScore: null,
|
||||
latencyMs: null,
|
||||
anonymity: null,
|
||||
lastValidated: null,
|
||||
});
|
||||
|
||||
addToPoolRoute._setConnectivityTesterForTests(async () => ({ success: false, latencyMs: 7 }));
|
||||
|
||||
const res = await addToPoolRoute.POST(makeReq(), { params: Promise.resolve({ id }) });
|
||||
assert.ok(
|
||||
!res.ok && res.status >= 400,
|
||||
`expected a non-2xx status on failure, got ${res.status}`
|
||||
);
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, false);
|
||||
assert.ok(body.error);
|
||||
|
||||
// Proxy must NOT have been added to the pool.
|
||||
const fp = await freeProxiesDb.getFreeProxyById(id);
|
||||
assert.ok(!fp?.inPool);
|
||||
});
|
||||
|
||||
test("#4878 add-to-pool still returns 2xx + success:true on the happy path", async () => {
|
||||
const { id } = await freeProxiesDb.upsertFreeProxy({
|
||||
source: "1proxy",
|
||||
host: "10.9.0.2",
|
||||
port: 8080,
|
||||
type: "http",
|
||||
countryCode: null,
|
||||
qualityScore: null,
|
||||
latencyMs: null,
|
||||
anonymity: null,
|
||||
lastValidated: null,
|
||||
});
|
||||
|
||||
addToPoolRoute._setConnectivityTesterForTests(async () => ({
|
||||
success: true,
|
||||
latencyMs: 5,
|
||||
publicIp: "1.2.3.4",
|
||||
}));
|
||||
|
||||
const res = await addToPoolRoute.POST(makeReq(), { params: Promise.resolve({ id }) });
|
||||
assert.ok(res.ok, `expected a 2xx status on success, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
assert.equal(body.success, true);
|
||||
});
|
||||
|
||||
// ── SUB-FIX 2: sync timestamp is persisted and surfaced in stats ─────────────
|
||||
|
||||
test("#4878 recordFreeProxySync persists a lastSyncAt surfaced by getFreeProxyStats", async () => {
|
||||
// No proxies upserted at all → MAX(last_validated) is NULL. The sync
|
||||
// timestamp must still be reported once a sync has run.
|
||||
const before = await freeProxiesDb.getFreeProxyStats();
|
||||
assert.equal(before.lastSyncAt, null);
|
||||
|
||||
const ts = await freeProxiesDb.recordFreeProxySync();
|
||||
assert.ok(typeof ts === "string" && ts.length > 0);
|
||||
|
||||
const after = await freeProxiesDb.getFreeProxyStats();
|
||||
assert.equal(after.lastSyncAt, ts);
|
||||
});
|
||||
|
||||
test("#4878 recordFreeProxySync advances lastSyncAt on a subsequent sync", async () => {
|
||||
const first = await freeProxiesDb.recordFreeProxySync("2020-01-01T00:00:00.000Z");
|
||||
assert.equal(first, "2020-01-01T00:00:00.000Z");
|
||||
const second = await freeProxiesDb.recordFreeProxySync("2030-06-25T12:00:00.000Z");
|
||||
assert.equal(second, "2030-06-25T12:00:00.000Z");
|
||||
|
||||
const stats = await freeProxiesDb.getFreeProxyStats();
|
||||
assert.equal(stats.lastSyncAt, "2030-06-25T12:00:00.000Z");
|
||||
});
|
||||
|
||||
// ── SUB-FIX 3: Redis error-log throttle is state-change-gated ─────────────────
|
||||
|
||||
test("#4878 shouldLogRedisError only logs once per error-state change", () => {
|
||||
const tracker = rateLimiter._createRedisLogThrottleForTests();
|
||||
|
||||
// First error in a run → log.
|
||||
assert.equal(tracker.shouldLog("ECONNREFUSED"), true);
|
||||
// Same error repeated (retry flood) → suppressed.
|
||||
assert.equal(tracker.shouldLog("ECONNREFUSED"), false);
|
||||
assert.equal(tracker.shouldLog("ECONNREFUSED"), false);
|
||||
// A different error message → state changed → log once more.
|
||||
assert.equal(tracker.shouldLog("ETIMEDOUT"), true);
|
||||
assert.equal(tracker.shouldLog("ETIMEDOUT"), false);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user