diff --git a/.env.example b/.env.example index 60b5074e6e70..85637745bae9 100644 --- a/.env.example +++ b/.env.example @@ -486,6 +486,12 @@ NEXT_PUBLIC_CLOUD_URL= # Values: true/loopback (trust loopback proxy peers), private/lan (also trust LAN peers). # OMNIROUTE_TRUST_PROXY= +# Client IP trust for login lockout/audit: additional exact proxy IPs or CIDRs. +# Loopback is trusted by default. Only list controlled proxies that overwrite XFF +# or append their immediate peer; block direct public access to the backend. +# Example for a dedicated Docker bridge: 172.19.0.0/16 +# OMNIROUTE_TRUSTED_PROXY_IPS= + # Public callback URL for asynchronous image/audio jobs (kie.ai, etc.). # Used by: open-sse/utils/kieTask.ts — overrides callbackUrlFromBaseUrl(). # Honor order: KIE_CALLBACK_URL → OMNIROUTE_KIE_CALLBACK_URL → OMNIROUTE_PUBLIC_URL. @@ -504,19 +510,11 @@ NEXT_PUBLIC_CLOUD_URL= # open-sse/services/usage.ts. #OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/ #OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com -#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota -#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit -#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace +# Official OpenCode Go usage endpoint, authenticated with the connection API key. +# Override only for relays or test fixtures. +#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/usage #OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings -# OpenCode Go dashboard quota scraping. Prefer configuring these per connection -# in Dashboard → Providers → OpenCode Go. Env vars are useful for headless -# deployments or shared server defaults. The cookie is sensitive. -#OPENCODE_GO_WORKSPACE_ID=wrk_... -#OMNIROUTE_OPENCODE_GO_WORKSPACE_ID=wrk_... -#OPENCODE_GO_AUTH_COOKIE=auth=... -#OMNIROUTE_OPENCODE_GO_AUTH_COOKIE=auth=... - # OpenCode Go/Zen VPS egress (#5997): on a datacenter VPS, Cloudflare in front of # opencode.ai/zen/go 403s chat requests that lack OpenCode CLI identity headers. # When your clients don't already send them, set this to synthesize the CLI headers @@ -994,7 +992,7 @@ CLAUDE_USER_AGENT="claude-cli/2.1.207 (external, cli)" # stream with a misleading 400 out-of-extra-usage placeholder. Set to true to # forward the original names verbatim (debugging only). # CLAUDE_DISABLE_TOOL_NAME_CLOAK=false -CODEX_USER_AGENT="codex-cli/0.144.1 (Windows 10.0.26200; x64)" +CODEX_USER_AGENT="codex-cli/0.153.4 (Windows 10.0.26200; x64)" GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0" ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0" KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0" @@ -1015,7 +1013,7 @@ CURSOR_USER_AGENT="Cursor/3.4" # Override Codex client version sent in headers independently of the # CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts. -# CODEX_CLIENT_VERSION=0.144.1 +# CODEX_CLIENT_VERSION=0.153.4 # Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits) # from the Codex Responses stream. These frames break the OpenAI SDK's @@ -1173,6 +1171,19 @@ CURSOR_USER_AGENT="Cursor/3.4" # STREAM_READINESS_TIMEOUT_MS=80000 # Time to receive the first non-ping SSE event # STREAM_READINESS_MAX_TIMEOUT_MS=180000 # Cap for adaptive first-event extensions # # (large/tool-heavy/high-reasoning requests). +# ── Early SSE keepalive / stream default (fork.4) ── +# How long a streaming route waits for the handler before committing a 200 SSE +# keepalive. Before the commit an upstream failure still reaches the client as a real +# HTTP status; after it, the status is pinned to 200 and the error can only be sent +# in-band. Must stay under Codex CLI reqwest's ~5s idle-read timeout: values >4500 or +# <0 fall back to the default. Set 2000 to restore the pre-fork.4 behavior. +# OMNIROUTE_KEEPALIVE_THRESHOLD_MS=4000 +# +# Default stream resolution for clients that omit `stream` and send a wildcard Accept +# (python-httpx et al.). "legacy" streams them; "json" routes them through the +# non-streaming path, where upstream 502/429 statuses survive to the client. +# A per-key streamDefaultMode always overrides this. +# OMNIROUTE_STREAM_DEFAULT_MODE=legacy # OMNIROUTE_AGENT_GOAL_POLICY_ENABLED=true # Kill-switch for the /goal heuristic below. # # Set to false to fully disable detection — # # readiness timeouts and stream recovery are @@ -1293,6 +1304,10 @@ APP_LOG_TO_FILE=true # not the cgroup limit, so leaving it to a RAM heuristic can oversize the heap and # get the container OOM-killed. (#2939) # OMNIROUTE_MEMORY_MB=512 +# Maximum concurrent background fact-extraction jobs. Keep at 1 on small VPS instances. +# OMNIROUTE_MEMORY_EXTRACTION_CONCURRENCY=1 +# Maximum queued background fact-extraction jobs. New jobs are dropped when this is full. +# OMNIROUTE_MEMORY_EXTRACTION_QUEUE_MAX=100 # Heap-pressure shed threshold (MB) — chatCore returns 503 when V8 heapUsed exceeds # it, to avoid hard OOM under concurrent large-context load. diff --git a/.github/workflows/fork-image-fenix007.yml b/.github/workflows/fork-image-fenix007.yml new file mode 100644 index 000000000000..0a50c0d7c166 --- /dev/null +++ b/.github/workflows/fork-image-fenix007.yml @@ -0,0 +1,156 @@ +name: Fork Image (fenix007) + +# Builds the stable-branch fork image (v3.8.48 + our patch set) and publishes it +# to GHCR. Tag scheme deliberately avoids the upstream "v*" pattern so the +# inherited release workflows (docker-publish, electron-release) never fire. +# +# Platforms build in separate jobs (like upstream docker-publish.yml): the +# Dockerfile's shared apt cache mounts deadlock when two platforms build +# concurrently in one buildx invocation. +on: + push: + branches: [stable] + tags: + - "*-fork.*" + workflow_dispatch: + +permissions: + contents: read + +env: + IMAGE_NAME: ghcr.io/fenix007/omniroute + +jobs: + build: + name: Build ${{ matrix.arch }} + if: github.repository == 'fenix007/OmniRoute' + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Resolve fork version label + id: fork-version + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + run: | + if [ "$REF_TYPE" = "tag" ]; then + printf 'version=%s\n' "$REF_NAME" >> "$GITHUB_OUTPUT" + else + printf 'version=fork · sha-%s\n' "${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # target mirrors upstream docker-publish.yml's main image (plain tag = + # runner-base; the -web variant is not built here). + - name: Build and push by digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + target: runner-base + platforms: ${{ matrix.platform }} + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + tags: ${{ env.IMAGE_NAME }} + build-args: | + OMNIROUTE_FORK_VERSION=${{ steps.fork-version.outputs.version }} + cache-from: type=gha,scope=fork-${{ matrix.arch }} + cache-to: type=gha,scope=fork-${{ matrix.arch }},mode=max + + - name: Export digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v6 + with: + name: digests-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge manifest and tag + if: github.repository == 'fenix007/OmniRoute' + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v7 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' }} + type=sha,prefix=sha- + type=ref,event=tag + labels: | + org.opencontainers.image.title=omniroute + org.opencontainers.image.description=OmniRoute stable fork (v3.8.48 + fenix007 patch set) + org.opencontainers.image.url=https://github.com/fenix007/OmniRoute + org.opencontainers.image.source=https://github.com/fenix007/OmniRoute + org.opencontainers.image.licenses=MIT + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + tags=() + while IFS= read -r tag; do + [ -n "$tag" ] && tags+=(-t "$tag") + done <<< "$DOCKER_METADATA_OUTPUT_TAGS" + refs=() + for d in *; do + refs+=("${IMAGE_NAME}@sha256:${d}") + done + docker buildx imagetools create "${tags[@]}" "${refs[@]}" + + - name: Inspect image + run: docker buildx imagetools inspect "${IMAGE_NAME}:${{ steps.meta.outputs.version }}" diff --git a/Dockerfile b/Dockerfile index adf3cf5e910d..2de811748d67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,9 @@ # ── Common base with runtime deps ────────────────────────────────────────── -FROM node:24-trixie-slim AS base +# Pinned to the node version upstream built the working v3.8.48 images with +# (NODE_VERSION=24.18.0 in diegosouzapw/omniroute:3.8.48). Newer node:24 minors +# SIGABRT better-sqlite3 12.11.1 (this base's lockfile) inside the Next.js +# build worker on both amd64 and arm64. +FROM node:24.18.0-trixie-slim AS base WORKDIR /app # `apt-get upgrade` pulls the security-patched versions of the Debian (trixie) @@ -98,6 +102,11 @@ ENV OMNIROUTE_MITM_STUB=1 ARG OMNIROUTE_BUILD_MEMORY_MB=4096 ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}" +# Tagged fork builds expose their release identifier in the client bundle so the +# admin sidebar can distinguish the frozen upstream base from the deployed patch set. +ARG OMNIROUTE_FORK_VERSION="" +ENV NEXT_PUBLIC_OMNIROUTE_FORK_VERSION=${OMNIROUTE_FORK_VERSION} + COPY . ./ RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \ mkdir -p /app/data && npm run build diff --git a/FORK.md b/FORK.md new file mode 100644 index 000000000000..8e6926841dca --- /dev/null +++ b/FORK.md @@ -0,0 +1,533 @@ +# fenix007/OmniRoute — stable fork + +Frozen distribution: upstream `v3.8.48` plus a small owned patch set. Upstream +releases after 3.8.48 are unstable for our deployment (ai-router), so prod runs +this fork instead of `diegosouzapw/omniroute`. + +## Branches and tags + +- `stable` — the branch prod images are built from: `v3.8.48` + patch set. +- `main` — tracks upstream for PR work; never deployed. +- Release tags: `3.8.48-fork.N` (deliberately **not** `v*`, so inherited + upstream workflows — docker-publish, electron-release — never fire). + +## Patch set on top of v3.8.48 + +| Commit | Upstream PR | What | +| ------------------------------------------------------------------------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| fix(executors): disable parallel tools for Codex Responses Lite | #7171 (merged) | cherry-pick | +| feat(providers): add xAI OAuth PKCE provider | #7399 (merged) | cherry-pick | +| fix(api): enforce image generation API key auth | #8306 (merged) | cherry-pick | +| fix(affinity): evict the sticky session pin on a combo per-model timeout | #10016 (merged) | cherry-pick, adapted (3.8.48 lacks the 3.8.50 dispatch seam context) | +| fix(api): retry Codex image generation by account | #8307 (open) | port of the functional subset; the model-access matcher also unwraps raw JSON error strings (3.8.48 codex handler has no sanitizeImageProviderError) | +| fix(codex): strip sampling params from native Responses passthrough | #12585 (open) | adapted: delete `temperature` and `top_p` at the existing 3.8.48 passthrough boundary without importing the newer shared param-rule refactor | +| fix(chat): reject non-object message entries with HTTP 400 | #12644 (open) | adapted at the existing 3.8.48 early guard so malformed chat payloads cannot reach translators and surface as HTTP 500 | +| ci(fork): fork-image-fenix007.yml | — | fork-only | + +Owned OpenCode Go protocol/catalog alignment (fork.16): + +| Change | Upstream issue | What | +| ------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| fix(opencode): route every live Go model through its documented API | #12196 | fork-only: sync all 34 live model ids; route GPT/Grok/Muse through Responses and MiniMax/Qwen through Messages; keep DeepSeek on Chat; add provider-scoped family fallbacks for models discovered before the next sync | + +Production on the pre-fix `sha-35359a0` image returned HTTP 500 for +`gpt-5.6-luna` and rejected `grok-4.6` as unsupported for `oa-compat`. The +executor defaulted every model missing from the static registry to +`/chat/completions`, while the upstream requires `/responses` for both models. +The shared fallback lives in `getModelTargetFormat`, so chatCore translates the +request body to the same format that `OpencodeExecutor` uses to choose the URL. + +The `v3.8.50` registry was not copied wholesale: it puts DeepSeek V4 Pro and +Flash on Responses, but the current official OpenCode Go endpoint table assigns +both to Chat Completions. Baseline production tests also reached both through +the Chat endpoint and stopped only at the workspace's explicit China-hosting +opt-in gate. + +Sources: `open-sse/config/providers/registry/opencode/go/index.ts`, +`open-sse/config/providerModels.ts`. Tests: +`tests/unit/opencode-go-catalog-alignment.test.ts`, +`tests/unit/opencode-executor.test.ts`, `tests/unit/chatcore-target-format.test.ts`. + +OpenCode Go quota API backport (fork.17): + +| Change | Upstream PR | What | +| --------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| feat(quota): use the official OpenCode Go usage API | #12124 | adapted: query `/zen/go/v1/usage` with the connection API key, parse rolling/weekly/monthly windows, bind the cache to the key, and remove obsolete Z.AI/dashboard scraping | + +The frozen 3.8.48 dispatcher sent OpenCode Go keys to a Z.AI quota endpoint. A valid +chat/models key therefore rendered an `Unknown` quota card asking for a workspace ID +and auth cookie. OpenCode now exposes an API-key-authenticated usage endpoint, so the +provider limits page no longer needs browser credentials. The fetcher remains +fail-open and keeps the existing 60-second successful-response cache. + +Sources: `open-sse/services/opencodeQuotaFetcher.ts`, +`open-sse/services/usage.ts`, `open-sse/services/opencodeOllamaUsage.ts`. Tests: +`tests/unit/opencode-quota-fetcher.test.ts`, +`tests/unit/opencode-go-usage.test.ts`, and the provider modal UI suites. + +Tier-1 upstream fixes ported from release/v3.8.50 (fork.2): + +| Commit | Upstream PR | What | +| ----------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------- | +| fix(codex): surface capacity errors embedded in 200-OK SSE streams | #6710 | cherry-pick (trivial conflicts) | +| fix(codex): non-stream chat 502 "Response body is already used" | #7526 | cherry-pick clean | +| fix(codex): check content-type before touching response.body in peek | #7570 | cherry-pick clean | +| fix(sse): preserve parallel_tool_calls for GPT-5.6 under Codex Responses Lite | #7957 | cherry-pick clean | +| fix(sse): bound Codex SSE peek read with per-read timeout | #8043 | cherry-pick clean | +| fix(images): refresh OAuth and rotate accounts on 401 | #9231 | adapted: merged with our #8307 port (fallback wrapper first, codex model-access retry after) | +| fix(settings,auth): debugMode false + no rotation on model-unsupported 400 | #10525 | cherry-pick + carried the settings.ts debugMode default flip the squash relied on | + +Tier-2 combo resilience + Tier-3 quota/limiter fixes ported from release/v3.8.50 (fork.3): + +| Commit | Upstream PR | What | +| ------------------------------------------------------------------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| fix(combo): recover provider circuit breaker from HALF_OPEN on success | #9207 | adapted: kept this base's `getProviderConnections` import and file-size baseline | +| fix(rate-limit): patch Bottleneck doExpire capacity leak | #9328 | cherry-pick (baseline only) | +| fix(combo): network errors must not trip provider circuit breaker | #9342 | adapted: the breaker guard lives inline in chat.ts here (upstream extracted chatPredicates.ts later) | +| fix(rate-limit): separate queue wait from execution timeout | #9164 | partial: classifier + combo leg only; the connection-cooldown leg rides on a later 3.8.50 predicate refactor | +| fix(combo): clear LKGP pin when its target fails | #10034 | adapted: took `clearLKGP` only, plus a matching `invalidateCachedLKGP` in readCache (that helper landed in the #10137 commit) | +| fix(combo): isolate session stickiness by combo | #10137 | adapted: namespaced BOTH call sites (this base still has the main path in combo.ts, not targetResolution.ts) | +| fix(combo): make failoverBeforeRetry actually skip the same-model retry | #10217 | cherry-pick clean | +| fix(account-fallback): classify 'insufficient credits' as credits-exhausted | #10116 | cherry-pick clean | +| fix(sse): clear quota_exhausted cooldown when the real window recovers | #10534 | cherry-pick clean | +| fix(resilience): retry Codex pre-output transport failures on the same account | #9708/#10792 | adapted: replaced the 3.8.50 `connectionFilterStatus` map with a transport-cooled id set, dropped the managed-lease call and the lease/occupancy locals this base has no use for | + +Owned fixes for upstream error-status masking (fork.4): + +A provider failure was reaching clients as HTTP 200 with the error buried in the body, +so every client whose retry logic keys on 429/5xx treated a transient overload as a +terminal success. Two independent layers caused it, and both are fixed here. + +| Change | Upstream PR | What | +| ------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| fix(sse): widen the keepalive commit window so failures keep their status | — | fork-only: `resolveKeepaliveThreshold` default 2000 → 4000 ms (`OMNIROUTE_KEEPALIVE_THRESHOLD_MS`, capped at 4500 to stay under reqwest's ~5s idle read). Handler failures inside the window keep a real status. | +| fix(sse): carry the upstream status in the in-band error frame | — | fork-only: `annotateErrorFrameStatus` adds `error.status` when the stream already committed to 200 — the only signal a client has left to tell a retryable 5xx/429 from a terminal 4xx. | +| fix(sse): emit Anthropic-shaped error frames on /v1/messages | — | fork-only: `buildErrorFrameData` + `errorFrameFormat: "anthropic"`. Claude clients branch on `error.type` and ignore an OpenAI-shaped frame, so a failure previously read as a stream that simply ended. | +| feat(compat): global stream default for machine clients | — | fork-only: `OMNIROUTE_STREAM_DEFAULT_MODE=json` applies the existing per-key `streamDefaultMode` fallback deployment-wide, so wildcard-Accept clients that omit `stream` take the status-preserving JSON path. | + +Sources: `open-sse/utils/keepaliveThreshold.ts`, `open-sse/utils/earlyStreamKeepalive.ts`, +`open-sse/utils/aiSdkCompat.ts`, `src/app/api/v1/messages/route.ts`. Tests: +`tests/unit/keepalive-threshold.test.ts`, `tests/unit/earlyStreamKeepalive.test.ts`, +`tests/unit/resolve-stream-flag.test.ts`. + +Gaps in that patch set, found while verifying fork.5 on production (fork.6): + +| Change | Upstream PR | What | +| ------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| fix(sse): emit Responses-shaped error frames on /v1/responses | — | fork-only: `errorFrameFormat: "responses"` + `errorFrameEventName`. fork.4 fixed the payload shape but not the event name, and Responses API clients (Codex CLI) terminate a stream on `response.failed` and ignore `event: error` whatever it carries — the fork.4 frame was inert. | +| fix(compat): let the deployment-wide stream default reach real keys | — | fork-only: `normalizeStreamDefaultMode` no longer treats a stored `legacy` as an explicit opt-out. Every key row is created with `stream_default_mode = "legacy"`, so `OMNIROUTE_STREAM_DEFAULT_MODE=json` was dead code on any real database — verified inert on production. | + +The stream-default change is a deliberate semantics trade: with the env var set, a key +cannot pin itself back to SSE through its stored mode. Per-request opt-in (`stream: true` +or `Accept: text/event-stream`) still outranks the deployment default, which is how a +caller that needs SSE asks for it. + +Sources: `open-sse/utils/earlyStreamKeepalive.ts`, `open-sse/utils/aiSdkCompat.ts`, +`src/app/api/v1/responses/route.ts`. Tests: `tests/unit/earlyStreamKeepalive.test.ts`, +`tests/unit/resolve-stream-flag.test.ts`. + +Owned fix for false client-disconnect accounting (fork.7): + +| Change | Upstream PR | What | +| --------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| fix(sse): detect the terminal marker in oversized client chunks | — | fork-only: `noteClientChunk` matched against a 4096-char trailing window, so any terminal SSE event whose own payload exceeded it lost the marker. `clientTerminalSeen` stayed false and a fully delivered stream was finalized as 499 `request_signal_aborted` with 0 tokens. | + +Production evidence (24h to 2026-08-21T13:49Z): 171 of 176 non-2xx call-logs were 499. +Matching call-logs 1:1 against nginx over a 2h window on `/v1/responses` (211 vs 212 +entries), 68 of 72 of those 499s have exactly one same-path nginx line within ±1s and it +is HTTP 200 with a full body; nginx saw a single genuine client abort. Reproduced twice +on prod (combo and direct model, 44–51 chunks, 360–418 KB, read through +`response.completed`, then hard close → logged 499 / 38.9s / 0 tokens); small +single-chunk streams logged 200. The window threshold is exact: a completed-event +payload of 4000 B was detected, 4100 B was not. + +Beyond the misreported success rate, the same defect zeroed `tokens.in`/`tokens.out` on +the largest ~5% of requests, which is what ai-router's `lib/omniroute-call-log-sync.ts` +aggregates into quota and `/limits`. + +Sources: `open-sse/utils/streamHandler.ts`. Tests: `tests/unit/stream-handler.test.ts`, +`tests/unit/stream-handler-terminal-marker.test.ts`. + +Owned fix for `response_format` dropped on the Kiro route (fork.8): + +| Change | Upstream PR | What | +| ----------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| fix(kiro): carry response_format into the Kiro prompt | — | fork-only: `buildKiroPayload` never read `body.response_format`. Kiro has no Structured Output parameter, so the field was dropped with no trace and the model answered with invented property names — every strict-schema caller failed closed. | + +`openai-to-claude.ts` and `DefaultExecutor.applyJsonSchemaFallback()` already +describe the schema in the prompt when the upstream cannot enforce it. Kiro had +neither: `KiroExecutor extends BaseExecutor`, not `DefaultExecutor`, so the +fallback was unreachable, and `register(FORMATS.OPENAI, FORMATS.KIRO, …)` is the +single request path into the provider. The contract is now appended to the final +user message as a `` (Kiro has no `system` role) — at the tail, +where output-format instructions hold best. + +Measured on `kr/claude-haiku-4.5` and `kr/claude-sonnet-4.5` against a 19-field +strict schema before the fix: both answered with their own field names +(`match_score`, `concerns`, `interview_topics`), while a 2-field schema passed +only because those two names appeared verbatim in the prompt. + +This is best-effort JSON, not constrained decoding, and it cannot become one on +this route: Kiro's only schema-carrying channel is a tool schema, and +`kiroSanitizer` must strip `additionalProperties`, `$ref`/`$defs` and `anyOf` +(Kiro 400s `Improperly formed request` otherwise) — exactly the keywords a +strict schema is built from. `tool_choice` is not forwarded either, so a tool +call cannot be forced. Callers that need enforcement must route to a provider +with native Structured Output. + +Sources: `open-sse/translator/request/openai-to-kiro/responseFormat.ts`, +`open-sse/translator/request/openai-to-kiro.ts`, +`open-sse/translator/request/openai-to-kiro/messageHelpers.ts` (gained +`wrapSystemReminder`, moved out of the frozen translator file). +Tests: `tests/unit/kiro-response-format.test.ts`. + +Gap in that fix, found verifying fork.8 on production (fork.9): + +| Change | Upstream PR | What | +| ------------------------------------------------------------------------ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| fix(kiro): strip the JSON code fence when response_format asked for JSON | — | fork-only: Claude on Kiro honours the prompt-level schema but wraps the object in a ```json fence anyway, so `JSON.parse(message.content)` still failed for a conforming client. | + +fork.8 was probed live against a strict schema whose property names never appear +in the prompt. Both `kr/claude-haiku-4.5` and `kr/claude-sonnet-4.5` returned +exactly `residual_gap_score` / `evidence_kind` / `rationale` — the contract +works — and both fenced the object, on every attempt, despite the instruction +forbidding it. Kiro has no `response_format` to enforce, so the fence is +unwrappable only on our side. + +`KiroExecutor.execute` now pipes its SSE through `stripJsonFenceFromSse` when the +request carries `response_format: json_schema | json_object`. The stripper +rewrites `delta.content` only — tool-call and reasoning deltas pass through — and +holds back a trailing run of whitespace/backticks so a closing fence can be told +from real content, releasing it as its own chunk just before the finishing chunk. +A normal chat answer keeps its code blocks: without a JSON `response_format` the +wrapper is never installed. + +Sources: `open-sse/executors/kiro/jsonFence.ts`, `open-sse/executors/kiro.ts`. +Tests: `tests/unit/kiro-json-fence.test.ts`. + +Gap in that fix, found verifying fork.9 on production (fork.10): + +| Change | Upstream PR | What | +| ------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| fix(kiro): detect the JSON contract on the translated payload | — | fork-only: fork.9 asked `body.response_format`, but chatCore hands the executor the already-built Kiro payload — the condition was never true and the stripper never installed. Verified inert on production. | + +`KiroExecutor.execute` receives `body` **after** the registry translated it +through `buildKiroPayload`, which is why the executor already reads its +`thinkingExpected` hint out of `transformedBody.conversationState`. fork.9's +`wantsJsonOnlyContent(body)` looked for `response_format` on that payload, found +nothing, and left every response fenced — the live probe on fork.9 returned the +same fenced JSON as fork.8. + +The translator now leaves `KIRO_JSON_CONTRACT_MARKER` +(`json`) at the head of the contract it +appends, and `kiroPayloadWantsJsonOnly` looks for it in the built prompt — the +same channel `enabled` already travels on. The +tests now drive that decision with real `buildKiroPayload` output instead of a +synthetic OpenAI body, which is the gap that let fork.9 ship green. + +Sources: `open-sse/executors/kiro/jsonFence.ts`, `open-sse/executors/kiro.ts`, +`open-sse/translator/request/openai-to-kiro/responseFormat.ts`. +Tests: `tests/unit/kiro-json-fence.test.ts`. + +Selected release/v3.8.50 and open-PR fixes adapted for our traffic (fork.11): + +| Change | Upstream PR | What | +| ------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| fix(combo): derive Responses stickiness from `input` | #7277 | adapted in both standard and round-robin dispatch while retaining the fork.3 `combo.name` namespace | +| fix(resilience): treat client aborts as local lifecycle failures | #7908/#8011 | adapted across raw-string/DOM abort status mapping, connection cooldown and provider breakers; fork.3 network/queue exclusions and HALF_OPEN recovery remain intact | +| fix(oauth): keep same-email Codex accounts distinct | #7825 | shared matcher across every OAuth completion path; workspace identity wins, otherwise a non-empty `chatgptUserId` must match, while an explicit `connectionId` remains authoritative | +| fix(codex): use the catalog's real maximum context window | #11179 | prefer `max_context_window` and advertise GPT-5.6 Codex as 872K input/context plus 128K output | +| fix(sse): fail completed streams that emitted no model content | #8732 | safety adaptation: a bounded frame gate suppresses the successful terminal and emits the protocol-native error first for OpenAI, Responses and Claude; split and 512-KiB completed events preserve fork.7 behavior | +| fix(sse): do not fabricate plaintext for encrypted reasoning | #8807 | removes placeholder summary mutation while preserving `encrypted_content` in output-item and completed snapshots | +| fix(kiro): preserve prose interleaved through parallel tool results | #8931 | defers assistant prose until the result batch is complete, without changing the fork.8–10 JSON contract/marker path | + +Sources: `open-sse/services/combo/sessionStickiness.ts`, +`src/shared/utils/circuitBreaker.ts`, `src/lib/oauth/connectionPersistence.ts`, +`src/app/api/providers/[id]/models/discovery/codex.ts`, +`open-sse/utils/streamTerminalGuard.ts`, `open-sse/utils/stream.ts`, and +`open-sse/translator/request/openai-to-kiro/toolResultGrouping.ts`. + +The #8732 upstream implementation was not copied literally: it appended an error +after `finish_reason: stop`, `[DONE]`, `response.completed`, or `message_stop`. +Clients are allowed to stop at those markers, so fork.11 withholds only the +terminal frame and guarantees that an empty stream's error is the first terminal +event. Complete keepalives and lifecycle frames continue streaming immediately; +the unfinished-frame buffer is capped at 1 MiB and fails open above the cap. + +Quality gates (fork.11): new Kiro and SSE logic was extracted into leaf modules, +so the frozen translator and the 800-line production-file cap remain green without +changing the file-size baseline. The nightly mutation test list also records six +fork tests that had drifted out of `stryker.conf.json`, plus the new abort suite. + +Quality gates (fork.8): `check:file-size` froze `openai-to-kiro.ts` at 912 lines, +so the helper lives in a sibling module (`openai-to-kiro/responseFormat.ts`, +alongside `messageHelpers.ts` / `adaptiveThinking.ts`) and the translator ends at +907 — no baseline was touched. Extracting it also dropped `complexity` to 2059 +against a 2060 baseline. `check:test-file-size` was red from fork.7 +(`stream-handler.test.ts` at 873 > cap 800); its six terminal-marker tests moved +to `stream-handler-terminal-marker.test.ts`, leaving 665 + 237 with the same 27 +tests passing. + +Quality gates (fork.5): + +`check:complexity-ratchets` was red on `stable` from fork.3 onward. Measured per tag: +the cyclomatic baseline (2056) was already 2 behind on pristine `v3.8.48`, and the +ported patches added +1 in fork.2 and +1 in fork.3; the cognitive baseline (890) matched +the base and grew +3 / +2 across the same two tags. fork.3 rebaselined `check:file-size` +for these ports but not the two complexity ratchets, so both were rebaselined to the +measured 2060 / 895 with the per-tag evidence recorded in +`config/quality/complexity-baseline.json` and `config/quality/quality-baseline.json`. +The fork.4 patch set itself added zero violations. + +Evaluated and deliberately NOT ported: + +| Upstream PR | Why not | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| #10744 (fail over streaming responses terminated with empty completions) | needs the `openAi` SSE-lifecycle tracker from the #7285 rework; validateQuality.ts has diverged by ~357/73 lines, so porting it would drag a large slice of 3.8.50 | + +## Upstream review — 2026-09-08 + +Reviewed against `origin/stable` at `214e687391ce6b3ebb105f93f0027b2f790e9612`. +Queried all 694 upstream PRs updated since 2026-09-01 and all 278 open PRs with +`gh`; shortlisted production-scope changes for diff, test, discussion and exact +head review. These counts describe discovery, not 694 full code reviews. + +Adapted [#12863](https://github.com/diegosouzapw/OmniRoute/pull/12863) +(OPEN at the reviewed upstream head): +subtract cached prompt tokens from Gemini-to-Claude `input_tokens`, clamped to +zero. Gemini's `promptTokenCount` includes the cached prefix; Anthropic's +`input_tokens` excludes `cache_read_input_tokens`. Previously a 100-token prompt +with 90 cached tokens was reported as 100 + 90 and counted as 190 by +`getLoggedInputTokens`. The translated usage now reports 10 + 90 = 100. + +- Reviewed PR head: `5002c412819bb439ba08df2ed84717459e1c852d`. +- Functional upstream commit: `d59b0d8dbb543d5bd65c3e8402107d36b92369b2`. +- Adaptation after Claude Code CLI / Opus 5 review: keep cache-inclusive + `prompt_tokens` and `completion_tokens` in the translator's internal state; + subtract cache only when building the outgoing Anthropic `message_delta`. + A literal one-line port made full-cache/zero-output usage fall back to an + estimate and made stream metadata and response-body totals omit cached input. + Keeping normalized internal usage also preserves the full input for pricing. + Reject non-finite, negative and nonnumeric cache counters. No shared stream, + pricing or metadata implementation changes are required. +- Risk: limited to Gemini-to-Anthropic usage accounting. OpenAI prompt totals, + output/thinking tokens, terminal events and wrapped stream handling retain + their existing contracts. No equivalent correction existed at the reviewed + stable head. +- Sources: `open-sse/translator/response/gemini-to-claude.ts` and + `src/lib/usage/tokenAccounting.ts`. +- Regression coverage: cached/uncached/full-cache prompts, invalid cache counters, + cache counts above prompt counts and retained usage across wrapped chunks. + Seven new `createSSEStream` tests exercise fragmented Gemini/Antigravity input, + emitted Anthropic usage, single terminal events, completion callback totals, + SSE metadata and pricing, including full-cache prompts with zero or missing + output counts. Five of these tests failed against the literal one-line port + and pass with this adaptation. All 83 tests across seven related suites pass. + `npm run typecheck:core`, scoped ESLint with the existing suppressions, + Prettier and `git diff --check` pass. + +The initial port was held because `check:file-size` found three production and +six test violations already present at the reviewed stable head. The user then +authorized resolving this debt. The size baseline and limits are unchanged: + +- Extract response-header helpers from `chatHelpers.ts`, WebSocket header filtering + from `codex.ts`, and error-status mappings from `streamHandler.ts` into leaf + modules. Existing exports and all six moved function bodies are preserved. +- Extract response/capability/log fixtures from the chat-pipeline, chatcore + translation, combo-routing and translator-helper test suites. +- Split translator replay and pure WebDAV scenarios into separately discovered + unit suites. All 290 original test cases remain. Their bodies are unchanged + except for the pre-existing combo fallback fixture failure described below. +- The old combo fallback test returned a Claude response to the second OpenAI + request and expected only two HTTP calls. Update its mock to fail OpenAI by URL + and assert the current bounded same-account retry from #9708: two OpenAI calls + with the same credential, followed by Claude. Production retry policy is unchanged. +- Replace eleven moved explicit `any` annotations with typed/unknown fixture + fields and reduce the corresponding ESLint suppression count from 261 to 250. + +The size gate now passes. The combined final run passes all 477 tests across +21 related files. Scoped ESLint, core typecheck, formatting, the explicit-any +budget, docs-sync and tracked-artifact checks pass. The circular-dependency scan +remains advisory; the extracted production modules have no imports and add no cycles. + +Other reviewed candidates (statuses and heads as observed on 2026-09-08): + +| PR | Status / head | Decision for v3.8.48 | +| -------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [#12585](https://github.com/diegosouzapw/OmniRoute/pull/12585) | OPEN `3df5ac987679` | Already adapted: Codex passthrough deletes both sampling parameters before returning. Do not import the later shared-rule refactor. | +| [#12644](https://github.com/diegosouzapw/OmniRoute/pull/12644) | OPEN `faa87c818e23` | Already adapted: malformed message entries are rejected before dispatch; recorded above. | +| [#12827](https://github.com/diegosouzapw/OmniRoute/pull/12827) | OPEN `cc46f41ecf5c` | Equivalent client version 0.153.4 already present in `open-sse/config/codexClient.ts` and identity tests through `290a8e722`. | +| [#12899](https://github.com/diegosouzapw/OmniRoute/pull/12899) | MERGED `cb924f14838b` | Rejected as written: the combo-name allowlist returns true before checking `disableNonPublicModels`. The new tests do not combine those settings; do not weaken key policy. | +| [#12935](https://github.com/diegosouzapw/OmniRoute/pull/12935) | OPEN `bb3d539829b2` | Not ported: accepts an empty sentinel with nonstandard Claude `stop_reason: length` for an Ollama/Qwen probe outside production scope; preserve empty-output failure handling. | +| [#12997](https://github.com/diegosouzapw/OmniRoute/pull/12997) | OPEN `1bfbf8636364` | Not applicable literally: the affected `imageCombo.ts` success-unwrapping path is absent on this base. Do not introduce later image-combo infrastructure. | +| [#12982](https://github.com/diegosouzapw/OmniRoute/pull/12982) | OPEN `a747a81555b4` | Deferred: depends on the later image-combo path in its tests, which also assert the bare-array response rejected by #12997. Needs direct-handler/Codex-path tests on this base before an independent empty-payload adaptation. | +| [#12785](https://github.com/diegosouzapw/OmniRoute/pull/12785) | OPEN `af7f3351362b` | Deferred: missing stop-sequence forwarding is relevant, but the literal nullish fallback can emit `[undefined]` for `stop_sequences: null`. Its three input cases do not establish null/empty/precedence behavior on this base. | +| [#11809](https://github.com/diegosouzapw/OmniRoute/pull/11809) | MERGED `cc4e038bc7e3` | Deferred: changes Kiro account-state policy for a missing-profile 403; upstream classifier tests alone do not verify old-base profile discovery, cooldown and account recovery together. | +| [#12391](https://github.com/diegosouzapw/OmniRoute/pull/12391) | OPEN `895b007c809d` | Deferred: cleanup runs only on abort; successful attempts that clear their timeout still leave listeners. The test aborts every attempt and does not cover that remaining lifecycle case. | +| [#12737](https://github.com/diegosouzapw/OmniRoute/pull/12737) | OPEN `8dade0d1c7c7` | Deferred: useful premature-WebSocket-close failure, but it relies on the newer Codex public-error allowlist. Adapt and test the old error boundary before porting; do not copy the file-size rebaseline. | +| [#12818](https://github.com/diegosouzapw/OmniRoute/pull/12818) | OPEN `911a18c05afa` | Deferred: `dispatchPrelude.ts` is absent; old inline pinned dispatch has a different fallback set including 524. Needs old-path credential and pin-cleanup regression coverage. | +| [#12964](https://github.com/diegosouzapw/OmniRoute/pull/12964) | OPEN `0b7be09f44fe` | Deferred: `credentialPatterns.ts` is absent; changing a regex in the newer sanitizer does not port its security boundary to this base. A separate old-boundary redaction adaptation is required. | + +Publication policy for this maintenance: `fork-image-fenix007.yml` publishes on +pushes to `stable`, while the inherited CI/quality workflows target other branches +or manual dispatch. Maintenance commits use `[skip ci]` to suppress push-triggered +image publication after local validation, as documented by +[GitHub Actions](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs). +No workflow configuration, release tag or deployment is part of this change. + +## Upstream review — 2026-09-09 + +Reviewed from current `origin/stable` at +`e5ed4b10b4f7f88a8dfa8f71650a4ecbee8732e9` after fetching origin and upstream. +`gh` discovery returned 632 PRs updated since 2026-09-02 and 302 open PRs. +These are discovery counts, not full code-review counts. Production candidates +were compared with the previous review, exact PR heads, diffs, available tests, +bodies and discussion/review records. No discussion or submitted review existed +on #13059 at inspection time; its checks did not establish full upstream CI success. + +Adapted [#13059](https://github.com/diegosouzapw/OmniRoute/pull/13059) +(OPEN; reviewed head and functional commit +`a596e5543b5ee43bbe49544d0cc7d0466ecf1e0b`): preserve user-defined property names +when traversing Gemini tool and response schemas. + +- Applicability: this base lacks the newer `injectObjectType` / `ensureArrayItems` + phases implicated by the upstream reproduction. However, its existing + `convertConstToEnum` and `normalizeAdditionalProperties` visitors already treat + property maps as schema nodes. A required argument named `const` becomes an + invalid `enum` / `type` entry in the property map; an argument named + `additionalProperties` disappears, potentially replaced with a required + placeholder `reason`. No equivalent fix existed in the reviewed stable code. +- Minimal adaptation: apply the PR's schema-map-aware traversal to the existing + sanitizer phases only. Keep their constraint transformations, numeric enum + handling, required-field cleanup, local references and empty-object placeholders. + Do not introduce newer sanitizer phases or change the unsupported-keyword set. +- Risk is confined to request schema normalization shared by Gemini and the + existing Cloud Code path, including OpenAI JSON response schemas. Authentication, + account rotation, quota policy and stream/error handling are unchanged. +- New regression suite: `tests/unit/gemini-schema-keyword-properties.test.ts`. + Twelve tests cover keyword arguments, nested objects/arrays, actual constraints, + composition/reference expansion, placeholders, input immutability, + `buildGeminiTools`, and OpenAI/Anthropic request translation with both stream flags. + The original code reproduced argument corruption before the fix; upstream's + type-injection tests alone would not reproduce this older-base defect. +- Validation: 115/115 tests across ten relevant schema/helper/request-translator + suites; scoped ESLint with existing suppressions, `npm run typecheck:core`, + production/test file-size gates, Prettier and `git diff --check` pass. + No live provider call or full application build is claimed. + +Other candidate decisions (heads/statuses observed during this run): + +| PR | Status / head | Decision for v3.8.48 | +| -------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [#12931](https://github.com/diegosouzapw/OmniRoute/pull/12931) | OPEN `c0b5f751c56c` | Its `injectObjectType` phase is absent. The broader existing-phase collision is addressed through #13059 above. | +| [#12872](https://github.com/diegosouzapw/OmniRoute/pull/12872) | OPEN `27aef816af5d` | Defer: stripping tuple keywords relies on `ensureArrayItems`, absent here; copying only the two keys can still emit invalid bare arrays. | +| [#13073](https://github.com/diegosouzapw/OmniRoute/pull/13073) | OPEN `02c30a85e906` | Defer: changes concurrent Codex cooldown persistence and requires later `providers/codexAccountState.ts`, absent here. The draft itself identifies pending concurrency/current-release review. | +| [#13069](https://github.com/diegosouzapw/OmniRoute/pull/13069) | OPEN `05cc73e3978e` | Not applicable literally: repairs the later `runNonStreamingProviderLeg` split, which this fork does not contain. Do not import that pipeline refactor. | +| [#13072](https://github.com/diegosouzapw/OmniRoute/pull/13072) | OPEN `dc3e47702d7d` | The old semantic passthrough already calls `extractSystemRoleMessages`; the newer mid-conversation/directive-only path is absent. No matching regression on this base. | +| [#13050](https://github.com/diegosouzapw/OmniRoute/pull/13050) | OPEN `6cac8d211036` | Its web-search branch forces `stream:false`; this base's fallback preparation does not contain that forced-non-streaming branch. Do not import the newer pipeline or file-size rebaseline. | +| [#13052](https://github.com/diegosouzapw/OmniRoute/pull/13052) | OPEN `2a3771cb52a7` | Defer: security-sensitive combo/alias authority redesign also carries #12899 semantics previously rejected here. Requires independent old-base policy/cache integration coverage. | +| [#13047](https://github.com/diegosouzapw/OmniRoute/pull/13047) | OPEN `39bce7bb099c` | Defer: changes the meaning of configured `minContentLength` for tool-only responses; helper tests do not verify interaction with this fork's empty-output/fallback guards. | +| [#13038](https://github.com/diegosouzapw/OmniRoute/pull/13038) | OPEN `d1e5c39e5aca` | Defer: diagnostics span later dispatch gates/runtime units and response-header seams. A separate adaptation must establish attempt-count semantics on the old combo loop. | +| [#13008](https://github.com/diegosouzapw/OmniRoute/pull/13008) | OPEN `c5bb91c4b07f` | Defer: missing quota error-text forwarding exists, but this changes account-wide quota poisoning for API-key providers. Verify the old dispatch/cache recovery path before a separate port; the upstream call-site test inspects source rather than exercising dispatch. | +| [#13083](https://github.com/diegosouzapw/OmniRoute/pull/13083) | OPEN `28488e54e20d` | Not applicable: the affected custom-node `dailyQuotaResetTimezone` schema is absent on this base. | +| [#12964](https://github.com/diegosouzapw/OmniRoute/pull/12964) | MERGED `0b7be09f44fe` | Status advanced to merged; functional head unchanged. The previous old-sanitizer-boundary deferral still applies (`credentialPatterns.ts` absent). | +| [#12863](https://github.com/diegosouzapw/OmniRoute/pull/12863) | OPEN `5002c412819b` | Already adapted by `e5ed4b10b`; no new functional head. | + +The open heads for #12585, #12644, #12827, #12785, #12737, #12818, +#12391, #12982, #12997 and #12935 still match the previous review, so its +ported/equivalent/deferred decisions stand. Unrelated UI, Electron, i18n, A2A, +MCP and out-of-scope provider work was excluded from the port. + +This maintenance uses the `[skip ci]` publication policy documented above. +No tag, publish dispatch or deployment is part of this change. + +## Releasing an image + +```bash +git switch stable +# ...commit the change... +git tag -a 3.8.48-fork.N -m "..." && git push origin stable 3.8.48-fork.N +``` + +CI (`.github/workflows/fork-image-fenix007.yml`) builds `runner-base` for +linux/amd64 + linux/arm64 (separate jobs — the Dockerfile's shared apt cache +mounts deadlock in a single multi-platform build) and publishes +`ghcr.io/fenix007/omniroute:3.8.48-fork.N` (+ `stable`, `sha-*`). + +ai-router side: set `OMNIROUTE_IMAGE=ghcr.io/fenix007/omniroute` and +`OMNIROUTE_VERSION=3.8.48-fork.N` in `.env`, then `make omniroute-update`. + +Each release pulls another ~1.8 GB image and the VPS keeps every one of them. +Pulling fork.8 filled `/` to 100% on 217.65.79.232, and OmniRoute crash-looped on +`Unable to inspect existing database at /app/data/storage.sqlite: disk I/O error` +until the old images were removed. Check `df -h /` before updating and drop the +tags older than the current one plus its rollback: + +```bash +docker rmi ghcr.io/fenix007/omniroute:3.8.48-fork. +``` + +## Taking a newer upstream release later + +```bash +git fetch upstream --tags +git switch -c stable-vX.Y.Z vX.Y.Z +git cherry-pick +# run: npx tsc -p tsconfig.typecheck-core.json + the patch-set tests +# then point `stable` at the result and tag X.Y.Z-fork.1 +``` + +Keep patches atomic and keep sending them upstream as PRs — every merged PR +shrinks the set to carry. + +## Upstream review — 2026-09-11 + +Reviewed against `origin/stable` at `a39b546fa0f88e4ee6eac4fe918ac24b8fcdabaa` after +fetching both remotes. Discovery queried 591 upstream PRs updated from +2026-09-04 through 2026-09-11 and 394 open PRs; production-scope candidates +were inspected with their exact head, diff, discussion, tests and checks. + +Adapted [#13110](https://github.com/diegosouzapw/OmniRoute/pull/13110) +(MERGED; reviewed head `3bc27642825e8477d6db32db0dbc41abb0f30167`, functional +commit `ef69a48c3d372ca3b166ff67e41f8fa5c1e9c184`): add `contentSchema` and +`unevaluatedItems` to the existing Claude JSON-schema sanitizer's schema-valued +slots. On this base a depth-truncation marker in either slot reached Anthropic as +a string and caused a native Messages schema HTTP 400. The minimal two-key +adaptation recursively replaces only recognized placeholders with `{}`; valid +boolean schemas, `required`, `additionalProperties`, descriptions and caller +input retain their values. + +Adapted [#13274](https://github.com/diegosouzapw/OmniRoute/pull/13274) +(OPEN; reviewed head `ad7d4bf9e804e9163daf85315390716f26243b54`, functional +commit `e0ba8167994807e7d4aee7767b5faf583a488ffe`): redact every normalized +`*api-key` header spelling in both request-pipeline capture and persisted log +payloads. Gemini's `x-goog-api-key`, Azure's `api-key`, and ElevenLabs' +`xi-api-key` were otherwise retained in logs. Rate-limit headers remain readable. + +[#13304](https://github.com/diegosouzapw/OmniRoute/pull/13304) (OPEN +`2b3e63a470d0bc35f608819f9b4480c9fc7b587f`) was reviewed for Responses-to-Chat +web-search replay. This frozen base already silently ignores the metadata item +and preserves the paired `function_call_output`; a regression test records that +equivalent behavior, so no production patch is needed. + +Other production-scope candidates were not ported: + +| PR | Status / head | Decision for v3.8.48 | +| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [#13285](https://github.com/diegosouzapw/OmniRoute/pull/13285) | OPEN `5eede28fa29d` | Defer: the existing stream terminal guard changes empty-stream ordering; the later Claude lifecycle integration must be adapted and tested as one unit. | +| [#13278](https://github.com/diegosouzapw/OmniRoute/pull/13278) | OPEN `1430ded91039` | Not applicable: the later native Responses passthrough/context-handoff seam is absent. | +| [#13193](https://github.com/diegosouzapw/OmniRoute/pull/13193) | OPEN `36a2c23e4758` | Defer: Kiro host fallback must be validated against this fork's Builder-ID auth and response-format adaptations. | +| [#13174](https://github.com/diegosouzapw/OmniRoute/pull/13174) | OPEN `475b75faa373` | Defer: changes Kiro historic tool-call semantics; preserve current parallel-tool grouping until old-base transcript coverage exists. | +| [#13171](https://github.com/diegosouzapw/OmniRoute/pull/13171) | OPEN `994a0258a9a2` | Not applicable: this base lacks the later JSON-to-SSE timeout wrapper seam. | +| [#13141](https://github.com/diegosouzapw/OmniRoute/pull/13141) | OPEN `993203c9d49d` | Already equivalent: current `cooldownUntilMs` normalizes numeric epochs before combo eligibility. | +| [#13128](https://github.com/diegosouzapw/OmniRoute/pull/13128) | OPEN `3b5729a2e813` | Defer: custom-tool choice depends on later Responses tool pipeline behavior. | +| [#13059](https://github.com/diegosouzapw/OmniRoute/pull/13059) | OPEN `8e98197411d6` | Already adapted by `fc71f2da9`; no new functional head. | +| [#13221](https://github.com/diegosouzapw/OmniRoute/pull/13221) | OPEN `f6569f387481` | Defer: quota-aware candidate expansion is a later routing architecture. | +| [#13217](https://github.com/diegosouzapw/OmniRoute/pull/13217) | OPEN `462732bfab99` | Defer: alters combo key persistence and live-key filtering together. | +| [#13224](https://github.com/diegosouzapw/OmniRoute/pull/13224) | OPEN `65c05ca1cb79` | Already equivalent to the current Codex catalog/plan-tier refresh patch. | +| [#13069](https://github.com/diegosouzapw/OmniRoute/pull/13069) | OPEN `84649e928895` | Not applicable: `runNonStreamingProviderLeg` is absent. | +| [#13050](https://github.com/diegosouzapw/OmniRoute/pull/13050) | OPEN `5f2f2fab076b` | Still not applicable: no forced-non-streaming web-search branch exists here. | +| [#12818](https://github.com/diegosouzapw/OmniRoute/pull/12818) | OPEN `5bc9f2cb71c7` | Existing deferral stands: 401 pin fallthrough needs old combo pin/failure coverage. | + +All unrelated UI, Electron, i18n, A2A, MCP and out-of-scope provider PRs were +excluded. No tag, publish dispatch or deployment is part of this maintenance. diff --git a/changelog.d/features/7399-xai-oauth-pkce.md b/changelog.d/features/7399-xai-oauth-pkce.md new file mode 100644 index 000000000000..7057b62489b2 --- /dev/null +++ b/changelog.d/features/7399-xai-oauth-pkce.md @@ -0,0 +1 @@ +- **feat(providers):** Add a first-class xAI OAuth PKCE provider for `api.x.ai` models, including Grok 4.5 and refresh-token rotation ([#7399](https://github.com/diegosouzapw/OmniRoute/pull/7399)) — thanks @fenix007 diff --git a/changelog.d/fixes/10136-combo-scoped-session-stickiness.md b/changelog.d/fixes/10136-combo-scoped-session-stickiness.md new file mode 100644 index 000000000000..6cab4a2a7bfd --- /dev/null +++ b/changelog.d/fixes/10136-combo-scoped-session-stickiness.md @@ -0,0 +1 @@ +- **fix(combo):** scope session-stickiness bindings to their owning Combo so identical first messages cannot carry a successful target into another priority chain and bypass its configured order (fixes #10136) diff --git a/changelog.d/fixes/6710-codex-200-sse-capacity-error.md b/changelog.d/fixes/6710-codex-200-sse-capacity-error.md new file mode 100644 index 000000000000..e84653d7bd56 --- /dev/null +++ b/changelog.d/fixes/6710-codex-200-sse-capacity-error.md @@ -0,0 +1 @@ +- **fix(codex):** detect "model at capacity"/overloaded errors embedded in a 200-OK SSE stream and surface them as a real error so account fallback rotates, instead of passing them through as a successful response. (thanks @ryanngit) diff --git a/changelog.d/fixes/7171-codex-responses-lite-parallel-tools.md b/changelog.d/fixes/7171-codex-responses-lite-parallel-tools.md new file mode 100644 index 000000000000..328f60441aaf --- /dev/null +++ b/changelog.d/fixes/7171-codex-responses-lite-parallel-tools.md @@ -0,0 +1 @@ +- **fix(executors):** Codex Responses Lite requests force serial tool calls required by the upstream API ([#7171](https://github.com/diegosouzapw/OmniRoute/pull/7171)) — thanks @fenix007 diff --git a/changelog.d/fixes/7536-codex-nonstream-peek-body-double-read.md b/changelog.d/fixes/7536-codex-nonstream-peek-body-double-read.md new file mode 100644 index 000000000000..9f156104fe0e --- /dev/null +++ b/changelog.d/fixes/7536-codex-nonstream-peek-body-double-read.md @@ -0,0 +1 @@ +- fix(codex): non-stream Codex (ChatGPT-account) chat no longer 502s with "Response body is already used". `peekCodexSseTransientError` now checks the content-type before touching `response.body`: on the wreq-js TLS-fingerprint transport the Response is backed by a native body handle and merely accessing `.body` disturbs it, so the empty-content-type non-stream response was being consumed by the peek guard and then re-read by `readNonStreamingResponseBody`. Streaming was unaffected. Validated live on the VPS (`codex/gpt-5.5` + `codex/gpt-5.6-terra` non-stream now return 200) (#7536) diff --git a/changelog.d/fixes/7821-gpt56-codex-responses-lite.md b/changelog.d/fixes/7821-gpt56-codex-responses-lite.md new file mode 100644 index 000000000000..ca852493aa23 --- /dev/null +++ b/changelog.d/fixes/7821-gpt56-codex-responses-lite.md @@ -0,0 +1 @@ +- fix(sse): preserve parallel_tool_calls for GPT-5.6 ultra/max delegation under Codex Responses Lite (#7821) diff --git a/changelog.d/fixes/8020-codex-peek-read-timeout.md b/changelog.d/fixes/8020-codex-peek-read-timeout.md new file mode 100644 index 000000000000..a9b28e327c5e --- /dev/null +++ b/changelog.d/fixes/8020-codex-peek-read-timeout.md @@ -0,0 +1 @@ +- fix(sse): bound the Codex SSE peek/passthrough body reads with a per-read timeout so a silently stalled upstream body settles in FETCH_BODY_TIMEOUT_MS instead of hanging ~15min and returning a generic 502 (#8020) diff --git a/changelog.d/fixes/8307-codex-image-account-fallback.md b/changelog.d/fixes/8307-codex-image-account-fallback.md new file mode 100644 index 000000000000..b4582c414ca9 --- /dev/null +++ b/changelog.d/fixes/8307-codex-image-account-fallback.md @@ -0,0 +1 @@ +- **fix(api):** Codex image generation now skips incompatible accounts, retries one eligible sibling account for model-access errors, and records the selected account in call logs ([#8307](https://github.com/diegosouzapw/OmniRoute/pull/8307)) — thanks @fenix007 diff --git a/changelog.d/fixes/9708-codex-same-account-retry.md b/changelog.d/fixes/9708-codex-same-account-retry.md new file mode 100644 index 000000000000..2ccb7fb97fc5 --- /dev/null +++ b/changelog.d/fixes/9708-codex-same-account-retry.md @@ -0,0 +1 @@ +- **fix(resilience):** retry a retryable Codex pre-output 502/503/504/507 once on the same account (2–3s jitter) before cooling the connection, and stop translating that mixed pool into an all-accounts quota `429` ([#9708](https://github.com/diegosouzapw/OmniRoute/issues/9708)) diff --git a/changelog.d/fixes/codex-incomplete-finish-reason.md b/changelog.d/fixes/codex-incomplete-finish-reason.md new file mode 100644 index 000000000000..11b390a4e26b --- /dev/null +++ b/changelog.d/fixes/codex-incomplete-finish-reason.md @@ -0,0 +1 @@ +- **fix(codex):** translate buffered Responses API `response.incomplete` terminals into Chat Completions `finish_reason: "length"` instead of reporting truncated output as a successful `stop` diff --git a/changelog.d/fixes/codex-nonstream-body-double-read.md b/changelog.d/fixes/codex-nonstream-body-double-read.md new file mode 100644 index 000000000000..143eb47edb6e --- /dev/null +++ b/changelog.d/fixes/codex-nonstream-body-double-read.md @@ -0,0 +1 @@ +- Fixed every non-streaming Codex (ChatGPT-account) chat request failing with `[502]: Response body is already used (reset after 1m)`: `peekCodexSseTransientError` re-acquired a reader on the upstream `response.body` after `releaseLock()` to continue draining it, which throws on undici. It now keeps the single reader it already holds. The thrown TypeError was also being mis-classified as a 60s rate limit (cooldown + circuit breaker) — that misfire disappears with the double-read fixed. diff --git a/changelog.d/fixes/fork-advisory-context-routing.md b/changelog.d/fixes/fork-advisory-context-routing.md new file mode 100644 index 000000000000..b5524617c142 --- /dev/null +++ b/changelog.d/fixes/fork-advisory-context-routing.md @@ -0,0 +1 @@ +- **fix(combo):** Keep models with stale or unknown context metadata available for fallback, prefer estimated-fitting targets, and enforce input and total window limits separately when ordering. Honor base and effort-specific context overrides while preserving hard capability checks. diff --git a/changelog.d/fixes/no-cache-request-dedup.md b/changelog.d/fixes/no-cache-request-dedup.md new file mode 100644 index 000000000000..6ef7fd8f4b6d --- /dev/null +++ b/changelog.d/fixes/no-cache-request-dedup.md @@ -0,0 +1 @@ +- **fix(sse):** make `X-OmniRoute-No-Cache: true` bypass concurrent request deduplication and prevent in-flight HTTP errors or empty bodies from being reused by waiting requests diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index 361666034ac6..507ad8c33b00 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 2056, + "count": 2060, + "_rebaseline_2026_08_20_fork_ports_inherited_drift": "2056->2060 (+4). Inherited drift from the fork's ported patch set, measured per tag with one ESLint walk each (scripts/check/complexityEslintReport.mjs): 3.8.48-fork.1 (= pristine v3.8.48 plus docker/CI-only commits) already measures 2058, so +2 was inherited from upstream before this fork existed — check:complexity does not run on the upstream fast-path PR->release, the same family as every rebaseline below. 3.8.48-fork.2 adds +1 (peekCodexSseTransientError in open-sse/executors/codex.ts, from the #7570/#8043 Codex SSE-peek ports) and 3.8.48-fork.3 adds +1 (recordProviderFailure in open-sse/services/accountFallback.ts, from the #10116 credits-exhausted port). Trust-but-verify: 3.8.48-fork.4 measures 2060 as well — the error-status patch set added zero violations (diff of the per-violation lists between the fork.3 and fork.4 trees is empty). No fork-owned code is over threshold; the flagged functions all come from upstream commits that upstream rebaselined on its own branch.", "_rebaseline_2026_07_10_v3847_merge_burst": "2053->2054 (+1). Drift herdado do merge burst do dia em release/v3.8.47 (campanha /implement-prs: ~36 PRs mergeados — órfãos, features do dono, ports). O check:complexity NÃO roda no fast-path PR->release, então o ramo acumulou o +1 sem rebaselinar (mesma família de todos os rebaselines abaixo). Trust-but-verify: medido 2054 no tip da release pós-burst; a única função flagada nova é pré-existente (getResolvedModelCapabilities em modelCapabilities.ts, já >teto antes de #6714). Nenhum PR órfão/feature introduz violação NOVA — os fixes deste ciclo são complexity-net-zero. Rebaseline aprovado pelo dono (2026-07-10) para destravar o FQG dos ~7 órfãos verdes-exceto-complexity. Tighten via --update next cycle.", "_rebaseline_2026_07_10_gcf_v3_2": "2054->2056 (+2). PR feat/headroom-gcf-v3.2-nested-flattening: own growth from re-vendoring the GCF (Headroom) codec to spec v3.2 (nested flattening). The 2 new over-threshold functions are the v3.2 `>`-path flatten/unflatten walk in the vendored generic-profile encode/decode paths (open-sse/services/compression/engines/headroom/gcf/{generic,decode_generic}.ts). This is imported third-party code kept byte-faithful to upstream gcf-typescript, not extractable without diverging from the vendored source; local measures 2055 on the merged tree; frozen at 2056 = the base's CI-observed 2054 + this PR's 2 new functions, matching the documented local-vs-CI off-by-one convention (see _rebaseline_2026_07_02_v3844_ci_observed) so the GitHub runner stays green. Round-trip guarded by tests/unit/compression/headroom-smartcrusher.test.ts (deep-nested case). Structural shrink belongs upstream in gcf, not here.", "_rebaseline_2026_07_08_6556_inherited_drift": "2052->2053 (+1). PR #6556 (omniglyph engine): drift herdado do merge burst da base (a catraca nao roda no fast-path PR->release, mesmo padrao dos rebaselines v3.8.44/46). Trust-but-verify: o proprio codigo do PR e complexity-net-zero — as 2 violacoes que ele introduzia (runCompressionAsync complexity 17 apos o branch do modo omniglyph; OmniglyphContextPageClient 161 linhas) foram CORRIGIDAS por extracao real (engines/omniglyphSingleMode.ts + split do componente em section components), medido: 2055->2053 local; base pura origin/release/v3.8.47 mede 2053 identico. Tighten via --update next cycle.", diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index dcea92f2a989..f7471a8a47a8 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -179,11 +179,6 @@ "count": 5 } }, - "src/app/(dashboard)/dashboard/HomePageClient.tsx": { - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": { "no-restricted-syntax": { "count": 3 @@ -1039,7 +1034,7 @@ }, "tests/unit/combo-routing-engine.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 261 + "count": 250 } }, "tests/unit/combo-same-provider-cascade.test.ts": { @@ -1662,11 +1657,6 @@ "count": 6 } }, - "tests/unit/opencode-quota-fetcher.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "tests/unit/openrouter-vision-sync-4264.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 4 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 6cc3bba432cd..4d594ee54bdf 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,9 @@ { + "_rebaseline_2026_08_09_9342_network_error_guard": "PR #9342 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2008 (+30 = the isQueueTimeout short-circuit plus a per-provider network-error dedup window in recordProviderFailure, keeping one VPN blip from the same provider's combo targets counting once per target). Covered by tests/unit/breaker-network-error-guard.test.ts. (chat.ts stays base-red: upstream tip is already 1918 > frozen 1904, this PR only adds +12 on top; not re-bumped per the no-inherit-ratchet rule.)", + "_rebaseline_2026_08_09_9296_adobe_media_capabilities": "PR #9296 (artickc, fix/adobe-firefly-model-capabilities) own growth: src/app/api/v1/models/catalog.ts 1590->1597 (+7). The image and video catalog serializers now expose the already-normalized Adobe Firefly discovery capability data (media_capabilities, plus the existing video modality/size fields) at their only response-emission chokepoints. The discovery parser and capability normalization remain in open-sse/services/adobeFireflyModels.ts; extracting these seven serialization fields would obscure the catalog contract. Covered by tests/unit/adobe-firefly.test.ts and tests/unit/image-upscale.test.ts.", + "_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.", + "_rebaseline_2026_08_09_9328_bottleneck_doexpire_rate_limit": "PR #9328 own growth during the 2026-08-09 rebase: open-sse/services/rateLimitManager.ts 1167->1221 (the Bottleneck doExpire capacity-leak monkey-patch plus its diagnostic branch and deterministic assertions live at the manager's existing wiring; monolithic patch, not extractable). Covered by tests/unit/bottleneck-doexpire-patch.test.ts.", + "_rebaseline_2026_08_09_9207_breaker_halfopen_recovery": "PR #9207 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2020 (+42 = recordProviderSuccess now also transitions the provider circuit breaker from HALF_OPEN to CLOSED when a request succeeds, so the breaker is not stuck half-open after repeated failures; the transition and its reset wiring grow the existing provider-success path, not extractable). Covered by tests/unit/provider-breaker-halfopen-recovery.test.ts.", "_rebaseline_2026_07_07_v3846_proxy_insecure_random": "PR #6580 (v3.8.46 post-release closing fix): proxies.ts 1173->1177 (+4) — o fix de segurança CodeQL #698/#699 troca Math.random por crypto.randomInt no random rotation strategy (#6365) e adiciona 4 linhas de comentário explicando por que (a seleção flui para credenciais do proxy). Crescimento irreducivel do proprio fix; frozen so encolhe daqui.", "_rebaseline_2026_07_07_v3846_release_close": "Release v3.8.46 Phase 0 (generate-release) — drift de ciclo absorvido no fechamento (fast-gates PR->release nao rodam check:file-size). PROD god-files crescidos por merges do ciclo (nao meus; DECOMPOR idealmente, debt #3501): proxies.ts 1060->1173, chat.ts 1681->1751, ApiManagerPageClient.tsx 3058->3120, ProxyRegistryManager.tsx 1125->1437 (feature de proxy). TEST frozen: models-catalog-route.test.ts 1600->1605 (+5 do fix#2 do captain, #6408 catalogo cache), vscode-token-routes.test.ts 1212->1285 (cycle drift + os asserts effort_tiers/supportsThinking do #6241 alinhados no release-PR-CI base-red), que adiciona o import + 2 chamadas do hook __resetCatalogBuilderRunsForTest existente no setup (harness, sem asserts). Shrink estrutural rastreado no roadmap #3501.", "_rebaseline_2026_07_04_v3844_release_close": "Release v3.8.44 Phase 0 (generate-release): drift de ciclo absorvido no fechamento, medido no tip 415d159c8 (fast-gates PR->release nao rodam check:file-size). oauth/[provider]/[action]/route.ts 924->960 (#6054 zed keychain-import 400 gracioso; PR #6158 aberto extrai o guard e restaura o freeze — quando mergear, o frozen so encolhe), providerLimits.ts 982->998 (#6139 TOCTOU quota recovery + #6128), chat.ts 1647->1662 (#6057 per-request Auto-Combo X-OmniRoute-Mode/Budget + #6097), auth.ts 2405->2426 (#6139 + #6090 quota preflight lockouts + #5943 codex session affinity). Crescimento irreducivel em chokepoints existentes, coberto por testes por-PR; shrink estrutural rastreado no roadmap #3501.", @@ -167,7 +172,7 @@ "open-sse/mcp-server/server.ts": 1555, "open-sse/mcp-server/tools/advancedTools.ts": 1120, "_rebaseline_2026_06_27_5193_antigravity_basered": "Base-red (pre-existing release drift, fast-gate PR->release skips check:file-size): accountFallback.ts 1773->1777 and src/app/api/providers/[id]/test/route.ts 924->940 were already over their frozen caps on release/v3.8.39 independent of any antigravity change. Owner chose to rebaseline (keep the documented issue-reference comments #1846/#1449/#347 etc.) rather than accept the contributor comment-stripping in #5200/#5198. Reverted #5200 to restore the comments; bumped these two frozen caps to the actual base sizes. No logic change.", - "open-sse/services/accountFallback.ts": 1864, + "open-sse/services/accountFallback.ts": 1953, "open-sse/services/batchProcessor.ts": 915, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, @@ -250,10 +255,11 @@ "src/lib/resilience/settings.ts": 841, "src/lib/tailscaleTunnel.ts": 1202, "src/lib/usage/callLogs.ts": 997, - "src/lib/usage/providerLimits.ts": 1000, + "src/lib/usage/providerLimits.ts": 1060, "src/lib/usage/usageHistory.ts": 988, "_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.", - "src/shared/components/OAuthModal.tsx": 993, + "_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.", + "src/shared/components/OAuthModal.tsx": 998, "src/shared/components/RequestLoggerV2.tsx": 1629, "src/shared/components/analytics/charts.tsx": 1558, "_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).", @@ -265,9 +271,9 @@ "src/shared/validation/schemas.ts": 2523, "_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.", - "src/sse/handlers/chat.ts": 1796, + "src/sse/handlers/chat.ts": 1853, "src/sse/handlers/chatHelpers.ts": 876, - "src/sse/services/auth.ts": 2458, + "src/sse/services/auth.ts": 2511, "open-sse/executors/default.ts": 877, "open-sse/translator/request/openai-responses.ts": 902, "open-sse/executors/kiro.ts": 944, @@ -279,8 +285,9 @@ "src/lib/providers/validation/webProvidersA.ts": 809, "src/lib/tokenHealthCheck.ts": 832, "_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge.", - "src/lib/localDb.ts": 805, - "_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable)." + "src/lib/localDb.ts": 806, + "_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable).", + "_rebaseline_2026_08_20_fork3_tier23_ports": "fork.3 rebaseline (fenix007/OmniRoute stable): irreducible growth from the tier-2/tier-3 upstream fixes ported onto the frozen v3.8.48 base — #9207 recordProviderSuccess, #9342 network/queue breaker guard, #10034 clearLKGP, #10534 real-window quota recovery, #9708/#10792 same-account transport retry (chat.ts guard + auth.ts transport-cooled set). Each lands inside an existing chokepoint the upstream commits also grew; upstream rebaselined these same files per commit, this fork absorbs the drift in one entry. Covered by the ported per-fix test suites. Frozen at the measured values; only shrinks from here." }, "testCap": 800, "testFrozen": { @@ -293,7 +300,7 @@ "tests/integration/chat-pipeline.test.ts": 1601, "tests/integration/chatcore-compression-integration.test.ts": 1111, "tests/integration/skills-pipeline.test.ts": 918, - "tests/unit/account-fallback-service.test.ts": 1572, + "tests/unit/account-fallback-service.test.ts": 1982, "tests/unit/arena-elo-sync.test.ts": 830, "tests/unit/batch_api.test.ts": 1324, "tests/unit/cc-compatible-provider.test.ts": 1217, @@ -301,11 +308,11 @@ "tests/unit/chatcore-translation-paths.test.ts": 2810, "tests/unit/chatgpt-web.test.ts": 3170, "tests/unit/combo-config.test.ts": 881, - "tests/unit/combo-routing-engine.test.ts": 3209, + "tests/unit/combo-routing-engine.test.ts": 3321, "tests/unit/combo-strategy-fallbacks.test.ts": 880, "tests/unit/db-core-init.test.ts": 877, "tests/unit/db-migration-runner.test.ts": 1491, - "tests/unit/db-settings-crud.test.ts": 941, + "tests/unit/db-settings-crud.test.ts": 957, "tests/unit/deepseek-web.test.ts": 1092, "tests/unit/executor-antigravity.test.ts": 942, "tests/unit/executor-codex.test.ts": 1340, @@ -335,7 +342,8 @@ "tests/unit/usage-service-hardening.test.ts": 1503, "tests/unit/vscode-token-routes.test.ts": 1285, "tests/unit/web-cookie-providers-new.test.ts": 890, - "_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail: translator-openai-responses-req.test.ts 1172->1195 (+23 = #6807 reasoning-summary-for-effort-only regression tests). Frozen only shrinks." + "_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail: translator-openai-responses-req.test.ts 1172->1195 (+23 = #6807 reasoning-summary-for-effort-only regression tests). Frozen only shrinks.", + "_rebaseline_2026_08_20_fork3_tier23_ports": "fork.3 rebaseline (fenix007/OmniRoute stable): irreducible growth from the tier-2/tier-3 upstream fixes ported onto the frozen v3.8.48 base — #9207 recordProviderSuccess, #9342 network/queue breaker guard, #10034 clearLKGP, #10534 real-window quota recovery, #9708/#10792 same-account transport retry (chat.ts guard + auth.ts transport-cooled set). Each lands inside an existing chokepoint the upstream commits also grew; upstream rebaselined these same files per commit, this fork absorbs the drift in one entry. Covered by the ported per-fix test suites. Frozen at the measured values; only shrinks from here." }, "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.", "_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.", diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index f660fc33d307..f8fc2ad8e412 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -122,7 +122,8 @@ "_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle." }, "cognitiveComplexity": { - "value": 890, + "value": 895, + "_rebaseline_2026_08_20_fork_ports_inherited_drift": "890->895 (+5). Same inherited drift as the cyclomatic entry for 2026-08-20, measured per tag: 3.8.48-fork.1 measures exactly 890 (baseline was correct for the pristine base), 3.8.48-fork.2 adds +3 (two functions in open-sse/executors/codex.ts from the #7570/#8043 SSE-peek ports plus src/sse/services/imageCredentialRetry.ts from the #9231/#8307 image-retry ports) and 3.8.48-fork.3 adds +2 (open-sse/services/accountFallback.ts from #10116, src/lib/usage/providerLimits.ts from #10534). 3.8.48-fork.4 measures 895 too: the error-status patch set added none. fork.3 rebaselined check:file-size for these same ports but left both complexity ratchets behind, so the gate has been red on stable since that tag.", "_rebaseline_2026_07_10_gcf_v3_2": "885->888 (+3). PR feat/headroom-gcf-v3.2-nested-flattening: own growth from re-vendoring the GCF (Headroom) codec to spec v3.2 (nested flattening). The new over-threshold functions are the vendored v3.2 flatten/unflatten walk in open-sse/services/compression/engines/headroom/gcf/{generic,decode_generic}.ts. Imported third-party code kept byte-faithful to upstream gcf-typescript; measured 888 with the update vs 885 on the pristine origin/release/v3.8.47 tip. Guarded by tests/unit/compression/headroom-smartcrusher.test.ts (deep-nested case). Structural shrink belongs upstream in gcf.", "_rebaseline_2026_07_12_v3847_mergetrain_burst": "885->890 (+5). v3.8.47 /merge-prs merge-train batch (23 merge-ready PRs) inherited drift: cognitive-complexity does NOT run on PR->release fast-gates, so incidental growth accrued unmeasured across the batch. Measured 890 on the combined merge-train tip (5d980352d) vs 885 on the pristine release tip 1b7a9150e. #6838 (headroom gcf codec re-vendor) accounts for +3 (its own baseline bump to 888, superseded here by this later 890 rebaseline which already covers it); the remaining +2 is parallel-batch drift across the other 22 PRs. Owner-approved rebaseline (merge-burst reconciliation, same class as the v3.8.46/v3.8.44 notes below). Tighten via --update next cycle.", "_rebaseline_2026_07_09_6587_kiro_api_key_auth": "884->885 (+1). PR #6587 (@strangersp) own growth: open-sse/services/usage/kiro.ts gains ONE new over-threshold function — getKiroUsage grew from a single fetch to a 3-endpoint fallback chain (codewhisperer-get / codewhisperer-post / q-get) with per-attempt auth-header selection (tokentype: API_KEY vs Bearer-only), needed so usage/quota lookups work for the new long-lived-API-key auth path in addition to the existing OAuth path (measured: 0 violations on release tip -> 1 violation, complexity 33, at open-sse/services/usage/kiro.ts). Covered by tests/unit/kiro-iam-profilearn-usage.test.ts (tokentype header selection, friendly auth-expired/rejected-token messages). Cohesive multi-endpoint-fallback logic at an existing usage chokepoint; not extractable without splitting the fallback loop mid-merge. Structural shrink tracked in #3501.", diff --git a/docs/architecture/AUTHZ_GUIDE.md b/docs/architecture/AUTHZ_GUIDE.md index 4f1055590153..1a6dd5a716b4 100644 --- a/docs/architecture/AUTHZ_GUIDE.md +++ b/docs/architecture/AUTHZ_GUIDE.md @@ -217,3 +217,35 @@ Use `assertAuth(req, expectedClass)` inside handlers — it throws `AuthzAsserti - [COMPLIANCE.md](../security/COMPLIANCE.md) — audit log for auth events - [MCP-SERVER.md](../frameworks/MCP-SERVER.md) — MCP scope enforcement details - Source: `src/server/authz/`, `src/lib/api/requireManagementAuth.ts` + +## Restricted CLI credentials and client IPs + +Remote CLI access tokens (`oma_`) use the `read < write < admin` hierarchy +implemented in `src/server/authz/accessScopes.ts`. Key creation, mutation and +rotation require `admin`; full key disclosure through `/api/cli-tools/keys` or +`/api/keys/[id]/reveal` also requires `admin`. Masked `/api/keys` inventory remains +available to `read` tokens. Provider list/detail responses mask keys for restricted +CLI tokens even when the global reveal flag is enabled. CLI settings, configuration +backups and Codex profiles require `admin`, as do sync/relay token mutations. +Dashboard sessions and management API keys retain +credential-management access. + +Login lockout and audit IPs are resolved by `src/lib/ipUtils.ts` from the socket +peer or the authenticated custom-server peer stamp. The pipeline forwards an +HMAC-authenticated client-IP stamp to socket-less handlers without exposing its +process secret. Requests without a trusted peer/stamp use the `unknown` bucket. +`CF-Connecting-IP` never selects the login bucket. + +Only loopback proxies are trusted by default. `OMNIROUTE_TRUSTED_PROXY_IPS` adds +comma-separated exact IPs/CIDRs for controlled reverse proxies, including Docker +bridges. Such proxies must overwrite `X-Forwarded-For` or append the immediate +client; resolution walks right-to-left and stops at the first untrusted hop. +Keep backend ports private and restrict configured CIDRs to the proxy network. + +For the ai-router nginx deployment, `/api/auth/dashboard-access` accepts GET/HEAD +and returns `204` only for a valid dashboard cookie; otherwise it returns `401`. +Responses use `Cache-Control: no-store`. This endpoint is intended for nginx +`auth_request` checks on additional dashboard client API aliases. Deploy the new +fork before enabling the ai-router nginx configuration that calls it. The existing +router-host `/v1` block remains intentional; additional aliases allow dashboard +sessions only, so ordinary API keys must use the gateway data plane. diff --git a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md index b97bf07d9674..dbc42c6c4110 100644 --- a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md @@ -1,6 +1,12 @@ +bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8): No such file or directory +bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8): No such file or directory +bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8): No such file or directory +bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8): No such file or directory + # 环境变量参考 (中文 (简体)) --- + title: "Environment Variables Reference" version: 3.8.40 lastUpdated: 2026-06-28 @@ -55,13 +61,13 @@ lastUpdated: 2026-06-28 这些变量 **必须** 在首次运行前设置。不设置则应用将拒绝启动,或使用不安全的默认值运行。 -| 变量 | 必需 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | --- | -| `JWT_SECRET` | **是** | _(无)_ | `src/lib/auth` | 签名/校验所有 Dashboard 会话 Cookie(JWT)。使用 `openssl rand -base64 48` 生成。 | -| `API_KEY_SECRET` | **是** | _(无)_ | `src/lib/db/apiKeys.ts` | SQLite 中 API key 静态加密的 AES 密钥。使用 `openssl rand -hex 32` 生成。 | -| `INITIAL_PASSWORD` | **是** | `CHANGEME` | 引导脚本 | 设置初始管理员 Dashboard 密码(与 `.env.example` 默认值一致 — 故意保持不安全以强制更换)。**首次使用前请修改。** 登录后,请通过 Dashboard → Settings → Security 修改。 | -| `OMNIROUTE_WS_BRIDGE_SECRET` | **是**(生产环境) | _(未设置)_ | `src/app/api/internal/codex-responses-ws/route.ts` | 内部 Codex Responses WebSocket 桥接的共享密钥。用于认证 Electron/浏览器 WS 中继与 OmniRoute 之间的桥接请求。⚠️ **生产环境必须设置 — 未设置时所有 WS 桥接请求都会被拒绝。** 使用 `openssl rand -base64 32` 生成。 | -| `OMNIROUTE_PEER_STAMP_TOKEN` | 否(自动) | _(每次启动自动生成)_ | `src/server/authz/policies/management.ts` | 每个进程的密钥,用于证明可信的对等 IP 戳记来自 OmniRoute 自身的 HTTP 服务器(`scripts/dev/peer-stamp.mjs`)。authz 中间件仅在戳记携带此 Token 时才信任请求的本地性(对 LOCAL_ONLY 路由进行 loopback/LAN 门控)。每次启动自动生成 — 保持未设置;仅在必须共享戳记的多进程场景下固定此值。 | +| 变量 | 必需 | 默认值 | 源文件 | 说明 | +| ---------------------------- | ------------------ | -------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `JWT_SECRET` | **是** | _(无)_ | `src/lib/auth` | 签名/校验所有 Dashboard 会话 Cookie(JWT)。使用 `openssl rand -base64 48` 生成。 | +| `API_KEY_SECRET` | **是** | _(无)_ | `src/lib/db/apiKeys.ts` | SQLite 中 API key 静态加密的 AES 密钥。使用 `openssl rand -hex 32` 生成。 | +| `INITIAL_PASSWORD` | **是** | `CHANGEME` | 引导脚本 | 设置初始管理员 Dashboard 密码(与 `.env.example` 默认值一致 — 故意保持不安全以强制更换)。**首次使用前请修改。** 登录后,请通过 Dashboard → Settings → Security 修改。 | +| `OMNIROUTE_WS_BRIDGE_SECRET` | **是**(生产环境) | _(未设置)_ | `src/app/api/internal/codex-responses-ws/route.ts` | 内部 Codex Responses WebSocket 桥接的共享密钥。用于认证 Electron/浏览器 WS 中继与 OmniRoute 之间的桥接请求。⚠️ **生产环境必须设置 — 未设置时所有 WS 桥接请求都会被拒绝。** 使用 `openssl rand -base64 32` 生成。 | +| `OMNIROUTE_PEER_STAMP_TOKEN` | 否(自动) | _(每次启动自动生成)_ | `src/server/authz/policies/management.ts` | 每个进程的密钥,用于证明可信的对等 IP 戳记来自 OmniRoute 自身的 HTTP 服务器(`scripts/dev/peer-stamp.mjs`)。authz 中间件仅在戳记携带此 Token 时才信任请求的本地性(对 LOCAL_ONLY 路由进行 loopback/LAN 门控)。每次启动自动生成 — 保持未设置;仅在必须共享戳记的多进程场景下固定此值。 | ### 生成命令 @@ -82,64 +88,64 @@ echo "OMNIROUTE_WS_BRIDGE_SECRET=$(openssl rand -base64 32)" OmniRoute 使用 **SQLite**(通过 `better-sqlite3`)进行所有持久化存储。以下变量控制数据位置、加密和生命周期。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | SQLite 数据库、备份和数据文件的根目录。在 Docker 卷或自定义路径中可覆盖。 | -| `STORAGE_ENCRYPTION_KEY` | _(空 = 禁用)_ | `src/lib/db/encryption.ts` | 用于 SQLite 数据库静态全量加密的 AES 密钥。使用 `openssl rand -hex 32` 生成。 | -| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | 加密密钥的版本标签。进行密钥轮换时递增,以支持解密旧备份。 | -| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | 设为 `true` 时,跳过每次启动前迁移时运行的自动数据库备份。 | -| `OMNIROUTE_CRYPT_KEY` | _(未设置)_ | `src/lib/db/encryption.ts` | `STORAGE_ENCRYPTION_KEY` 的**旧版别名**。主变量缺失时作为回退被接受。 | -| `OMNIROUTE_API_KEY_BASE64` | _(未设置)_ | `src/lib/db/encryption.ts` | **旧版别名**(Base64 编码形式),作为回退被接受。使用前自动解码。 | -| `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(未设置)_ | `src/lib/db/core.ts` | 覆盖定期 SQLite 健康检查的间隔(毫秒)。未设置时根据 `NODE_ENV` 推导默认值。 | -| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts`, `src/lib/db/healthCheck.ts` | 设为 `1` 可在启动时完全跳过数据库健康检查。适用于短生命周期任务和集成测试。 | -| `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | 设为 `1` 可强制开启数据库健康检查循环,即使正常会被跳过(如短生命周期任务)。 | -| `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | 设为 `1` 可在 `npm install` 期间跳过原生运行时预热。适用于 CI/无头安装,此时 sqlite 已构建好。 | -| `OMNIROUTE_MIGRATIONS_DIR` | _(自动检测)_ | `src/lib/db/migrationRunner.ts` | 覆盖迁移运行器扫描的目录。在自定义构建中打包迁移文件时很有用。 | -| `OMNIROUTE_MAX_PENDING_MIGRATIONS` | `50` | `src/lib/db/migrationRunner.ts` | 大量待处理迁移的安全阈值(#3416)。如果现有数据库上有超过此数量的待处理迁移,启动将中止(防止跟踪表被清空)。恢复旧备份时提高此值;设为 `0` 可禁用检查。 | -| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 批量消费/成本写入器的刷新间隔(毫秒)。值越小写合并越少;值越大数据库争用越少。 | -| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 强制刷新前的最大缓存消费条目数。在高 QPS 部署中提高;在内存受限场景下降低。 | -| `OMNIROUTE_PROXY_FETCH_DEBUG` | _(未设置)_ | `open-sse/utils/proxyFetch.ts` | 设为 `"true"` 可在 Vercel 中继路径上发出 `[ProxyFetch]` 调试日志。默认关闭以避免泄露路由提示。 | -| `BATCH_RETRY_DURATION_MS` | `86400000`(24小时) | `open-sse/services/batchProcessor.ts` | 单个批次项的最大重试窗口(毫秒)。超过此时间的项被标记为失败。 | -| `BATCH_BACKOFF_BASE_MS` | `5000` | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的基础延迟(毫秒)。 | -| `BATCH_BACKOFF_MAX_MS` | `3600000`(1小时) | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的上限(毫秒)。 | -| `BATCH_MAX_CONCURRENT` | `1` | `open-sse/services/batchProcessor.ts` | 并发处理的批次最大数量。提高以增加吞吐量;保持低值以避免速率限制风暴。 | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------------------- | -------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | SQLite 数据库、备份和数据文件的根目录。在 Docker 卷或自定义路径中可覆盖。 | +| `STORAGE_ENCRYPTION_KEY` | _(空 = 禁用)_ | `src/lib/db/encryption.ts` | 用于 SQLite 数据库静态全量加密的 AES 密钥。使用 `openssl rand -hex 32` 生成。 | +| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | 加密密钥的版本标签。进行密钥轮换时递增,以支持解密旧备份。 | +| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | 设为 `true` 时,跳过每次启动前迁移时运行的自动数据库备份。 | +| `OMNIROUTE_CRYPT_KEY` | _(未设置)_ | `src/lib/db/encryption.ts` | `STORAGE_ENCRYPTION_KEY` 的**旧版别名**。主变量缺失时作为回退被接受。 | +| `OMNIROUTE_API_KEY_BASE64` | _(未设置)_ | `src/lib/db/encryption.ts` | **旧版别名**(Base64 编码形式),作为回退被接受。使用前自动解码。 | +| `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(未设置)_ | `src/lib/db/core.ts` | 覆盖定期 SQLite 健康检查的间隔(毫秒)。未设置时根据 `NODE_ENV` 推导默认值。 | +| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts`, `src/lib/db/healthCheck.ts` | 设为 `1` 可在启动时完全跳过数据库健康检查。适用于短生命周期任务和集成测试。 | +| `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | 设为 `1` 可强制开启数据库健康检查循环,即使正常会被跳过(如短生命周期任务)。 | +| `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | 设为 `1` 可在 `npm install` 期间跳过原生运行时预热。适用于 CI/无头安装,此时 sqlite 已构建好。 | +| `OMNIROUTE_MIGRATIONS_DIR` | _(自动检测)_ | `src/lib/db/migrationRunner.ts` | 覆盖迁移运行器扫描的目录。在自定义构建中打包迁移文件时很有用。 | +| `OMNIROUTE_MAX_PENDING_MIGRATIONS` | `50` | `src/lib/db/migrationRunner.ts` | 大量待处理迁移的安全阈值(#3416)。如果现有数据库上有超过此数量的待处理迁移,启动将中止(防止跟踪表被清空)。恢复旧备份时提高此值;设为 `0` 可禁用检查。 | +| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 批量消费/成本写入器的刷新间隔(毫秒)。值越小写合并越少;值越大数据库争用越少。 | +| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 强制刷新前的最大缓存消费条目数。在高 QPS 部署中提高;在内存受限场景下降低。 | +| `OMNIROUTE_PROXY_FETCH_DEBUG` | _(未设置)_ | `open-sse/utils/proxyFetch.ts` | 设为 `"true"` 可在 Vercel 中继路径上发出 `[ProxyFetch]` 调试日志。默认关闭以避免泄露路由提示。 | +| `BATCH_RETRY_DURATION_MS` | `86400000`(24小时) | `open-sse/services/batchProcessor.ts` | 单个批次项的最大重试窗口(毫秒)。超过此时间的项被标记为失败。 | +| `BATCH_BACKOFF_BASE_MS` | `5000` | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的基础延迟(毫秒)。 | +| `BATCH_BACKOFF_MAX_MS` | `3600000`(1小时) | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的上限(毫秒)。 | +| `BATCH_MAX_CONCURRENT` | `1` | `open-sse/services/batchProcessor.ts` | 并发处理的批次最大数量。提高以增加吞吐量;保持低值以避免速率限制风暴。 | ### 场景 -| 场景 | 配置 | -| --- | --- | -| **本地开发** | 保留所有默认值。数据库位于 `~/.omniroute/omniroute.db`。 | -| **Docker** | `DATA_DIR=/data` + 挂载卷到 `/data`。 | +| 场景 | 配置 | +| ------------ | --------------------------------------------------------------- | +| **本地开发** | 保留所有默认值。数据库位于 `~/.omniroute/omniroute.db`。 | +| **Docker** | `DATA_DIR=/data` + 挂载卷到 `/data`。 | | **静态加密** | 设置 `STORAGE_ENCRYPTION_KEY` + 备份密钥!丢失密钥 = 丢失数据。 | -| **CI/测试** | `DATA_DIR=/tmp/omniroute-test` — 临时目录,无需加密。 | +| **CI/测试** | `DATA_DIR=/tmp/omniroute-test` — 临时目录,无需加密。 | --- ## 3. 网络与端口 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Dashboard UI 和 API 端点共用的主端口(单端口模式)。 | -| `API_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 设置时,在另外的端口上提供 `/v1/*` 代理 API。 | -| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | API 端口的绑定地址。 | -| `DASHBOARD_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 设置时,在另外的端口上提供 Dashboard UI。 | -| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Docker 生产模式下 Dashboard 的主机侧发布端口。 | -| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Docker 生产模式下 API 的主机侧发布端口。 | -| `OMNIROUTE_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 在 Electron 或其他包装器中运行时优先于 `PORT`。 | -| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | 实时 WebSocket 监控服务器的端口。 | -| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | 实时 WebSocket 服务器的绑定地址。设为 `0.0.0.0` 可暴露到 LAN(还需配置 `LIVE_WS_ALLOWED_ORIGINS`)。 | -| `LIVE_WS_ALLOWED_ORIGINS` | _(未设置)_ | `src/server/ws/liveServer.ts` | 逗号分隔的额外允许打开实时 WebSocket 的源。loopback Dashboard 源已默认允许。 | -| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` | 设为 `0` 或 `false` 可禁用实时 WebSocket 服务器(默认启用,绑定 loopback)。 | -| `OMNIROUTE_DISABLE_LIVE_WS` | `false` | `scripts/start-ws-server.mjs` | CI/测试工具开关,禁用独立的实时 WebSocket 辅助脚本。 | -| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | 每个 (Token, IP) 的中继速率限制,请求数/分钟。基于内存,每个实例独立。`0` 或负数可禁用 IP 维度门控(每个 Token 的数据库限制仍然生效)。 | -| `NODE_ENV` | `production` | Next.js 核心 | 控制日志详细程度、缓存、错误详情暴露和 Next.js 优化。 | -| `OMNIROUTE_USE_TURBOPACK` | `1`(`.env.example` 中默认值) | `package.json` / Next.js 16 | 在 `npm run dev` 和 `npm run build` 中切换 Next.js 16 Turbopack 打包器。在 Windows 或遇到原生绑定不兼容时设为 `0`。 | -| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(未设置)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | 设为 `1` 可跳过启动时的 SQLite 完整性健康检查。适用于大型数据库需要更快启动时。 | -| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | 后台凭证健康检查调度器的间隔(毫秒)。最低:10000(10 秒)。 | -| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | 凭证健康状态缓存的 TTL(毫秒)。 | -| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | 设为 `1` 或 `true` 可禁用后台定期服务商连接测试。 | -| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Next.js 开发/启动服务器的绑定地址。设置时覆盖默认的 `0.0.0.0`。 | -| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Playwright 启动 Next.js 时使用的绑定地址。默认为 `127.0.0.1` 以确保测试隔离。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Dashboard UI 和 API 端点共用的主端口(单端口模式)。 | +| `API_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 设置时,在另外的端口上提供 `/v1/*` 代理 API。 | +| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | API 端口的绑定地址。 | +| `DASHBOARD_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 设置时,在另外的端口上提供 Dashboard UI。 | +| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Docker 生产模式下 Dashboard 的主机侧发布端口。 | +| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Docker 生产模式下 API 的主机侧发布端口。 | +| `OMNIROUTE_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 在 Electron 或其他包装器中运行时优先于 `PORT`。 | +| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | 实时 WebSocket 监控服务器的端口。 | +| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | 实时 WebSocket 服务器的绑定地址。设为 `0.0.0.0` 可暴露到 LAN(还需配置 `LIVE_WS_ALLOWED_ORIGINS`)。 | +| `LIVE_WS_ALLOWED_ORIGINS` | _(未设置)_ | `src/server/ws/liveServer.ts` | 逗号分隔的额外允许打开实时 WebSocket 的源。loopback Dashboard 源已默认允许。 | +| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` | 设为 `0` 或 `false` 可禁用实时 WebSocket 服务器(默认启用,绑定 loopback)。 | +| `OMNIROUTE_DISABLE_LIVE_WS` | `false` | `scripts/start-ws-server.mjs` | CI/测试工具开关,禁用独立的实时 WebSocket 辅助脚本。 | +| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | 每个 (Token, IP) 的中继速率限制,请求数/分钟。基于内存,每个实例独立。`0` 或负数可禁用 IP 维度门控(每个 Token 的数据库限制仍然生效)。 | +| `NODE_ENV` | `production` | Next.js 核心 | 控制日志详细程度、缓存、错误详情暴露和 Next.js 优化。 | +| `OMNIROUTE_USE_TURBOPACK` | `1`(`.env.example` 中默认值) | `package.json` / Next.js 16 | 在 `npm run dev` 和 `npm run build` 中切换 Next.js 16 Turbopack 打包器。在 Windows 或遇到原生绑定不兼容时设为 `0`。 | +| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(未设置)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | 设为 `1` 可跳过启动时的 SQLite 完整性健康检查。适用于大型数据库需要更快启动时。 | +| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | 后台凭证健康检查调度器的间隔(毫秒)。最低:10000(10 秒)。 | +| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | 凭证健康状态缓存的 TTL(毫秒)。 | +| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | 设为 `1` 或 `true` 可禁用后台定期服务商连接测试。 | +| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Next.js 开发/启动服务器的绑定地址。设置时覆盖默认的 `0.0.0.0`。 | +| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Playwright 启动 Next.js 时使用的绑定地址。默认为 `127.0.0.1` 以确保测试隔离。 | ### 端口模式 @@ -169,22 +175,22 @@ OmniRoute 使用 **SQLite**(通过 `better-sqlite3`)进行所有持久化存 ## 4. 安全与认证 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | 与硬件标识符组合用于机器指纹的盐值。按部署修改以实现隔离。 | -| `OMNIROUTE_CLI_SALT` | `omniroute-cli-auth-v1` | `src/lib/machineToken.ts` | 用于派生本地 CLI 认证 Token 的 HMAC 盐值。修改此值将轮换机器上的所有 CLI Token。参阅 `docs/security/CLI_TOKEN.md`。 | -| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | 设置会话 Cookie 的 `Secure` 标志。运行在 HTTPS 背后时 **必须设为 `true`**。 | -| `REQUIRE_API_KEY` | `false` | API 中间件 | 设为 `true` 后,所有 `/v1/*` 代理请求必须包含有效的 API key。 | -| `ALLOW_API_KEY_REVEAL` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | 允许在 Dashboard UI 中显示完整 API key 值。可从 Dashboard Feature Flags 配置;共享实例上有安全风险。 | -| `NO_LOG_API_KEY_IDS` | _(空)_ | `src/lib/compliance/index.ts` | 逗号分隔的 API key ID 列表,其请求将绕过日志记录(GDPR 合规)。 | -| `DEFAULT_RATE_LIMIT_PER_DAY` | `1000` | `src/shared/utils/apiKeyPolicy.ts` | 应用于 `rate_limits` 列为 null 的 API key 的回退每日请求预算。默认(未设置/空/格式错误)保持传统的 1000/天、5000/周、20000/月 窗口。显式设为 `0` 可选择退出(无限制)。任意正整数 N 则启用 N/天、5N/周、20N/月。Zod 校验;无效值记录警告并使用传统默认值。 | -| `MAX_BODY_SIZE_BYTES` | `10485760`(10 MB) | `src/shared/middleware/bodySizeGuard.ts` | 允许的最大请求体大小。拒绝超限载荷。 | -| `CORS_ORIGIN` | _(未设置)_ | `src/server/cors/origins.ts` | 旧版单一源 CORS 允许列表。新部署推荐使用 `CORS_ALLOWED_ORIGINS`。CORS 仅用于跨源浏览器 API 客户端;反向代理后的同源 Dashboard 请求使用 `NEXT_PUBLIC_BASE_URL` / 公共源校验。 | -| `CORS_ALLOWED_ORIGINS` | _(未设置)_ | `src/server/cors/origins.ts` | 逗号分隔的 CORS 允许列表。除非显式配置 `CORS_ALLOW_ALL=true`,否则不发送通配符。 | -| `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | 仅供开发使用的逃生口,可回显任意浏览器 `Origin`。不要在共享或生产部署中启用。 | -| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | 阻止目标为私有/loopback/链路本地 IP 范围的服务商调用。仅在隔离的测试环境中禁用。 | -| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | 允许指向私有/本地网络(localhost、192.168.x.x、10.x.x.x 等)的服务商 URL。**自托管服务商必需**(LM Studio、Ollama、vLLM、Llamafile、Triton、SearXNG)。设为 `false` 时,Dashboard 拒绝校验本地 URL。 | -| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | 允许在本地/私有地址上添加/校验服务商(127.0.0.1、localhost、LAN、私有范围)— 仅影响服务商校验路径。**默认 `true`**(本地优先);设为 `false` 可强制严格的仅公网阻断。云元数据端点(169.254.169.254, metadata.google.internal)始终被阻断。(#5066) | +| 变量 | 默认值 | 源文件 | 说明 | +| --------------------------------------- | ----------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | 与硬件标识符组合用于机器指纹的盐值。按部署修改以实现隔离。 | +| `OMNIROUTE_CLI_SALT` | `omniroute-cli-auth-v1` | `src/lib/machineToken.ts` | 用于派生本地 CLI 认证 Token 的 HMAC 盐值。修改此值将轮换机器上的所有 CLI Token。参阅 `docs/security/CLI_TOKEN.md`。 | +| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | 设置会话 Cookie 的 `Secure` 标志。运行在 HTTPS 背后时 **必须设为 `true`**。 | +| `REQUIRE_API_KEY` | `false` | API 中间件 | 设为 `true` 后,所有 `/v1/*` 代理请求必须包含有效的 API key。 | +| `ALLOW_API_KEY_REVEAL` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | 允许在 Dashboard UI 中显示完整 API key 值。可从 Dashboard Feature Flags 配置;共享实例上有安全风险。 | +| `NO_LOG_API_KEY_IDS` | _(空)_ | `src/lib/compliance/index.ts` | 逗号分隔的 API key ID 列表,其请求将绕过日志记录(GDPR 合规)。 | +| `DEFAULT_RATE_LIMIT_PER_DAY` | `1000` | `src/shared/utils/apiKeyPolicy.ts` | 应用于 `rate_limits` 列为 null 的 API key 的回退每日请求预算。默认(未设置/空/格式错误)保持传统的 1000/天、5000/周、20000/月 窗口。显式设为 `0` 可选择退出(无限制)。任意正整数 N 则启用 N/天、5N/周、20N/月。Zod 校验;无效值记录警告并使用传统默认值。 | +| `MAX_BODY_SIZE_BYTES` | `10485760`(10 MB) | `src/shared/middleware/bodySizeGuard.ts` | 允许的最大请求体大小。拒绝超限载荷。 | +| `CORS_ORIGIN` | _(未设置)_ | `src/server/cors/origins.ts` | 旧版单一源 CORS 允许列表。新部署推荐使用 `CORS_ALLOWED_ORIGINS`。CORS 仅用于跨源浏览器 API 客户端;反向代理后的同源 Dashboard 请求使用 `NEXT_PUBLIC_BASE_URL` / 公共源校验。 | +| `CORS_ALLOWED_ORIGINS` | _(未设置)_ | `src/server/cors/origins.ts` | 逗号分隔的 CORS 允许列表。除非显式配置 `CORS_ALLOW_ALL=true`,否则不发送通配符。 | +| `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | 仅供开发使用的逃生口,可回显任意浏览器 `Origin`。不要在共享或生产部署中启用。 | +| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | 阻止目标为私有/loopback/链路本地 IP 范围的服务商调用。仅在隔离的测试环境中禁用。 | +| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | 允许指向私有/本地网络(localhost、192.168.x.x、10.x.x.x 等)的服务商 URL。**自托管服务商必需**(LM Studio、Ollama、vLLM、Llamafile、Triton、SearXNG)。设为 `false` 时,Dashboard 拒绝校验本地 URL。 | +| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | 允许在本地/私有地址上添加/校验服务商(127.0.0.1、localhost、LAN、私有范围)— 仅影响服务商校验路径。**默认 `true`**(本地优先);设为 `false` 可强制严格的仅公网阻断。云元数据端点(169.254.169.254, metadata.google.internal)始终被阻断。(#5066) | ### 加固清单 @@ -205,84 +211,78 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱 ### 请求侧:提示注入安全护栏 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `INPUT_SANITIZER_ENABLED` | `true` | `src/middleware/promptInjectionGuard.ts` | 启用扫描传入消息中的提示注入模式。 | -| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | `warn` = 仅记录日志,`block` = 以 400 拒绝请求,`redact` = 脱敏可疑模式。 | -| `INJECTION_GUARD_MODE` | _(未设置)_ | `src/middleware/promptInjectionGuard.ts` | `INPUT_SANITIZER_MODE` 的旧版别名 — 行为相同。 | -| `PII_REDACTION_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | 检测传入请求中的 PII(邮箱、电话、社会安全号码等)。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------- | ---------- | ---------------------------------------- | ------------------------------------------------------------------------- | +| `INPUT_SANITIZER_ENABLED` | `true` | `src/middleware/promptInjectionGuard.ts` | 启用扫描传入消息中的提示注入模式。 | +| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | `warn` = 仅记录日志,`block` = 以 400 拒绝请求,`redact` = 脱敏可疑模式。 | +| `INJECTION_GUARD_MODE` | _(未设置)_ | `src/middleware/promptInjectionGuard.ts` | `INPUT_SANITIZER_MODE` 的旧版别名 — 行为相同。 | +| `PII_REDACTION_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | 检测传入请求中的 PII(邮箱、电话、社会安全号码等)。 | ### 响应侧:PII 脱敏器 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `PII_RESPONSE_SANITIZATION` | `false` | `src/lib/piiSanitizer.ts` | 在返回给客户端之前扫描大语言模型响应中的泄露 PII。 | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------------- | -------- | ------------------------- | ------------------------------------------------------------------ | +| `PII_RESPONSE_SANITIZATION` | `false` | `src/lib/piiSanitizer.ts` | 在返回给客户端之前扫描大语言模型响应中的泄露 PII。 | | `PII_RESPONSE_SANITIZATION_MODE` | `redact` | `src/lib/piiSanitizer.ts` | `redact` = 脱敏 PII,`warn` = 仅记录日志,`block` = 丢弃整个响应。 | ### VS Code Token 化路由上下文净化器 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_VSCODE_SANITIZE_CONTEXT` | `1` | `src/app/api/v1/vscode/contextSanitizer.ts` | 从 `/v1/vscode/[token]/*` 请求中剥离隐式的活跃编辑器上下文(`editorContext`, `activeEditor`, `currentFile`, `selection`, `openTabs`...),并脱敏显式附加的敏感文件内容。安全默认启用;设为 `0` 可禁用。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------- | ------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_VSCODE_SANITIZE_CONTEXT` | `1` | `src/app/api/v1/vscode/contextSanitizer.ts` | 从 `/v1/vscode/[token]/*` 请求中剥离隐式的活跃编辑器上下文(`editorContext`, `activeEditor`, `currentFile`, `selection`, `openTabs`...),并脱敏显式附加的敏感文件内容。安全默认启用;设为 `0` 可禁用。 | ### 场景 -| 场景 | 配置 | -| --- | --- | +| 场景 | 配置 | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------- | | **企业合规** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=block`, `PII_REDACTION_ENABLED=true`, `PII_RESPONSE_SANITIZATION=true` | -| **仅监控** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=warn` — 记录日志但永不阻断 | -| **个人使用** | 全部禁用 — 零开销 | +| **仅监控** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=warn` — 记录日志但永不阻断 | +| **个人使用** | 全部禁用 — 零开销 | --- ## 6. 工具与路由策略 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `TOOL_POLICY_MODE` | `disabled` | `src/lib/toolPolicy.ts` | 控制大语言模型的工具/函数调用访问。`allowlist` = 仅允许列表中的工具,`denylist` = 除列表外全部允许,`disabled` = 无限制。 | -| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | 载荷操作规则 JSON 文件路径(按模型/协议进行上游调整)。 | -| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | 热加载载荷规则文件的重载间隔(毫秒)。最低 `1000`。 | -| `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | 启用后:将来自 Claude Code 客户端的无前缀 `claude-*` 模型 ID 通过 Claude Code OAuth 账户路由,而非要求服务商前缀。显式服务商前缀优先级更高。也可通过 Dashboard 中 Claude 服务商页面的开关配置。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------------------------------- | ---------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TOOL_POLICY_MODE` | `disabled` | `src/lib/toolPolicy.ts` | 控制大语言模型的工具/函数调用访问。`allowlist` = 仅允许列表中的工具,`denylist` = 除列表外全部允许,`disabled` = 无限制。 | +| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | 载荷操作规则 JSON 文件路径(按模型/协议进行上游调整)。 | +| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | 热加载载荷规则文件的重载间隔(毫秒)。最低 `1000`。 | +| `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | 启用后:将来自 Claude Code 客户端的无前缀 `claude-*` 模型 ID 通过 Claude Code OAuth 账户路由,而非要求服务商前缀。显式服务商前缀优先级更高。也可通过 Dashboard 中 Claude 服务商页面的开关配置。 | --- ## 7. URL 与云同步 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | 内部同步任务调用 `/api/sync/cloud` 的服务器端 URL。即使应用被公共代理,也保持为 loopback/容器 URL。 | -| `CLOUD_URL` | _(空)_ | `src/lib/cloudSync.ts` | 云中继端点 URL(高级功能)。 | -| `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | 云同步请求的 HTTP 超时。 | -| `OMNIROUTE_BUILD_PROFILE` | `full` | Webpack 构建配置 | 构建时配置文件(设为 `minimal` 可物理排除特权模块不打包)。 | -| `OMNIROUTE_CLOUD_SYNC_SECRET` | _(空)_ | `src/lib/cloudSync.ts` | 用于校验云同步响应 HMAC-SHA256 签名的共享密钥。 | -| `OMNIROUTE_CLOUD_SYNC_SECRETS` | `false` | `src/lib/cloudSync.ts` | 设为 `true` 允许云同步端点覆盖本地凭证。默认 `false`。 | -| `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP` | `false` | `src/app/api/providers/zed/import/route.ts` | 设为 `true` 可回退到 v3.8.5 的一步式"导入全部"行为,无需用户确认。 | -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth、Dashboard、同步 | 面向公共的 URL,用于 OAuth redirect_uri、Dashboard 链接、生成的公共 URL 以及同源浏览器变更检查。**在反向代理背后时,必须匹配你的公共 URL。** | -| `NEXT_PUBLIC_CLOUD_URL` | _(空)_ | 客户端侧 | `CLOUD_URL` 的客户端镜像。 | -| `NEXT_PUBLIC_APP_URL` | _(未设置)_ | `src/shared/services/cloudSyncScheduler.ts` | `NEXT_PUBLIC_BASE_URL` 的旧版回退。 | -| `OMNIROUTE_PUBLIC_BASE_URL` | _(未设置)_ | 公共源解析器、图片 URL | 最高优先级的浏览器侧 OmniRoute 源,用于公共 URL 生成和源校验(例如 `/v1/chatgpt-web/image/`)。当 OpenWebUI 或其他中继通过内部 URL 访问 OmniRoute,但用户浏览器必须从 LAN、隧道或公共源获取图片时设置。**不要**包含 `/v1`。 | -| `OMNIROUTE_TRUST_PROXY` | _(未设置)_ | `src/server/origin/publicOrigin.ts` | 可选的转发公共源头信任模式。未设置 = 出于安全考虑不信任 `Forwarded` / `X-Forwarded-*`。`true` / `loopback` 仅信任来自经过 Token 戳记的 loopback 代理的转发 host/proto。`private` / `lan` 还信任私有 LAN 代理对端。生产环境中推荐显式设置 `NEXT_PUBLIC_BASE_URL`。 | -| `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000`(3 分钟) | `open-sse/executors/chatgpt-web.ts` | 等待异步 chatgpt-web 图片通过 Celsius WebSocket 到达的最大时间。在上游排队窗口较长时提高此值。 | -| `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | 为 `/v1/chatgpt-web/image/` 提供服务的 chatgpt-web 图片缓存的内存预算总额(MB)。在内存受限的主机上降低;图片生成量大且客户端争抢 30 分钟 TTL 时提高。 | -| `OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS` | `1200000`(20 分钟) | `open-sse/executors/chatgpt-web.ts` | chatgpt-web GPT-5.5 Pro 后台轮询交接的总体等待预算。Pro 推理在带外完成,OmniRoute 轮询直到结果到达或预算耗尽。如果 Pro 请求完成前超时,请提高此值。 | -| `OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS` | `4000`(4 秒) | `open-sse/executors/chatgpt-web.ts` | chatgpt-web GPT-5.5 Pro 后台轮询尝试的间隔。降低可更快完成但增加上游轮询;提高可减少请求量。 | -| `THEOLDLLM_NAV_TIMEOUT_MS` | `30000`(30 秒) | `open-sse/executors/theoldllm.ts` | 浏览器端 Token 捕获(The Old LLM (theoldllm) 免费服务商使用)的 Playwright 导航超时(毫秒)。如果中继页面加载慢,可在慢速网络上提高。 | -| `KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 异步 kie.ai 任务的公共回调 URL。优先级高于 `OMNIROUTE_KIE_CALLBACK_URL` 和 `OMNIROUTE_PUBLIC_URL`。 | -| `OMNIROUTE_KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | `KIE_CALLBACK_URL` 的替代写法。主变量未设置时的回退。 | -| `OMNIROUTE_PUBLIC_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 用于组合异步回调 URL 的公共源。kie.ai 回调的最低优先级回退;也用作其他中继的通用公共 URL。 | -| `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | Usage 页面使用的 CrofAI 配额查询端点。可覆盖为中继/测试固定件。 | -| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | Usage 页面使用的 OpenCode (zen/go) 配额查询端点。可覆盖为中继/测试固定件。 | -| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | `https://api.z.ai/api/monitor/usage/quota/limit` | `open-sse/services/usage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。可覆盖为中继/测试固定件。 | -| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | 配置了 workspace ID 和 auth Cookie 时用于配额抓取的 OpenCode Go Dashboard 基础 URL。可覆盖为中继/测试固定件。 | -| `OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go workspace ID。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID 环境变量的备选名,在较短的别名之前使用。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go `auth` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | 用于配额抓取的 Ollama Cloud settings URL。可覆盖为中继/测试固定件。 | -| `OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于设置页面配额抓取的 Ollama Cloud `__Secure-session` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OLLAMA_CLOUD_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_CODEWHISPERER_BASE_URL` | `https://codewhisperer.us-east-1.amazonaws.com` | `open-sse/services/usage.ts` | CodeWhisperer (AWS Kiro) 用量限制端点。可覆盖为中继/测试固定件。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------------- | ----------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | 内部同步任务调用 `/api/sync/cloud` 的服务器端 URL。即使应用被公共代理,也保持为 loopback/容器 URL。 | +| `CLOUD_URL` | _(空)_ | `src/lib/cloudSync.ts` | 云中继端点 URL(高级功能)。 | +| `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | 云同步请求的 HTTP 超时。 | +| `OMNIROUTE_BUILD_PROFILE` | `full` | Webpack 构建配置 | 构建时配置文件(设为 `minimal` 可物理排除特权模块不打包)。 | +| `OMNIROUTE_CLOUD_SYNC_SECRET` | _(空)_ | `src/lib/cloudSync.ts` | 用于校验云同步响应 HMAC-SHA256 签名的共享密钥。 | +| `OMNIROUTE_CLOUD_SYNC_SECRETS` | `false` | `src/lib/cloudSync.ts` | 设为 `true` 允许云同步端点覆盖本地凭证。默认 `false`。 | +| `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP` | `false` | `src/app/api/providers/zed/import/route.ts` | 设为 `true` 可回退到 v3.8.5 的一步式"导入全部"行为,无需用户确认。 | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth、Dashboard、同步 | 面向公共的 URL,用于 OAuth redirect_uri、Dashboard 链接、生成的公共 URL 以及同源浏览器变更检查。**在反向代理背后时,必须匹配你的公共 URL。** | +| `NEXT_PUBLIC_CLOUD_URL` | _(空)_ | 客户端侧 | `CLOUD_URL` 的客户端镜像。 | +| `NEXT_PUBLIC_APP_URL` | _(未设置)_ | `src/shared/services/cloudSyncScheduler.ts` | `NEXT_PUBLIC_BASE_URL` 的旧版回退。 | +| `OMNIROUTE_PUBLIC_BASE_URL` | _(未设置)_ | 公共源解析器、图片 URL | 最高优先级的浏览器侧 OmniRoute 源,用于公共 URL 生成和源校验(例如 `/v1/chatgpt-web/image/`)。当 OpenWebUI 或其他中继通过内部 URL 访问 OmniRoute,但用户浏览器必须从 LAN、隧道或公共源获取图片时设置。**不要**包含 `/v1`。 | +| `OMNIROUTE_TRUST_PROXY` | _(未设置)_ | `src/server/origin/publicOrigin.ts` | 可选的转发公共源头信任模式。未设置 = 出于安全考虑不信任 `Forwarded` / `X-Forwarded-*`。`true` / `loopback` 仅信任来自经过 Token 戳记的 loopback 代理的转发 host/proto。`private` / `lan` 还信任私有 LAN 代理对端。生产环境中推荐显式设置 `NEXT_PUBLIC_BASE_URL`。 | +| `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000`(3 分钟) | `open-sse/executors/chatgpt-web.ts` | 等待异步 chatgpt-web 图片通过 Celsius WebSocket 到达的最大时间。在上游排队窗口较长时提高此值。 | +| `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | 为 `/v1/chatgpt-web/image/` 提供服务的 chatgpt-web 图片缓存的内存预算总额(MB)。在内存受限的主机上降低;图片生成量大且客户端争抢 30 分钟 TTL 时提高。 | +| `OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS` | `1200000`(20 分钟) | `open-sse/executors/chatgpt-web.ts` | chatgpt-web GPT-5.5 Pro 后台轮询交接的总体等待预算。Pro 推理在带外完成,OmniRoute 轮询直到结果到达或预算耗尽。如果 Pro 请求完成前超时,请提高此值。 | +| `OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS` | `4000`(4 秒) | `open-sse/executors/chatgpt-web.ts` | chatgpt-web GPT-5.5 Pro 后台轮询尝试的间隔。降低可更快完成但增加上游轮询;提高可减少请求量。 | +| `THEOLDLLM_NAV_TIMEOUT_MS` | `30000`(30 秒) | `open-sse/executors/theoldllm.ts` | 浏览器端 Token 捕获(The Old LLM (theoldllm) 免费服务商使用)的 Playwright 导航超时(毫秒)。如果中继页面加载慢,可在慢速网络上提高。 | +| `KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 异步 kie.ai 任务的公共回调 URL。优先级高于 `OMNIROUTE_KIE_CALLBACK_URL` 和 `OMNIROUTE_PUBLIC_URL`。 | +| `OMNIROUTE_KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | `KIE_CALLBACK_URL` 的替代写法。主变量未设置时的回退。 | +| `OMNIROUTE_PUBLIC_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 用于组合异步回调 URL 的公共源。kie.ai 回调的最低优先级回退;也用作其他中继的通用公共 URL。 | +| `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | Usage 页面使用的 CrofAI 配额查询端点。可覆盖为中继/测试固定件。 | +| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/usage` | `open-sse/services/opencodeQuotaFetcher.ts` | Usage 页面使用的 OpenCode Go 官方 API key 认证用量端点。可覆盖为中继/测试固定件。 | +| `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | 用于配额抓取的 Ollama Cloud settings URL。可覆盖为中继/测试固定件。 | +| `OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于设置页面配额抓取的 Ollama Cloud `__Secure-session` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OLLAMA_CLOUD_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_CODEWHISPERER_BASE_URL` | `https://codewhisperer.us-east-1.amazonaws.com` | `open-sse/services/usage.ts` | CodeWhisperer (AWS Kiro) 用量限制端点。可覆盖为中继/测试固定件。 | > [!IMPORTANT] > 当部署在反向代理(nginx、Caddy)之后时,**必须**将 `NEXT_PUBLIC_BASE_URL` 设置为你的公共 URL(例如 `https://omniroute.example.com`)。否则 OAuth 回调可能因 redirect_uri 不匹配而失败,生成的公共链接可能指向内部容器源,同源 Dashboard 变更可能被浏览器源检查拒绝。 @@ -297,29 +297,29 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱 将上游大语言模型服务商调用通过 HTTP 或 SOCKS5 代理路由,以实现出口控制、异地路由或 IP 掩码。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | 启用上游调用的 SOCKS5 代理代理。可用 `false` 退出。 | -| `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | 客户端侧 | 客户端对 SOCKS5 可用性的感知。 | -| `HTTP_PROXY` | _(未设置)_ | Node.js 标准 | 上游调用的 HTTP 代理。 | -| `HTTPS_PROXY` | _(未设置)_ | Node.js 标准 | 上游调用的 HTTPS 代理。 | -| `ALL_PROXY` | _(未设置)_ | Node.js 标准 | 通用代理(支持 `socks5://`)。 | -| `NO_PROXY` | _(未设置)_ | Node.js 标准 | 逗号分隔的绕过代理的主机名/IP。 | -| `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | 每个缓存的 HTTP/SOCKS 代理调度器的最大并发套接字数。长连接 SSE 流(如 Codex `/v1/responses`)在多个请求共享同一账户级代理时需要不止一个连接。超过 `256` 的值会被截断。 | -| `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 握手(连接)超时,毫秒。当单个住宅网关主机高并发(如 100 个并发请求)时提高 — 在池饱和情况下实际握手可能超过 10 秒,即使代理可达,否则会显示为虚假的 `[Proxy Fast-Fail] Proxy unreachable`。上限为 `120000`。 | -| `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | 设为 `false`(默认)时,代理解析失败的请求会被**拒绝(fail-closed)**,而不会回退到直连 — 防止真实 IP 泄露。设为 `true` 可恢复旧版的 DIRECT 回退。 | -| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | 使用 wreq-js 伪装 TLS 指纹(模拟 Chrome 124)。对抗 JA3/JA4 阻断。 | -| `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | 允许 Claude Turnstile 的 Playwright 浏览器上下文忽略 HTTPS 证书错误。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ---------------------------------------- | ---------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | 启用上游调用的 SOCKS5 代理代理。可用 `false` 退出。 | +| `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | 客户端侧 | 客户端对 SOCKS5 可用性的感知。 | +| `HTTP_PROXY` | _(未设置)_ | Node.js 标准 | 上游调用的 HTTP 代理。 | +| `HTTPS_PROXY` | _(未设置)_ | Node.js 标准 | 上游调用的 HTTPS 代理。 | +| `ALL_PROXY` | _(未设置)_ | Node.js 标准 | 通用代理(支持 `socks5://`)。 | +| `NO_PROXY` | _(未设置)_ | Node.js 标准 | 逗号分隔的绕过代理的主机名/IP。 | +| `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | 每个缓存的 HTTP/SOCKS 代理调度器的最大并发套接字数。长连接 SSE 流(如 Codex `/v1/responses`)在多个请求共享同一账户级代理时需要不止一个连接。超过 `256` 的值会被截断。 | +| `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 握手(连接)超时,毫秒。当单个住宅网关主机高并发(如 100 个并发请求)时提高 — 在池饱和情况下实际握手可能超过 10 秒,即使代理可达,否则会显示为虚假的 `[Proxy Fast-Fail] Proxy unreachable`。上限为 `120000`。 | +| `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | 设为 `false`(默认)时,代理解析失败的请求会被**拒绝(fail-closed)**,而不会回退到直连 — 防止真实 IP 泄露。设为 `true` 可恢复旧版的 DIRECT 回退。 | +| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | 使用 wreq-js 伪装 TLS 指纹(模拟 Chrome 124)。对抗 JA3/JA4 阻断。 | +| `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | 允许 Claude Turnstile 的 Playwright 浏览器上下文忽略 HTTPS 证书错误。 | ### 场景 -| 场景 | 配置 | -| --- | --- | -| **通过 SSH 隧道走 SOCKS5** | `ALL_PROXY=socks5://127.0.0.1:7890`, `ENABLE_SOCKS5_PROXY=true` | -| **企业 HTTP 代理** | `HTTP_PROXY=http://proxy.corp.com:3128`, `HTTPS_PROXY=http://proxy.corp.com:3128`, `NO_PROXY=localhost,internal.corp.com` | -| **反指纹** | `ENABLE_TLS_FINGERPRINT=true` — 需要 `wreq-js`(已包含) | -| **出口受控 / 无直连访问** | 保持 `PROXY_FAIL_OPEN=false`(默认)。代理不可用时请求直接失败,不会通过直连泄露。 | -| **旧版/开发 — 允许直连回退** | `PROXY_FAIL_OPEN=true`。恢复加固前行为:代理解析失败时使用直连连接。 | +| 场景 | 配置 | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| **通过 SSH 隧道走 SOCKS5** | `ALL_PROXY=socks5://127.0.0.1:7890`, `ENABLE_SOCKS5_PROXY=true` | +| **企业 HTTP 代理** | `HTTP_PROXY=http://proxy.corp.com:3128`, `HTTPS_PROXY=http://proxy.corp.com:3128`, `NO_PROXY=localhost,internal.corp.com` | +| **反指纹** | `ENABLE_TLS_FINGERPRINT=true` — 需要 `wreq-js`(已包含) | +| **出口受控 / 无直连访问** | 保持 `PROXY_FAIL_OPEN=false`(默认)。代理不可用时请求直接失败,不会通过直连泄露。 | +| **旧版/开发 — 允许直连回退** | `PROXY_FAIL_OPEN=true`。恢复加固前行为:代理解析失败时使用直连连接。 | > **注意(NVIDIA 校验绕过 — #3226):** NVIDIA 的 API Key 校验端点 > 在通过全局代理/TLS 修补 fetch(undici dispatcher → 504)路由时会卡住。 @@ -334,23 +334,23 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱 控制 OmniRoute 如何发现和启动 CLI sidecar(Claude Code、Codex 等)。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = 搜索系统 PATH;`manual` = 仅使用显式路径。 | -| `CLI_EXTRA_PATHS` | _(未设置)_ | `src/shared/services/cliRuntime.ts` | 用于 CLI 二进制文件发现的额外 PATH 条目(冒号分隔)。 | -| `CLI_CONFIG_HOME` | _(未设置)_ | `src/shared/services/cliRuntime.ts` | 覆盖读取 CLI 配置(`~/.claude`、`~/.codex`)的主目录。 | -| `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | 允许 OmniRoute 写入 CLI 配置文件(Token 刷新、会话数据)。 | -| `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Claude CLI 二进制文件的自定义路径。 | -| `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Codex CLI 二进制文件的自定义路径。 | -| `CLI_DROID_BIN` | `droid` | `src/shared/services/cliRuntime.ts` | Droid CLI 二进制文件的自定义路径。 | -| `CLI_OPENCLAW_BIN` | `openclaw` | `src/shared/services/cliRuntime.ts` | OpenClaw CLI 二进制文件的自定义路径。 | -| `CLI_CURSOR_BIN` | `agent` | `src/shared/services/cliRuntime.ts` | Cursor agent 二进制文件的自定义路径。 | -| `CLI_CLINE_BIN` | `cline` | `src/shared/services/cliRuntime.ts` | Cline CLI 二进制文件的自定义路径。 | -| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Continue CLI 二进制文件的自定义路径。 | -| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Qoder CLI 二进制文件的自定义路径。 | -| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Qwen Code CLI 二进制文件的自定义路径。 | -| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Devin CLI 二进制文件的自定义路径(v3.8.0)。由 Windsurf/Devin executor 使用。 | -| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent 主目录,OmniRoute 从此处读取/写入 Hermes CLI 配置。与 Hermes PowerShell 安装程序在 Windows 上设置的环境变量(`%LOCALAPPDATA%\hermes`)一致。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------- | ----------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = 搜索系统 PATH;`manual` = 仅使用显式路径。 | +| `CLI_EXTRA_PATHS` | _(未设置)_ | `src/shared/services/cliRuntime.ts` | 用于 CLI 二进制文件发现的额外 PATH 条目(冒号分隔)。 | +| `CLI_CONFIG_HOME` | _(未设置)_ | `src/shared/services/cliRuntime.ts` | 覆盖读取 CLI 配置(`~/.claude`、`~/.codex`)的主目录。 | +| `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | 允许 OmniRoute 写入 CLI 配置文件(Token 刷新、会话数据)。 | +| `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Claude CLI 二进制文件的自定义路径。 | +| `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Codex CLI 二进制文件的自定义路径。 | +| `CLI_DROID_BIN` | `droid` | `src/shared/services/cliRuntime.ts` | Droid CLI 二进制文件的自定义路径。 | +| `CLI_OPENCLAW_BIN` | `openclaw` | `src/shared/services/cliRuntime.ts` | OpenClaw CLI 二进制文件的自定义路径。 | +| `CLI_CURSOR_BIN` | `agent` | `src/shared/services/cliRuntime.ts` | Cursor agent 二进制文件的自定义路径。 | +| `CLI_CLINE_BIN` | `cline` | `src/shared/services/cliRuntime.ts` | Cline CLI 二进制文件的自定义路径。 | +| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Continue CLI 二进制文件的自定义路径。 | +| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Qoder CLI 二进制文件的自定义路径。 | +| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Qwen Code CLI 二进制文件的自定义路径。 | +| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Devin CLI 二进制文件的自定义路径(v3.8.0)。由 Windsurf/Devin executor 使用。 | +| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent 主目录,OmniRoute 从此处读取/写入 Hermes CLI 配置。与 Hermes PowerShell 安装程序在 Windows 上设置的环境变量(`%LOCALAPPDATA%\hermes`)一致。 | ### Docker 示例 @@ -366,59 +366,59 @@ CLI_CLAUDE_BIN=/host-cli/bin/claude 以下变量调优 `omniroute` CLI 二进制文件自身的行为(而非上面的 sidecar 检测)。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_LANG` | _(系统)_ | `bin/cli/i18n.mjs` | 强制 CLI 输出语言。BCP-47 locale(如 `en`、`pt-BR`)。覆盖系统 locale 环境变量(LC_ALL, LC_MESSAGES)。 | -| `OMNIROUTE_SHOW_LOG` | _(未设置)_ | `bin/cli/runtime/processSupervisor.mjs` | 设为 `1` 可在受监管模式下将服务器 stdout/stderr 转发到终端。等同于 `omniroute serve` 的 `--log` 标志。 | -| `OMNIROUTE_CLI_TOKEN` | _(未设置)_ | `bin/cli/api.mjs` | 作为 `x-omniroute-cli-token` 头注入的机器认证 Token。在任务 8.12 中自动生成。 | -| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | CLI → 服务器请求的单次尝试 HTTP 超时(毫秒)。 | -| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | 设为 `1` 可在 CLI 命令期间将重试/退避诊断信息打印到 stderr。 | -| `OMNIROUTE_PLUGIN_PATH` | _(未设置)_ | `bin/cli/plugins.mjs` | CLI 插件发现的自定义目录(`omniroute-cmd-*` 包)。未设置时默认为 `~/.omniroute/plugins/`。 | -| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | 设为 `1` 允许插件请求 `exec` 权限(从 Worker 沙箱中生成子进程)。仅供本地运维人员。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------------ | ---------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_LANG` | _(系统)_ | `bin/cli/i18n.mjs` | 强制 CLI 输出语言。BCP-47 locale(如 `en`、`pt-BR`)。覆盖系统 locale 环境变量(LC_ALL, LC_MESSAGES)。 | +| `OMNIROUTE_SHOW_LOG` | _(未设置)_ | `bin/cli/runtime/processSupervisor.mjs` | 设为 `1` 可在受监管模式下将服务器 stdout/stderr 转发到终端。等同于 `omniroute serve` 的 `--log` 标志。 | +| `OMNIROUTE_CLI_TOKEN` | _(未设置)_ | `bin/cli/api.mjs` | 作为 `x-omniroute-cli-token` 头注入的机器认证 Token。在任务 8.12 中自动生成。 | +| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | CLI → 服务器请求的单次尝试 HTTP 超时(毫秒)。 | +| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | 设为 `1` 可在 CLI 命令期间将重试/退避诊断信息打印到 stderr。 | +| `OMNIROUTE_PLUGIN_PATH` | _(未设置)_ | `bin/cli/plugins.mjs` | CLI 插件发现的自定义目录(`omniroute-cmd-*` 包)。未设置时默认为 `~/.omniroute/plugins/`。 | +| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | 设为 `1` 允许插件请求 `exec` 权限(从 Worker 沙箱中生成子进程)。仅供本地运维人员。 | --- ## 10. 内部 Agent 与 MCP 集成 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_BASE_URL` | 自动检测 | `open-sse/mcp-server/server.ts` | MCP/A2A 工具访问 OmniRoute 的显式 URL。覆盖 localhost 自动检测。 | -| `OMNIROUTE_API_KEY` | _(未设置)_ | MCP/A2A 模块 | 内部 MCP 工具和 A2A 技能调用的 API key。 | -| `OMNIROUTE_API_KEY_ID` | _(未设置)_ | `open-sse/mcp-server/audit.ts` | 用于 MCP 审计日志归属的 Key ID。 | -| `ROUTER_API_KEY` | _(未设置)_ | 旧版 | `OMNIROUTE_API_KEY` 的旧版别名。 | -| `OMNIROUTE_CONTEXT` | _(活跃上下文)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | `omniroute` 命令的 CLI 远程模式上下文/配置文件;覆盖本地上下文存储中的活跃上下文。等同于 `--context `。 | -| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `true` | `open-sse/mcp-server/server.ts` | 对 MCP 工具调用强制执行基于权限域的访问控制。 | -| `OMNIROUTE_MCP_SCOPES` | _(全部)_ | `open-sse/mcp-server/server.ts` | 逗号分隔的权限域:`admin`、`combos`、`health`、`models`、`routing`、`budget`、`metrics`、`pricing`、`memory`、`skills`。 | -| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | `false` | `open-sse/mcp-server/descriptionCompressor.ts` | 在序列化清单之前压缩 MCP 工具描述。启用值:`1`、`true`、`on`。 | -| `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | `rtk` | `open-sse/mcp-server/descriptionCompressor.ts` | 压缩算法/配置文件。禁用值:`0`、`false`、`off`。 | -| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | 模型目錄同步间隔,小时。 | -| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | 服务商速率限制和配额轮询间隔。 | -| `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | 批量同步中连续 OAuth 配额获取之间的间隔(毫秒);OAuth 连接逐个获取以避免冲击上游。`0` 表示退出(并发)。 | -| `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | 真实用量事件后刷新服务商限制前的延迟(毫秒),给上游配额 API 时间记录消费。 | -| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | 禁用所有后台服务(同步、价格、模型刷新)。适用于 CI/测试。 | -| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(未设置)_ | `src/lib/config/runtimeSettings.ts` | 在自动测试检测下强制运行后台任务。设为 `1` 可覆盖测试推断。 | -| `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | 预算重置检查频率(毫秒)。最低 `10000`。 | -| `OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS` | `60000` | `src/lib/quota/connectionRecovery.ts` | 主动连接冷却恢复频率(毫秒):对瞬态 `rate_limited_until` 已过期的连接进行重新校验,脱离请求热路径。最低 `5000`。 | -| `OMNIROUTE_DISABLE_CONNECTION_RECOVERY` | `false` | `src/lib/quota/connectionRecovery.ts` | 禁用主动连接冷却恢复调度器(`getProviderCredentials` 中的惰性恢复仍然生效)。 | -| `OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS` | `1800000` | `src/lib/jobs/reasoningCacheCleanupJob.ts` | 推理缓存清理频率(毫秒)。最低 `60000`。 | -| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | 配置热加载的轮询间隔(毫秒)。低于 `1000` 会被拒绝。 | -| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(启用)_ | `src/lib/db/apiKeys.ts` | 设为 `1` 可绕过 Redis 支持的 API Key 认证缓存(强制走数据库读取)。 | -| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | `0` | `open-sse/services/compression/engines/rtk/filterLoader.ts` | 信任用户管理的 RTK 项目过滤器规则,无需严格的签名检查。 | -| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | 引导脚本在初始设置后设为 `true`。控制设置向导的可见性。 | -| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | 逃生口:允许请求体覆盖 Antigravity 项目字段。 | -| `ANTIGRAVITY_CREDITS` | _(未设置)_ | `open-sse/services/antigravityCredits.ts` | 覆盖 Antigravity 的广告剩余积分(测试/强制值)。 | -| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | 覆盖自动检测本地登录导入的 Antigravity CLI (agy) Token 文件路径。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `OMNIROUTE_BASE_URL` | 自动检测 | `open-sse/mcp-server/server.ts` | MCP/A2A 工具访问 OmniRoute 的显式 URL。覆盖 localhost 自动检测。 | +| `OMNIROUTE_API_KEY` | _(未设置)_ | MCP/A2A 模块 | 内部 MCP 工具和 A2A 技能调用的 API key。 | +| `OMNIROUTE_API_KEY_ID` | _(未设置)_ | `open-sse/mcp-server/audit.ts` | 用于 MCP 审计日志归属的 Key ID。 | +| `ROUTER_API_KEY` | _(未设置)_ | 旧版 | `OMNIROUTE_API_KEY` 的旧版别名。 | +| `OMNIROUTE_CONTEXT` | _(活跃上下文)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | `omniroute` 命令的 CLI 远程模式上下文/配置文件;覆盖本地上下文存储中的活跃上下文。等同于 `--context `。 | +| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `true` | `open-sse/mcp-server/server.ts` | 对 MCP 工具调用强制执行基于权限域的访问控制。 | +| `OMNIROUTE_MCP_SCOPES` | _(全部)_ | `open-sse/mcp-server/server.ts` | 逗号分隔的权限域:`admin`、`combos`、`health`、`models`、`routing`、`budget`、`metrics`、`pricing`、`memory`、`skills`。 | +| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | `false` | `open-sse/mcp-server/descriptionCompressor.ts` | 在序列化清单之前压缩 MCP 工具描述。启用值:`1`、`true`、`on`。 | +| `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | `rtk` | `open-sse/mcp-server/descriptionCompressor.ts` | 压缩算法/配置文件。禁用值:`0`、`false`、`off`。 | +| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | 模型目錄同步间隔,小时。 | +| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | 服务商速率限制和配额轮询间隔。 | +| `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | 批量同步中连续 OAuth 配额获取之间的间隔(毫秒);OAuth 连接逐个获取以避免冲击上游。`0` 表示退出(并发)。 | +| `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | 真实用量事件后刷新服务商限制前的延迟(毫秒),给上游配额 API 时间记录消费。 | +| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | 禁用所有后台服务(同步、价格、模型刷新)。适用于 CI/测试。 | +| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(未设置)_ | `src/lib/config/runtimeSettings.ts` | 在自动测试检测下强制运行后台任务。设为 `1` 可覆盖测试推断。 | +| `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | 预算重置检查频率(毫秒)。最低 `10000`。 | +| `OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS` | `60000` | `src/lib/quota/connectionRecovery.ts` | 主动连接冷却恢复频率(毫秒):对瞬态 `rate_limited_until` 已过期的连接进行重新校验,脱离请求热路径。最低 `5000`。 | +| `OMNIROUTE_DISABLE_CONNECTION_RECOVERY` | `false` | `src/lib/quota/connectionRecovery.ts` | 禁用主动连接冷却恢复调度器(`getProviderCredentials` 中的惰性恢复仍然生效)。 | +| `OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS` | `1800000` | `src/lib/jobs/reasoningCacheCleanupJob.ts` | 推理缓存清理频率(毫秒)。最低 `60000`。 | +| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | 配置热加载的轮询间隔(毫秒)。低于 `1000` 会被拒绝。 | +| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(启用)_ | `src/lib/db/apiKeys.ts` | 设为 `1` 可绕过 Redis 支持的 API Key 认证缓存(强制走数据库读取)。 | +| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | `0` | `open-sse/services/compression/engines/rtk/filterLoader.ts` | 信任用户管理的 RTK 项目过滤器规则,无需严格的签名检查。 | +| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | 引导脚本在初始设置后设为 `true`。控制设置向导的可见性。 | +| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | 逃生口:允许请求体覆盖 Antigravity 项目字段。 | +| `ANTIGRAVITY_CREDITS` | _(未设置)_ | `open-sse/services/antigravityCredits.ts` | 覆盖 Antigravity 的广告剩余积分(测试/强制值)。 | +| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | 覆盖自动检测本地登录导入的 Antigravity CLI (agy) Token 文件路径。 | ### OAuth CLI 桥接(内部) -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_SERVER` | 自动检测 | `src/lib/oauth/config/index.ts` | CLI↔OmniRoute 认证桥接的服务器 URL。 | -| `OMNIROUTE_TOKEN` | _(未设置)_ | `src/lib/oauth/config/index.ts` | CLI 桥接的认证 Token。 | -| `OMNIROUTE_USER_ID` | `cli` | `src/lib/oauth/config/index.ts` | CLI 桥接会话的用户 ID。 | -| `SERVER_URL` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_SERVER` 的旧版别名。 | -| `CLI_TOKEN` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_TOKEN` 的旧版别名。 | -| `CLI_USER_ID` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_USER_ID` 的旧版别名。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------- | ---------- | ------------------------------- | ------------------------------------ | +| `OMNIROUTE_SERVER` | 自动检测 | `src/lib/oauth/config/index.ts` | CLI↔OmniRoute 认证桥接的服务器 URL。 | +| `OMNIROUTE_TOKEN` | _(未设置)_ | `src/lib/oauth/config/index.ts` | CLI 桥接的认证 Token。 | +| `OMNIROUTE_USER_ID` | `cli` | `src/lib/oauth/config/index.ts` | CLI 桥接会话的用户 ID。 | +| `SERVER_URL` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_SERVER` 的旧版别名。 | +| `CLI_TOKEN` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_TOKEN` 的旧版别名。 | +| `CLI_USER_ID` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_USER_ID` 的旧版别名。 | --- @@ -426,38 +426,38 @@ CLI_CLAUDE_BIN=/host-cli/bin/claude 用于 **localhost 开发** 的内置凭证。对于远程部署,请在各个服务商的开发者控制台中注册你自己的凭证。 -| 变量 | 服务商 | 备注 | -| --- | --- | --- | -| `CLAUDE_OAUTH_CLIENT_ID` | Claude Code (Anthropic) | 公共客户端 — 无需 secret。 | -| `CLAUDE_CODE_REDIRECT_URI` | Claude Code | 覆盖重定向 URI。默认值:`https://platform.claude.com/oauth/code/callback` | -| `CODEX_OAUTH_CLIENT_ID` | Codex / OpenAI | 公共客户端。 | -| `GEMINI_OAUTH_CLIENT_ID` | Gemini (Google) | 需要匹配的 `_SECRET`。 | -| `GEMINI_OAUTH_CLIENT_SECRET` | Gemini (Google) | — | -| `QWEN_OAUTH_CLIENT_ID` | Qwen (Alibaba) | 公共客户端。 | -| `KIMI_CODING_OAUTH_CLIENT_ID` | Kimi Coding (Moonshot) | 公共客户端。 | -| `ANTIGRAVITY_OAUTH_CLIENT_ID` | Antigravity (Google) | 需要匹配的 `_SECRET`。 | -| `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Antigravity (Google) | — | -| `GITHUB_OAUTH_CLIENT_ID` | GitHub Copilot | 公共客户端。 | -| `WINDSURF_FIREBASE_API_KEY` | Windsurf / Devin (v3.8) | Windsurf 安全 Token 服务用于刷新的公共 Firebase Web API key。客户端凭证(非密钥)。长期导入 Token 完全跳过此步骤。来源:从 Devin CLI 二进制文件中提取。 | -| `WINDSURF_API_KEY` | Windsurf / Devin (v3.8) | 无每个连接凭证时 `open-sse/executors/devin-cli.ts` 使用的 API key 回退。可选。 | -| `CLI_DEVIN_BIN` | Devin CLI (v3.8) | Devin CLI 二进制文件(`devin`)的自定义路径。由 `open-sse/executors/devin-cli.ts` 解析。 | -| `GITLAB_DUO_OAUTH_CLIENT_ID` | GitLab Duo (v3.8) | GitLab Duo 的 OAuth client ID。在 `https://gitlab.com/-/profile/applications` 注册应用,redirect URI 为 `/callback`,权限域为 `api, read_user, openid, profile, email`。回退到 `GITLAB_OAUTH_CLIENT_ID`。 | -| `GITLAB_DUO_OAUTH_CLIENT_SECRET` | GitLab Duo (v3.8) | GitLab Duo 的 OAuth client secret。可选 — PKCE 流程不需要 secret。回退到 `GITLAB_OAUTH_CLIENT_SECRET`。 | -| `GITLAB_DUO_BASE_URL` | GitLab Duo (v3.8) | 覆盖 GitLab 基础 URL(自托管 GitLab)。默认为 `https://gitlab.com`。回退到 `GITLAB_BASE_URL`。 | -| `GITLAB_BASE_URL` | GitLab Duo (v3.8) | `GITLAB_DUO_BASE_URL` 的旧版回退。在 `_DUO_` 变体未设置时使用。 | -| `GITLAB_OAUTH_CLIENT_ID` | GitLab Duo (v3.8) | `GITLAB_DUO_OAUTH_CLIENT_ID` 的旧版回退,由 `src/lib/oauth/constants/oauth.ts` 使用。 | -| `GITLAB_OAUTH_CLIENT_SECRET` | GitLab Duo (v3.8) | `GITLAB_DUO_OAUTH_CLIENT_SECRET` 的旧版回退,由 `src/lib/oauth/constants/oauth.ts` 使用。 | -| `QODER_OAUTH_CLIENT_SECRET` | Qoder | — | -| `QODER_OAUTH_AUTHORIZE_URL` | Qoder | 设置以启用 Qoder OAuth。 | -| `QODER_OAUTH_TOKEN_URL` | Qoder | — | -| `QODER_OAUTH_USERINFO_URL` | Qoder | — | -| `QODER_OAUTH_CLIENT_ID` | Qoder | — | -| `QODER_PERSONAL_ACCESS_TOKEN` | Qoder | 直接 API key 回退(绕过 OAuth)。 | -| `QODER_CLI_WORKSPACE` | Qoder | Qoder CLI 的 workspace ID。 | -| `OMNIROUTE_QODER_WORKSPACE` | Qoder | `QODER_CLI_WORKSPACE` 的别名。 | -| `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | 作为 `validated` 发送到 `/api/chat` 的前端 `tk` Token。当 Blackbox 强制 Token 匹配时必需;否则 OmniRoute 回退到随机 UUID。参阅 issue #2252。 | -| `VISION_BRIDGE_BASE_URL` | Vision Bridge 安全护栏 | 非 Anthropic 视觉桥接调用的 OpenAI 兼容基础 URL。默认为旧版 OpenAI URL 或 api.openai.com。指向 OmniRoute 的 `/v1` 自循环或任意 OpenAI 兼容端点(Gemini OpenAI-compat、OpenRouter)。Issue #2232。 | -| `VISION_BRIDGE_API_KEY` | Vision Bridge 安全护栏 | 上面 URL 的 API key。对于非 Anthropic 视觉桥接调用,覆盖每个服务商的 OpenAI / Google 环境变量。Anthropic 模型保留其专用的 Anthropic key 路径。Issue #2232。 | +| 变量 | 服务商 | 备注 | +| --------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CLAUDE_OAUTH_CLIENT_ID` | Claude Code (Anthropic) | 公共客户端 — 无需 secret。 | +| `CLAUDE_CODE_REDIRECT_URI` | Claude Code | 覆盖重定向 URI。默认值:`https://platform.claude.com/oauth/code/callback` | +| `CODEX_OAUTH_CLIENT_ID` | Codex / OpenAI | 公共客户端。 | +| `GEMINI_OAUTH_CLIENT_ID` | Gemini (Google) | 需要匹配的 `_SECRET`。 | +| `GEMINI_OAUTH_CLIENT_SECRET` | Gemini (Google) | — | +| `QWEN_OAUTH_CLIENT_ID` | Qwen (Alibaba) | 公共客户端。 | +| `KIMI_CODING_OAUTH_CLIENT_ID` | Kimi Coding (Moonshot) | 公共客户端。 | +| `ANTIGRAVITY_OAUTH_CLIENT_ID` | Antigravity (Google) | 需要匹配的 `_SECRET`。 | +| `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Antigravity (Google) | — | +| `GITHUB_OAUTH_CLIENT_ID` | GitHub Copilot | 公共客户端。 | +| `WINDSURF_FIREBASE_API_KEY` | Windsurf / Devin (v3.8) | Windsurf 安全 Token 服务用于刷新的公共 Firebase Web API key。客户端凭证(非密钥)。长期导入 Token 完全跳过此步骤。来源:从 Devin CLI 二进制文件中提取。 | +| `WINDSURF_API_KEY` | Windsurf / Devin (v3.8) | 无每个连接凭证时 `open-sse/executors/devin-cli.ts` 使用的 API key 回退。可选。 | +| `CLI_DEVIN_BIN` | Devin CLI (v3.8) | Devin CLI 二进制文件(`devin`)的自定义路径。由 `open-sse/executors/devin-cli.ts` 解析。 | +| `GITLAB_DUO_OAUTH_CLIENT_ID` | GitLab Duo (v3.8) | GitLab Duo 的 OAuth client ID。在 `https://gitlab.com/-/profile/applications` 注册应用,redirect URI 为 `/callback`,权限域为 `api, read_user, openid, profile, email`。回退到 `GITLAB_OAUTH_CLIENT_ID`。 | +| `GITLAB_DUO_OAUTH_CLIENT_SECRET` | GitLab Duo (v3.8) | GitLab Duo 的 OAuth client secret。可选 — PKCE 流程不需要 secret。回退到 `GITLAB_OAUTH_CLIENT_SECRET`。 | +| `GITLAB_DUO_BASE_URL` | GitLab Duo (v3.8) | 覆盖 GitLab 基础 URL(自托管 GitLab)。默认为 `https://gitlab.com`。回退到 `GITLAB_BASE_URL`。 | +| `GITLAB_BASE_URL` | GitLab Duo (v3.8) | `GITLAB_DUO_BASE_URL` 的旧版回退。在 `_DUO_` 变体未设置时使用。 | +| `GITLAB_OAUTH_CLIENT_ID` | GitLab Duo (v3.8) | `GITLAB_DUO_OAUTH_CLIENT_ID` 的旧版回退,由 `src/lib/oauth/constants/oauth.ts` 使用。 | +| `GITLAB_OAUTH_CLIENT_SECRET` | GitLab Duo (v3.8) | `GITLAB_DUO_OAUTH_CLIENT_SECRET` 的旧版回退,由 `src/lib/oauth/constants/oauth.ts` 使用。 | +| `QODER_OAUTH_CLIENT_SECRET` | Qoder | — | +| `QODER_OAUTH_AUTHORIZE_URL` | Qoder | 设置以启用 Qoder OAuth。 | +| `QODER_OAUTH_TOKEN_URL` | Qoder | — | +| `QODER_OAUTH_USERINFO_URL` | Qoder | — | +| `QODER_OAUTH_CLIENT_ID` | Qoder | — | +| `QODER_PERSONAL_ACCESS_TOKEN` | Qoder | 直接 API key 回退(绕过 OAuth)。 | +| `QODER_CLI_WORKSPACE` | Qoder | Qoder CLI 的 workspace ID。 | +| `OMNIROUTE_QODER_WORKSPACE` | Qoder | `QODER_CLI_WORKSPACE` 的别名。 | +| `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | 作为 `validated` 发送到 `/api/chat` 的前端 `tk` Token。当 Blackbox 强制 Token 匹配时必需;否则 OmniRoute 回退到随机 UUID。参阅 issue #2252。 | +| `VISION_BRIDGE_BASE_URL` | Vision Bridge 安全护栏 | 非 Anthropic 视觉桥接调用的 OpenAI 兼容基础 URL。默认为旧版 OpenAI URL 或 api.openai.com。指向 OmniRoute 的 `/v1` 自循环或任意 OpenAI 兼容端点(Gemini OpenAI-compat、OpenRouter)。Issue #2232。 | +| `VISION_BRIDGE_API_KEY` | Vision Bridge 安全护栏 | 上面 URL 的 API key。对于非 Anthropic 视觉桥接调用,覆盖每个服务商的 OpenAI / Google 环境变量。Anthropic 模型保留其专用的 Anthropic key 路径。Issue #2232。 | > [!WARNING] > @@ -478,20 +478,20 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] > **来源:** `open-sse/executors/base.ts` → `buildHeaders()` -| 变量 | 默认值 | 何时更新 | -| --- | --- | --- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.207 (external, cli)` | Anthropic 发布新的 CLI 版本时 | -| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | 设为 `1`/`true` 可将第三方测试工具的工具名称原封不动地转发到 Anthropic 的两条绑定路径上(原生 OAuth 和 CLIProxyAPI)。默认情况下 executor 会将非 Claude Code 的工具名称确定性别名化(Claude Code 存在规范映射的用规范映射,否则用 PascalCase),并通过 `_toolNameMap` 在响应中还原,从而确保带 snake_case 工具的测试工具不会被视为指纹化第三方客户端而被拒绝。仅供调试。 | -| `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | OpenAI 更新 Codex CLI 时 | -| `CODEX_CLIENT_VERSION` | `0.131.0` | 独立于完整 UA 字符串覆盖 Codex 客户端版本 | -| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | GitHub Copilot Chat 更新时 | -| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | Antigravity IDE 更新时 | -| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | Kiro IDE 更新时 | -| `KIRO_OAUTH_CLIENT_ID` | `kiro-cli` | 覆盖 Kiro social device-code `clientId`(公共 ID) | -| `KIRO_VERIFY_FULL_CRC` | `false` | 启用:在 Kiro 事件流上全帧消息 CRC 校验(调试损坏的流) | -| `QODER_USER_AGENT` | `Qoder-Cli` | Qoder CLI 更新时 | -| `QWEN_USER_AGENT` | `QwenCode/0.19.3 (linux; x64)` | Qwen Code 更新时 | -| `CURSOR_USER_AGENT` | `Cursor/3.3` | Cursor 更新时 | +| 变量 | 默认值 | 何时更新 | +| -------------------------------- | --------------------------------------------- | ------------------------------------------------------- | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.207 (external, cli)` | Anthropic 发布新的 CLI 版本时 | +| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | 设为 `1`/`true` 可将第三方测试工具的工具名称原封不动地转发到 Anthropic 的两条绑定路径上(原生 OAuth 和 CLIProxyAPI)。默认情况下 executor 会将非 Claude Code 的工具名称确定性别名化(Claude Code 存在规范映射的用规范映射,否则用 PascalCase),并通过 `_toolNameMap` 在响应中还原,从而确保带 snake_case 工具的测试工具不会被视为指纹化第三方客户端而被拒绝。仅供调试。 | +| `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | OpenAI 更新 Codex CLI 时 | +| `CODEX_CLIENT_VERSION` | `0.131.0` | 独立于完整 UA 字符串覆盖 Codex 客户端版本 | +| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | GitHub Copilot Chat 更新时 | +| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | Antigravity IDE 更新时 | +| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | Kiro IDE 更新时 | +| `KIRO_OAUTH_CLIENT_ID` | `kiro-cli` | 覆盖 Kiro social device-code `clientId`(公共 ID) | +| `KIRO_VERIFY_FULL_CRC` | `false` | 启用:在 Kiro 事件流上全帧消息 CRC 校验(调试损坏的流) | +| `QODER_USER_AGENT` | `Qoder-Cli` | Qoder CLI 更新时 | +| `QWEN_USER_AGENT` | `QwenCode/0.19.3 (linux; x64)` | Qwen Code 更新时 | +| `CURSOR_USER_AGENT` | `Cursor/3.3` | Cursor 更新时 | > [!TIP] > 你可以通过 `{PROVIDER_ID}_USER_AGENT` 模式为 **任意** 服务商添加 User-Agent 覆盖。Executor 会动态构建环境变量名。 @@ -506,30 +506,30 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] ### 按服务商 -| 变量 | 激活方式 | 效果 | -| --- | --- | --- | -| `CLI_COMPAT_CODEX` | `=1` | 模拟 Codex CLI 请求签名 | -| `CLI_COMPAT_CLAUDE` | `=1` | 模拟 Claude Code 请求签名 | -| `CLI_COMPAT_GITHUB` | `=1` | 模拟 GitHub Copilot 请求签名 | -| `CLI_COMPAT_ANTIGRAVITY` | `=1` | 模拟 Antigravity 请求签名 | -| `CLI_COMPAT_CURSOR` | `=1` | 模拟 Cursor 请求签名 | -| `CLI_COMPAT_KIMI_CODING` | `=1` | 模拟 Kimi Coding 请求签名 | -| `CLI_COMPAT_KILOCODE` | `=1` | 模拟 Kilo Code 请求签名 | -| `CLI_COMPAT_CLINE` | `=1` | 模拟 Cline 请求签名 | -| `CLI_COMPAT_QWEN` | `=1` | 模拟 Qwen Code 请求签名 | +| 变量 | 激活方式 | 效果 | +| ------------------------ | -------- | ---------------------------- | +| `CLI_COMPAT_CODEX` | `=1` | 模拟 Codex CLI 请求签名 | +| `CLI_COMPAT_CLAUDE` | `=1` | 模拟 Claude Code 请求签名 | +| `CLI_COMPAT_GITHUB` | `=1` | 模拟 GitHub Copilot 请求签名 | +| `CLI_COMPAT_ANTIGRAVITY` | `=1` | 模拟 Antigravity 请求签名 | +| `CLI_COMPAT_CURSOR` | `=1` | 模拟 Cursor 请求签名 | +| `CLI_COMPAT_KIMI_CODING` | `=1` | 模拟 Kimi Coding 请求签名 | +| `CLI_COMPAT_KILOCODE` | `=1` | 模拟 Kilo Code 请求签名 | +| `CLI_COMPAT_CLINE` | `=1` | 模拟 Cline 请求签名 | +| `CLI_COMPAT_QWEN` | `=1` | 模拟 Qwen Code 请求签名 | ### 全局 -| 变量 | 激活方式 | 效果 | -| --- | --- | --- | -| `CLI_COMPAT_ALL` | `=1` | 一次性为**所有**服务商启用指纹兼容。 | +| 变量 | 激活方式 | 效果 | +| ---------------- | -------- | ------------------------------------ | +| `CLI_COMPAT_ALL` | `=1` | 一次性为**所有**服务商启用指纹兼容。 | ### Kimi Coding CLI 身份标识覆盖 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `KIMI_CLI_VERSION` | `1.36.0` | `src/lib/oauth/providers/kimi-coding.ts` | 覆盖 OAuth/API 调用时发送的 Kimi CLI 版本。 | -| `KIMI_CODING_DEVICE_ID` | _(已捕获的默认值)_ | `src/lib/oauth/providers/kimi-coding.ts` | 覆盖客户端头中使用的已捕获 Kimi 设备 ID。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------- | ------------------ | ---------------------------------------- | ------------------------------------------- | +| `KIMI_CLI_VERSION` | `1.36.0` | `src/lib/oauth/providers/kimi-coding.ts` | 覆盖 OAuth/API 调用时发送的 Kimi CLI 版本。 | +| `KIMI_CODING_DEVICE_ID` | _(已捕获的默认值)_ | `src/lib/oauth/providers/kimi-coding.ts` | 覆盖客户端头中使用的已捕获 Kimi 设备 ID。 | > [!NOTE] > 此功能与 User-Agent 覆盖(§12)协同工作。指纹系统处理头部排序和 body 字段排序,User-Agent 覆盖处理具体的 UA 字符串。两者可独立启用。 @@ -544,10 +544,10 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] 识别模式:`{PROVIDER_ID}_API_KEY` -| 变量 | 服务商 | -| --- | --- | -| `DEEPSEEK_API_KEY` | DeepSeek | -| `NVIDIA_API_KEY` | NVIDIA NIM | +| 变量 | 服务商 | +| ------------------ | ---------- | +| `DEEPSEEK_API_KEY` | DeepSeek | +| `NVIDIA_API_KEY` | NVIDIA NIM | > [!NOTE] > 在 v3.8.0 中移除了 Groq、xAI、Mistral、Perplexity、Together AI、Fireworks、Cerebras、Cohere、Nebius 和 Qianfan 的静态 `${PROVIDER}_API_KEY` 条目,因为运行时不再读取它们 — 这些服务商仅通过 Dashboard / `data/provider-credentials.json` / 加密数据库获取凭证。参阅本文档末尾的 _审计:已移除/废弃的变量_ 部分了解迁移路径。 @@ -580,36 +580,36 @@ REQUEST_TIMEOUT_MS (全局覆盖) └── API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS (默认值: 0 = 禁用) ``` -| 变量 | 默认值 | 说明 | -| --- | --- | --- | -| `REQUEST_TIMEOUT_MS` | _(未设置)_ | 全局快捷方式 — 覆盖 `FETCH_TIMEOUT_MS` 和 `STREAM_IDLE_TIMEOUT_MS` 两者的默认值。 | -| `FETCH_TIMEOUT_MS` | `600000` | 上游服务商调用的 HTTP 请求总超时。 | -| `STREAM_IDLE_TIMEOUT_MS` | `600000` | SSE 块之间的最长静默时间,超时则中止。扩展推理模型很少暂停超过 90 秒。 | -| `STREAM_READINESS_TIMEOUT_MS` | `80000` | 接收第一个非 ping SSE 事件的超时时间。设置时继承 `REQUEST_TIMEOUT_MS`。 | -| `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | _(关闭)_ | 剥离会导致 OpenAI SDK 的 `responses.stream()` 以 502 报错的非标准 `codex.*` SSE 事件(如 `codex.rate_limits`)。设为 `true`/`1`/`yes` 可启用。 | -| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收响应头的超时时间。 | -| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收完整响应体的超时时间。 | -| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP 连接建立超时。 | -| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive 套接字空闲超时。 | -| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS 指纹代理(wreq-js)超时。 | -| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | `/v1` 桥接请求的代理跳超时。 | -| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | 桥接的服务器请求总超时。 | -| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | 通过桥接发送响应头的超时时间。 | -| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | 桥接 keep-alive 空闲超时。 | -| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | 原始套接字超时(0 = 禁用)。 | -| `SHUTDOWN_TIMEOUT_MS` | `30000` | SIGTERM/SIGINT 后强制退出前的宽限期。 | -| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | `FETCH_TIMEOUT_MS` 未设置时 `src/shared/utils/fetchTimeout.ts` 使用的回退值。 | -| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`chatgptTlsClient.ts`)。 | -| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000`(30 秒) | ChatGPT TLS sidecar(`chatgptTlsClient.ts`)在中止死流前等待第一个流式字节的最大时间。如果上游冷启动超过窗口则提高。 | -| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`claudeTlsClient.ts`)。 | -| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`perplexityTlsClient.ts`)。 | -| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`grokTlsClient.ts`)。 | -| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_BROWSER_POOL` | `on` | 用于浏览器端 Web Cookie 聊天的共享 Playwright 浏览器池(`browserPool.ts`);设为 `off` 可禁用。 | -| `WEB_COOKIE_USE_BROWSER` | `0` | 将 Web Cookie 聊天请求选择进入浏览器端路径(`browserBackedChat.ts`);`1` 启用。 | +| 变量 | 默认值 | 说明 | +| ------------------------------------------------ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | _(未设置)_ | 全局快捷方式 — 覆盖 `FETCH_TIMEOUT_MS` 和 `STREAM_IDLE_TIMEOUT_MS` 两者的默认值。 | +| `FETCH_TIMEOUT_MS` | `600000` | 上游服务商调用的 HTTP 请求总超时。 | +| `STREAM_IDLE_TIMEOUT_MS` | `600000` | SSE 块之间的最长静默时间,超时则中止。扩展推理模型很少暂停超过 90 秒。 | +| `STREAM_READINESS_TIMEOUT_MS` | `80000` | 接收第一个非 ping SSE 事件的超时时间。设置时继承 `REQUEST_TIMEOUT_MS`。 | +| `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | _(关闭)_ | 剥离会导致 OpenAI SDK 的 `responses.stream()` 以 502 报错的非标准 `codex.*` SSE 事件(如 `codex.rate_limits`)。设为 `true`/`1`/`yes` 可启用。 | +| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收响应头的超时时间。 | +| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收完整响应体的超时时间。 | +| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP 连接建立超时。 | +| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive 套接字空闲超时。 | +| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS 指纹代理(wreq-js)超时。 | +| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | `/v1` 桥接请求的代理跳超时。 | +| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | 桥接的服务器请求总超时。 | +| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | 通过桥接发送响应头的超时时间。 | +| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | 桥接 keep-alive 空闲超时。 | +| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | 原始套接字超时(0 = 禁用)。 | +| `SHUTDOWN_TIMEOUT_MS` | `30000` | SIGTERM/SIGINT 后强制退出前的宽限期。 | +| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | `FETCH_TIMEOUT_MS` 未设置时 `src/shared/utils/fetchTimeout.ts` 使用的回退值。 | +| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`chatgptTlsClient.ts`)。 | +| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | +| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000`(30 秒) | ChatGPT TLS sidecar(`chatgptTlsClient.ts`)在中止死流前等待第一个流式字节的最大时间。如果上游冷启动超过窗口则提高。 | +| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`claudeTlsClient.ts`)。 | +| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | +| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`perplexityTlsClient.ts`)。 | +| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | +| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`grokTlsClient.ts`)。 | +| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | +| `OMNIROUTE_BROWSER_POOL` | `on` | 用于浏览器端 Web Cookie 聊天的共享 Playwright 浏览器池(`browserPool.ts`);设为 `off` 可禁用。 | +| `WEB_COOKIE_USE_BROWSER` | `0` | 将 Web Cookie 聊天请求选择进入浏览器端路径(`browserBackedChat.ts`);`1` 启用。 | Combo 目标尝试继承已解析的上游请求超时(`FETCH_TIMEOUT_MS`,或当它提供 fetch 默认值时的 `REQUEST_TIMEOUT_MS`)。仅在 Combo 中设置 `targetTimeoutMs`、Combo 默认值或服务商覆盖值以加快 Combo 回退;超过当前上游超时的值会被截断到上游超时。 @@ -617,24 +617,24 @@ Combo 目标尝试继承已解析的上游请求超时(`FETCH_TIMEOUT_MS`, 服务商级熔断器调优。默认值反映了 v3.6 以来用于 500+ 连接的缩放值。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_THRESHOLD` | `8` | `open-sse/config/constants.ts` | OAuth 服务商的连续失败阈值,超过则熔断器跳开。 | -| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_RESET_MS` | `60000` | `open-sse/config/constants.ts` | OAuth 服务商熔断器的重置窗口(毫秒)。 | -| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_THRESHOLD` | `12` | `open-sse/config/constants.ts` | API-key 服务商的连续失败阈值。 | -| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS` | `30000` | `open-sse/config/constants.ts` | API-key 服务商熔断器的重置窗口(毫秒)。 | -| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD` | `2` | `open-sse/config/constants.ts` | 本地服务商(Ollama、LM Studio 等)的连续失败阈值。 | -| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS` | `15000` | `open-sse/config/constants.ts` | 本地服务商熔断器的重置窗口(毫秒)。 | -| `PIN_DROP_BACKOFF_LEVEL` | `2` | `open-sse/services/combo.ts` | 退避深度,达到此值后上下文缓存 pin 的服务商被视为持续不健康,pin 被丢弃以进行故障转移。 | -| `PIN_DROP_GRACE_MS` | `20000` | `open-sse/services/combo.ts` | 防抖窗口(毫秒),在丢弃上下文缓存 pin 之前容忍短暂的瞬态冷却。 | +| 变量 | 默认值 | 源文件 | 说明 | +| --------------------------------------------- | ------- | ------------------------------ | --------------------------------------------------------------------------------------- | +| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_THRESHOLD` | `8` | `open-sse/config/constants.ts` | OAuth 服务商的连续失败阈值,超过则熔断器跳开。 | +| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_RESET_MS` | `60000` | `open-sse/config/constants.ts` | OAuth 服务商熔断器的重置窗口(毫秒)。 | +| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_THRESHOLD` | `12` | `open-sse/config/constants.ts` | API-key 服务商的连续失败阈值。 | +| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS` | `30000` | `open-sse/config/constants.ts` | API-key 服务商熔断器的重置窗口(毫秒)。 | +| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD` | `2` | `open-sse/config/constants.ts` | 本地服务商(Ollama、LM Studio 等)的连续失败阈值。 | +| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS` | `15000` | `open-sse/config/constants.ts` | 本地服务商熔断器的重置窗口(毫秒)。 | +| `PIN_DROP_BACKOFF_LEVEL` | `2` | `open-sse/services/combo.ts` | 退避深度,达到此值后上下文缓存 pin 的服务商被视为持续不健康,pin 被丢弃以进行故障转移。 | +| `PIN_DROP_GRACE_MS` | `20000` | `open-sse/services/combo.ts` | 防抖窗口(毫秒),在丢弃上下文缓存 pin 之前容忍短暂的瞬态冷却。 | ### 场景 -| 场景 | 配置 | -| --- | --- | -| **长时间代码生成** | `REQUEST_TIMEOUT_MS=900000`(15 分钟) | -| **生产 API 快速失败** | `API_BRIDGE_PROXY_TIMEOUT_MS=10000` | -| **扩展推理模型** | `STREAM_IDLE_TIMEOUT_MS=300000`(块间 5 分钟) | +| 场景 | 配置 | +| --------------------- | ---------------------------------------------- | +| **长时间代码生成** | `REQUEST_TIMEOUT_MS=900000`(15 分钟) | +| **生产 API 快速失败** | `API_BRIDGE_PROXY_TIMEOUT_MS=10000` | +| **扩展推理模型** | `STREAM_IDLE_TIMEOUT_MS=300000`(块间 5 分钟) | --- @@ -642,65 +642,65 @@ Combo 目标尝试继承已解析的上游请求超时(`FETCH_TIMEOUT_MS`, 日志系统同时写入 stdout 和轮转日志文件。所有配置由 `src/lib/logEnv.ts` 读取。 -| 变量 | 默认值 | 说明 | -| --- | --- | --- | -| `APP_LOG_LEVEL` | `info` | 最低日志级别:`debug`、`info`、`warn`、`error`。 | -| `APP_LOG_FORMAT` | `text` | 输出格式:`text`(人类可读)或 `json`(结构化)。 | -| `APP_LOG_TO_FILE` | `true` | 同时写入日志文件和 stdout。 | -| `APP_LOG_FILE_PATH` | `logs/application/app.log` | 日志文件路径(相对于项目根目录或 `DATA_DIR`)。 | -| `APP_LOG_MAX_FILE_SIZE` | `50M` | 轮转前的最大文件大小。可接受:`50M`、`1G`、`512K` 或纯字节数。 | -| `APP_LOG_RETENTION_DAYS` | `7` | 保留轮转后应用日志文件的天数。 | -| `APP_LOG_MAX_FILES` | `20` | 最大轮转日志文件备份数。 | -| `CALL_LOG_RETENTION_DAYS` | `7` | 在数据库中保留请求/调用日志条目的天数。 | -| `CALL_LOG_MAX_ENTRIES` | `10000` | 内存缓冲区中最大调用日志条目数。 | -| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | `call_logs` SQLite 表在清理前的最大行数。 | -| `MAX_PENDING_REQUEST_AGE_MS` | `3600000`(1 小时) | 孤立活跃请求日志条目在内存清理前的最大生存时间。 | -| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | 当 `call_log_pipeline_enabled=true` 时在 pipeline artifacts 中存储流块。 | -| `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | 当 `call_log_pipeline_enabled=true` 时的最大 pipeline 调用日志工件大小(KB)。 | -| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | `proxy_logs` SQLite 表在清理前的最大行数。 | -| `APP_LOG_ROTATION_CHECK_INTERVAL_MS` | `60000`(1 分钟) | `src/lib/logRotation.ts` 重新检查活跃日志文件大小的频率。 | -| `CHAT_LOG_TEXT_LIMIT` | `65536` | 聊天日志工件中保留的最大字符串长度(默认 64 KB)。 | -| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `24` | 截断聊天日志载荷时从尾部保留的数组项数量。 | -| `CHAT_LOG_MAX_DEPTH` | `6` | 聊天日志载荷被截断前的最大嵌套深度。 | -| `CHAT_DEBUG_FILE` | `false` | 设为 true 时,`serializeArtifactForStorage` 跳过基于大小的截断。仅供调试。 | +| 变量 | 默认值 | 说明 | +| ----------------------------------------- | -------------------------- | ------------------------------------------------------------------------------ | +| `APP_LOG_LEVEL` | `info` | 最低日志级别:`debug`、`info`、`warn`、`error`。 | +| `APP_LOG_FORMAT` | `text` | 输出格式:`text`(人类可读)或 `json`(结构化)。 | +| `APP_LOG_TO_FILE` | `true` | 同时写入日志文件和 stdout。 | +| `APP_LOG_FILE_PATH` | `logs/application/app.log` | 日志文件路径(相对于项目根目录或 `DATA_DIR`)。 | +| `APP_LOG_MAX_FILE_SIZE` | `50M` | 轮转前的最大文件大小。可接受:`50M`、`1G`、`512K` 或纯字节数。 | +| `APP_LOG_RETENTION_DAYS` | `7` | 保留轮转后应用日志文件的天数。 | +| `APP_LOG_MAX_FILES` | `20` | 最大轮转日志文件备份数。 | +| `CALL_LOG_RETENTION_DAYS` | `7` | 在数据库中保留请求/调用日志条目的天数。 | +| `CALL_LOG_MAX_ENTRIES` | `10000` | 内存缓冲区中最大调用日志条目数。 | +| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | `call_logs` SQLite 表在清理前的最大行数。 | +| `MAX_PENDING_REQUEST_AGE_MS` | `3600000`(1 小时) | 孤立活跃请求日志条目在内存清理前的最大生存时间。 | +| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | 当 `call_log_pipeline_enabled=true` 时在 pipeline artifacts 中存储流块。 | +| `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | 当 `call_log_pipeline_enabled=true` 时的最大 pipeline 调用日志工件大小(KB)。 | +| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | `proxy_logs` SQLite 表在清理前的最大行数。 | +| `APP_LOG_ROTATION_CHECK_INTERVAL_MS` | `60000`(1 分钟) | `src/lib/logRotation.ts` 重新检查活跃日志文件大小的频率。 | +| `CHAT_LOG_TEXT_LIMIT` | `65536` | 聊天日志工件中保留的最大字符串长度(默认 64 KB)。 | +| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `24` | 截断聊天日志载荷时从尾部保留的数组项数量。 | +| `CHAT_LOG_MAX_DEPTH` | `6` | 聊天日志载荷被截断前的最大嵌套深度。 | +| `CHAT_DEBUG_FILE` | `false` | 设为 true 时,`serializeArtifactForStorage` 跳过基于大小的截断。仅供调试。 | --- ## 17. 内存优化 -| 变量 | 默认值 | 说明 | -| --- | --- | --- | -| `OMNIROUTE_MEMORY_MB` | _自动_ | 运行时 V8 堆限制(MB)。未设置时动态校准(约 35% 系统内存,限制在 `[512, 4096]`);`512` 仅是总内存不可读取时的下限。显式设置以覆盖。Docker 独立运行和 `omniroute serve` 用它设置 `--max-old-space-size`。 | -| `PROMPT_CACHE_MAX_SIZE` | `50` | 最大缓存系统提示条目数。 | -| `PROMPT_CACHE_MAX_BYTES` | `2097152`(2 MB) | 最大提示缓存总大小。 | -| `PROMPT_CACHE_TTL_MS` | `300000`(5 分钟) | 提示缓存条目 TTL。 | -| `SEMANTIC_CACHE_MAX_SIZE` | `100` | 最大缓存 temperature=0 响应数。 | -| `SEMANTIC_CACHE_MAX_BYTES` | `4194304`(4 MB) | 最大语义缓存总大小。 | -| `SEMANTIC_CACHE_TTL_MS` | `1800000`(30 分钟) | 语义缓存条目 TTL。 | -| `STREAM_HISTORY_MAX` | `50` | Dashboard 实时视图缓冲区中最大近期流事件数。 | -| `CONTEXT_LENGTH_DEFAULT` | `128000` | 没有显式配置的模型的全局回退最大上下文长度。 | -| `USAGE_TOKEN_BUFFER` | `100` | 跟踪用量配额时保留的额外 Token 余量。 | +| 变量 | 默认值 | 说明 | +| -------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_MEMORY_MB` | _自动_ | 运行时 V8 堆限制(MB)。未设置时动态校准(约 35% 系统内存,限制在 `[512, 4096]`);`512` 仅是总内存不可读取时的下限。显式设置以覆盖。Docker 独立运行和 `omniroute serve` 用它设置 `--max-old-space-size`。 | +| `PROMPT_CACHE_MAX_SIZE` | `50` | 最大缓存系统提示条目数。 | +| `PROMPT_CACHE_MAX_BYTES` | `2097152`(2 MB) | 最大提示缓存总大小。 | +| `PROMPT_CACHE_TTL_MS` | `300000`(5 分钟) | 提示缓存条目 TTL。 | +| `SEMANTIC_CACHE_MAX_SIZE` | `100` | 最大缓存 temperature=0 响应数。 | +| `SEMANTIC_CACHE_MAX_BYTES` | `4194304`(4 MB) | 最大语义缓存总大小。 | +| `SEMANTIC_CACHE_TTL_MS` | `1800000`(30 分钟) | 语义缓存条目 TTL。 | +| `STREAM_HISTORY_MAX` | `50` | Dashboard 实时视图缓冲区中最大近期流事件数。 | +| `CONTEXT_LENGTH_DEFAULT` | `128000` | 没有显式配置的模型的全局回退最大上下文长度。 | +| `USAGE_TOKEN_BUFFER` | `100` | 跟踪用量配额时保留的额外 Token 余量。 | ### 压缩 -| 变量 | 默认值 | 说明 | -| --- | --- | --- | +| 变量 | 默认值 | 说明 | +| ------------------------------------- | ------ | --------------------------------------------------------------------------------------- | | `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | 未设置 | 无需 `.rtk/trust.json` 哈希即可信任项目 `.rtk/filters.json`。仅在受控的本地开发中使用。 | ### 记忆引擎(plan 21) 持久记忆子系统(`src/lib/memory/`)的嵌入层、向量存储和重排序开关。 -| 变量 | 默认值 | 说明 | -| --- | --- | --- | -| `MEMORY_EMBEDDING_CACHE_TTL_MS` | `300000`(5 分钟) | 内存嵌入缓存(每个源/模型/维度签名)的 TTL。 | -| `MEMORY_EMBEDDING_CACHE_MAX` | `1000` | 嵌入缓存中保留的最大 LRU 条目数。 | -| `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | 用于可选的 `@huggingface/transformers` 本地 MiniLM 管线的 HF 仓库 ID(约 23 MB int8,约 400 MB RAM)。 | -| `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | 用于静态 potion/Model2Vec 查找表嵌入器的 HF 仓库 ID。懒加载下载到缓存目录。 | -| `MEMORY_STATIC_CACHE_DIR` | `/embeddings` | 用于缓存静态 potion 模型文件的目录。未设置时默认为 `DATA_DIR` 下。 | -| `MEMORY_VEC_TOP_K` | `20` | `src/lib/memory/vectorStore.ts` 内部 `sqlite-vec` 暴力向量搜索使用的默认 top-K。 | -| `MEMORY_RRF_K` | `60` | FTS5 + 向量混合检索的 Reciprocal Rank Fusion 常数 `k`(sqlite-vec 方案)。 | -| `HF_HUB_ENDPOINT` | `https://huggingface.co` | 覆盖 `staticPotion.ts` 使用的 Hugging Face Hub 基础 URL(如气隙环境下的镜像端点)。 | +| 变量 | 默认值 | 说明 | +| ------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------ | +| `MEMORY_EMBEDDING_CACHE_TTL_MS` | `300000`(5 分钟) | 内存嵌入缓存(每个源/模型/维度签名)的 TTL。 | +| `MEMORY_EMBEDDING_CACHE_MAX` | `1000` | 嵌入缓存中保留的最大 LRU 条目数。 | +| `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | 用于可选的 `@huggingface/transformers` 本地 MiniLM 管线的 HF 仓库 ID(约 23 MB int8,约 400 MB RAM)。 | +| `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | 用于静态 potion/Model2Vec 查找表嵌入器的 HF 仓库 ID。懒加载下载到缓存目录。 | +| `MEMORY_STATIC_CACHE_DIR` | `/embeddings` | 用于缓存静态 potion 模型文件的目录。未设置时默认为 `DATA_DIR` 下。 | +| `MEMORY_VEC_TOP_K` | `20` | `src/lib/memory/vectorStore.ts` 内部 `sqlite-vec` 暴力向量搜索使用的默认 top-K。 | +| `MEMORY_RRF_K` | `60` | FTS5 + 向量混合检索的 Reciprocal Rank Fusion 常数 `k`(sqlite-vec 方案)。 | +| `HF_HUB_ENDPOINT` | `https://huggingface.co` | 覆盖 `staticPotion.ts` 使用的 Hugging Face Hub 基础 URL(如气隙环境下的镜像端点)。 | ### 低内存 Docker 示例 @@ -719,60 +719,60 @@ STREAM_HISTORY_MAX=10 从外部源自动同步模型价格数据。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `PRICING_SYNC_ENABLED` | `false` | `src/lib/pricingSync.ts` | 可选的定期价格同步。 | -| `PRICING_SYNC_INTERVAL` | `86400`(24 小时) | `src/lib/pricingSync.ts` | 同步间隔,秒。 | -| `PRICING_SYNC_SOURCES` | `litellm` | `src/lib/pricingSync.ts` | 逗号分隔的数据源。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------- | ------------------ | ------------------------ | -------------------- | +| `PRICING_SYNC_ENABLED` | `false` | `src/lib/pricingSync.ts` | 可选的定期价格同步。 | +| `PRICING_SYNC_INTERVAL` | `86400`(24 小时) | `src/lib/pricingSync.ts` | 同步间隔,秒。 | +| `PRICING_SYNC_SOURCES` | `litellm` | `src/lib/pricingSync.ts` | 逗号分隔的数据源。 | --- ## Arena ELO 同步 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `ARENA_ELO_SYNC_ENABLED` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | 定期 Arena AI 排行榜 ELO 同步,可从 Dashboard Feature Flags 配置或设为 `false` 退出。 | -| `ARENA_ELO_SYNC_INTERVAL` | `86400`(24 小时) | `src/lib/arenaEloSync.ts` | 同步间隔,秒。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------- | ------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------------- | +| `ARENA_ELO_SYNC_ENABLED` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | 定期 Arena AI 排行榜 ELO 同步,可从 Dashboard Feature Flags 配置或设为 `false` 退出。 | +| `ARENA_ELO_SYNC_INTERVAL` | `86400`(24 小时) | `src/lib/arenaEloSync.ts` | 同步间隔,秒。 | --- ## 19. 模型同步(开发) -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------- | ------------------ | -------------------------- | ------------------------------ | | `MODELS_DEV_SYNC_INTERVAL` | `86400`(24 小时) | `src/lib/modelsDevSync.ts` | 开发时的模型目錄同步间隔,秒。 | --- ## 20. 服务商特定设置 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OPENROUTER_CATALOG_TTL_MS` | `86400000`(24 小时) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter 模型目錄缓存 TTL。 | -| `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | 在 `/v1/models` 响应中包含显示友好的 `name` 字段。对于只需要 ID 的客户端可禁用。 | -| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | NanoBanana 图片生成任务的最大等待时间。 | -| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana 任务轮询频率。 | -| `AWS_REGION` | _(未设置)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | 用于构建 AWS Bedrock 端点的区域(Kiro、音频)。 | -| `AWS_DEFAULT_REGION` | _(未设置)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | `AWS_REGION` 未设置时的回退。 | -| `CLOUDFLARE_ACCOUNT_ID` | _(未设置)_ | `open-sse/executors/cloudflare-ai.ts` | Cloudflare Workers AI 的 Account ID。 | -| `CLOUDFLARE_API_BASE` | `https://api.cloudflare.com/client/v4` | `src/app/api/settings/proxy/cloudflare-deploy/route.ts` | 覆盖代理池 Workers 中继部署器使用的 Cloudflare REST API 基础 URL(#4640 / 9router#1360)。 | -| `NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx` | 代理池"Deploy Relay"弹窗中建议的默认 worker 项目名。 | -| `NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | 设为 `false` 可从 Proxy Pool 选项卡中隐藏 Cloudflare Workers 中继选项。 | -| `CLOUDFLARED_BIN` | 自动检测 | `src/lib/cloudflaredTunnel.ts` | `cloudflared` 二进制文件的自定义路径。 | -| `DENO_DEPLOY_API_BASE` | `https://api.deno.com/v2` | `src/app/api/settings/proxy/deno-deploy/route.ts` | 覆盖代理池中继部署器使用的 Deno Deploy REST API 基础 URL(#4643 / 9router#1437)。 | -| `NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT` | `omniroute-deno-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx` | 代理池"Deploy Relay"弹窗中建议的默认 Deno Deploy 应用名。 | -| `NEXT_PUBLIC_DENO_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | 设为 `false` 可从 Proxy Pool 选项卡中隐藏 Deno Deploy 中继选项。 | -| `SEARCH_CACHE_TTL_MS` | `300000`(5 分钟) | `open-sse/services/searchCache.ts` | 搜索 API(Perplexity、Brave 等)响应缓存的 TTL。 | -| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | 允许每个 OpenAI 兼容服务商同时建立多个连接。 | -| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | 为仅 Claude Code 中继显示实验性 CC 兼容服务商 UI。 | -| `NINEROUTER_HOST` | `127.0.0.1` | `open-sse/executors/ninerouter.ts` | 覆盖嵌入式 9router 实例监听的主机。 | -| `NINEROUTER_PORT` | `20130` | `open-sse/executors/ninerouter.ts` | 覆盖嵌入式 9router 实例监听的端口。 | -| `EMBED_WS_PROXY_HOST` | `127.0.0.1` | `src/lib/services/embedWsProxy.ts` | 嵌入式服务 WebSocket 代理的绑定主机(默认仅 loopback)。 | -| `EMBED_WS_PROXY_PORT` | `20131` | `src/lib/services/embedWsProxy.ts` | 嵌入式服务 WebSocket 代理服务器的端口。 | -| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI 桥接主机(旧版集成)。 | -| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI 桥接端口。 | -| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI 配置目录。 | -| `LOCAL_HOSTNAMES` | _(空)_ | `open-sse/config/providerRegistry.ts` | 逗号分隔的额外被视为"本地"的主机名(Docker 服务名称等)。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ---------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `OPENROUTER_CATALOG_TTL_MS` | `86400000`(24 小时) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter 模型目錄缓存 TTL。 | +| `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | 在 `/v1/models` 响应中包含显示友好的 `name` 字段。对于只需要 ID 的客户端可禁用。 | +| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | NanoBanana 图片生成任务的最大等待时间。 | +| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana 任务轮询频率。 | +| `AWS_REGION` | _(未设置)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | 用于构建 AWS Bedrock 端点的区域(Kiro、音频)。 | +| `AWS_DEFAULT_REGION` | _(未设置)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | `AWS_REGION` 未设置时的回退。 | +| `CLOUDFLARE_ACCOUNT_ID` | _(未设置)_ | `open-sse/executors/cloudflare-ai.ts` | Cloudflare Workers AI 的 Account ID。 | +| `CLOUDFLARE_API_BASE` | `https://api.cloudflare.com/client/v4` | `src/app/api/settings/proxy/cloudflare-deploy/route.ts` | 覆盖代理池 Workers 中继部署器使用的 Cloudflare REST API 基础 URL(#4640 / 9router#1360)。 | +| `NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx` | 代理池"Deploy Relay"弹窗中建议的默认 worker 项目名。 | +| `NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | 设为 `false` 可从 Proxy Pool 选项卡中隐藏 Cloudflare Workers 中继选项。 | +| `CLOUDFLARED_BIN` | 自动检测 | `src/lib/cloudflaredTunnel.ts` | `cloudflared` 二进制文件的自定义路径。 | +| `DENO_DEPLOY_API_BASE` | `https://api.deno.com/v2` | `src/app/api/settings/proxy/deno-deploy/route.ts` | 覆盖代理池中继部署器使用的 Deno Deploy REST API 基础 URL(#4643 / 9router#1437)。 | +| `NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT` | `omniroute-deno-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx` | 代理池"Deploy Relay"弹窗中建议的默认 Deno Deploy 应用名。 | +| `NEXT_PUBLIC_DENO_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | 设为 `false` 可从 Proxy Pool 选项卡中隐藏 Deno Deploy 中继选项。 | +| `SEARCH_CACHE_TTL_MS` | `300000`(5 分钟) | `open-sse/services/searchCache.ts` | 搜索 API(Perplexity、Brave 等)响应缓存的 TTL。 | +| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | 允许每个 OpenAI 兼容服务商同时建立多个连接。 | +| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | 为仅 Claude Code 中继显示实验性 CC 兼容服务商 UI。 | +| `NINEROUTER_HOST` | `127.0.0.1` | `open-sse/executors/ninerouter.ts` | 覆盖嵌入式 9router 实例监听的主机。 | +| `NINEROUTER_PORT` | `20130` | `open-sse/executors/ninerouter.ts` | 覆盖嵌入式 9router 实例监听的端口。 | +| `EMBED_WS_PROXY_HOST` | `127.0.0.1` | `src/lib/services/embedWsProxy.ts` | 嵌入式服务 WebSocket 代理的绑定主机(默认仅 loopback)。 | +| `EMBED_WS_PROXY_PORT` | `20131` | `src/lib/services/embedWsProxy.ts` | 嵌入式服务 WebSocket 代理服务器的端口。 | +| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI 桥接主机(旧版集成)。 | +| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI 桥接端口。 | +| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI 配置目录。 | +| `LOCAL_HOSTNAMES` | _(空)_ | `open-sse/config/providerRegistry.ts` | 逗号分隔的额外被视为"本地"的主机名(Docker 服务名称等)。 | `ENABLE_CC_COMPATIBLE_PROVIDER` 仅适用于接受 Claude Code 客户端的第三方中继。 OmniRoute 会重写请求以使这些中继接受。如果你只想使用 @@ -783,23 +783,23 @@ Anthropic 兼容服务商。 ## 21. 代理健康 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | 快速失败健康检查超时。 | -| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | 健康检查结果缓存 TTL。 | -| `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | 代理健康探测失败的缓存 TTL。保持在低于 `PROXY_HEALTH_CACHE_TTL_MS` 的值,以便高并发下的瞬态代理超时能快速重试,同时不会为真正死掉的代理禁用快速失败。 | -| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | 允许 OAuth 和服务商校验流程在代理可达性预检失败时绕过固定代理直接连接。有效优先级为 Feature Flags DB 覆盖 > 环境变量 > 默认值。 | -| `RATE_LIMIT_MAX_WAIT_MS` | `120000`(2 分钟) | `open-sse/services/rateLimitManager.ts` | 在请求失败前等待 429 的最长时间。 | -| `RATE_LIMIT_AUTO_ENABLE` | _(未设置)_ | `open-sse/services/rateLimitManager.ts` | 强制打开/关闭自动启用速率限制安全网,不管持久化的 Dashboard 设置。接受 `true`/`1`/`on` 强制开启,`false`/`0`/`off` 强制关闭。 | -| `PROVIDER_COOLDOWN_ENABLED` | _(未设置 → 关闭)_ | `open-sse/services/providerCooldownTracker.ts` | 启用全局跨请求服务商/连接冷却跟踪。默认关闭(与 Connection Cooldown / Provider Circuit Breaker 重叠)。接受 `true`/`1`/`on` 启用。 | -| `PROVIDER_COOLDOWN_MIN_MS` | `5000` | `open-sse/services/providerCooldownTracker.ts` | 失败的服务商/连接重试前的最短冷却时间(毫秒)。随连续失败次数指数级增长。仅在 `PROVIDER_COOLDOWN_ENABLED` 时使用。 | -| `PROVIDER_COOLDOWN_MAX_MS` | `300000`(5 分钟) | `open-sse/services/providerCooldownTracker.ts` | 失败的服务商/连接重试前的最大冷却上限(毫秒)。仅在 `PROVIDER_COOLDOWN_ENABLED` 时使用。 | -| `STREAM_RECOVERY_ENABLED` | _(未设置 → 关闭)_ | `src/lib/resilience/settings.ts`(种子) → `open-sse/services/streamRecovery.ts`(逻辑) | **这是什么:** 透明恢复被截断的上游流(free-claude-code 端口)。将打开 SSE 窗口保持最多 `STREAM_RECOVERY.HOLDBACK_MS`(750 毫秒),使得 _提交前_ 截断 — 即任何字节到达客户端之前 — 被透明地重新打开和重试。**何时启用:** 频繁在流开始时 0 字节截断的不稳定上游;如果无法承受每个流最多 750 毫秒的首 Token 延迟增加,请保持关闭。接受 `true`/`1`/`on`。为持久化容灾设置提供种子;Dashboard 设置一旦设置即生效。 | -| `STREAM_RECOVERY_MIDSTREAM_ENABLED` | _(未设置 → 关闭)_ | `src/lib/resilience/settings.ts`(种子) → `open-sse/services/streamRecovery.ts`(逻辑) | **这是什么:** 流中续传(Fase 4.4)— _提交后_ 截断(字节已到达客户端)之后,用部分文本作为 assistant 预填充重新请求,拼接缺失的后缀。仅限纯文本 OpenAI 兼容流;有工具调用在进行时永不触发。**何时启用:** 长生成被中途截断,且你接受恢复的后缀以一次性爆发而非逐 Token 到达。独立于 `STREAM_RECOVERY_ENABLED`(不同的风险特征)。接受 `true`/`1`/`on`。 | -| `HEALTHCHECK_STAGGER_MS` | `3000` | `src/lib/tokenHealthCheck.ts` | 启动时服务商 Token 健康检查之间的错开间隔(毫秒)。 | -| `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | 模型作用域冷却响应上的自动重试次数,之后将错误返回给客户端。 | -| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | 冷却重试之间的最大退避间隔(秒)。不管上游 `Retry-After` 如何,均受此值限制。 | -| `HEADROOM_URL` | `http://localhost:8787` | `src/lib/headroom/detect.ts` | Headroom Token 节省器代理 URL。Dashboard 生命周期(`api/headroom/*`)默认在 loopback 上启动本地 `headroom-ai` CLI;覆盖以指向外部 Docker sidecar 代理。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | 快速失败健康检查超时。 | +| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | 健康检查结果缓存 TTL。 | +| `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | 代理健康探测失败的缓存 TTL。保持在低于 `PROXY_HEALTH_CACHE_TTL_MS` 的值,以便高并发下的瞬态代理超时能快速重试,同时不会为真正死掉的代理禁用快速失败。 | +| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | 允许 OAuth 和服务商校验流程在代理可达性预检失败时绕过固定代理直接连接。有效优先级为 Feature Flags DB 覆盖 > 环境变量 > 默认值。 | +| `RATE_LIMIT_MAX_WAIT_MS` | `120000`(2 分钟) | `open-sse/services/rateLimitManager.ts` | 在请求失败前等待 429 的最长时间。 | +| `RATE_LIMIT_AUTO_ENABLE` | _(未设置)_ | `open-sse/services/rateLimitManager.ts` | 强制打开/关闭自动启用速率限制安全网,不管持久化的 Dashboard 设置。接受 `true`/`1`/`on` 强制开启,`false`/`0`/`off` 强制关闭。 | +| `PROVIDER_COOLDOWN_ENABLED` | _(未设置 → 关闭)_ | `open-sse/services/providerCooldownTracker.ts` | 启用全局跨请求服务商/连接冷却跟踪。默认关闭(与 Connection Cooldown / Provider Circuit Breaker 重叠)。接受 `true`/`1`/`on` 启用。 | +| `PROVIDER_COOLDOWN_MIN_MS` | `5000` | `open-sse/services/providerCooldownTracker.ts` | 失败的服务商/连接重试前的最短冷却时间(毫秒)。随连续失败次数指数级增长。仅在 `PROVIDER_COOLDOWN_ENABLED` 时使用。 | +| `PROVIDER_COOLDOWN_MAX_MS` | `300000`(5 分钟) | `open-sse/services/providerCooldownTracker.ts` | 失败的服务商/连接重试前的最大冷却上限(毫秒)。仅在 `PROVIDER_COOLDOWN_ENABLED` 时使用。 | +| `STREAM_RECOVERY_ENABLED` | _(未设置 → 关闭)_ | `src/lib/resilience/settings.ts`(种子) → `open-sse/services/streamRecovery.ts`(逻辑) | **这是什么:** 透明恢复被截断的上游流(free-claude-code 端口)。将打开 SSE 窗口保持最多 `STREAM_RECOVERY.HOLDBACK_MS`(750 毫秒),使得 _提交前_ 截断 — 即任何字节到达客户端之前 — 被透明地重新打开和重试。**何时启用:** 频繁在流开始时 0 字节截断的不稳定上游;如果无法承受每个流最多 750 毫秒的首 Token 延迟增加,请保持关闭。接受 `true`/`1`/`on`。为持久化容灾设置提供种子;Dashboard 设置一旦设置即生效。 | +| `STREAM_RECOVERY_MIDSTREAM_ENABLED` | _(未设置 → 关闭)_ | `src/lib/resilience/settings.ts`(种子) → `open-sse/services/streamRecovery.ts`(逻辑) | **这是什么:** 流中续传(Fase 4.4)— _提交后_ 截断(字节已到达客户端)之后,用部分文本作为 assistant 预填充重新请求,拼接缺失的后缀。仅限纯文本 OpenAI 兼容流;有工具调用在进行时永不触发。**何时启用:** 长生成被中途截断,且你接受恢复的后缀以一次性爆发而非逐 Token 到达。独立于 `STREAM_RECOVERY_ENABLED`(不同的风险特征)。接受 `true`/`1`/`on`。 | +| `HEALTHCHECK_STAGGER_MS` | `3000` | `src/lib/tokenHealthCheck.ts` | 启动时服务商 Token 健康检查之间的错开间隔(毫秒)。 | +| `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | 模型作用域冷却响应上的自动重试次数,之后将错误返回给客户端。 | +| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | 冷却重试之间的最大退避间隔(秒)。不管上游 `Retry-After` 如何,均受此值限制。 | +| `HEADROOM_URL` | `http://localhost:8787` | `src/lib/headroom/detect.ts` | Headroom Token 节省器代理 URL。Dashboard 生命周期(`api/headroom/*`)默认在 loopback 上启动本地 `headroom-ai` CLI;覆盖以指向外部 Docker sidecar 代理。 | ### 流恢复调优常量(非环境变量) @@ -828,19 +828,19 @@ Anthropic 兼容服务商。 > [!CAUTION] > 这些变量会产生**详细输出**,并可能泄露敏感数据。**切勿在生产环境中启用。** -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `CURSOR_DEBUG` | _(未设置)_ | `open-sse/executors/cursor.ts` | 设为 `1` 可启用详细的 Cursor executor 日志(解码的 SSE 块等)。 | -| `CURSOR_STREAM_DEBUG` | _(未设置)_ | `open-sse/executors/cursor.ts` | `CURSOR_DEBUG` 的向后兼容别名。 | -| `CURSOR_DUMP_FILE` | _(未设置)_ | `open-sse/executors/cursor.ts` | 当 `CURSOR_DEBUG=1` 时,接收原始解码 Cursor 块的可选文件路径。 | -| `CURSOR_STREAM_TIMEOUT_MS` | `300000` | `open-sse/executors/cursor.ts` | Cursor executor 的流空闲超时(毫秒)。 | -| `CURSOR_TOOL_DIRECTIVE` | 启用 (`!== "0"`) | `open-sse/executors/cursor.ts` | 使 composer-2.5 可靠发出工具调用的工具提交指令。设为 `0` 可禁用。 | -| `CURSOR_IMAGE_FETCH_TIMEOUT_MS` | `15000` | `open-sse/utils/cursorImages.ts` | 远程 `image_url` 视觉输入的单张图片获取超时(毫秒)。 | -| `CURSOR_STATE_DB_PATH` | _(探测)_ | `open-sse/utils/cursorVersionDetector.ts` | 覆盖版本检测使用的 Cursor 状态数据库查询。 | -| `CURSOR_TOKEN` | _(未设置)_ | `scripts/ad-hoc/cursor-tap.cjs` | 开发工具使用的直接 Cursor bearer Token。 | -| `OMNIROUTE_LOG_REQUEST_SHAPE` | 启用 (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | 记录大型聊天载荷的 content-type/length 标记。设为 `"0"` 可静默。 | -| `DEBUG_RESPONSES_SSE_TO_JSON` | _(未设置)_ | `open-sse/handlers/responseTranslator.ts` | 设为 `true` 可记录 Responses API SSE→JSON 转换详情。 | -| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(未设置)_ | E2E 测试工具 | 设为 `true` 可启用 E2E 测试模式(放宽认证、测试钩子)。 | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------------- | ---------------- | ------------------------------------------ | ----------------------------------------------------------------- | +| `CURSOR_DEBUG` | _(未设置)_ | `open-sse/executors/cursor.ts` | 设为 `1` 可启用详细的 Cursor executor 日志(解码的 SSE 块等)。 | +| `CURSOR_STREAM_DEBUG` | _(未设置)_ | `open-sse/executors/cursor.ts` | `CURSOR_DEBUG` 的向后兼容别名。 | +| `CURSOR_DUMP_FILE` | _(未设置)_ | `open-sse/executors/cursor.ts` | 当 `CURSOR_DEBUG=1` 时,接收原始解码 Cursor 块的可选文件路径。 | +| `CURSOR_STREAM_TIMEOUT_MS` | `300000` | `open-sse/executors/cursor.ts` | Cursor executor 的流空闲超时(毫秒)。 | +| `CURSOR_TOOL_DIRECTIVE` | 启用 (`!== "0"`) | `open-sse/executors/cursor.ts` | 使 composer-2.5 可靠发出工具调用的工具提交指令。设为 `0` 可禁用。 | +| `CURSOR_IMAGE_FETCH_TIMEOUT_MS` | `15000` | `open-sse/utils/cursorImages.ts` | 远程 `image_url` 视觉输入的单张图片获取超时(毫秒)。 | +| `CURSOR_STATE_DB_PATH` | _(探测)_ | `open-sse/utils/cursorVersionDetector.ts` | 覆盖版本检测使用的 Cursor 状态数据库查询。 | +| `CURSOR_TOKEN` | _(未设置)_ | `scripts/ad-hoc/cursor-tap.cjs` | 开发工具使用的直接 Cursor bearer Token。 | +| `OMNIROUTE_LOG_REQUEST_SHAPE` | 启用 (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | 记录大型聊天载荷的 content-type/length 标记。设为 `"0"` 可静默。 | +| `DEBUG_RESPONSES_SSE_TO_JSON` | _(未设置)_ | `open-sse/handlers/responseTranslator.ts` | 设为 `true` 可记录 Responses API SSE→JSON 转换详情。 | +| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(未设置)_ | E2E 测试工具 | 设为 `true` 可启用 E2E 测试模式(放宽认证、测试钩子)。 | --- @@ -848,11 +848,11 @@ Anthropic 兼容服务商。 允许用户直接从 Dashboard 报告问题。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `GITHUB_ISSUES_REPO` | _(未设置)_ | `src/app/api/v1/issues/report/route.ts` | 仓库,`owner/repo` 格式。 | -| `GITHUB_ISSUES_TOKEN` | _(未设置)_ | `src/app/api/v1/issues/report/route.ts` | 具有 `issues:write` 权限域的 GitHub Personal Access Token。 | -| `GITHUB_TOKEN` | _(未设置)_ | issue 分类 / 云代理辅助 | 通用 GitHub 访问 Token,用作 `GITHUB_ISSUES_TOKEN` 的回退,并被 `src/lib/cloudAgent/*` 中的云代理辅助使用。 | +| 变量 | 默认值 | 源文件 | 说明 | +| --------------------- | ---------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `GITHUB_ISSUES_REPO` | _(未设置)_ | `src/app/api/v1/issues/report/route.ts` | 仓库,`owner/repo` 格式。 | +| `GITHUB_ISSUES_TOKEN` | _(未设置)_ | `src/app/api/v1/issues/report/route.ts` | 具有 `issues:write` 权限域的 GitHub Personal Access Token。 | +| `GITHUB_TOKEN` | _(未设置)_ | issue 分类 / 云代理辅助 | 通用 GitHub 访问 Token,用作 `GITHUB_ISSUES_TOKEN` 的回退,并被 `src/lib/cloudAgent/*` 中的云代理辅助使用。 | --- @@ -922,16 +922,16 @@ CLI_COMPAT_ALL=1 技能框架(`src/lib/skills/`)在沙箱环境中执行用户定义自动化时应用的限制和安全开关。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `SKILLS_SANDBOX_TIMEOUT_MS` | `10000`(10 秒) | `src/lib/skills/builtins.ts` | 沙箱技能代码的每次执行挂钟超时时间。硬限制;超时则杀死。 | -| `SKILLS_EXECUTION_TIMEOUT_MS` | _(回退到 `SKILLS_SANDBOX_TIMEOUT_MS`)_ | `src/lib/skills/` | 高级技能编排超时。设为高于 `SKILLS_SANDBOX_TIMEOUT_MS` 以允许多步工作流。 | -| `SKILLS_MAX_FILE_BYTES` | `1048576`(1 MB) | `src/lib/skills/builtins.ts` | 技能可从单个沙箱文件读取的最大字节数。 | -| `SKILLS_MAX_HTTP_RESPONSE_BYTES` | `256000`(250 KB) | `src/lib/skills/builtins.ts` | 技能内从单个 HTTP 响应捕获的最大字节数。 | -| `SKILLS_MAX_SANDBOX_OUTPUT_CHARS` | `100000` | `src/lib/skills/builtins.ts` | 沙箱调用返回的 stdout/stderr 字符硬上限。 | -| `SKILLS_SANDBOX_NETWORK_ENABLED` | `false` | `src/lib/skills/builtins.ts` | 设为 `1`/`true` 允许沙箱内部向外联网。默认**隔离**以确保安全。 | -| `SKILLS_ALLOWED_SANDBOX_IMAGES` | _(空)_ | `src/lib/skills/builtins.ts` | 逗号分隔的允许用于沙箱执行的容器镜像允许列表。空意味着仅使用内置默认值。 | -| `SKILLS_SANDBOX_DOCKER_IMAGE` | _(内置默认值)_ | `src/lib/skills/` | 启动 Docker 沙箱时使用的容器镜像。覆盖以固定自定义加固的基础镜像。 | +| 变量 | 默认值 | 源文件 | 说明 | +| --------------------------------- | -------------------------------------- | ---------------------------- | ------------------------------------------------------------------------- | +| `SKILLS_SANDBOX_TIMEOUT_MS` | `10000`(10 秒) | `src/lib/skills/builtins.ts` | 沙箱技能代码的每次执行挂钟超时时间。硬限制;超时则杀死。 | +| `SKILLS_EXECUTION_TIMEOUT_MS` | _(回退到 `SKILLS_SANDBOX_TIMEOUT_MS`)_ | `src/lib/skills/` | 高级技能编排超时。设为高于 `SKILLS_SANDBOX_TIMEOUT_MS` 以允许多步工作流。 | +| `SKILLS_MAX_FILE_BYTES` | `1048576`(1 MB) | `src/lib/skills/builtins.ts` | 技能可从单个沙箱文件读取的最大字节数。 | +| `SKILLS_MAX_HTTP_RESPONSE_BYTES` | `256000`(250 KB) | `src/lib/skills/builtins.ts` | 技能内从单个 HTTP 响应捕获的最大字节数。 | +| `SKILLS_MAX_SANDBOX_OUTPUT_CHARS` | `100000` | `src/lib/skills/builtins.ts` | 沙箱调用返回的 stdout/stderr 字符硬上限。 | +| `SKILLS_SANDBOX_NETWORK_ENABLED` | `false` | `src/lib/skills/builtins.ts` | 设为 `1`/`true` 允许沙箱内部向外联网。默认**隔离**以确保安全。 | +| `SKILLS_ALLOWED_SANDBOX_IMAGES` | _(空)_ | `src/lib/skills/builtins.ts` | 逗号分隔的允许用于沙箱执行的容器镜像允许列表。空意味着仅使用内置默认值。 | +| `SKILLS_SANDBOX_DOCKER_IMAGE` | _(内置默认值)_ | `src/lib/skills/` | 启动 Docker 沙箱时使用的容器镜像。覆盖以固定自定义加固的基础镜像。 | > [!CAUTION] > 启用 `SKILLS_SANDBOX_NETWORK_ENABLED=true` 会打开任意技能代码的出口路径。在共享部署中请搭配 `OUTBOUND_SSRF_GUARD_ENABLED=true` 和严格的 `CORS_ORIGIN`/代理策略。 @@ -942,88 +942,88 @@ CLI_COMPAT_ALL=1 服务商配额端点、网络隧道(Tailscale、Ngrok、MITM 调试代理)、1Proxy 出口池、数据库备份以及 executor 层或脚本引用的小型按功能覆盖。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | 速率限制器后端的 Redis 连接字符串。 | -| `ALIBABA_CODING_PLAN_HOST` | _(生产主机)_ | `open-sse/services/bailianQuotaFetcher.ts` | 覆盖用于获取阿里巴巴 Bailian coding-plan 配额的主机。 | -| `ALIBABA_CODING_PLAN_QUOTA_URL` | 派生自主机 | `open-sse/services/bailianQuotaFetcher.ts` | 阿里巴巴 Bailian 的完整配额 URL 覆盖。 | -| `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | 计算提示预算时为补全输出保留的 Token 数。 | -| `MODEL_ALIAS_COMPAT_ENABLED` | 启用 | `open-sse/services/model.ts` | 切换旧客户端使用的旧版模型别名兼容层。 | -| `OMNIROUTE_EMERGENCY_FALLBACK` | 启用 | `open-sse/services/emergencyFallback.ts` | 设为 `false`(或 `0`)可禁用紧急预算耗尽回退,该回退将失败的请求重新路由到免费 `nvidia`/`openai/gpt-oss-120b` 模型。有效优先级为 Feature Flags DB 覆盖 > 环境变量 > 默认值;如果不可用,服务回退到原始环境变量值。 | -| `COMMAND_CODE_CALLBACK_PORT` | _(未设置)_ | `src/app/api/providers/command-code/auth/shared.ts` | Command Code CLI 辅助使用的 OAuth 风格回调的本地端口。 | -| `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | 作为 `x-command-code-version` 头发送到 Command Code 上游的值。覆盖以升级 CLI 版本。 | -| `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | MITM 调试代理的本地绑定端口。 | -| `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | 设为 `1` 可禁用上游 TLS 校验(仅限开发)。 | -| `MITM_IDLE_TIMEOUT_MS` | `60000` | `src/mitm/socketTimeouts.ts`, `src/mitm/server.cjs` | 代理连接的空闲套接字超时(毫秒);超过该时间的空闲套接字会被拆除,避免泄露半打开隧道。 | -| `MITM_VERBOSE` | `1` | `src/mitm/server.cjs`, `src/mitm/_internal/bypass.cjs` | 路由决策日志详细程度:`0` 静默,值越大记录越多 bypass/路由决策。 | -| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | 启用 1Proxy 出口池同步。 | -| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy 服务 API URL 覆盖。 | -| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | 每次同步导入的最大代理数。 | -| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | `src/lib/oneproxySync.ts` | 导入代理的最低质量分。 | -| `FREE_PROXY_1PROXY_ENABLED` | `true` | `src/lib/freeProxyProviders/oneproxy.ts` | 启用 1proxy 免费代理源。设为 `false` 可禁用。 | -| `FREE_PROXY_1PROXY_API_URL` | _(见 oneproxy.ts)_ | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy API URL 覆盖。 | -| `FREE_PROXY_1PROXY_MAX` | `500` | `src/lib/freeProxyProviders/oneproxy.ts` | 从 1proxy 每次同步获取的最大代理数。 | -| `FREE_PROXY_1PROXY_MIN_QUALITY` | `50` | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy 导入的最低质量分阈值。 | -| `FREE_PROXY_PROXIFLY_ENABLED` | `true` | `src/lib/freeProxyProviders/proxifly.ts` | 启用 Proxifly 免费代理源。设为 `false` 可禁用。 | -| `FREE_PROXY_PROXIFLY_QUANTITY` | `100` | `src/lib/freeProxyProviders/proxifly.ts` | 每次 Proxifly 同步获取的代理数量。 | -| `FREE_PROXY_PROXIFLY_ANONYMITY` | `elite` | `src/lib/freeProxyProviders/proxifly.ts` | Proxifly 的匿名级别过滤(`elite`、`anonymous`、`transparent`)。 | -| `FREE_PROXY_IPLOCATE_ENABLED` | `false` | `src/lib/freeProxyProviders/iplocate.ts` | 启用 IPLocate 免费代理源。仅手动启用。 | -| `FREE_PROXY_IPLOCATE_BASE_URL` | `https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols` | `src/lib/freeProxyProviders/iplocate.ts` | IPLocate 代理列表基础 URL 覆盖。 | -| `NEXT_PUBLIC_VERCEL_RELAY_ENABLED` | `true` | `src/app/(dashboard)/…/ProxyPoolTab.tsx` | 在 Proxy Pool 选项卡中显示/隐藏 Deploy Vercel Relay 按钮。 | -| `VERCEL_API_BASE` | `https://api.vercel.com` | `src/app/api/settings/proxy/vercel-deploy/route.ts` | Vercel API 基础 URL 覆盖(用于测试)。 | -| `NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/…/VercelRelayModal.tsx` | Vercel Relay 部署弹窗中预填的默认项目名。 | -| `TAILSCALE_BIN` | _(自动检测)_ | `src/lib/tailscaleTunnel.ts` | `tailscale` 二进制文件的显式路径。 | -| `TAILSCALED_BIN` | _(自动检测)_ | `src/lib/tailscaleTunnel.ts` | `tailscaled` 守护进程二进制文件的显式路径。 | -| `TAILSCALE_AUTHKEY` | _(未设置)_ | `src/lib/tailscaleTunnel.ts` | 非交互式/无头 `tailscale up` 的预共享 Tailscale 认证密钥(通过 `--auth-key=` 传递)。未设置时,登录回退到交互式浏览器认证 URL。 | -| `NGROK_AUTHTOKEN` | _(未设置)_ | `src/lib/ngrokTunnel.ts` | 认证出口 ngrok 隧道。 | -| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | 磁盘上保留的最大 SQLite 备份文件数。覆盖从 Settings → Database backup retention 保存的值。 | -| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | 保留备份的最大天数。`0` 禁用基于时间的清理。覆盖从 Settings → Database backup retention 保存的值。 | -| `OMNIROUTE_TLS_PROXY_URL` | _(未设置)_ | `open-sse/services/chatgptTlsClient.ts` | 覆盖测试用的 TLS sidecar URL。生产环境应保持未设置。 | -| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | 入口点权限检查的容器运行时提示。在无根 Podman 下设为 `podman`,以便修复指令使用 `podman unshare chown` 而非 `sudo chown`。 | -| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | 配额共享消费存储后端:`sqlite`(默认)或 `redis`。 | -| `QUOTA_STORE_REDIS_URL` | _(未设置)_ | `src/lib/quota/storeFactory.ts` | `QUOTA_STORE_DRIVER=redis` 时使用的 Redis 连接字符串(如 `redis://localhost:6379`)。 | -| `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | 池饱和比率(0..1);达到或超过该值时池进入严格模式(不允许借用)。 | -| `QUOTA_SOFT_DEPRIORITIZE_FACTOR` | `0.7` | `open-sse/services/combo.ts` | 软配额策略降低目标优先级时应用的分数乘数(0..1)。 | -| `STATUS_SOFT_DEPRIORITIZE_FACTOR` | `0.5` | `open-sse/services/combo/autoStrategy.ts` | 预检配额截止关闭时(#4540),在 auto-combo 评分中对已耗尽的服务商(`credits_exhausted`/`rate_limited`)应用的分数乘数(0..1)。 | -| `QUOTA_CONSUMPTION_RETENTION_DAYS` | `14` | `src/lib/db/quotaConsumption.ts` | `quota_consumption` 桶在 GC(`gcQuotaConsumption`)前的保留窗口(天)。 | -| `QUOTA_PREFLIGHT_CUTOFF_ENABLED` | `false` | `src/lib/resilience/settings.ts` | 启用(默认关闭):在评分前丢弃低配额候选项的自动路由硬配额截止。 | -| `OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL` | `false` | `open-sse/services/autoCombo/virtualFactory.ts` | 启用(默认关闭):当 `auto/:` 过滤器没有匹配到已连接候选项时,恢复旧版回退到完整(未过滤)池的行为,而非返回空池。默认关闭使 `:free` 意为"仅免费层"。 | -| `AGENTBRIDGE_UPSTREAM_CA_CERT` | _(未设置)_ | `src/mitm/manager.ts` | AgentBridge 上游 TLS 连接信任的额外 CA 证书(PEM)。 | -| `INSPECTOR_BUFFER_SIZE` | `1000` | `src/mitm/inspector/buffer.ts` | Traffic Inspector 环形缓冲区中保留的最大捕获请求数。 | -| `INSPECTOR_MAX_BODY_KB` | `1024` | `src/mitm/inspector/buffer.ts` | 截断前捕获的请求/响应体最大大小(KB)。 | -| `INSPECTOR_HTTP_PROXY_PORT` | `8080` | `src/mitm/inspector/httpProxyServer.ts` | Traffic Inspector HTTP 代理的本地端口。 | -| `INSPECTOR_HTTP_PROXY_AUTOSTART` | `false` | `src/mitm/inspector/httpProxyServer.ts` | 启动时自动启动 inspector HTTP 代理。 | -| `INSPECTOR_TLS_INTERCEPT` | `false` | `src/lib/inspector/captureState.ts` | 对捕获的 HTTPS 流量启用 TLS 拦截(MITM)。 | -| `INSPECTOR_LLM_HOSTS_EXTRA` | _(未设置)_ | `src/lib/inspector/captureState.ts` | 额外的主机名(逗号分隔),被视为大语言模型端点以进行捕获。 | -| `INSPECTOR_MASK_SECRETS` | `true` | `src/mitm/inspector/buffer.ts` | 在捕获流量中脱敏(认证头 / API key)。 | -| `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES` | `30` | `src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts` | 系统代理安全护栏自动还原操作系统代理设置前的分钟数。 | -| `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(自动)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | 认证进入 inspector 的内部捕获摄入的 Token。 | -| `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Playground 比较模式下的最大并排列数。 | -| `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(未设置)_ | `src/app/(dashboard)/dashboard/playground/` | Playground 'improve prompt' 操作的默认模型(未设置时回退到活跃模型)。 | -| `BIFROST_ENABLED` | `1` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | bifrost sidecar 代理的主开关。设为 `0` 时,路由返回 503 并带 `X-Bifrost-Killswitch` 头,运维人员被弹回 TS 路径。无需重新部署即可禁用 sidecar(tier-1 路由事件、密钥轮换)。 | -| `BIFROST_BASE_URL` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 设置时,Bifrost sidecar 代理路由将 `/v1/chat/completions` 流量转发到此 Go 网关而非 TS 中继处理器。未设置 → 503-with-fallback。尾部斜杠会被剥离。 | -| `BIFROST_API_KEY` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Bifrost 网关的 API key(作为 `Authorization: Bearer ...` 发送)。未设置时,路由期望请求携带有效的 OmniRoute API key;此 key 仅用于网关侧认证。 | -| `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 设为 true 时,Bifrost sidecar 路由通过网关以 SSE 流式返回响应,而非 TS 流式 executor。设为 `0` 可强制通过网关返回非流式 JSON 响应。 | -| `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 代理到 Bifrost 网关时的每个请求超时(毫秒)。超时时路由通过 `X-Bifrost-Fallback` 头返回 TS 中继路径。 | -| `OMNIROUTE_BIFROST_KEY` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | `BIFROST_API_KEY` 的别名(供通过 `OMNIROUTE_*` 读取环境变量的脚本使用)。两者同时设置时 `BIFROST_API_KEY` 优先。 | -| `OMNIROUTE_RELAY_BACKEND` | `ts` / `auto` | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | `/api/v1/relay/chat/completions` 的中继后端:`ts | bifrost | auto`。`ts` = TypeScript 中继(未配置 Bifrost 时的默认值);`auto` 在 `BIFROST_BASE_URL` 已设置且 `BIFROST_ENABLED` ≠ `0` 时选择 Bifrost,若 sidecar 不可达则自动回退到 TS;`bifrost` 强制 Bifrost(严格,无回退)。认证/速率限制/注入安全护栏/allowlist 始终在 Next 路由中首先运行。响应携带 `X-Routing-Backend` / `X-Routing-Fallback`。 | -| `RELAY_ROUTING_BACKEND` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | `OMNIROUTE_RELAY_BACKEND` 的已接受别名(相同的 `ts | bifrost | auto` 值)。两者同时设置时 `OMNIROUTE_RELAY_BACKEND` 优先。 | -| `OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS` | `5000` | `src/app/api/v1/relay/chat/completions/bifrostCooldown.ts` | 在 `auto` 模式下 Bifrost sidecar 跳失败后的冷却时间(毫秒),在此期间中继重新尝试 sidecar 之前直接走 TS 路径;冷却过后再次探测。`0` 禁用。仅在 `OMNIROUTE_RELAY_BACKEND=auto` 时生效。 | -| `OMNIROUTE_TLS_CERT` | _(未设置)_ | `bin/cli/commands/serve.mjs` | PEM TLS 证书路径,用于 `omniroute serve` 通过 HTTPS 提供服务(等同于 `--tls-cert`)。必须与 `OMNIROUTE_TLS_KEY` 配对;独立服务器随后在同一监听器上终止 TLS(`wss://` 工作不变)。未设置 → 纯 HTTP。仅提供证书或密钥中的一个,或路径不可读,会记录警告并保持 HTTP。 | -| `OMNIROUTE_TLS_KEY` | _(未设置)_ | `bin/cli/commands/serve.mjs` | `omniroute serve` HTTPS 的 PEM TLS 私钥路径(等同于 `--tls-key`)。必须与 `OMNIROUTE_TLS_CERT` 配对。参阅 `OMNIROUTE_TLS_CERT`。 | -| `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED` | `0` | `src/lib/security/localEndpoints.ts` | `/api/local/*` 路由的主开关。未设置或设为 `0` 时,所有 `/api/local/*` 路由在生产环境中返回 503。在非 loopback 部署中必须设为 `1` 才能启用 Redis 启动器及类似的一键本地服务启动器。与 `isLocalOnlyPath()` 路由守卫分类(`src/server/authz/routeGuard.ts` 中的 `LOCAL_ONLY_API_PREFIXES`)构成双保险。 | -| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(未设置)_ | `src/lib/security/localEndpoints.ts` | 非 loopback 上的 `/api/local/*` 调用者的 Bearer Token(如桌面应用)。设置时,来自非 loopback IP 的请求必须携带 `Authorization: Bearer `。在非 loopback 部署中且 `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` 时必需。 | -| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器(`omniroute redis up`)的容器名。CLI 和 `RedisLauncherPanel` GUI 均使用。 | -| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器的主机端口。如果主机已绑定 6379 则提升。容器内部端口保持 6379。 | -| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器使用的 Redis 镜像。根据需要覆盖为 `redis:8-alpine` 或私有注册表镜像。 | -| `QDRANT_HOST` | `qdrant` | _(可选集群配置文件)_ | `--profile memory` 活跃时 Qdrant sidecar 的主机名。默认指向网内 qdrant 服务名;覆盖为外部部署。仅在代码中 `qdrantEnabled` 为 `true` 时使用(`src/lib/memory/vectorStore.ts:108`)。 | -| `QDRANT_PORT` | `6333` | _(可选集群配置文件)_ | Qdrant sidecar 的 REST 端口。 | -| `QDRANT_GRPC_PORT` | `6334` | _(可选集群配置文件)_ | Qdrant sidecar 的 gRPC 端口。偏好在流式操作中使用 gRPC 而非 REST 的客户端库使用。 | -| `QDRANT_API_KEY` | _(未设置)_ | _(可选集群配置文件)_ | Qdrant Cloud 或认证的本地实例的可选 API key。空 → 不发送 `api-key` 头。 | -| `QDRANT_COLLECTION` | `omniroute-memory` | _(可选集群配置文件)_ | OmniRoute 的对话记忆嵌入的 collection 名称。首次运行时以 `QDRANT_VECTOR_SIZE` 维度创建。 | -| `QDRANT_EMBEDDING_MODEL` | `text-embedding-3-small` | _(可选集群配置文件)_ | Qdrant collection 元数据中记录的默认嵌入模型名称。实际嵌入由 OmniRoute 设置中 `embeddingModel` 字段指向的任意服务商生成。 | -| `QDRANT_VECTOR_SIZE` | `1536` | _(可选集群配置文件)_ | 嵌入向量维度。必须与用于嵌入的模型匹配(text-embedding-3-small → 1536;ada-002 → 1536;nomic-embed-text → 768)。 | -| `QDRANT_HNSW_EF_CONSTRUCT` | `128` | _(可选集群配置文件)_ | HNSW 索引构建时精度。值越高构建越慢、搜索越快。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | 速率限制器后端的 Redis 连接字符串。 | +| `ALIBABA_CODING_PLAN_HOST` | _(生产主机)_ | `open-sse/services/bailianQuotaFetcher.ts` | 覆盖用于获取阿里巴巴 Bailian coding-plan 配额的主机。 | +| `ALIBABA_CODING_PLAN_QUOTA_URL` | 派生自主机 | `open-sse/services/bailianQuotaFetcher.ts` | 阿里巴巴 Bailian 的完整配额 URL 覆盖。 | +| `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | 计算提示预算时为补全输出保留的 Token 数。 | +| `MODEL_ALIAS_COMPAT_ENABLED` | 启用 | `open-sse/services/model.ts` | 切换旧客户端使用的旧版模型别名兼容层。 | +| `OMNIROUTE_EMERGENCY_FALLBACK` | 启用 | `open-sse/services/emergencyFallback.ts` | 设为 `false`(或 `0`)可禁用紧急预算耗尽回退,该回退将失败的请求重新路由到免费 `nvidia`/`openai/gpt-oss-120b` 模型。有效优先级为 Feature Flags DB 覆盖 > 环境变量 > 默认值;如果不可用,服务回退到原始环境变量值。 | +| `COMMAND_CODE_CALLBACK_PORT` | _(未设置)_ | `src/app/api/providers/command-code/auth/shared.ts` | Command Code CLI 辅助使用的 OAuth 风格回调的本地端口。 | +| `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | 作为 `x-command-code-version` 头发送到 Command Code 上游的值。覆盖以升级 CLI 版本。 | +| `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | MITM 调试代理的本地绑定端口。 | +| `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | 设为 `1` 可禁用上游 TLS 校验(仅限开发)。 | +| `MITM_IDLE_TIMEOUT_MS` | `60000` | `src/mitm/socketTimeouts.ts`, `src/mitm/server.cjs` | 代理连接的空闲套接字超时(毫秒);超过该时间的空闲套接字会被拆除,避免泄露半打开隧道。 | +| `MITM_VERBOSE` | `1` | `src/mitm/server.cjs`, `src/mitm/_internal/bypass.cjs` | 路由决策日志详细程度:`0` 静默,值越大记录越多 bypass/路由决策。 | +| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | 启用 1Proxy 出口池同步。 | +| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy 服务 API URL 覆盖。 | +| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | 每次同步导入的最大代理数。 | +| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | `src/lib/oneproxySync.ts` | 导入代理的最低质量分。 | +| `FREE_PROXY_1PROXY_ENABLED` | `true` | `src/lib/freeProxyProviders/oneproxy.ts` | 启用 1proxy 免费代理源。设为 `false` 可禁用。 | +| `FREE_PROXY_1PROXY_API_URL` | _(见 oneproxy.ts)_ | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy API URL 覆盖。 | +| `FREE_PROXY_1PROXY_MAX` | `500` | `src/lib/freeProxyProviders/oneproxy.ts` | 从 1proxy 每次同步获取的最大代理数。 | +| `FREE_PROXY_1PROXY_MIN_QUALITY` | `50` | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy 导入的最低质量分阈值。 | +| `FREE_PROXY_PROXIFLY_ENABLED` | `true` | `src/lib/freeProxyProviders/proxifly.ts` | 启用 Proxifly 免费代理源。设为 `false` 可禁用。 | +| `FREE_PROXY_PROXIFLY_QUANTITY` | `100` | `src/lib/freeProxyProviders/proxifly.ts` | 每次 Proxifly 同步获取的代理数量。 | +| `FREE_PROXY_PROXIFLY_ANONYMITY` | `elite` | `src/lib/freeProxyProviders/proxifly.ts` | Proxifly 的匿名级别过滤(`elite`、`anonymous`、`transparent`)。 | +| `FREE_PROXY_IPLOCATE_ENABLED` | `false` | `src/lib/freeProxyProviders/iplocate.ts` | 启用 IPLocate 免费代理源。仅手动启用。 | +| `FREE_PROXY_IPLOCATE_BASE_URL` | `https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols` | `src/lib/freeProxyProviders/iplocate.ts` | IPLocate 代理列表基础 URL 覆盖。 | +| `NEXT_PUBLIC_VERCEL_RELAY_ENABLED` | `true` | `src/app/(dashboard)/…/ProxyPoolTab.tsx` | 在 Proxy Pool 选项卡中显示/隐藏 Deploy Vercel Relay 按钮。 | +| `VERCEL_API_BASE` | `https://api.vercel.com` | `src/app/api/settings/proxy/vercel-deploy/route.ts` | Vercel API 基础 URL 覆盖(用于测试)。 | +| `NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/…/VercelRelayModal.tsx` | Vercel Relay 部署弹窗中预填的默认项目名。 | +| `TAILSCALE_BIN` | _(自动检测)_ | `src/lib/tailscaleTunnel.ts` | `tailscale` 二进制文件的显式路径。 | +| `TAILSCALED_BIN` | _(自动检测)_ | `src/lib/tailscaleTunnel.ts` | `tailscaled` 守护进程二进制文件的显式路径。 | +| `TAILSCALE_AUTHKEY` | _(未设置)_ | `src/lib/tailscaleTunnel.ts` | 非交互式/无头 `tailscale up` 的预共享 Tailscale 认证密钥(通过 `--auth-key=` 传递)。未设置时,登录回退到交互式浏览器认证 URL。 | +| `NGROK_AUTHTOKEN` | _(未设置)_ | `src/lib/ngrokTunnel.ts` | 认证出口 ngrok 隧道。 | +| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | 磁盘上保留的最大 SQLite 备份文件数。覆盖从 Settings → Database backup retention 保存的值。 | +| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | 保留备份的最大天数。`0` 禁用基于时间的清理。覆盖从 Settings → Database backup retention 保存的值。 | +| `OMNIROUTE_TLS_PROXY_URL` | _(未设置)_ | `open-sse/services/chatgptTlsClient.ts` | 覆盖测试用的 TLS sidecar URL。生产环境应保持未设置。 | +| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | 入口点权限检查的容器运行时提示。在无根 Podman 下设为 `podman`,以便修复指令使用 `podman unshare chown` 而非 `sudo chown`。 | +| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | 配额共享消费存储后端:`sqlite`(默认)或 `redis`。 | +| `QUOTA_STORE_REDIS_URL` | _(未设置)_ | `src/lib/quota/storeFactory.ts` | `QUOTA_STORE_DRIVER=redis` 时使用的 Redis 连接字符串(如 `redis://localhost:6379`)。 | +| `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | 池饱和比率(0..1);达到或超过该值时池进入严格模式(不允许借用)。 | +| `QUOTA_SOFT_DEPRIORITIZE_FACTOR` | `0.7` | `open-sse/services/combo.ts` | 软配额策略降低目标优先级时应用的分数乘数(0..1)。 | +| `STATUS_SOFT_DEPRIORITIZE_FACTOR` | `0.5` | `open-sse/services/combo/autoStrategy.ts` | 预检配额截止关闭时(#4540),在 auto-combo 评分中对已耗尽的服务商(`credits_exhausted`/`rate_limited`)应用的分数乘数(0..1)。 | +| `QUOTA_CONSUMPTION_RETENTION_DAYS` | `14` | `src/lib/db/quotaConsumption.ts` | `quota_consumption` 桶在 GC(`gcQuotaConsumption`)前的保留窗口(天)。 | +| `QUOTA_PREFLIGHT_CUTOFF_ENABLED` | `false` | `src/lib/resilience/settings.ts` | 启用(默认关闭):在评分前丢弃低配额候选项的自动路由硬配额截止。 | +| `OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL` | `false` | `open-sse/services/autoCombo/virtualFactory.ts` | 启用(默认关闭):当 `auto/:` 过滤器没有匹配到已连接候选项时,恢复旧版回退到完整(未过滤)池的行为,而非返回空池。默认关闭使 `:free` 意为"仅免费层"。 | +| `AGENTBRIDGE_UPSTREAM_CA_CERT` | _(未设置)_ | `src/mitm/manager.ts` | AgentBridge 上游 TLS 连接信任的额外 CA 证书(PEM)。 | +| `INSPECTOR_BUFFER_SIZE` | `1000` | `src/mitm/inspector/buffer.ts` | Traffic Inspector 环形缓冲区中保留的最大捕获请求数。 | +| `INSPECTOR_MAX_BODY_KB` | `1024` | `src/mitm/inspector/buffer.ts` | 截断前捕获的请求/响应体最大大小(KB)。 | +| `INSPECTOR_HTTP_PROXY_PORT` | `8080` | `src/mitm/inspector/httpProxyServer.ts` | Traffic Inspector HTTP 代理的本地端口。 | +| `INSPECTOR_HTTP_PROXY_AUTOSTART` | `false` | `src/mitm/inspector/httpProxyServer.ts` | 启动时自动启动 inspector HTTP 代理。 | +| `INSPECTOR_TLS_INTERCEPT` | `false` | `src/lib/inspector/captureState.ts` | 对捕获的 HTTPS 流量启用 TLS 拦截(MITM)。 | +| `INSPECTOR_LLM_HOSTS_EXTRA` | _(未设置)_ | `src/lib/inspector/captureState.ts` | 额外的主机名(逗号分隔),被视为大语言模型端点以进行捕获。 | +| `INSPECTOR_MASK_SECRETS` | `true` | `src/mitm/inspector/buffer.ts` | 在捕获流量中脱敏(认证头 / API key)。 | +| `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES` | `30` | `src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts` | 系统代理安全护栏自动还原操作系统代理设置前的分钟数。 | +| `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(自动)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | 认证进入 inspector 的内部捕获摄入的 Token。 | +| `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Playground 比较模式下的最大并排列数。 | +| `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(未设置)_ | `src/app/(dashboard)/dashboard/playground/` | Playground 'improve prompt' 操作的默认模型(未设置时回退到活跃模型)。 | +| `BIFROST_ENABLED` | `1` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | bifrost sidecar 代理的主开关。设为 `0` 时,路由返回 503 并带 `X-Bifrost-Killswitch` 头,运维人员被弹回 TS 路径。无需重新部署即可禁用 sidecar(tier-1 路由事件、密钥轮换)。 | +| `BIFROST_BASE_URL` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 设置时,Bifrost sidecar 代理路由将 `/v1/chat/completions` 流量转发到此 Go 网关而非 TS 中继处理器。未设置 → 503-with-fallback。尾部斜杠会被剥离。 | +| `BIFROST_API_KEY` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Bifrost 网关的 API key(作为 `Authorization: Bearer ...` 发送)。未设置时,路由期望请求携带有效的 OmniRoute API key;此 key 仅用于网关侧认证。 | +| `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 设为 true 时,Bifrost sidecar 路由通过网关以 SSE 流式返回响应,而非 TS 流式 executor。设为 `0` 可强制通过网关返回非流式 JSON 响应。 | +| `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 代理到 Bifrost 网关时的每个请求超时(毫秒)。超时时路由通过 `X-Bifrost-Fallback` 头返回 TS 中继路径。 | +| `OMNIROUTE_BIFROST_KEY` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | `BIFROST_API_KEY` 的别名(供通过 `OMNIROUTE_*` 读取环境变量的脚本使用)。两者同时设置时 `BIFROST_API_KEY` 优先。 | +| `OMNIROUTE_RELAY_BACKEND` | `ts` / `auto` | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | `/api/v1/relay/chat/completions` 的中继后端:`ts | bifrost | auto`。`ts` = TypeScript 中继(未配置 Bifrost 时的默认值);`auto`在`BIFROST_BASE_URL`已设置且`BIFROST_ENABLED`≠`0` 时选择 Bifrost,若 sidecar 不可达则自动回退到 TS;`bifrost`强制 Bifrost(严格,无回退)。认证/速率限制/注入安全护栏/allowlist 始终在 Next 路由中首先运行。响应携带`X-Routing-Backend`/`X-Routing-Fallback`。 | +| `RELAY_ROUTING_BACKEND` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | `OMNIROUTE_RELAY_BACKEND` 的已接受别名(相同的 `ts | bifrost | auto`值)。两者同时设置时`OMNIROUTE_RELAY_BACKEND` 优先。 | +| `OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS` | `5000` | `src/app/api/v1/relay/chat/completions/bifrostCooldown.ts` | 在 `auto` 模式下 Bifrost sidecar 跳失败后的冷却时间(毫秒),在此期间中继重新尝试 sidecar 之前直接走 TS 路径;冷却过后再次探测。`0` 禁用。仅在 `OMNIROUTE_RELAY_BACKEND=auto` 时生效。 | +| `OMNIROUTE_TLS_CERT` | _(未设置)_ | `bin/cli/commands/serve.mjs` | PEM TLS 证书路径,用于 `omniroute serve` 通过 HTTPS 提供服务(等同于 `--tls-cert`)。必须与 `OMNIROUTE_TLS_KEY` 配对;独立服务器随后在同一监听器上终止 TLS(`wss://` 工作不变)。未设置 → 纯 HTTP。仅提供证书或密钥中的一个,或路径不可读,会记录警告并保持 HTTP。 | +| `OMNIROUTE_TLS_KEY` | _(未设置)_ | `bin/cli/commands/serve.mjs` | `omniroute serve` HTTPS 的 PEM TLS 私钥路径(等同于 `--tls-key`)。必须与 `OMNIROUTE_TLS_CERT` 配对。参阅 `OMNIROUTE_TLS_CERT`。 | +| `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED` | `0` | `src/lib/security/localEndpoints.ts` | `/api/local/*` 路由的主开关。未设置或设为 `0` 时,所有 `/api/local/*` 路由在生产环境中返回 503。在非 loopback 部署中必须设为 `1` 才能启用 Redis 启动器及类似的一键本地服务启动器。与 `isLocalOnlyPath()` 路由守卫分类(`src/server/authz/routeGuard.ts` 中的 `LOCAL_ONLY_API_PREFIXES`)构成双保险。 | +| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(未设置)_ | `src/lib/security/localEndpoints.ts` | 非 loopback 上的 `/api/local/*` 调用者的 Bearer Token(如桌面应用)。设置时,来自非 loopback IP 的请求必须携带 `Authorization: Bearer `。在非 loopback 部署中且 `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` 时必需。 | +| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器(`omniroute redis up`)的容器名。CLI 和 `RedisLauncherPanel` GUI 均使用。 | +| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器的主机端口。如果主机已绑定 6379 则提升。容器内部端口保持 6379。 | +| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器使用的 Redis 镜像。根据需要覆盖为 `redis:8-alpine` 或私有注册表镜像。 | +| `QDRANT_HOST` | `qdrant` | _(可选集群配置文件)_ | `--profile memory` 活跃时 Qdrant sidecar 的主机名。默认指向网内 qdrant 服务名;覆盖为外部部署。仅在代码中 `qdrantEnabled` 为 `true` 时使用(`src/lib/memory/vectorStore.ts:108`)。 | +| `QDRANT_PORT` | `6333` | _(可选集群配置文件)_ | Qdrant sidecar 的 REST 端口。 | +| `QDRANT_GRPC_PORT` | `6334` | _(可选集群配置文件)_ | Qdrant sidecar 的 gRPC 端口。偏好在流式操作中使用 gRPC 而非 REST 的客户端库使用。 | +| `QDRANT_API_KEY` | _(未设置)_ | _(可选集群配置文件)_ | Qdrant Cloud 或认证的本地实例的可选 API key。空 → 不发送 `api-key` 头。 | +| `QDRANT_COLLECTION` | `omniroute-memory` | _(可选集群配置文件)_ | OmniRoute 的对话记忆嵌入的 collection 名称。首次运行时以 `QDRANT_VECTOR_SIZE` 维度创建。 | +| `QDRANT_EMBEDDING_MODEL` | `text-embedding-3-small` | _(可选集群配置文件)_ | Qdrant collection 元数据中记录的默认嵌入模型名称。实际嵌入由 OmniRoute 设置中 `embeddingModel` 字段指向的任意服务商生成。 | +| `QDRANT_VECTOR_SIZE` | `1536` | _(可选集群配置文件)_ | 嵌入向量维度。必须与用于嵌入的模型匹配(text-embedding-3-small → 1536;ada-002 → 1536;nomic-embed-text → 768)。 | +| `QDRANT_HNSW_EF_CONSTRUCT` | `128` | _(可选集群配置文件)_ | HNSW 索引构建时精度。值越高构建越慢、搜索越快。 | --- @@ -1033,38 +1033,38 @@ CLI_COMPAT_ALL=1 `scripts/dev/run-ecosystem-tests.mjs` 和 `scripts/build/uninstall.mjs` 使用。 在生产部署中保持下面所有值未设置。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_E2E_BOOTSTRAP_MODE` | `auth` | `scripts/dev/run-next-playwright.mjs` | Playwright 运行器的 E2E 引导模式(`auth`、`fresh`、`reuse`)。 | -| `OMNIROUTE_E2E_PASSWORD` | 回退到 `INITIAL_PASSWORD` | `scripts/dev/run-next-playwright.mjs` | 注入到 Playwright 环境的管理员密码。 | -| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | Playwright 运行期间禁用本地健康检查轮询。 | -| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | 测试期间禁用 OAuth Token 健康检查循环。 | -| `OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS` | _(未设置)_ | `src/lib/tokenHealthCheck.ts` | 逗号分隔的排除在主动 Token 刷新扫除之外的服务商(如 `codex,openai`)。完全禁用健康检查的目标替代方案 — 短 TTL 的服务商保持刷新,级联服务商保持仅响应式。 | -| `OMNIROUTE_HIDE_HEALTHCHECK_LOGS` | `true` | `scripts/dev/run-next-playwright.mjs` | Playwright stdout 中的健康检查日志静默。 | -| `OMNIROUTE_PLAYWRIGHT_SKIP_BUILD` | `0` | `scripts/dev/run-next-playwright.mjs` | Playwright 启动前跳过 Next.js 生产构建(CI 优化)。 | -| `OMNIROUTE_SKIP_UNINSTALL_HOOK` | `0` | `scripts/build/uninstall.mjs` | 跳过 OmniRoute 卸载钩子(CI 用,保持 `node_modules` 完整)。 | -| `ECOSYSTEM_SERVER_WAIT_MS` | `180000` | `scripts/dev/run-ecosystem-tests.mjs` | 服务器在运行生态系统/协议测试前变为健康的等待时间(毫秒)。 | -| `ELECTRON_SMOKE_URL` | `http://127.0.0.1:20128/login` | `scripts/dev/smoke-electron-packaged.mjs` | Electron 烟雾测试工具期望打包应用提供服务的 URL。 | -| `ELECTRON_SMOKE_TIMEOUT_MS` | `45000` | `scripts/dev/smoke-electron-packaged.mjs` | 烟雾测试工具放弃前的总超时时间(毫秒)。 | -| `ELECTRON_SMOKE_SETTLE_MS` | `2000` | `scripts/dev/smoke-electron-packaged.mjs` | 页面加载后的稳定窗口(毫秒)。 | -| `ELECTRON_SMOKE_APP_EXECUTABLE` | _(自动)_ | `scripts/dev/smoke-electron-packaged.mjs` | 打包的 Electron 可执行文件的显式路径。 | -| `ELECTRON_SMOKE_DATA_DIR` | _(临时目录)_ | `scripts/dev/smoke-electron-packaged.mjs` | Electron 烟雾测试运行的数据目录。 | -| `ELECTRON_SMOKE_KEEP_DATA` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | 设为 `1` 可在运行后保留烟雾测试数据目录。 | -| `ELECTRON_SMOKE_STREAM_LOGS` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | 设为 `1` 可在运行期间将 Electron 日志流式输出到 stdout。 | -| `CLI_DEVIN_BIN` | _(PATH 查找)_ | `open-sse/executors/devin-cli.ts` | 覆盖 Devin CLI 二进制文件路径。 | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------------------- | ------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_E2E_BOOTSTRAP_MODE` | `auth` | `scripts/dev/run-next-playwright.mjs` | Playwright 运行器的 E2E 引导模式(`auth`、`fresh`、`reuse`)。 | +| `OMNIROUTE_E2E_PASSWORD` | 回退到 `INITIAL_PASSWORD` | `scripts/dev/run-next-playwright.mjs` | 注入到 Playwright 环境的管理员密码。 | +| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | Playwright 运行期间禁用本地健康检查轮询。 | +| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | 测试期间禁用 OAuth Token 健康检查循环。 | +| `OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS` | _(未设置)_ | `src/lib/tokenHealthCheck.ts` | 逗号分隔的排除在主动 Token 刷新扫除之外的服务商(如 `codex,openai`)。完全禁用健康检查的目标替代方案 — 短 TTL 的服务商保持刷新,级联服务商保持仅响应式。 | +| `OMNIROUTE_HIDE_HEALTHCHECK_LOGS` | `true` | `scripts/dev/run-next-playwright.mjs` | Playwright stdout 中的健康检查日志静默。 | +| `OMNIROUTE_PLAYWRIGHT_SKIP_BUILD` | `0` | `scripts/dev/run-next-playwright.mjs` | Playwright 启动前跳过 Next.js 生产构建(CI 优化)。 | +| `OMNIROUTE_SKIP_UNINSTALL_HOOK` | `0` | `scripts/build/uninstall.mjs` | 跳过 OmniRoute 卸载钩子(CI 用,保持 `node_modules` 完整)。 | +| `ECOSYSTEM_SERVER_WAIT_MS` | `180000` | `scripts/dev/run-ecosystem-tests.mjs` | 服务器在运行生态系统/协议测试前变为健康的等待时间(毫秒)。 | +| `ELECTRON_SMOKE_URL` | `http://127.0.0.1:20128/login` | `scripts/dev/smoke-electron-packaged.mjs` | Electron 烟雾测试工具期望打包应用提供服务的 URL。 | +| `ELECTRON_SMOKE_TIMEOUT_MS` | `45000` | `scripts/dev/smoke-electron-packaged.mjs` | 烟雾测试工具放弃前的总超时时间(毫秒)。 | +| `ELECTRON_SMOKE_SETTLE_MS` | `2000` | `scripts/dev/smoke-electron-packaged.mjs` | 页面加载后的稳定窗口(毫秒)。 | +| `ELECTRON_SMOKE_APP_EXECUTABLE` | _(自动)_ | `scripts/dev/smoke-electron-packaged.mjs` | 打包的 Electron 可执行文件的显式路径。 | +| `ELECTRON_SMOKE_DATA_DIR` | _(临时目录)_ | `scripts/dev/smoke-electron-packaged.mjs` | Electron 烟雾测试运行的数据目录。 | +| `ELECTRON_SMOKE_KEEP_DATA` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | 设为 `1` 可在运行后保留烟雾测试数据目录。 | +| `ELECTRON_SMOKE_STREAM_LOGS` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | 设为 `1` 可在运行期间将 Electron 日志流式输出到 stdout。 | +| `CLI_DEVIN_BIN` | _(PATH 查找)_ | `open-sse/executors/devin-cli.ts` | 覆盖 Devin CLI 二进制文件路径。 | ### 文档翻译管线 由 `scripts/i18n/run-translation.mjs`(`npm run i18n:run` 命令)使用。 所有五个变量默认未设置 — 仅在能够运行文档翻译器的机器上的 `.env` 中设置。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_TRANSLATION_API_URL` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 翻译后端的 OpenAI 兼容基础 URL。 | -| `OMNIROUTE_TRANSLATION_API_KEY` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 翻译后端的 Bearer Token(永不被记录)。 | -| `OMNIROUTE_TRANSLATION_MODEL` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 模型 ID,如 `gpt-4o-mini` 或 `cx/gpt-5.4-mini`。 | -| `OMNIROUTE_TRANSLATION_TIMEOUT_MS` | `60000` | `scripts/i18n/run-translation.mjs` | 每个请求的超时,毫秒。 | -| `OMNIROUTE_TRANSLATION_CONCURRENCY` | `4` | `scripts/i18n/run-translation.mjs` | 跨多个文件/locale 运行时的并行翻译请求数。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------- | ---------- | ---------------------------------- | ------------------------------------------------ | +| `OMNIROUTE_TRANSLATION_API_URL` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 翻译后端的 OpenAI 兼容基础 URL。 | +| `OMNIROUTE_TRANSLATION_API_KEY` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 翻译后端的 Bearer Token(永不被记录)。 | +| `OMNIROUTE_TRANSLATION_MODEL` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 模型 ID,如 `gpt-4o-mini` 或 `cx/gpt-5.4-mini`。 | +| `OMNIROUTE_TRANSLATION_TIMEOUT_MS` | `60000` | `scripts/i18n/run-translation.mjs` | 每个请求的超时,毫秒。 | +| `OMNIROUTE_TRANSLATION_CONCURRENCY` | `4` | `scripts/i18n/run-translation.mjs` | 跨多个文件/locale 运行时的并行翻译请求数。 | --- @@ -1072,26 +1072,26 @@ CLI_COMPAT_ALL=1 以下变量出现在旧版 `.env.example` 中,但在当前代码库中**没有运行时引用**。它们已被移除: -| 变量 | 原因 | -| --- | --- | -| `STORAGE_DRIVER=sqlite` | 任何源文件均未读取。SQLite 是唯一支持的驱动 — 无需选择。 | -| `INSTANCE_NAME=omniroute` | 存在于旧文档/环境模板中但运行时未使用。可能在未来多实例功能中回归。 | -| `SQLITE_MAX_SIZE_MB=2048` | 源代码中未引用。数据库大小未被人为限制。 | -| `SQLITE_CLEAN_LEGACY_FILES=true` | 源代码中未引用。旧版清理可能已被移除。 | -| `CLI_ROO_BIN` | 未在 `src/shared/services/cliRuntime.ts` 中注册。 | -| `CLI_KIMI_CODING_BIN` | 未在 `src/shared/services/cliRuntime.ts` 中注册(Kimi Coding 使用 OAuth,而非 CLI 二进制文件)。 | -| `IFLOW_OAUTH_CLIENT_ID` / `IFLOW_OAUTH_CLIENT_SECRET` | 源代码中任何地方均未引用。 | +| 变量 | 原因 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_DRIVER=sqlite` | 任何源文件均未读取。SQLite 是唯一支持的驱动 — 无需选择。 | +| `INSTANCE_NAME=omniroute` | 存在于旧文档/环境模板中但运行时未使用。可能在未来多实例功能中回归。 | +| `SQLITE_MAX_SIZE_MB=2048` | 源代码中未引用。数据库大小未被人为限制。 | +| `SQLITE_CLEAN_LEGACY_FILES=true` | 源代码中未引用。旧版清理可能已被移除。 | +| `CLI_ROO_BIN` | 未在 `src/shared/services/cliRuntime.ts` 中注册。 | +| `CLI_KIMI_CODING_BIN` | 未在 `src/shared/services/cliRuntime.ts` 中注册(Kimi Coding 使用 OAuth,而非 CLI 二进制文件)。 | +| `IFLOW_OAUTH_CLIENT_ID` / `IFLOW_OAUTH_CLIENT_SECRET` | 源代码中任何地方均未引用。 | | `CEREBRAS_API_KEY` / `COHERE_API_KEY` / `FIREWORKS_API_KEY` / `GROQ_API_KEY` / `MISTRAL_API_KEY` / `NEBIUS_API_KEY` / `PERPLEXITY_API_KEY` / `TOGETHER_API_KEY` / `XAI_API_KEY` | 在 v3.8.0 中移除。运行时不再读取这些环境变量 — 凭证来自 Dashboard / `data/provider-credentials.json` / 加密数据库。 | -| `CURSOR_PROTOBUF_DEBUG` | 在 v3.8.0 中移除。Cursor executor 使用 `CURSOR_DEBUG` / `CURSOR_STREAM_DEBUG`(参阅 §22)。 | -| `CLI_COMPAT_KIRO` | 在 v3.8.0 中移除。Kiro 在 `CLI_COMPAT_OMITTED_PROVIDER_IDS` 中 — 其开关无效。 | -| `QIANFAN_API_KEY` | 在 v3.8.0 中随其他未使用的服务商 API key 桩一起移除。 | +| `CURSOR_PROTOBUF_DEBUG` | 在 v3.8.0 中移除。Cursor executor 使用 `CURSOR_DEBUG` / `CURSOR_STREAM_DEBUG`(参阅 §22)。 | +| `CLI_COMPAT_KIRO` | 在 v3.8.0 中移除。Kiro 在 `CLI_COMPAT_OMITTED_PROVIDER_IDS` 中 — 其开关无效。 | +| `QIANFAN_API_KEY` | 在 v3.8.0 中随其他未使用的服务商 API key 桩一起移除。 | ### 默认值修正 -| 变量 | 旧 `.env.example` 值 | 实际代码默认值 | 修正 | -| --- | --- | --- | --- | -| `APP_LOG_RETENTION_DAYS` | `90` | `7` | ✅ 已移除误导性值;记录 `7` 为默认值 | -| `CALL_LOG_RETENTION_DAYS` | `90` | `7` | ✅ 已移除误导性值;记录 `7` 为默认值 | +| 变量 | 旧 `.env.example` 值 | 实际代码默认值 | 修正 | +| ------------------------- | -------------------- | -------------- | ------------------------------------ | +| `APP_LOG_RETENTION_DAYS` | `90` | `7` | ✅ 已移除误导性值;记录 `7` 为默认值 | +| `CALL_LOG_RETENTION_DAYS` | `90` | `7` | ✅ 已移除误导性值;记录 `7` 为默认值 | ### OpenCode 配置重新生成(临时工具) @@ -1099,17 +1099,17 @@ CLI_COMPAT_ALL=1 其中包含从运行中的 OmniRoute 实例拉取的准确的 `limit.context` 和 `limit.output` 值。 这些变量都不是正常运行所需的 — 该脚本仅供开发者工具使用。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_URL` | `http://localhost:20128` | `scripts/ad-hoc/regen-opencode-config.ts` | 查询 `/v1/models` 的 OmniRoute 实例基础 URL。 | -| `OMNIROUTE_KEY` | _(未设置)_ | `scripts/ad-hoc/regen-opencode-config.ts` | 认证 OmniRoute `/v1/models` 端点的 API key。未设置时回退到 `OPENCODE_API_KEY`。 | -| `OPENCODE_API_KEY` | _(未设置)_ | `scripts/ad-hoc/regen-opencode-config.ts` | 写入重新生成的 `opencode.json` 的 OpenCode 风格 API key (`sk-...`)。未设置时回退到 `OMNIROUTE_KEY`。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------ | ------------------------ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_URL` | `http://localhost:20128` | `scripts/ad-hoc/regen-opencode-config.ts` | 查询 `/v1/models` 的 OmniRoute 实例基础 URL。 | +| `OMNIROUTE_KEY` | _(未设置)_ | `scripts/ad-hoc/regen-opencode-config.ts` | 认证 OmniRoute `/v1/models` 端点的 API key。未设置时回退到 `OPENCODE_API_KEY`。 | +| `OPENCODE_API_KEY` | _(未设置)_ | `scripts/ad-hoc/regen-opencode-config.ts` | 写入重新生成的 `opencode.json` 的 OpenCode 风格 API key (`sk-...`)。未设置时回退到 `OMNIROUTE_KEY`。 | ### 压缩离线评估工具(临时工具) 由离线压缩评估 CLI `scripts/compression-eval/index.ts` 使用。 正常运行不需要 — 仅供开发者工具使用。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | +| 变量 | 默认值 | 源文件 | 说明 | +| ---------------------------- | ---------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `OMNIROUTE_EVAL_CREDENTIALS` | `{}`(空) | `scripts/compression-eval/index.ts` | 运维人员提供的 JSON 凭证,供离线压缩评估 CLI 使用的服务商使用(通过 `JSON.parse` 解析)。未设置时进行试运行。 | diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index bd8fe069461f..b70636ee6c84 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -279,17 +279,11 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `OMNIROUTE_KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Alternate spelling of `KIE_CALLBACK_URL`. Falls back when the primary variable is unset. | | `OMNIROUTE_PUBLIC_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public origin used to compose async callback URLs. Lowest-priority fallback for kie.ai callbacks; also used as a generic public URL for other relays. | | `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | CrofAI quota lookup endpoint used by the Usage page. Override for relays / test fixtures. | -| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | OpenCode (zen/go) quota lookup endpoint used by the Usage page. Override for relays / test fixtures. | -| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | `https://api.z.ai/api/monitor/usage/quota/limit` | `open-sse/services/usage.ts` | OpenCode Go quota lookup endpoint used by the Usage page. Override for relays / test fixtures. | -| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | OpenCode Go dashboard base URL used for quota scraping when a workspace ID and auth cookie are configured. Override for relays / test fixtures. | -| `OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID used for dashboard quota scraping. Prefer the per-connection Dashboard field when multiple accounts are configured. | -| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go workspace ID env var used before the shorter alias. Prefer the per-connection Dashboard field when multiple accounts are configured. | -| `OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` cookie used for dashboard quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | +| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/usage` | `open-sse/services/opencodeQuotaFetcher.ts` | Official API-key-authenticated OpenCode Go usage endpoint used by the Usage page. Override for relays / test fixtures. | | `OPENCODE_SYNTHESIZE_CLI_HEADERS` | `false` | `open-sse/executors/opencode.ts` | Opt-in: synthesize OpenCode CLI identity headers (User-Agent, x-opencode-client/project, request/session UUIDs) on opencode-go/zen upstream requests the client didn't send, so Cloudflare on VPS egress accepts them (#6210/#5997). Off by default (forward-only is safer). | | `OPENCODE_USER_AGENT` | `opencode-cli/1.0.0` | `open-sse/executors/opencode.ts` | Default User-Agent used when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on and no per-provider `_USER_AGENT` override is set. Only applied to opencode executors. | | `OPENCODE_CLIENT` | `cli` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-client` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | | `OPENCODE_PROJECT` | `default` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-project` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | -| `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go `auth` cookie env var used before the shorter alias. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | | `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | Ollama Cloud settings URL used for quota scraping. Override for relays / test fixtures. | | `OLLAMA_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` cookie used for settings-page quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | | `OLLAMA_CLOUD_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate Ollama Cloud `__Secure-session` cookie env var. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | @@ -621,6 +615,8 @@ REQUEST_TIMEOUT_MS (global override) | `STREAM_IDLE_TIMEOUT_MS` | `600000` | Max silence between SSE chunks before aborting. Extended-thinking models rarely pause >90s. | | `STREAM_READINESS_TIMEOUT_MS` | `80000` | Time to receive the first non-ping SSE event. Inherits `REQUEST_TIMEOUT_MS` when set. | | `STREAM_READINESS_MAX_TIMEOUT_MS` | `180000` | Maximum adaptive first-event readiness window for large, tool-heavy, or high-reasoning streaming requests. | +| `OMNIROUTE_KEEPALIVE_THRESHOLD_MS` | `4000` | How long a streaming route waits for the handler before committing a 200 SSE keepalive. Until it commits, an upstream failure still reaches the client as a real HTTP status; after it commits the status is fixed at 200 and the error can only be delivered in-band. Values above `4500` or below `0` are ignored (the keepalive must stay under Codex CLI reqwest's ~5s idle-read timeout). Set `2000` to restore the pre-fork.4 behavior. | +| `OMNIROUTE_STREAM_DEFAULT_MODE` | `legacy` | Default stream resolution for requests that omit `stream` and send a wildcard `Accept` (python-httpx and most machine clients). `legacy` streams them; `json` routes them through the non-streaming path, where an upstream failure keeps its 502/429 status instead of arriving inside a committed 200. A per-key `streamDefaultMode` always wins. | | `OMNIROUTE_AGENT_GOAL_POLICY_ENABLED` | `true` | Kill-switch for the `/goal` heuristic. Set `false`/`0`/`off` to fully disable detection — readiness timeouts and stream recovery are never elevated by request body/headers, mitigating client-controlled timeout amplification. | | `OMNIROUTE_AGENT_GOAL_READINESS_MAX_TIMEOUT_MS` | `600000` | Maximum first-event readiness window for detected `/goal` agent runs or requests forced with `x-omniroute-agent-goal`. | | `OMNIROUTE_AGENT_GOAL_STREAM_RECOVERY` | `true` | Enable early stream recovery automatically for detected `/goal` agent runs. Set `false`/`0`/`off` to disable the goal-specific opt-in. This can only ADD recovery on top of the operator default — it never overrides an explicit `STREAM_RECOVERY_ENABLED`/DB settings opt-out. | @@ -715,6 +711,8 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `OMNIROUTE_MEMORY_MB` | _auto_ | Runtime V8 heap limit (MB). When unset, calibrated dynamically (~35% of system RAM, clamped to `[512, 4096]`); `512` is only the floor when total memory can't be read. Set explicitly to override. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_EXTRACTION_CONCURRENCY` | `1` | Maximum concurrent local background fact-extraction jobs. Values are clamped to `[1, 4]`. | +| `OMNIROUTE_MEMORY_EXTRACTION_QUEUE_MAX` | `100` | Maximum waiting background fact-extraction jobs. Values are clamped to `[1, 1000]`; jobs submitted after the limit are logged and dropped so user requests remain responsive. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 144d0f563f0d..738b8069a614 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -115,6 +115,15 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = { ], }, + salutespeech: { + id: "salutespeech", + baseUrl: "smartspeech.sber.ru:443", + authType: "oauth-client", + authHeader: "none", + format: "salutespeech-grpc", + models: [{ id: "general", name: "General (Russian speech recognition)" }], + }, + nvidia: { id: "nvidia", baseUrl: "https://integrate.api.nvidia.com/v1/audio/transcriptions", @@ -257,6 +266,15 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { ], }, + "inference-sh": { + id: "inference-sh", + baseUrl: "https://api.inference.sh/v1", + authType: "apikey", + authHeader: "bearer", + format: "inference-sh-omnivoice", + models: [{ id: "omnivoice@0yb22kbj", name: "OmniVoice (Inference.sh)" }], + }, + huggingface: { id: "huggingface", baseUrl: "https://api-inference.huggingface.co/models", diff --git a/open-sse/config/codexClient.ts b/open-sse/config/codexClient.ts index 339886a8e604..2113b5dd2308 100644 --- a/open-sse/config/codexClient.ts +++ b/open-sse/config/codexClient.ts @@ -1,4 +1,8 @@ -const DEFAULT_CODEX_CLIENT_VERSION = "0.144.1"; +// Codex's OAuth backend gates newer models by client version: GPT-6 Astra rejects +// older clients with "requires a newer version of Codex" (upstream issue #12761). +// Keep this in lockstep with CODEX_CLI_PROFILE in src/shared/constants/clientIdentityProfiles.ts. +// https://github.com/openai/codex/releases/tag/rust-v0.153.4 +const DEFAULT_CODEX_CLIENT_VERSION = "0.153.4"; const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200"; const DEFAULT_CODEX_USER_AGENT_ARCH = "x64"; const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION"; diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 41850fe2cff9..50acc3e66350 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -131,6 +131,19 @@ export const IMAGE_PROVIDERS: Record = { authHeader: "bearer", format: "openai", // native OpenAI format models: [ + { + id: "gpt-image-2.5-sunburst", + name: "GPT Image 2.5 Sunburst", + inputModalities: ["text", "image"], + description: "OpenAI's most capable GPT Image 2.5 model for image generation and editing.", + }, + { + id: "gpt-image-2.5-flare", + name: "GPT Image 2.5 Flare", + inputModalities: ["text", "image"], + description: + "OpenAI's fast GPT Image 2.5 model for high-quality everyday image generation and editing.", + }, { id: "gpt-image-2", name: "GPT Image 2" }, { id: "gpt-image-1.5", name: "GPT Image 1.5" }, { id: "gpt-image-1-mini", name: "GPT Image 1 Mini" }, diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index ac8c0aa48dfe..adb588c07391 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -48,16 +48,26 @@ export function findModelName(aliasOrId: string, modelId: string): string { } export function getModelTargetFormat(aliasOrId: string, modelId: string): string | null { - const models = PROVIDER_MODELS[aliasOrId]; + const alias = PROVIDER_ID_TO_ALIAS[aliasOrId] || aliasOrId; + const models = PROVIDER_MODELS[alias] || PROVIDER_MODELS[aliasOrId]; const found = models?.find((m) => m.id === modelId); if (found?.targetFormat) return found.targetFormat; + // OpenCode Go discovers models dynamically, while its three endpoint families + // are not wire-compatible. Keep a provider-scoped family fallback so a newly + // released GPT/Grok/Muse or MiniMax/Qwen model cannot silently fall through to + // /chat/completions before the static catalog catches up. This resolver is + // shared by chatCore (body translation) and OpencodeExecutor (URL selection). + if (alias === "opencode-go") { + if (/^(?:gpt-|grok-|muse-spark-)/i.test(modelId)) return "openai-responses"; + if (/^(?:minimax-|qwen3\.)/i.test(modelId)) return "claude"; + } // #5842: OpenAI "*-pro" reasoning models (o1-pro, gpt-5.x-pro) are only served by // the native /v1/responses endpoint — /v1/chat/completions 404s ("only supported // in v1/responses"). Curated catalog entries are tagged explicitly; this heuristic // covers dynamically-synced ids that post-date the catalog (same spirit as the gh // executor's /codex/i routing, 9router#102). Scoped to the openai alias so other // providers shipping *-pro ids keep their own endpoint semantics. - if (aliasOrId === "openai" && /-pro$/i.test(modelId)) return "openai-responses"; + if (alias === "openai" && /-pro$/i.test(modelId)) return "openai-responses"; return null; } diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index ae083ecf345e..dfb8c416dc59 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -122,6 +122,7 @@ import { vertex_partnerProvider } from "./registry/vertex/partner/index.ts"; import { vertexProvider } from "./registry/vertex/index.ts"; import { duckduckgo_webProvider } from "./registry/duckduckgo-web/index.ts"; import { xaiProvider } from "./registry/xai/index.ts"; +import { xai_oauthProvider } from "./registry/xai-oauth/index.ts"; import { morphProvider } from "./registry/morph/index.ts"; import { siliconflowProvider } from "./registry/siliconflow/index.ts"; import { gitlab_duoProvider } from "./registry/gitlab-duo/index.ts"; @@ -308,6 +309,7 @@ export const REGISTRY: Record = { vertex: vertexProvider, "duckduckgo-web": duckduckgo_webProvider, xai: xaiProvider, + "xai-oauth": xai_oauthProvider, morph: morphProvider, siliconflow: siliconflowProvider, "gitlab-duo": gitlab_duoProvider, diff --git a/open-sse/config/providers/registry/codex/index.ts b/open-sse/config/providers/registry/codex/index.ts index 01a43c71fb13..deb4e1ae58ed 100644 --- a/open-sse/config/providers/registry/codex/index.ts +++ b/open-sse/config/providers/registry/codex/index.ts @@ -124,6 +124,45 @@ export const codexProvider: RegistryEntry = { name: "GPT 5.6 Luna (Low)", ...GPT_5_6_CODEX_CAPABILITIES, }, + // Astra shares GPT-5.6's Codex limits: the live OAuth catalog reports + // max_context_window=872000 (context_window=272000 is only the pricing tier). + // Astra is absent from the free tier and requires a recent client version + // (see DEFAULT_CODEX_CLIENT_VERSION), or the backend rejects it with HTTP 400. + { + id: "gpt-6-astra", + name: "GPT 6 Astra", + ...GPT_5_6_CODEX_CAPABILITIES, + }, + { + id: "gpt-6-astra-ultra", + name: "GPT 6 Astra (Ultra)", + ...GPT_5_6_CODEX_CAPABILITIES, + }, + { + id: "gpt-6-astra-max", + name: "GPT 6 Astra (Max)", + ...GPT_5_6_CODEX_CAPABILITIES, + }, + { + id: "gpt-6-astra-xhigh", + name: "GPT 6 Astra (xHigh)", + ...GPT_5_6_CODEX_CAPABILITIES, + }, + { + id: "gpt-6-astra-high", + name: "GPT 6 Astra (High)", + ...GPT_5_6_CODEX_CAPABILITIES, + }, + { + id: "gpt-6-astra-medium", + name: "GPT 6 Astra (Medium)", + ...GPT_5_6_CODEX_CAPABILITIES, + }, + { + id: "gpt-6-astra-low", + name: "GPT 6 Astra (Low)", + ...GPT_5_6_CODEX_CAPABILITIES, + }, // gpt-5.5 codex OAuth backend caps context at 400K (not the public-API // 1.05M). Public refs : openai/codex#19208, #19319, #19464 ; // opencode#24171. max_output_tokens is stripped server-side diff --git a/open-sse/config/providers/registry/openai/index.ts b/open-sse/config/providers/registry/openai/index.ts index 63b6a30fa37e..999419c56257 100644 --- a/open-sse/config/providers/registry/openai/index.ts +++ b/open-sse/config/providers/registry/openai/index.ts @@ -15,6 +15,14 @@ export const openaiProvider: RegistryEntry = { { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", ...GPT_5_6_API_CAPABILITIES }, { id: "gpt-5.6-terra", name: "GPT-5.6 Terra", ...GPT_5_6_API_CAPABILITIES }, { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", ...GPT_5_6_API_CAPABILITIES }, + // Astra shares the public GPT-5.6 limits and rejects sampling params like the + // rest of the reasoning lineup. + { + id: "gpt-6-astra", + name: "GPT-6 Astra", + ...GPT_5_6_API_CAPABILITIES, + unsupportedParams: REASONING_UNSUPPORTED, + }, { id: "gpt-5.5", name: "GPT-5.5", contextLength: 1050000 }, // #5842: *-pro reasoning models are responses-only upstream — /v1/chat/completions // 404s ("only supported in v1/responses"). targetFormat routes them natively. diff --git a/open-sse/config/providers/registry/opencode/go/index.ts b/open-sse/config/providers/registry/opencode/go/index.ts index c8e575b185b4..bb7f0dd19500 100644 --- a/open-sse/config/providers/registry/opencode/go/index.ts +++ b/open-sse/config/providers/registry/opencode/go/index.ts @@ -13,18 +13,28 @@ export const opencode_goProvider: RegistryEntry = { authPrefix: "Bearer", defaultContextLength: 200000, models: [ - // Port from decolua/9router 8efacc11: align with official Go endpoints — - // glm-5.2 is now advertised and Kimi chat traffic must route through - // `kimi-k2.7-code` (the live API rejects the plain `kimi-k2.7` alias for - // `/chat/completions`, even though the docs config example uses it). - { id: "glm-5.2", name: "GLM-5.2" }, - { id: "glm-5.1", name: "GLM-5.1" }, - { id: "glm-5", name: "GLM-5" }, - { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, - { id: "kimi-k2.6", name: "Kimi K2.6" }, - { id: "kimi-k2.5", name: "Kimi K2.5" }, - { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro" }, - { id: "mimo-v2.5", name: "MiMo-V2.5" }, + // Keep this list aligned with GET /zen/go/v1/models and the endpoint table + // in the official OpenCode Go docs. Models without targetFormat use the + // provider default (/chat/completions). + { id: "glm-5.2", name: "GLM-5.2", supportsReasoning: true }, + { id: "glm-5.1", name: "GLM-5.1", supportsReasoning: true }, + { id: "glm-5", name: "GLM-5", supportsReasoning: true }, + { id: "glm-5.3", name: "GLM-5.3", supportsReasoning: true }, + { + id: "glm-5.3-flash", + name: "GLM-5.3 Flash", + supportsReasoning: true, + supportsVision: true, + }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportsReasoning: true }, + { id: "kimi-k2.6", name: "Kimi K2.6", supportsReasoning: true }, + { id: "kimi-k2.5", name: "Kimi K2.5", supportsReasoning: true }, + { id: "kimi-k3", name: "Kimi K3", supportsReasoning: true }, + { id: "longcat-2.0", name: "LongCat 2.0", supportsReasoning: true }, + { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", supportsReasoning: true }, + { id: "mimo-v2.5", name: "MiMo-V2.5", supportsReasoning: true }, + { id: "mimo-v2-pro", name: "MiMo V2 Pro", supportsReasoning: true }, + { id: "mimo-v2-omni", name: "MiMo V2 Omni", supportsReasoning: true }, // #3110: MiniMax M3 via OpenCode Go tier { id: "minimax-m3", @@ -43,9 +53,29 @@ export const opencode_goProvider: RegistryEntry = { // so combo routing skips them when the request contains image blocks, // preventing image content from reaching a vision-incapable upstream. { id: "qwen3.7-max", name: "Qwen3.7 Max", targetFormat: "claude", supportsVision: false }, + { + id: "qwen3.7-plus", + name: "Qwen3.7 Plus", + targetFormat: "claude", + supportsVision: false, + }, + { + id: "qwen3.8-flash", + name: "Qwen3.8 Flash", + targetFormat: "claude", + supportsVision: false, + }, + { + id: "qwen3.8-max", + name: "Qwen3.8 Max", + targetFormat: "claude", + supportsVision: false, + }, { id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false }, { id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false }, + { id: "hy3", name: "Hunyuan3", contextLength: 256000, supportsReasoning: true }, { id: "hy3-preview", name: "Hunyuan3 Preview" }, + { id: "hy4-preview", name: "Hunyuan4 Preview", supportsReasoning: true }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, // OpencodeExecutor rewrites these aliases to the canonical upstream id and injects reasoning_effort. { id: "deepseek-v4-pro-low", name: "DeepSeek V4 Pro (low effort)", supportsReasoning: true }, @@ -57,5 +87,52 @@ export const opencode_goProvider: RegistryEntry = { { id: "deepseek-v4-pro-high", name: "DeepSeek V4 Pro (high effort)", supportsReasoning: true }, { id: "deepseek-v4-pro-max", name: "DeepSeek V4 Pro (max effort)", supportsReasoning: true }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, + { + id: "deepseek-v4-flash-vision-exp", + name: "DeepSeek V4 Flash Vision Exp", + supportsReasoning: true, + supportsVision: true, + }, + { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + targetFormat: "openai-responses", + contextLength: 1050000, + maxOutputTokens: 128000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "grok-4.5", + name: "Grok 4.5", + targetFormat: "openai-responses", + supportsReasoning: true, + supportsVision: true, + }, + { + id: "grok-4.6", + name: "Grok 4.6", + targetFormat: "openai-responses", + supportsReasoning: true, + supportsVision: true, + }, + { + id: "muse-spark-1.2-contributor", + name: "Muse Spark 1.2 Contributor", + targetFormat: "openai-responses", + contextLength: 1048576, + maxOutputTokens: 131072, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "muse-spark-1.3-contributor", + name: "Muse Spark 1.3 Contributor", + targetFormat: "openai-responses", + contextLength: 1048576, + maxOutputTokens: 131072, + supportsReasoning: true, + supportsVision: true, + }, ], }; diff --git a/open-sse/config/providers/registry/xai-oauth/index.ts b/open-sse/config/providers/registry/xai-oauth/index.ts new file mode 100644 index 000000000000..4ec030be28b4 --- /dev/null +++ b/open-sse/config/providers/registry/xai-oauth/index.ts @@ -0,0 +1,24 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { resolvePublicCred } from "../../shared.ts"; +import { xaiProvider } from "../xai/index.ts"; + +export const xai_oauthProvider: RegistryEntry = { + id: "xai-oauth", + alias: "xao", + format: "openai", + executor: "xai-oauth", + baseUrl: xaiProvider.baseUrl, + responsesBaseUrl: xaiProvider.responsesBaseUrl, + authType: "oauth", + authHeader: "bearer", + passthroughModels: true, + oauth: { + clientIdEnv: "GROK_OAUTH_CLIENT_ID", + clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"), + tokenUrl: "https://auth.x.ai/oauth2/token", + }, + models: [ + { id: "grok-4.5", name: "Grok 4.5", contextLength: 500000 }, + ...(xaiProvider.models || []), + ], +}; diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index acf510ecf922..7a430f5491a0 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -295,16 +295,18 @@ export const GPT_5_6_API_CAPABILITIES = { maxOutputTokens: 128000, } as const; -// Codex's live catalog reports a 372K usable input budget for GPT-5.6. -// Keep the reserved 128K output budget explicit, matching the GPT-5.5 catalog contract. +// Codex OAuth catalog limits. The live OAuth `/codex/models` endpoint reports +// `context_window` (~272K, the first pricing tier) alongside +// `max_context_window` (~872K, the real usable window). Prefer and advertise +// the usable window so discovery does not cap routing at the pricing tier. export const GPT_5_6_CODEX_CAPABILITIES = { targetFormat: "openai-responses", toolCalling: true, supportsReasoning: true, supportsVision: true, supportsXHighEffort: true, - contextLength: 500000, - maxInputTokens: 372000, + contextLength: 872000, + maxInputTokens: 872000, maxOutputTokens: 128000, } as const; diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index dd91bb64bbcd..15c96c2db4a3 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -1,3 +1,4 @@ +import { normalizeCodexWsHeaders } from "./codex/websocketHeaders.ts"; import { getCodexRequestDefaults } from "@/lib/providers/requestDefaults"; import { getCodexModelScope, @@ -17,7 +18,8 @@ import { CODEX_CHAT_DEFAULT_INSTRUCTIONS, CODEX_DEFAULT_INSTRUCTIONS, } from "../config/codexInstructions.ts"; -import { HTTP_STATUS, PROVIDERS } from "../config/constants.ts"; +import { FETCH_BODY_TIMEOUT_MS, HTTP_STATUS, PROVIDERS } from "../config/constants.ts"; +import { readCodexPeekChunk, buildCodexTimeoutSafePassthroughBody } from "./codex/bodyTimeout.ts"; import { getCodexClientVersion, getCodexUserAgent, @@ -122,8 +124,73 @@ type EffortLevel = (typeof EFFORT_ORDER)[number]; const STANDARD_EFFORT_SUFFIXES = ["none", "low", "medium", "high", "xhigh"] as const; const GPT_5_6_MAX_ALIAS_MODELS = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); const GPT_5_6_ULTRA_ALIAS_MODELS = new Set(["gpt-5.6-sol", "gpt-5.6-terra"]); +// GPT-6 Astra takes both alias suffixes. `max` and `ultra` are not part of +// STANDARD_EFFORT_SUFFIXES, so without this set neither would ever split off the +// model id. `ultra` is an OmniRoute-side tier that goes out as wire effort `max` +// while keeping parallel tool calls for sub-agent delegation. +const GPT_6_ALIAS_MODELS = new Set(["gpt-6-astra"]); const CODEX_FAST_WIRE_VALUE = "priority"; const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses"; +const CODEX_RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite"; +const CODEX_RESPONSES_LITE_WS_METADATA_KEY = + "ws_request_header_x_openai_internal_codex_responses_lite"; + +// The official Codex client marks Responses Lite over an HTTP header or, for WebSocket +// requests, mirrors the same signal into client_metadata. Lite rejects parallel tool calls. +function isEnabledResponsesLiteFlag(value: unknown): boolean { + return value === true || (typeof value === "string" && value.trim().toLowerCase() === "true"); +} + +function isCodexResponsesLiteRequest( + bodyInput: unknown, + clientHeaders?: Record | null +): boolean { + const hasLiteHeader = Object.entries(clientHeaders ?? {}).some( + ([key, value]) => + key.toLowerCase() === CODEX_RESPONSES_LITE_HEADER && isEnabledResponsesLiteFlag(value) + ); + if (hasLiteHeader) return true; + + if (!bodyInput || typeof bodyInput !== "object" || Array.isArray(bodyInput)) return false; + const metadata = (bodyInput as Record).client_metadata; + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return false; + + return isEnabledResponsesLiteFlag( + (metadata as Record)[CODEX_RESPONSES_LITE_WS_METADATA_KEY] + ); +} + +// Astra/Sol/Terra at "ultra" and Luna at "max" coordinate delegation to +// sub-agents via parallel tool calls (see the effort-clamp comment near clampEffort()). +// Responses Lite must not strip parallel_tool_calls for those model/effort combos, or +// delegation silently breaks while the request still returns HTTP 200 (issue #7821). +function isCodexDelegationDependentModel(model: unknown): boolean { + const { baseModel, effort } = splitCodexReasoningSuffix(model); + if (effort === "ultra" && GPT_5_6_ULTRA_ALIAS_MODELS.has(baseModel)) return true; + if (effort === "ultra" && GPT_6_ALIAS_MODELS.has(baseModel)) return true; + if (effort === "max" && baseModel === "gpt-5.6-luna") return true; + return false; +} + +function enforceCodexResponsesLiteParallelToolCalls( + bodyInput: unknown, + clientHeaders: Record | null | undefined, + model: unknown +): unknown { + if ( + !isCodexResponsesLiteRequest(bodyInput, clientHeaders) || + !bodyInput || + typeof bodyInput !== "object" || + Array.isArray(bodyInput) || + isCodexDelegationDependentModel(model) + ) { + return bodyInput; + } + + const body = bodyInput as Record; + if (body.parallel_tool_calls === false) return bodyInput; + return { ...body, parallel_tool_calls: false }; +} function splitCodexReasoningSuffix(model: unknown): { baseModel: string; @@ -140,6 +207,14 @@ function splitCodexReasoningSuffix(model: unknown): { } } + const gpt6AliasMatch = /^(gpt-6-astra)-(max|ultra)$/.exec(modelId); + if (gpt6AliasMatch) { + const [, baseModel, alias] = gpt6AliasMatch; + if (GPT_6_ALIAS_MODELS.has(baseModel)) { + return { baseModel, effort: alias as EffortLevel }; + } + } + for (const level of STANDARD_EFFORT_SUFFIXES) { if (modelId.endsWith(`-${level}`)) { return { @@ -355,6 +430,7 @@ function normalizeServiceTierValue(value: unknown): string | undefined { * Update this table when Codex releases new models with different caps. */ const MAX_EFFORT_BY_MODEL: Record = { + "gpt-6-astra": "ultra", "gpt-5.6-sol": "ultra", "gpt-5.6-terra": "ultra", "gpt-5.6-luna": "max", @@ -616,18 +692,34 @@ function extractCodexSseErrorMessage(text: string, fallback: string): string { } type CodexSseTransientErrorPeek = - | { matched: string; message: string; replacementBody: null } - | { matched: null; message: null; replacementBody: ReadableStream | null }; + | { matched: string; message: string; replacementBody: null; timedOut?: false } + | { + matched: null; + message: null; + replacementBody: ReadableStream | null; + timedOut?: boolean; + }; /** * Peek the first bytes of a Codex SSE response body looking for a transient * error embedded in an otherwise 200-OK stream. Exported for unit testing. + * `timeoutMs` bounds EACH individual read (#8020) — defaults to + * FETCH_BODY_TIMEOUT_MS; overridable so tests can settle fast/deterministically. */ export async function peekCodexSseTransientError( - response: Response + response: Response, + timeoutMs: number = FETCH_BODY_TIMEOUT_MS ): Promise { const contentType = response.headers.get("content-type") || ""; - if (!response.ok || !response.body || !contentType.includes("text/event-stream")) { + // #7536: check content-type BEFORE touching `response.body`. On the wreq-js + // TLS-fingerprint transport (used by Codex), the Response is backed by a native + // body handle and merely accessing `.body` disturbs it, so a downstream + // `.text()` throws "Response body is already used". The Codex non-stream + // upstream response has an empty content-type, so it must short-circuit here + // WITHOUT reading `.body` — otherwise chatCore's readNonStreamingResponseBody + // 502s. Only genuine SSE responses (which this peek intends to buffer) reach + // the `.body` access below. + if (!response.ok || !contentType.includes("text/event-stream") || !response.body) { return { matched: null, message: null, replacementBody: null }; } @@ -639,8 +731,12 @@ export async function peekCodexSseTransientError( try { while (text.length < CODEX_SSE_PEEK_MAX_BYTES) { - const { done, value } = await reader.read(); + const { done, value, timedOut } = await readCodexPeekChunk(reader, timeoutMs); + if (timedOut) { + return { matched: null, message: null, replacementBody: null, timedOut: true }; + } if (done) break; + if (!value) continue; chunks.push(value); text += decoder.decode(value, { stream: true }); const lower = text.toLowerCase(); @@ -675,31 +771,14 @@ export async function peekCodexSseTransientError( return { matched, message: extractCodexSseErrorMessage(text, matched), replacementBody: null }; } - reader.releaseLock(); - // Re-assemble the stream: peeked prefix chunks, then continue draining the - // same underlying body so bytes downstream of the peek window are untouched. - const upstreamReader = response.body.getReader(); - const replacementBody = new ReadableStream({ - start(controller) { - for (const chunk of chunks) controller.enqueue(chunk); - }, - async pull(controller) { - const { done, value } = await upstreamReader.read(); - if (done) { - controller.close(); - return; - } - controller.enqueue(value); - }, - cancel(reason) { - try { - upstreamReader.cancel(reason).catch(() => {}); - } catch { - // noop — upstream socket may already be closing. - } - }, - }); + // SAME reader we already hold. The previous code called reader.releaseLock() + // and then response.body.getReader() a second time — but re-acquiring a reader + // on an already-disturbed body throws "Response body is already used" on + // undici (every non-stream Codex request 502'd, then got mis-classified as a + // 60s rate limit). Keep the original reader; never touch response.body again. + const upstreamReader = reader; + const replacementBody = buildCodexTimeoutSafePassthroughBody(chunks, upstreamReader, timeoutMs); return { matched: null, message: null, replacementBody }; } @@ -760,26 +839,6 @@ function toWebSocketUrl(url: string): string { return CODEX_RESPONSES_WS_URL; } -function normalizeCodexWsHeaders(headers: Record): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(headers)) { - const lower = key.toLowerCase(); - if ( - lower === "host" || - lower === "connection" || - lower === "upgrade" || - lower === "sec-websocket-key" || - lower === "sec-websocket-version" || - lower === "sec-websocket-extensions" - ) { - continue; - } - result[key] = value; - } - result.Origin = "https://chatgpt.com"; - return result; -} - /** * Codex Executor - handles OpenAI Codex API (Responses API format) * Automatically injects default instructions if missing. @@ -791,24 +850,30 @@ export class CodexExecutor extends BaseExecutor { } async execute(input: ExecuteInput) { + const requestBody = enforceCodexResponsesLiteParallelToolCalls( + input.body, + input.clientHeaders, + input.model + ); + const requestInput = requestBody === input.body ? input : { ...input, body: requestBody }; const sessionId = this.getPromptCacheSessionId( - input.credentials, - input.body as Record | null + requestInput.credentials, + requestInput.body as Record | null ); const identity = createCodexClientIdentity( sessionId, - input.credentials?.providerSpecificData ?? null + requestInput.credentials?.providerSpecificData ?? null ); const credentials = identity ? { - ...input.credentials, + ...requestInput.credentials, providerSpecificData: { - ...(input.credentials?.providerSpecificData || {}), + ...(requestInput.credentials?.providerSpecificData || {}), codexClientIdentity: identity, }, } - : input.credentials; - const nextInput = { ...input, credentials }; + : requestInput.credentials; + const nextInput = { ...requestInput, credentials }; if (!isCodexResponsesWebSocketRequired(nextInput.model, nextInput.credentials)) { const httpResult = await super.execute(nextInput); @@ -830,6 +895,17 @@ export class CodexExecutor extends BaseExecutor { HTTP_STATUS.SERVICE_UNAVAILABLE, peek.message ); + } else if (peek.timedOut) { + // #8020: the peek's first-chunk read never returned (upstream body went + // silent). Convert to a bounded 504 instead of letting the caller hang. + input.log?.warn?.( + "TIMEOUT", + "CODEX | 200-OK SSE peek read timed out — upstream body stalled, returning 504" + ); + (httpResult as { response: Response }).response = errorResponse( + HTTP_STATUS.GATEWAY_TIMEOUT, + "Upstream Codex SSE body read timed out" + ); } else if (peek.replacementBody) { (httpResult as { response: Response }).response = new Response(peek.replacementBody, { status: resp.status, @@ -1135,13 +1211,12 @@ export class CodexExecutor extends BaseExecutor { "TOKEN_REFRESH", `Codex: token refresh failed (${result.error}) — re-authentication required` ); - // Return null (not the error-only object): base.ts spreads any truthy - // result onto activeCredentials and persists it via onCredentialsRefreshed. - // Spreading `{ error }` would keep the stale/expired accessToken in place - // and write garbage to the connection. Returning null leaves the original - // credentials untouched so the upstream 401/403 drives the proper - // re-auth / mark-expired path instead. - return null; + // Preserve the sentinel so refreshWithRetry can stop immediately instead + // of repeating a permanently failed refresh. BaseExecutor explicitly + // recognizes this shape and does not spread it into active credentials; + // the reactive chatCore path uses it to mark the confirmed-bad connection + // expired after an upstream 401/403. + return result; } return result; } @@ -1370,6 +1445,10 @@ export class CodexExecutor extends BaseExecutor { // both native passthrough and translated requests. delete body.truncation; delete body.background; // Droid CLI sends this but Codex Responses API rejects it + // Codex /responses rejects sampling parameters even when a Responses request + // takes the native passthrough path below (#12585). + delete body.temperature; + delete body.top_p; // Issue #3317: strip client-only fields the Codex Responses API rejects with // 400 "Unsupported parameter" — for BOTH the native passthrough (early return diff --git a/open-sse/executors/codex/bodyTimeout.ts b/open-sse/executors/codex/bodyTimeout.ts new file mode 100644 index 000000000000..a07c4b150c53 --- /dev/null +++ b/open-sse/executors/codex/bodyTimeout.ts @@ -0,0 +1,88 @@ +/** + * Per-read timeout helpers for the Codex SSE peek/passthrough body reads (#8020). + * + * `peekCodexSseTransientError()` in ../codex.ts reads the first bytes of a Codex + * SSE response body BEFORE the response reaches chatCore's normal readiness/idle + * pipeline, so a 200 text/event-stream whose body never emits a byte bypassed + * FETCH_BODY_TIMEOUT_MS / STREAM_IDLE_TIMEOUT_MS entirely and hung on a bare + * `reader.read()` for ~15 minutes before the platform killed the connection as a + * generic 502. These helpers wrap every read (the peek loop AND the re-assembled + * passthrough body's pull()) in `readStreamChunkWithTimeout`, PER READ rather than + * against a single total-request deadline, so a long-but-alive reasoning stream + * that keeps emitting chunks never trips the timeout — only a stream that goes + * silent for `timeoutMs` does. + */ +import { readStreamChunkWithTimeout } from "../../handlers/chatCore/upstreamTimeouts.ts"; + +function isBodyTimeoutError(err: unknown): boolean { + return err instanceof Error && err.name === "BodyTimeoutError"; +} + +async function cancelReaderSafely(reader: ReadableStreamDefaultReader): Promise { + try { + await reader.cancel(); + } catch { + // Upstream socket may already be closing; nothing to clean up. + } +} + +/** + * Reads the next peek-loop chunk under `timeoutMs`. On a `BodyTimeoutError` the + * upstream reader is cancelled (releasing the socket) and `timedOut: true` is + * returned instead of throwing, so the caller can short-circuit straight to a + * bounded error response rather than falling through to an unbounded passthrough. + */ +export async function readCodexPeekChunk( + reader: ReadableStreamDefaultReader, + timeoutMs: number +): Promise<{ done: boolean; value?: Uint8Array; timedOut: boolean }> { + try { + const { done, value } = await readStreamChunkWithTimeout(reader, timeoutMs); + return { done, value, timedOut: false }; + } catch (err) { + if (isBodyTimeoutError(err)) { + await cancelReaderSafely(reader); + return { done: true, timedOut: true }; + } + throw err; + } +} + +/** + * Builds the re-assembled Codex SSE body (peeked prefix chunks + continued drain + * of the same reader), with every subsequent read bounded by `timeoutMs`. A + * timeout on the passthrough cancels the upstream reader and errors the stream + * controller instead of hanging the client connection forever. + */ +export function buildCodexTimeoutSafePassthroughBody( + chunks: Uint8Array[], + upstreamReader: ReadableStreamDefaultReader, + timeoutMs: number +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + }, + async pull(controller) { + try { + const { done, value } = await readStreamChunkWithTimeout(upstreamReader, timeoutMs); + if (done) { + controller.close(); + return; + } + if (!value) return; + controller.enqueue(value); + } catch (err) { + await cancelReaderSafely(upstreamReader); + controller.error(err); + } + }, + cancel(reason) { + try { + upstreamReader.cancel(reason).catch(() => {}); + } catch { + // noop — upstream socket may already be closing. + } + }, + }); +} diff --git a/open-sse/executors/codex/websocketHeaders.ts b/open-sse/executors/codex/websocketHeaders.ts new file mode 100644 index 000000000000..81f58abd5c53 --- /dev/null +++ b/open-sse/executors/codex/websocketHeaders.ts @@ -0,0 +1,19 @@ +export function normalizeCodexWsHeaders(headers: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const lower = key.toLowerCase(); + if ( + lower === "host" || + lower === "connection" || + lower === "upgrade" || + lower === "sec-websocket-key" || + lower === "sec-websocket-version" || + lower === "sec-websocket-extensions" + ) { + continue; + } + result[key] = value; + } + result.Origin = "https://chatgpt.com"; + return result; +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 039e7fa6373b..96ce0901ef8e 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -166,6 +166,8 @@ const executors = { zmf: new ZenmuxFreeExecutor(), // Alias for zenmux-free auggie: new AuggieExecutor(), xai: new XaiExecutor(), + "xai-oauth": new XaiExecutor("xai-oauth"), + xao: new XaiExecutor("xai-oauth"), }; const defaultCache = new Map(); diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 3dab64c39525..9037375c7ee5 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -18,6 +18,7 @@ import { flushPendingThinking, type KiroThinkingState, } from "./kiroThinking.ts"; +import { kiroPayloadWantsJsonOnly, stripJsonFenceFromSse } from "./kiro/jsonFence.ts"; import { ByteQueue, TEXT_ENCODER, parseEventFrame } from "./kiro/eventstream.ts"; import { kiroRuntimeHost, resolveKiroRuntimeRegion } from "../services/kiroRegion.ts"; @@ -303,10 +304,21 @@ export class KiroExecutor extends BaseExecutor { )?.userInputMessage as Record )?.content as string) || ""; const thinkingExpected = userContent.includes("enabled"); - const transformedResponse = this.transformEventStreamToSSE(response, model, { + let transformedResponse = this.transformEventStreamToSSE(response, model, { thinkingExpected, }); + // Kiro has no response_format, so the JSON contract only lives in the prompt + // and Claude still fences the object it returns. Unwrap it for the clients + // that asked for JSON — see executors/kiro/jsonFence.ts. + if (kiroPayloadWantsJsonOnly(transformedBody) && transformedResponse.body) { + transformedResponse = new Response(stripJsonFenceFromSse(transformedResponse.body), { + status: transformedResponse.status, + statusText: transformedResponse.statusText, + headers: transformedResponse.headers, + }); + } + return { response: transformedResponse, url, headers, transformedBody }; } diff --git a/open-sse/executors/kiro/jsonFence.ts b/open-sse/executors/kiro/jsonFence.ts new file mode 100644 index 000000000000..a5d467395b7f --- /dev/null +++ b/open-sse/executors/kiro/jsonFence.ts @@ -0,0 +1,168 @@ +/** + * Strip a markdown code fence from Kiro content when the request asked for JSON. + * + * Kiro/CodeWhisperer has no `response_format`, so the JSON contract can only be + * stated in the prompt (see `translator/request/openai-to-kiro/responseFormat.ts`). + * Claude on Kiro honours the schema but wraps the object in a ```json fence + * anyway — both `claude-haiku-4.5` and `claude-sonnet-4.5` did so on every + * production probe, despite the prompt forbidding it. An OpenAI-conforming + * client that `JSON.parse`s `message.content` breaks on that fence, so remove it + * here, on the one route where `response_format` cannot be enforced upstream. + * + * Applied only when the request carries `response_format: json_schema | + * json_object`; a normal chat answer keeps its code blocks untouched. + */ + +import { KIRO_JSON_CONTRACT_MARKER } from "../../translator/request/openai-to-kiro/responseFormat.ts"; + +const TEXT_DECODER = new TextDecoder(); +const TEXT_ENCODER = new TextEncoder(); + +/** Opening fence, with optional language tag, at the very start of the reply. */ +const OPENING_FENCE = /^\s*```[A-Za-z0-9_-]*[ \t]*\r?\n/; +/** Trailing run that could still grow into a closing fence — held back. */ +const CLOSING_FENCE_HOLD = /[\s`]*$/; +/** Closing fence at the very end of the reply. */ +const CLOSING_FENCE = /\s*```\s*$/; +/** Give up looking for an opening fence once this much content has arrived. */ +const PREFIX_DECISION_CHARS = 16; + +export type JsonFenceState = { pending: string; prefixResolved: boolean }; + +export function createJsonFenceState(): JsonFenceState { + return { pending: "", prefixResolved: false }; +} + +/** + * True when the built Kiro payload carries a JSON contract. + * + * The executor is handed the already-translated payload, so `response_format` + * is gone by then; the translator leaves `KIRO_JSON_CONTRACT_MARKER` in the + * prompt for exactly this lookup. + */ +export function kiroPayloadWantsJsonOnly(transformedBody: unknown): boolean { + const record = transformedBody as + | { conversationState?: { currentMessage?: { userInputMessage?: { content?: unknown } } } } + | null + | undefined; + const content = record?.conversationState?.currentMessage?.userInputMessage?.content; + return typeof content === "string" && content.includes(KIRO_JSON_CONTRACT_MARKER); +} + +/** + * Feed one content delta through the stripper and return what may be emitted + * now. A trailing run of whitespace/backticks is held back until `flush` can + * tell a closing fence from real content. + */ +export function pushJsonFenceChunk(state: JsonFenceState, text: string): string { + state.pending += text; + + if (!state.prefixResolved) { + const opening = OPENING_FENCE.exec(state.pending); + if (opening) { + state.pending = state.pending.slice(opening[0].length); + state.prefixResolved = true; + } else if ( + /\r?\n/.test(state.pending) || + state.pending.trimStart().length > PREFIX_DECISION_CHARS + ) { + state.prefixResolved = true; + } else { + return ""; + } + } + + const hold = CLOSING_FENCE_HOLD.exec(state.pending)?.[0] ?? ""; + const emit = state.pending.slice(0, state.pending.length - hold.length); + state.pending = hold; + return emit; +} + +/** Emit whatever is still held back, minus a closing fence. */ +export function flushJsonFence(state: JsonFenceState): string { + const out = state.pending.replace(CLOSING_FENCE, ""); + state.pending = ""; + state.prefixResolved = true; + return out; +} + +type ChunkMeta = { id?: unknown; created?: unknown; model?: unknown }; + +function contentChunkLine(meta: ChunkMeta, content: string): string { + return `data: ${JSON.stringify({ + ...meta, + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { content }, finish_reason: null }], + })}`; +} + +/** + * Rewrite the `delta.content` of every chunk on an OpenAI SSE stream through the + * fence stripper. Held-back content is released as its own chunk immediately + * before the finishing chunk (or `[DONE]`), so ordering stays intact. + */ +export function stripJsonFenceFromSse( + body: ReadableStream +): ReadableStream { + const state = createJsonFenceState(); + let lineBuffer = ""; + let meta: ChunkMeta = {}; + + const processLine = (line: string): string[] => { + if (!line.startsWith("data: ")) return [line]; + const payload = line.slice("data: ".length); + + if (payload === "[DONE]") { + const tail = flushJsonFence(state); + return tail ? [contentChunkLine(meta, tail), line] : [line]; + } + + let chunk: Record; + try { + chunk = JSON.parse(payload) as Record; + } catch { + return [line]; + } + + meta = { id: chunk.id, created: chunk.created, model: chunk.model }; + const choice = (chunk.choices as Array> | undefined)?.[0]; + if (!choice) return [line]; + + if (choice.finish_reason != null) { + const tail = flushJsonFence(state); + return tail ? [contentChunkLine(meta, tail), line] : [line]; + } + + const delta = choice.delta as Record | undefined; + if (!delta || typeof delta.content !== "string") return [line]; + + delta.content = pushJsonFenceChunk(state, delta.content); + return [`data: ${JSON.stringify(chunk)}`]; + }; + + return body.pipeThrough( + new TransformStream({ + transform(bytes, controller) { + lineBuffer += TEXT_DECODER.decode(bytes, { stream: true }); + const lines = lineBuffer.split("\n"); + lineBuffer = lines.pop() ?? ""; + for (const line of lines) { + for (const out of processLine(line)) { + controller.enqueue(TEXT_ENCODER.encode(`${out}\n`)); + } + } + }, + flush(controller) { + if (lineBuffer) { + for (const out of processLine(lineBuffer)) { + controller.enqueue(TEXT_ENCODER.encode(out)); + } + } + const tail = flushJsonFence(state); + if (tail) { + controller.enqueue(TEXT_ENCODER.encode(`${contentChunkLine(meta, tail)}\n\n`)); + } + }, + }) + ); +} diff --git a/open-sse/executors/trae.ts b/open-sse/executors/trae.ts index d779fd77a261..3120cb8d643b 100644 --- a/open-sse/executors/trae.ts +++ b/open-sse/executors/trae.ts @@ -26,6 +26,20 @@ type ChatMessage = { role?: string; content?: unknown }; const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10); +class TraeUpstreamError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + this.name = "TraeUpstreamError"; + } +} + +function upstreamErrorStatus(error: unknown): number { + return error instanceof TraeUpstreamError ? error.status : 502; +} + function flattenQuery(messages: ChatMessage[]): string { const parts: string[] = []; for (const m of messages) { @@ -151,7 +165,7 @@ export class TraeExecutor extends BaseExecutor { signal: signal || undefined, }); const text = await res.text(); - if (!res.ok) throw new Error(`[${res.status}] ${text}`); + if (!res.ok) throw new TraeUpstreamError(`[${res.status}] ${text}`, res.status); const json = JSON.parse(text); if (json?.code !== 0) throw new Error(`Trae create_session: ${JSON.stringify(json)}`); return { sessionId: json.data.chat_session_id, messageId: json.data.message_id }; @@ -178,7 +192,8 @@ export class TraeExecutor extends BaseExecutor { if (signal) signal.addEventListener("abort", onAbort, { once: true }); try { const res = await fetch(url, { method: "GET", headers, signal: ctrl.signal }); - if (!res.ok || !res.body) throw new Error(`[${res.status}] events stream failed`); + if (!res.ok) throw new TraeUpstreamError(`[${res.status}] events stream failed`, res.status); + if (!res.body) throw new Error("Trae events stream response has no body"); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; @@ -242,7 +257,10 @@ export class TraeExecutor extends BaseExecutor { ); } catch (err) { return { - response: errResponse(502, err instanceof Error ? err.message : String(err)), + response: errResponse( + upstreamErrorStatus(err), + err instanceof Error ? err.message : String(err) + ), url: this.base(), headers, transformedBody: body, @@ -384,7 +402,10 @@ export class TraeExecutor extends BaseExecutor { ); } catch (err) { return { - response: errResponse(502, err instanceof Error ? err.message : String(err)), + response: errResponse( + upstreamErrorStatus(err), + err instanceof Error ? err.message : String(err) + ), url: this.base(), headers, transformedBody: body, diff --git a/open-sse/executors/xai.ts b/open-sse/executors/xai.ts index e5de5c037a0e..5fc621772911 100644 --- a/open-sse/executors/xai.ts +++ b/open-sse/executors/xai.ts @@ -1,4 +1,4 @@ -import { BaseExecutor, type ProviderCredentials } from "./base.ts"; +import { BaseExecutor, type ExecutorLog, type ProviderCredentials } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; @@ -48,8 +48,8 @@ function asRecord(value: unknown): JsonRecord | null { * 3. Leaves unclassified models and bodies untouched otherwise. */ export class XaiExecutor extends BaseExecutor { - constructor() { - super("xai", PROVIDERS.xai); + constructor(provider = "xai") { + super(provider, PROVIDERS[provider]); } /** @@ -64,12 +64,58 @@ export class XaiExecutor extends BaseExecutor { * -pro heuristic in open-sse/executors/default.ts. */ buildUrl(model: string, _stream: boolean, _urlIndex = 0) { - if (getModelTargetFormat("xai", model) === "openai-responses") { + if (getModelTargetFormat(this.provider, model) === "openai-responses") { return this.config.responsesBaseUrl || this.config.baseUrl; } return this.config.baseUrl; } + async refreshCredentials( + credentials: ProviderCredentials, + log?: ExecutorLog | null + ): Promise | null> { + if (this.provider !== "xai-oauth" || !credentials.refreshToken) return null; + + try { + const response = await fetch(this.config.tokenUrl || "https://auth.x.ai/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: this.config.clientId || "", + refresh_token: credentials.refreshToken, + }), + }); + + if (!response.ok) { + log?.warn?.("TOKEN_REFRESH", `xAI OAuth refresh failed with status ${response.status}`); + return null; + } + + const data = await response.json(); + if (!data.access_token) { + log?.warn?.("TOKEN_REFRESH", "xAI OAuth refresh response omitted access_token"); + return null; + } + + const expiresIn = Number(data.expires_in) || 21600; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || credentials.refreshToken, + expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), + }; + } catch (error) { + log?.warn?.( + "TOKEN_REFRESH", + `xAI OAuth refresh error: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } + transformRequest( model: string, body: unknown, diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 8885d4edba0c..2f69ce102a07 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -30,6 +30,7 @@ import { parseKieResultJson, } from "../utils/kieTask.ts"; import { signAwsRequest } from "../utils/awsSigV4.ts"; +import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; /** * Return a CORS error response from an upstream fetch failure @@ -178,6 +179,42 @@ function getStringValue(value): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function getInferenceShAudioUrl(task: unknown): string | null { + if (!isJsonObject(task) || !isJsonObject(task.output)) return null; + const audio = task.output.audio; + if (typeof audio !== "string" || !audio.trim()) return null; + + try { + const url = new URL(audio); + return url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + +function isInferenceShTaskTerminal(task: unknown): boolean { + if (!isJsonObject(task)) return true; + const status = task.status; + return ( + status === "completed" || + status === "failed" || + status === "cancelled" || + status === 10 || + status === 11 || + status === 12 + ); +} + +function getInferenceShTask(taskEnvelope: unknown): Record | null { + const task = + isJsonObject(taskEnvelope) && "data" in taskEnvelope ? taskEnvelope.data : taskEnvelope; + return isJsonObject(task) ? task : null; +} + +function wait(milliseconds: number) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + function getAwsPollyProviderData(credentials) { return credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" && @@ -377,6 +414,83 @@ async function handleElevenLabsSpeech(providerConfig, body, modelId, token) { return audioStreamResponse(res); } +async function handleInferenceShOmniVoiceSpeech(providerConfig, body, modelId, token) { + const voice = getStringValue(body.voice); + const input: Record = { text: body.input }; + if (voice && voice.toLowerCase() !== "alloy") input.instruct = voice; + + const speed = Number(body.speed); + if (Number.isFinite(speed) && speed >= 0.25 && speed <= 4) input.speed = speed; + + const taskResponse = await safeOutboundFetch(`${providerConfig.baseUrl}/apps/run`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...buildAuthHeaders(providerConfig, token), + }, + body: JSON.stringify({ + app: `infsh/${modelId}`, + input, + }), + allowRedirect: false, + guard: "public-only", + retry: false, + timeoutMs: 120000, + }); + + if (!taskResponse.ok) { + return upstreamErrorResponse(taskResponse, await taskResponse.text()); + } + + let task = getInferenceShTask(await taskResponse.json()); + for ( + let attempt = 0; + task && !getInferenceShAudioUrl(task) && !isInferenceShTaskTerminal(task); + attempt++ + ) { + const taskId = getStringValue(task.id); + if (!taskId || attempt >= 240) break; + + if (attempt > 0) await wait(500); + const pollResponse = await safeOutboundFetch( + `${providerConfig.baseUrl}/tasks/${encodeURIComponent(taskId)}`, + { + method: "GET", + headers: buildAuthHeaders(providerConfig, token), + allowRedirect: false, + guard: "public-only", + retry: false, + timeoutMs: 120000, + } + ); + + if (!pollResponse.ok) { + return upstreamErrorResponse(pollResponse, await pollResponse.text()); + } + task = getInferenceShTask(await pollResponse.json()); + } + + const audioUrl = getInferenceShAudioUrl(task); + if (!audioUrl) { + return errorResponse(502, "Inference.sh did not return generated audio"); + } + + // The hosted artifact URL is public. Never forward the provider API key to it. + const audioResponse = await safeOutboundFetch(audioUrl, { + method: "GET", + allowRedirect: false, + guard: "public-only", + retry: false, + timeoutMs: 120000, + }); + + if (!audioResponse.ok) { + return upstreamErrorResponse(audioResponse, await audioResponse.text()); + } + + return audioStreamResponse(audioResponse, "audio/wav"); +} + /** * Handle Nvidia NIM TTS * POST with { input: { text }, voice, model } → audio binary @@ -952,7 +1066,7 @@ export async function handleAudioSpeech({ if (!providerConfig) { return errorResponse( 400, - `No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, kie, aws-polly, xiaomi-mimo, coqui, tortoise, qwen` + `No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, inference-sh, huggingface, inworld, cartesia, playht, kie, aws-polly, xiaomi-mimo, coqui, tortoise, qwen` ); } @@ -989,6 +1103,10 @@ export async function handleAudioSpeech({ return handleElevenLabsSpeech(providerConfig, body, modelId, token); } + if (providerConfig.format === "inference-sh-omnivoice") { + return handleInferenceShOmniVoiceSpeech(providerConfig, body, modelId, token); + } + if (providerConfig.format === "nvidia-tts") { return handleNvidiaTtsSpeech(providerConfig, body, modelId, token); } diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index af443427c800..544457ba7eec 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -23,10 +23,12 @@ import { buildAuthHeaders } from "../config/registryUtils.ts"; import { kieExecutor } from "../executors/kie.ts"; import { vertexTranscribe } from "../executors/vertexMedia.ts"; import { errorResponse } from "../utils/error.ts"; +import { isSaluteSpeechError, transcribeWithSaluteSpeech } from "./saluteSpeechTranscription.ts"; type TranscriptionCredentials = { apiKey?: string; accessToken?: string; + providerSpecificData?: Record; }; /** @@ -497,6 +499,21 @@ export async function handleAudioTranscription({ return handleAssemblyAITranscription(providerConfig, file, modelId, token); } + if (providerConfig.format === "salutespeech-grpc") { + try { + const text = await transcribeWithSaluteSpeech({ + file, + formData, + modelId, + credentials: credentials ?? {}, + }); + return Response.json({ text }, { headers: { ...CORS_HEADERS } }); + } catch (error) { + if (isSaluteSpeechError(error)) return errorResponse(error.statusCode, error.message); + return errorResponse(502, "SaluteSpeech transcription failed"); + } + } + if (providerConfig.format === "nvidia-asr") { return handleNvidiaTranscription(providerConfig, file, modelId, token); } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index e134a744f8cb..c25ba6d34f1b 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -21,11 +21,13 @@ import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { getHeaderValueCaseInsensitive, + isNoCacheRequested, isNoMemoryRequested, resolveCompressionHeader, isStripReasoningRequested, } from "./chatCore/headers.ts"; import { markCodexScopeRateLimited } from "./chatCore/codexFailover.ts"; +import { isTerminalCodexOAuthFailure } from "./chatCore/codexAuthFailure.ts"; import { isCodexOriginatedHeaders } from "../config/codexIdentity.ts"; import { trackDevice, extractIpFromHeaders } from "../services/deviceTracker.ts"; import { getCombosCached } from "./chatCore/comboContextCache.ts"; @@ -42,7 +44,9 @@ import { } from "./chatCore/passthroughHelpers.ts"; import { buildStreamingResponseHeaders, + isReusableDeduplicatedExecutionResult, materializeDeduplicatedExecutionResult, + stripCodexAccountQuotaHeaders, stripNextMiddlewareControlHeaders, stripStaleForwardingHeaders, } from "./chatCore/responseHeaders.ts"; @@ -234,7 +238,7 @@ import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; import { getCacheControlSettings } from "@/lib/cacheControlSettings"; import { guardrailRegistry } from "@/lib/guardrails"; import { shouldPreserveCacheControl } from "../utils/cacheControlPolicy.ts"; -import { getCachedSettings } from "@/lib/db/readCache"; +import { getCachedDatabaseCacheSettings, getCachedSettings } from "@/lib/db/readCache"; import { applyCodexGlobalFastServiceTier } from "@/lib/providers/codexFastTier"; import { buildUpstreamHeadersForExecute as buildUpstreamHeadersForExecuteFor } from "./chatCore/upstreamExecuteHeaders.ts"; import { @@ -273,11 +277,13 @@ import { } from "../services/accountSemaphore.ts"; import { lockModel, lockModelIfPerModelQuota } from "../services/accountFallback.ts"; import { + configureSemanticCache, generateSignature, getCachedResponse, setCachedResponse, isCacheableForRead, isCacheableForWrite, + requestVariantOf, } from "@/lib/semanticCache"; import { saveIdempotency } from "@/lib/idempotencyLayer"; import { @@ -304,6 +310,7 @@ import { stripMarkdownCodeFence, } from "../utils/aiSdkCompat.ts"; import { generateRequestId } from "@/shared/utils/requestId"; +import { classifyLocalAbortFailure } from "@/sse/handlers/chatPredicates"; import { extractFacts } from "@/lib/memory/extraction"; import { handleToolCallExecution } from "@/lib/skills/interception"; import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; @@ -350,6 +357,7 @@ import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; * @param {string} options.comboStrategy - Combo routing strategy (e.g., 'priority', 'cost-optimized') * @param {boolean} options.isCombo - Whether this request is from a combo * @param {string} options.connectionId - Connection ID for settings lookup + * @param {AbortSignal} options.upstreamAbortSignal - Orchestration cancellation signal */ // extractSystemRoleMessages extracted to chatCore/claudeSystemRole.ts (#3501); re-exported above so @@ -378,6 +386,7 @@ export async function handleChatCore({ createPiiTransform = null, correlationId = null, modelPinned = false, + upstreamAbortSignal = null, }) { let { provider, model, extendedContext } = modelInfo; // ── Memory pressure guard ──────────────────────────────────────────── @@ -755,6 +764,7 @@ export async function handleChatCore({ const noLogEnabled = apiKeyInfo?.noLog === true; // Consolidate settings reads — fetch once, reuse throughout the request const settings = cachedSettings ?? (await getCachedSettings()); + const databaseCacheSettings = await getCachedDatabaseCacheSettings(); // Opt-in tool-source diagnostics (#1825): summarize the request's tool definitions // (count + MCP/hosted/client source breakdown + first names) as a single debug line. if (settings.logToolSources === true) { @@ -927,7 +937,23 @@ export async function handleChatCore({ }); effectiveServiceTier = resolveEffectiveServiceTier(body); setGeminiThoughtSignatureMode(settings.antigravitySignatureCacheMode); - const semanticCacheEnabled = settings.semanticCacheEnabled !== false; + configureSemanticCache({ + maxSize: databaseCacheSettings.semanticCacheMaxSize, + ttlMs: databaseCacheSettings.semanticCacheTTL, + }); + const semanticCacheEnabled = databaseCacheSettings.semanticCacheEnabled !== false; + const semanticCacheSignature = generateSignature( + model, + body.messages ?? body.input, + body.temperature, + body.top_p, + apiKeyInfo?.id ?? undefined, + requestVariantOf(body, { + endpoint: clientRawRequest?.endpoint, + sourceFormat, + provider, + }) + ); const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model, { enabled: detailedLoggingEnabled, @@ -966,6 +992,7 @@ export async function handleChatCore({ log, persistAttemptLogs, apiKeyId: apiKeyInfo?.id ?? undefined, + signature: semanticCacheSignature, }); if (cacheHit) { return cacheHit; @@ -1708,6 +1735,7 @@ export async function handleChatCore({ normalizeToolCallId, preserveDeveloperRole, preserveCacheControl, + reasoningCacheApiKeyId: apiKeyInfo?.id, copilotClient: copilotCompatibleReasoning, } ); @@ -1883,6 +1911,7 @@ export async function handleChatCore({ preserveDeveloperRole, preserveCacheControl, signatureNamespace: connectionId, + reasoningCacheApiKeyId: apiKeyInfo?.id, copilotClient: copilotCompatibleReasoning, ...(preCompressionBody ? { preCompressionBody } : {}), } @@ -2265,11 +2294,13 @@ export async function handleChatCore({ connectionId, clientResponseFormat, clientAbortSignal: clientRawRequest?.signal, + upstreamAbortSignal, }); const dedupRequestBody = { ...translatedBody, model: `${provider}/${model}`, stream }; - const dedupEnabled = shouldDeduplicate(dedupRequestBody); - const dedupHash = dedupEnabled ? computeRequestHash(dedupRequestBody) : null; + const dedupEnabled = + !isNoCacheRequested(clientRawRequest?.headers) && shouldDeduplicate(dedupRequestBody); + const dedupHash = dedupEnabled ? computeRequestHash(dedupRequestBody, apiKeyInfo?.id) : null; const executeProviderRequest = async (modelToCall = effectiveModel, allowDedup = false) => { const execute = async () => { @@ -2735,6 +2766,7 @@ export async function handleChatCore({ const responseHeaders = new Headers(headersObj); stripStaleForwardingHeaders(responseHeaders); stripNextMiddlewareControlHeaders(responseHeaders); + stripCodexAccountQuotaHeaders(responseHeaders); const contentType = (responseHeaders.get("content-type") || "").toLowerCase(); const payload = await readNonStreamingResponseBody( rawResult.response, @@ -2768,6 +2800,13 @@ export async function handleChatCore({ const dedupResult = await deduplicate(dedupHash, execute); if (dedupResult.wasDeduplicated) { log?.debug?.("DEDUP", `Joined in-flight request hash=${dedupHash}`); + if (!isReusableDeduplicatedExecutionResult(dedupResult.result)) { + log?.warn?.( + "DEDUP", + `In-flight request hash=${dedupHash} returned an error or empty body; executing waiter independently` + ); + return execute(); + } } return materializeDeduplicatedExecutionResult(dedupResult.result); } @@ -2920,18 +2959,21 @@ export async function handleChatCore({ errorCode: error.code, }; } - const failureStatus = - error.name === "AbortError" - ? 499 - : error.name === "TimeoutError" || error.name === "BodyTimeoutError" - ? HTTP_STATUS.GATEWAY_TIMEOUT - : error.status && typeof error.status === "number" - ? error.status - : HTTP_STATUS.BAD_GATEWAY; - const failureMessage = - error.name === "AbortError" - ? "Request aborted" - : formatProviderError(error, provider, model, failureStatus); + const localAbortFailure = classifyLocalAbortFailure( + error, + streamController.signal.aborted, + streamController.signal.reason + ); + const failureStatus = localAbortFailure + ? localAbortFailure.status + : error.name === "TimeoutError" || error.name === "BodyTimeoutError" + ? HTTP_STATUS.GATEWAY_TIMEOUT + : error.status && typeof error.status === "number" + ? error.status + : HTTP_STATUS.BAD_GATEWAY; + const failureMessage = localAbortFailure + ? localAbortFailure.message + : formatProviderError(error, provider, model, failureStatus); const upstreamErrorCode = getUpstreamErrorIdentifier(error); // Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError, // both surfaced as a 504) as "upstream_timeout" so the cooldown layer can tell a @@ -2960,9 +3002,9 @@ export async function handleChatCore({ claudeCacheMeta: claudePromptCacheLogMeta, cacheSource: "upstream", }); - if (error.name === "AbortError") { + if (localAbortFailure) { streamController.handleError(error); - return createErrorResult(499, "Request aborted"); + return createErrorResult(499, localAbortFailure.message); } persistFailureUsage( failureStatus, @@ -2990,14 +3032,20 @@ export async function handleChatCore({ upstreamErrorType ); } - // We need to peek at the error text if it's 400 for Qwen + // Peek before refresh when the provider exposes a terminal auth signal in + // the response body. Qwen needs this for its non-standard 400 expiration; + // Codex needs it to avoid spending ~90s refreshing an explicitly invalidated + // OAuth credential before combo routing can fail over. let upstreamErrorParsed = false; let parsedStatusCode = providerResponse.status; let parsedMessage = ""; let parsedRetryAfterMs: number | null = null; let upstreamErrorBody: unknown = null; - if (provider === "qwen" && providerResponse.status === HTTP_STATUS.BAD_REQUEST) { + if ( + (provider === "qwen" && providerResponse.status === HTTP_STATUS.BAD_REQUEST) || + (provider === "codex" && providerResponse.status === HTTP_STATUS.UNAUTHORIZED) + ) { const errorDetails = await parseUpstreamError(providerResponse, provider); parsedStatusCode = errorDetails.statusCode; parsedMessage = errorDetails.message; @@ -3023,6 +3071,23 @@ export async function handleChatCore({ parsedStatusCode === HTTP_STATUS.BAD_REQUEST && parsedMessage?.toLowerCase().includes("session has expired"); + const isTerminalCodexOAuthError = isTerminalCodexOAuthFailure({ + provider, + status: providerResponse.status, + message: parsedMessage, + responseBody: upstreamErrorBody, + }); + + if (isTerminalCodexOAuthError) { + log?.warn?.( + "TOKEN", + "CODEX | upstream reported an invalidated OAuth token; expiring the connection without refresh retries" + ); + if (onCredentialsRefreshed) { + await onCredentialsRefreshed({ testStatus: "expired", isActive: false }); + } + } + // Track whether stream_options was present and stripped — if so, 401/403 after // that may be from the modification rather than a genuine auth failure, so we // skip the credential refresh attempt in that case. @@ -3037,7 +3102,8 @@ export async function handleChatCore({ (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN || isQwenExpiredError) && - !hadStreamOptions // Skip refresh if failure may be from stream_options removal, not auth + !hadStreamOptions && // Skip refresh if failure may be from stream_options removal, not auth + !isTerminalCodexOAuthError ) { // Fix A: wrap refreshCredentials in runWithOnPersist so the persist callback // executes INSIDE the per-connection mutex held by getAccessToken. This makes @@ -3339,7 +3405,13 @@ export async function handleChatCore({ // otherwise degenerate into a 429 rate-limit storm). Connection stays // active since only the specific model is unavailable. (#6827) const notFoundCooldownMs = COOLDOWN_MS.notFound; - lockModel(provider, errorConnectionId, currentModel, "model_not_found", notFoundCooldownMs); + lockModel( + provider, + errorConnectionId, + currentModel, + "model_not_found", + notFoundCooldownMs + ); console.warn( `[provider] Node ${errorConnectionId} model not found (${statusCode}) for ${currentModel} - locking model for ${Math.ceil(notFoundCooldownMs / 1000)}s (connection stays active)` ); @@ -3875,6 +3947,7 @@ export async function handleChatCore({ const firstChoice = translatedResponse?.choices?.[0]; const msg = firstChoice?.message; cacheReasoningFromAssistantMessage(msg, provider, model, { + apiKeyId: apiKeyInfo?.id, requestId: skillRequestId, messageIndex: 0, }); @@ -4028,6 +4101,7 @@ export async function handleChatCore({ const malformedMessage = `[${provider}/${model}] returned an empty response (no usable choices/output)`; persistAttemptLogs({ status: HTTP_STATUS.BAD_GATEWAY, + error: malformedMessage, tokens: usage, responseBody, providerRequest: finalBody || translatedBody, @@ -4041,7 +4115,7 @@ export async function handleChatCore({ }); persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "malformed_translated_response"); trackPendingRequest(model, provider, pendingConnId, false); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, malformedMessage); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, malformedMessage, null, "empty_response"); } // ── Phase 9.1: Cache store (non-streaming, temp=0) ── @@ -4054,6 +4128,7 @@ export async function handleChatCore({ apiKeyId: apiKeyInfo?.id ?? undefined, usage, log, + signature: semanticCacheSignature, }); // ── Phase 9.2: Save for idempotency ── @@ -4255,6 +4330,7 @@ export async function handleChatCore({ const choices = body.choices as { message?: Record }[] | undefined; const msg = choices?.[0]?.message; cacheReasoningFromAssistantMessage(msg, provider, model, { + apiKeyId: apiKeyInfo?.id, requestId: skillRequestId, messageIndex: 0, }); @@ -4379,6 +4455,7 @@ export async function handleChatCore({ apiKeyId: apiKeyInfo?.id ?? undefined, streamUsage, log, + signature: semanticCacheSignature, }); }; diff --git a/open-sse/handlers/chatCore/codexAuthFailure.ts b/open-sse/handlers/chatCore/codexAuthFailure.ts new file mode 100644 index 000000000000..267f3cf7dd2f --- /dev/null +++ b/open-sse/handlers/chatCore/codexAuthFailure.ts @@ -0,0 +1,34 @@ +const TERMINAL_CODEX_OAUTH_MARKERS = [ + "invalidated oauth token", + "authentication token has been invalidated", +] as const; + +function normalizeErrorText(value: unknown): string { + if (typeof value === "string") return value.toLowerCase(); + if (value == null) return ""; + + try { + return JSON.stringify(value).toLowerCase(); + } catch { + return ""; + } +} + +/** + * OpenAI emits these messages only after permanently invalidating the OAuth + * credential. Refreshing the same token cannot recover it, so the connection + * should be expired immediately and combo routing should move to the next one. + */ +export function isTerminalCodexOAuthFailure(params: { + provider: string; + status: number; + message?: unknown; + responseBody?: unknown; +}): boolean { + if (params.provider !== "codex" || params.status !== 401) return false; + + const errorText = `${normalizeErrorText(params.message)} ${normalizeErrorText( + params.responseBody + )}`; + return TERMINAL_CODEX_OAUTH_MARKERS.some((marker) => errorText.includes(marker)); +} diff --git a/open-sse/handlers/chatCore/headers.ts b/open-sse/handlers/chatCore/headers.ts index ba6d1c271282..3770b6ffb71a 100644 --- a/open-sse/handlers/chatCore/headers.ts +++ b/open-sse/handlers/chatCore/headers.ts @@ -15,6 +15,18 @@ export function getHeaderValueCaseInsensitive( return null; } +/** + * Bypass cache-like response reuse for this request. The semantic response cache + * already honors this header; in-flight request deduplication must honor it too. + */ +export function isNoCacheRequested( + headers: Record | Headers | null | undefined +): boolean { + return ( + (getHeaderValueCaseInsensitive(headers, "x-omniroute-no-cache") || "").toLowerCase() === "true" + ); +} + /** * Per-request opt-out of memory (and skills) injection via the * `x-omniroute-no-memory` header. Mirrors the existing `x-omniroute-no-cache` diff --git a/open-sse/handlers/chatCore/responseHeaders.ts b/open-sse/handlers/chatCore/responseHeaders.ts index 3c09538716b7..b0b50d796063 100644 --- a/open-sse/handlers/chatCore/responseHeaders.ts +++ b/open-sse/handlers/chatCore/responseHeaders.ts @@ -3,6 +3,9 @@ import { buildOmniRouteResponseMetaHeaders, } from "@/domain/omnirouteResponseMeta"; import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; +import { isEmptyContentResponse } from "../../services/errorClassifier.ts"; +import { detectMalformedNonStream } from "../../utils/diagnostics.ts"; +import { parseNonStreamingSSEPayload } from "./nonStreamingSse.ts"; const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([ "content-type", @@ -11,6 +14,22 @@ const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([ "transfer-encoding", ]); +const CODEX_ACCOUNT_QUOTA_HEADER_PREFIX = "x-codex-"; +const CODEX_ACCOUNT_QUOTA_HEADER_SUFFIXES = [ + "-used-percent", + "-window-minutes", + "-reset-at", + "-reset-after-seconds", +]; +const CODEX_ACCOUNT_QUOTA_HEADER_NAMES = new Set([ + "x-codex-credits-balance", + "x-codex-credits-has-credits", + "x-codex-credits-unlimited", + "x-codex-limit-name", + "x-codex-plan-type", + "x-codex-rate-limit-reached-type", +]); + /** * Prefix of Next.js internal middleware control headers. * @@ -31,6 +50,61 @@ const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([ */ const NEXTJS_MIDDLEWARE_HEADER_PREFIX = "x-middleware-"; +interface DeduplicatedExecutionSnapshot { + status: number; + statusText: string; + headers: [string, string][]; + payload: string; +} + +function getDeduplicatedExecutionSnapshot( + result: Record | null | undefined +): DeduplicatedExecutionSnapshot | undefined { + return result && typeof result === "object" + ? (result._dedupSnapshot as DeduplicatedExecutionSnapshot | undefined) + : undefined; +} + +function getSnapshotContentType(snapshot: DeduplicatedExecutionSnapshot): string { + return ( + snapshot.headers.find(([name]) => name.toLowerCase() === "content-type")?.[1] || "" + ).toLowerCase(); +} + +function parseSnapshotPayload(snapshot: DeduplicatedExecutionSnapshot): unknown { + const contentType = getSnapshotContentType(snapshot); + const looksLikeSSE = + contentType.includes("text/event-stream") || /(^|\n)\s*(event|data):/m.test(snapshot.payload); + + if (looksLikeSSE) { + return parseNonStreamingSSEPayload(snapshot.payload, "", "")?.body ?? null; + } + + try { + return JSON.parse(snapshot.payload); + } catch { + return null; + } +} + +function hasUnusableCompletionPayload(payload: unknown): boolean { + if (!payload || typeof payload !== "object") return true; + + const body = payload as Record; + const hasStructuredCompletionShape = + body.object === "response" || + Array.isArray(body.choices) || + (body.type === "message" && Array.isArray(body.content)); + if (hasStructuredCompletionShape) { + return detectMalformedNonStream(body) !== null; + } + + const hasRecognizedCompletionShape = typeof body.text === "string" || "content" in body; + if (!hasRecognizedCompletionShape) return true; + + return isEmptyContentResponse(body); +} + /** * True when `headerName` is a Next.js internal middleware control header that * must never be forwarded from a proxied upstream response. @@ -39,6 +113,25 @@ export function isNextMiddlewareControlHeader(headerName: string): boolean { return headerName.toLowerCase().startsWith(NEXTJS_MIDDLEWARE_HEADER_PREFIX); } +export function isCodexAccountQuotaHeader(headerName: string): boolean { + const normalized = headerName.toLowerCase(); + if (!normalized.startsWith(CODEX_ACCOUNT_QUOTA_HEADER_PREFIX)) return false; + if (CODEX_ACCOUNT_QUOTA_HEADER_NAMES.has(normalized)) return true; + return CODEX_ACCOUNT_QUOTA_HEADER_SUFFIXES.some((suffix) => normalized.endsWith(suffix)); +} + +export function stripCodexAccountQuotaHeaders(headers: Headers): void { + const toDelete: string[] = []; + headers.forEach((_value, key) => { + if (isCodexAccountQuotaHeader(key)) { + toDelete.push(key); + } + }); + for (const key of toDelete) { + headers.delete(key); + } +} + /** * Strip the whole `x-middleware-*` family (see {@link isNextMiddlewareControlHeader}) * from a `Headers` instance. Used on the non-streaming JSON path alongside @@ -64,7 +157,8 @@ export function buildStreamingResponseHeaders( providerHeaders.forEach((value, key) => { if ( !STREAMING_RESPONSE_HEADER_DENYLIST.has(key.toLowerCase()) && - !isNextMiddlewareControlHeader(key) + !isNextMiddlewareControlHeader(key) && + !isCodexAccountQuotaHeader(key) ) { forwardedHeaders.push([key, value]); } @@ -85,17 +179,7 @@ export function buildStreamingResponseHeaders( export function materializeDeduplicatedExecutionResult>( result: T ): T { - const snapshot = - result && typeof result === "object" - ? ((result as Record)._dedupSnapshot as - | { - status: number; - statusText: string; - headers: [string, string][]; - payload: string; - } - | undefined) - : undefined; + const snapshot = getDeduplicatedExecutionSnapshot(result); if (!snapshot) return result; @@ -109,6 +193,22 @@ export function materializeDeduplicatedExecutionResult | null | undefined +): boolean { + const snapshot = getDeduplicatedExecutionSnapshot(result); + if (!snapshot) return false; + if (snapshot.status < 200 || snapshot.status >= 300 || snapshot.payload.trim().length === 0) { + return false; + } + return !hasUnusableCompletionPayload(parseSnapshotPayload(snapshot)); +} + /** * Strip hop-by-hop headers that describe the upstream wire encoding. * diff --git a/open-sse/handlers/chatCore/semanticCache.ts b/open-sse/handlers/chatCore/semanticCache.ts index bb247b085f58..4b36ab2944cd 100644 --- a/open-sse/handlers/chatCore/semanticCache.ts +++ b/open-sse/handlers/chatCore/semanticCache.ts @@ -2,6 +2,7 @@ import { generateSignature, getCachedResponse, isCacheableForRead, + requestVariantOf, } from "@/lib/semanticCache"; import { calculateCost } from "@/lib/usage/costCalculator"; import { trackPendingRequest } from "@/lib/usageDb"; @@ -24,6 +25,7 @@ export async function checkSemanticCache({ log, persistAttemptLogs, apiKeyId, + signature, }: { semanticCacheEnabled: boolean; body: Record; @@ -38,16 +40,20 @@ export async function checkSemanticCache({ log: unknown; persistAttemptLogs: (args: unknown) => void; apiKeyId?: string | null; + signature?: string; }) { if (semanticCacheEnabled && isCacheableForRead(body, clientRawRequest?.headers)) { - const signature = generateSignature( - model, - body.messages ?? body.input, - body.temperature, - body.top_p, - apiKeyId ?? undefined - ); - const cached = getCachedResponse(signature); + const cacheSignature = + signature ?? + generateSignature( + model, + body.messages ?? body.input, + body.temperature, + body.top_p, + apiKeyId ?? undefined, + requestVariantOf(body, { endpoint: clientRawRequest?.endpoint }) + ); + const cached = getCachedResponse(cacheSignature); if (cached) { log?.debug?.("CACHE", `Semantic cache HIT for ${model} (stream=${stream})`); reqLogger.logConvertedResponse(cached as Record); diff --git a/open-sse/handlers/chatCore/semanticCacheStore.ts b/open-sse/handlers/chatCore/semanticCacheStore.ts index 8d119a863ff5..e9c9b50c58d8 100644 --- a/open-sse/handlers/chatCore/semanticCacheStore.ts +++ b/open-sse/handlers/chatCore/semanticCacheStore.ts @@ -10,6 +10,7 @@ */ import { generateSignature as defaultGenerateSignature, + requestVariantOf, setCachedResponse as defaultSetCachedResponse, isCacheableForWrite as defaultIsCacheableForWrite, } from "@/lib/semanticCache"; @@ -18,6 +19,7 @@ import { isSmallEnoughForSemanticCache as defaultIsSmallEnough } from "../../uti type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; type CacheBody = { + [key: string]: unknown; messages?: unknown; input?: unknown; temperature?: unknown; @@ -48,6 +50,7 @@ export function storeSemanticCacheResponse( translatedResponse: unknown; model: string; apiKeyId?: string | number; + signature?: string; usage?: UsageLike; log?: LoggerLike; }, @@ -60,13 +63,16 @@ export function storeSemanticCacheResponse( ) { return; } - const signature = deps.generateSignature( - args.model, - args.body.messages ?? args.body.input, - args.body.temperature, - args.body.top_p, - args.apiKeyId ?? undefined - ); + const signature = + args.signature ?? + deps.generateSignature( + args.model, + args.body.messages ?? args.body.input, + args.body.temperature, + args.body.top_p, + args.apiKeyId == null ? undefined : String(args.apiKeyId), + requestVariantOf(args.body) + ); const tokensSaved = args.usage?.prompt_tokens + args.usage?.completion_tokens || 0; deps.setCachedResponse(signature, args.model, args.translatedResponse, tokensSaved); args.log?.debug?.("CACHE", `Stored response for ${args.model} (${tokensSaved} tokens)`); diff --git a/open-sse/handlers/chatCore/streamingSemanticCacheStore.ts b/open-sse/handlers/chatCore/streamingSemanticCacheStore.ts index 3aa16a978156..4258e47740d5 100644 --- a/open-sse/handlers/chatCore/streamingSemanticCacheStore.ts +++ b/open-sse/handlers/chatCore/streamingSemanticCacheStore.ts @@ -11,6 +11,7 @@ */ import { generateSignature as defaultGenerateSignature, + requestVariantOf, setCachedResponse as defaultSetCachedResponse, isCacheableForWrite as defaultIsCacheableForWrite, } from "@/lib/semanticCache"; @@ -19,6 +20,7 @@ import { isSmallEnoughForSemanticCache as defaultIsSmallEnough } from "../../uti type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; type CacheBody = { + [key: string]: unknown; messages?: unknown; input?: unknown; temperature?: unknown; @@ -47,6 +49,7 @@ interface StreamingCacheArgs { headers: unknown; model: string; apiKeyId?: string | number; + signature?: string; streamUsage?: Record | null; log?: LoggerLike; } @@ -64,16 +67,22 @@ function writeStreamingCacheEntry( const cleanBody = { ...(args.streamResponseBody as Record) }; delete cleanBody._streamed; if (!deps.isSmallEnoughForSemanticCache(cleanBody)) return; - const sig = deps.generateSignature( - args.model, - args.body.messages ?? args.body.input, - args.body.temperature, - args.body.top_p, - args.apiKeyId ?? undefined - ); + const sig = + args.signature ?? + deps.generateSignature( + args.model, + args.body.messages ?? args.body.input, + args.body.temperature, + args.body.top_p, + args.apiKeyId == null ? undefined : String(args.apiKeyId), + requestVariantOf(args.body) + ); const tokensSaved = streamTokensSaved(args.streamUsage); deps.setCachedResponse(sig, args.model, cleanBody, tokensSaved); - args.log?.debug?.("CACHE", `Stored streaming response for ${args.model} (${tokensSaved} tokens)`); + args.log?.debug?.( + "CACHE", + `Stored streaming response for ${args.model} (${tokensSaved} tokens)` + ); } catch { // Cache write failed — non-critical } diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 2bfc452a0a6b..4d05cca857af 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -41,7 +41,11 @@ import { extractComfyOutputFiles, } from "../utils/comfyuiClient.ts"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; -import { FetchTimeoutError, fetchWithTimeout, getConfiguredTimeout } from "@/shared/utils/fetchTimeout"; +import { + FetchTimeoutError, + fetchWithTimeout, + getConfiguredTimeout, +} from "@/shared/utils/fetchTimeout"; import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts"; // --- Per-provider handlers (extracted to co-located files in PR-#4582-batch) --- @@ -62,7 +66,10 @@ import { CHATGPT_WEB_IMAGE_ID_RE, } from "./imageGeneration/providers/chatgptWeb.ts"; import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "./imageGeneration/callLogResult.ts"; +// Re-export so route-level fallbacks can log per-connection results. +export { saveImageErrorResult, saveImageSuccessResult }; interface KieImageOptions { model: string; @@ -128,9 +135,7 @@ const IMAGE_ASPECT_RATIO_PATTERN = /^\d+:\d+$/; */ export function resolveImageBaseUrl( credentials: - | { baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } - | null - | undefined, + { baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined, fallback: string, endpoint: "generations" | "edits" = "generations" ): string { @@ -945,7 +950,7 @@ async function handleOpenAIImageGeneration({ if (body.style !== undefined) upstreamBody.style = body.style; const { imageUrl } = extractImageInputs(body); - if (imageUrl && OPENAI_IMAGE_TO_IMAGE_MODELS.has(model)) { + if (imageUrl && OPENAI_IMAGE_TO_IMAGE_MODELS.has(`${provider}/${model}`)) { upstreamBody.image_url = imageUrl; } @@ -1065,7 +1070,9 @@ export async function handleOpenAIImageEdit({ const startTime = Date.now(); const url = resolveImageBaseUrl( credentials, - `https://generativelanguage.googleapis.com/v1beta/openai/images/edits`, + provider === "openai" + ? "https://api.openai.com/v1/images/edits" + : "https://generativelanguage.googleapis.com/v1beta/openai/images/edits", "edits" ); @@ -1107,7 +1114,10 @@ export async function handleOpenAIImageEdit({ if (token) headers["Authorization"] = `Bearer ${token}`; if (log) { - log.info("IMAGE", `${provider}/${model} (edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}`); + log.info( + "IMAGE", + `${provider}/${model} (edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}` + ); } const result = await fetchImageEndpoint( @@ -2223,6 +2233,7 @@ async function handleCodexImageGeneration({ return saveImageErrorResult({ provider, model, + connectionId: credentials?.connectionId, status: 400, startTime, error: "Prompt is required for Codex image generation", @@ -2243,6 +2254,7 @@ async function handleCodexImageGeneration({ return saveImageErrorResult({ provider, model, + connectionId: credentials?.connectionId, status: 401, startTime, error: "Codex credentials missing accessToken — reconnect the Codex provider", @@ -2318,6 +2330,7 @@ async function handleCodexImageGeneration({ error: { provider, model, + connectionId: credentials?.connectionId, status: 502, startTime, error: `Image provider error: ${(err as Error).message}`, @@ -2335,6 +2348,7 @@ async function handleCodexImageGeneration({ error: { provider, model, + connectionId: credentials?.connectionId, status: response.status, startTime, error: errorText, @@ -2351,6 +2365,7 @@ async function handleCodexImageGeneration({ error: { provider, model, + connectionId: credentials?.connectionId, status: 502, startTime, error: @@ -2389,6 +2404,7 @@ async function handleCodexImageGeneration({ return saveImageSuccessResult({ provider, model, + connectionId: credentials?.connectionId, startTime, requestBody: upstreamBody, responseBody: { images_count: data.length }, @@ -2396,54 +2412,6 @@ async function handleCodexImageGeneration({ }); } -export function saveImageSuccessResult({ - provider, - model, - startTime, - requestBody = null, - responseBody = null, - created = null, - images, -}) { - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - requestBody, - responseBody, - }).catch(() => {}); - - return { - success: true, - data: { - created: created || Math.floor(Date.now() / 1000), - data: images, - }, - }; -} - -export function saveImageErrorResult({ provider, model, status, startTime, error, requestBody = null }) { - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: typeof error === "string" ? error.slice(0, 500) : String(error).slice(0, 500), - requestBody, - }).catch(() => {}); - - return { - success: false, - status, - error, - }; -} - /** * Fetch a single image endpoint and normalize response */ @@ -2877,4 +2845,4 @@ function normalizePositiveNumber(value, fallback) { * Handle SD WebUI image generation (local, no auth) * POST {baseUrl} with { prompt, negative_prompt, width, height, steps } * Response: { images: ["base64..."] } - */ \ No newline at end of file + */ diff --git a/open-sse/handlers/imageGeneration/callLogResult.ts b/open-sse/handlers/imageGeneration/callLogResult.ts new file mode 100644 index 000000000000..502613a6eb00 --- /dev/null +++ b/open-sse/handlers/imageGeneration/callLogResult.ts @@ -0,0 +1,62 @@ +import { saveCallLog } from "@/lib/usageDb"; + +export function saveImageSuccessResult({ + provider, + model, + connectionId = null, + startTime, + requestBody = null, + responseBody = null, + created = null, + images, + path = "/v1/images/generations", +}) { + saveCallLog({ + method: "POST", + path, + status: 200, + model: `${provider}/${model}`, + provider, + connectionId, + duration: Date.now() - startTime, + requestBody, + responseBody, + }).catch(() => {}); + + return { + success: true, + data: { + created: created || Math.floor(Date.now() / 1000), + data: images, + }, + }; +} + +export function saveImageErrorResult({ + provider, + model, + connectionId = null, + status, + startTime, + error, + requestBody = null, + path = "/v1/images/generations", +}) { + saveCallLog({ + method: "POST", + path, + status, + model: `${provider}/${model}`, + provider, + connectionId, + duration: Date.now() - startTime, + error: typeof error === "string" ? error.slice(0, 500) : String(error).slice(0, 500), + requestBody, + }).catch(() => {}); + + return { + success: false, + status, + error, + }; +} diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index faaf50d84be9..ac279dcb8515 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -122,6 +122,17 @@ function findBestMessageText(output: unknown[]): { return { text: "", selectedMessageIndex: -1, messageItems: [] }; } +function resolveResponsesFinishReason( + response: JsonRecord, + hasToolCalls: boolean +): "stop" | "length" | "content_filter" | "tool_calls" { + if (toString(response.status).toLowerCase() === "incomplete") { + const incompleteReason = toString(toRecord(response.incomplete_details).reason).toLowerCase(); + return incompleteReason === "content_filter" ? "content_filter" : "length"; + } + return hasToolCalls ? "tool_calls" : "stop"; +} + /** * Translate non-streaming response to OpenAI format * Handles different provider response formats (Gemini, Claude, etc.) @@ -233,7 +244,7 @@ export function translateNonStreamingResponse( const createdAt = toNumber(response.created_at, Math.floor(Date.now() / 1000)); const model = toString(response.model || responseRoot.model, "openai-responses"); - const finishReason = toolCalls.length > 0 ? "tool_calls" : "stop"; + const finishReason = resolveResponsesFinishReason(response, toolCalls.length > 0); const result: JsonRecord = { id: `chatcmpl-${toString(response.id, String(Date.now()))}`, diff --git a/open-sse/handlers/saluteSpeechLive.ts b/open-sse/handlers/saluteSpeechLive.ts new file mode 100644 index 000000000000..0d2da8e47b05 --- /dev/null +++ b/open-sse/handlers/saluteSpeechLive.ts @@ -0,0 +1,176 @@ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { credentials, loadPackageDefinition, Metadata } from "@grpc/grpc-js"; +import protoLoader from "@grpc/proto-loader"; +import type { SaluteSpeechCredentials } from "./saluteSpeechTranscription.ts"; + +const GRPC_HOST = "smartspeech.sber.ru:443"; +const OAUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"; +const OAUTH_SCOPE = "SALUTE_SPEECH_PERS"; +const PROTO_PATH = join(process.cwd(), "open-sse", "proto", "salutespeech", "recognitionv2.proto"); + +export type SaluteSpeechLiveCall = { + write(value: unknown): boolean; + end(): void; + cancel(): void; + on(event: "data", listener: (response: unknown) => void): SaluteSpeechLiveCall; + on(event: "error", listener: (error: Error) => void): SaluteSpeechLiveCall; + on(event: "end", listener: () => void): SaluteSpeechLiveCall; +}; + +type SaluteSpeechLiveClient = { + Recognize(metadata: Metadata): SaluteSpeechLiveCall; + close(): void; +}; + +type Dependencies = { + fetch: typeof globalThis.fetch; + createClient: () => SaluteSpeechLiveClient; +}; + +function createClient(): SaluteSpeechLiveClient { + const definition = protoLoader.loadSync(PROTO_PATH, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [join(process.cwd(), "node_modules", "protobufjs")], + }); + const loaded = loadPackageDefinition(definition) as unknown as { + smartspeech: { + recognition: { + v2: { SmartSpeech: new (host: string, sslCredentials: object) => SaluteSpeechLiveClient }; + }; + }; + }; + return new loaded.smartspeech.recognition.v2.SmartSpeech(GRPC_HOST, credentials.createSsl()); +} + +const defaults: Dependencies = { fetch: globalThis.fetch, createClient }; +let dependencies = defaults; + +export function setSaluteSpeechLiveDependenciesForTests( + overrides: Partial | null +): void { + dependencies = overrides ? { ...defaults, ...overrides } : defaults; +} + +function getClientId(credentialsInput: SaluteSpeechCredentials): string | null { + const value = credentialsInput.providerSpecificData?.clientId; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +async function getAccessToken(credentialsInput: SaluteSpeechCredentials): Promise { + const clientId = getClientId(credentialsInput); + const clientSecret = credentialsInput.apiKey || credentialsInput.accessToken; + if (!clientId || !clientSecret) + throw new Error("SaluteSpeech OAuth client ID and secret are required"); + + let response: Response; + try { + response = await dependencies.fetch(OAUTH_URL, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + RqUID: randomUUID(), + }, + body: new URLSearchParams({ scope: OAUTH_SCOPE }), + signal: AbortSignal.timeout(10_000), + }); + } catch { + throw new Error("SaluteSpeech authorization is unavailable"); + } + const payload = (await response.json().catch(() => null)) as { access_token?: unknown } | null; + if (!response.ok || typeof payload?.access_token !== "string" || !payload.access_token) { + throw new Error("SaluteSpeech authorization failed"); + } + return payload.access_token; +} + +export async function openSaluteSpeechLiveRecognition(input: { + credentials: SaluteSpeechCredentials; + language: string; + model: "general" | "callcenter"; + sampleRate: 8000 | 16000 | 48000; + onResult: (result: { + type: "partial" | "final"; + text: string; + confidence?: number; + channel?: number; + reason?: string; + }) => void; + onError: () => void; + onEnd: () => void; +}): Promise<{ writeAudio(chunk: Buffer): boolean; close(): void }> { + const token = await getAccessToken(input.credentials); + const client = dependencies.createClient(); + const metadata = new Metadata(); + metadata.set("authorization", `Bearer ${token}`); + const call = client.Recognize(metadata); + let closed = false; + const close = () => { + if (closed) return; + closed = true; + try { + call.end(); + } catch {} + try { + client.close(); + } catch {} + }; + + call.on("data", (response) => { + const transcription = ( + response as { + transcription?: { + eou?: unknown; + eou_reason?: unknown; + channel?: unknown; + results?: Array<{ normalized_text?: unknown; text?: unknown; confidence?: unknown }>; + }; + } + ).transcription; + const result = transcription?.results?.[0]; + const text = + typeof result?.normalized_text === "string" + ? result.normalized_text + : typeof result?.text === "string" + ? result.text + : ""; + if (!text) return; + input.onResult({ + type: transcription?.eou === true ? "final" : "partial", + text, + ...(typeof result?.confidence === "number" ? { confidence: result.confidence } : {}), + ...(typeof transcription?.channel === "number" ? { channel: transcription.channel } : {}), + ...(typeof transcription?.eou_reason === "string" + ? { reason: transcription.eou_reason } + : {}), + }); + }); + call.on("error", input.onError); + call.on("end", input.onEnd); + call.write({ + options: { + audio_encoding: "PCM_S16LE", + sample_rate: input.sampleRate, + channels_count: 1, + language: input.language, + model: input.model, + hypotheses_count: 1, + enable_partial_results: { enable: true }, + enable_multi_utterance: { enable: true }, + no_speech_timeout: { seconds: 7 }, + max_speech_timeout: { seconds: 20 }, + normalization_options: { + enable: { enable: true }, + capitalization: { enable: true }, + punctuation: { enable: true }, + }, + }, + }); + + return { writeAudio: (chunk) => call.write({ audio_chunk: chunk }), close }; +} diff --git a/open-sse/handlers/saluteSpeechTranscription.ts b/open-sse/handlers/saluteSpeechTranscription.ts new file mode 100644 index 000000000000..19f924912c72 --- /dev/null +++ b/open-sse/handlers/saluteSpeechTranscription.ts @@ -0,0 +1,249 @@ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { credentials, loadPackageDefinition, Metadata, status } from "@grpc/grpc-js"; +import protoLoader from "@grpc/proto-loader"; + +const DEFAULT_GRPC_HOST = "smartspeech.sber.ru:443"; +const DEFAULT_OAUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"; +const DEFAULT_OAUTH_SCOPE = "SALUTE_SPEECH_PERS"; +const PROTO_PATH = join(process.cwd(), "open-sse", "proto", "salutespeech", "recognitionv2.proto"); +const AUDIO_CHUNK_BYTES = 64 * 1024; + +type SaluteSpeechProviderData = Record; + +export type SaluteSpeechCredentials = { + apiKey?: string; + accessToken?: string; + providerSpecificData?: SaluteSpeechProviderData; +}; + +type GrpcDuplexCall = { + write(value: unknown): boolean; + end(): void; + cancel(): void; + on(event: "data", listener: (response: unknown) => void): GrpcDuplexCall; + on(event: "error", listener: (error: Error & { code?: number }) => void): GrpcDuplexCall; + on(event: "end", listener: () => void): GrpcDuplexCall; +}; + +type SaluteSpeechClient = { + Recognize(metadata: Metadata): GrpcDuplexCall; + close(): void; +}; + +type TranscriptionDependencies = { + fetch: typeof globalThis.fetch; + createClient: (host: string) => SaluteSpeechClient; +}; + +function getProviderString(data: SaluteSpeechProviderData | undefined, key: string): string | null { + const value = data?.[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function getAudioEncoding(file: Blob): "MP3" | "FLAC" | "OPUS" | "PCM_S16LE" | null { + const type = file.type.toLowerCase(); + if (type === "audio/mpeg" || type === "audio/mp3") return "MP3"; + if (type === "audio/flac" || type === "audio/x-flac") return "FLAC"; + if (type === "audio/ogg" || type.includes("opus")) return "OPUS"; + if (type === "audio/l16" || type === "audio/pcm" || type === "audio/s16le") return "PCM_S16LE"; + return null; +} + +function resolveSampleRate(formData: FormData, encoding: string): number | undefined { + if (encoding !== "PCM_S16LE") return undefined; + const value = formData.get("sample_rate"); + const parsed = typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN; + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 16_000; +} + +function createRecognitionClient(host: string): SaluteSpeechClient { + const definition = protoLoader.loadSync(PROTO_PATH, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [join(process.cwd(), "node_modules", "protobufjs")], + }); + const loaded = loadPackageDefinition(definition) as unknown as { + smartspeech: { + recognition: { + v2: { SmartSpeech: new (target: string, sslCredentials: object) => SaluteSpeechClient }; + }; + }; + }; + return new loaded.smartspeech.recognition.v2.SmartSpeech(host, credentials.createSsl()); +} + +const defaultDependencies: TranscriptionDependencies = { + fetch: globalThis.fetch, + createClient: createRecognitionClient, +}; + +let dependencies = defaultDependencies; + +export function setSaluteSpeechTranscriptionDependenciesForTests( + overrides: Partial | null +) { + dependencies = overrides ? { ...defaultDependencies, ...overrides } : defaultDependencies; +} + +async function getAccessToken(credentialsInput: SaluteSpeechCredentials): Promise { + const providerData = credentialsInput.providerSpecificData; + const clientId = getProviderString(providerData, "clientId"); + const clientSecret = credentialsInput.apiKey || credentialsInput.accessToken; + if (!clientId || !clientSecret) { + throw new SaluteSpeechError(401, "SaluteSpeech OAuth client ID and secret are required"); + } + + const scope = DEFAULT_OAUTH_SCOPE; + let response: Response; + try { + response = await dependencies.fetch(DEFAULT_OAUTH_URL, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + RqUID: randomUUID(), + }, + body: new URLSearchParams({ scope }), + signal: AbortSignal.timeout(10_000), + }); + } catch { + throw new SaluteSpeechError(502, "SaluteSpeech authorization is unavailable"); + } + + const payload = (await response.json().catch(() => null)) as { access_token?: unknown } | null; + if ( + !response.ok || + !payload || + typeof payload.access_token !== "string" || + !payload.access_token + ) { + throw new SaluteSpeechError( + response.status === 401 || response.status === 403 ? 401 : 502, + response.status === 401 || response.status === 403 + ? "SaluteSpeech authorization failed" + : "SaluteSpeech authorization is unavailable" + ); + } + + return payload.access_token; +} + +class SaluteSpeechError extends Error { + constructor( + public readonly statusCode: number, + message: string + ) { + super(message); + } +} + +function mapGrpcError(error: Error & { code?: number }): SaluteSpeechError { + if (error.code === status.UNAUTHENTICATED || error.code === status.PERMISSION_DENIED) { + return new SaluteSpeechError(401, "SaluteSpeech authorization failed"); + } + if (error.code === status.INVALID_ARGUMENT) { + return new SaluteSpeechError(400, "SaluteSpeech rejected the audio request"); + } + if (error.code === status.RESOURCE_EXHAUSTED) { + return new SaluteSpeechError(429, "SaluteSpeech rate limit exceeded"); + } + return new SaluteSpeechError(502, "SaluteSpeech transcription failed"); +} + +function extractFinalText(response: unknown): string | null { + if (!response || typeof response !== "object") return null; + const transcription = (response as { transcription?: unknown }).transcription; + if (!transcription || typeof transcription !== "object") return null; + if ((transcription as { eou?: unknown }).eou !== true) return null; + const result = (transcription as { results?: unknown }).results; + if ( + !Array.isArray(result) || + result.length === 0 || + !result[0] || + typeof result[0] !== "object" + ) { + return null; + } + const hypothesis = result[0] as { normalized_text?: unknown; text?: unknown }; + if (typeof hypothesis.normalized_text === "string" && hypothesis.normalized_text.trim()) { + return hypothesis.normalized_text.trim(); + } + return typeof hypothesis.text === "string" && hypothesis.text.trim() + ? hypothesis.text.trim() + : null; +} + +export async function transcribeWithSaluteSpeech({ + file, + formData, + modelId, + credentials: credentialsInput, +}: { + file: Blob; + formData: FormData; + modelId: string | null; + credentials: SaluteSpeechCredentials; +}): Promise { + const encoding = getAudioEncoding(file); + if (!encoding) { + throw new SaluteSpeechError( + 400, + "SaluteSpeech supports MP3, FLAC, Opus, and raw PCM S16LE audio" + ); + } + + const accessToken = await getAccessToken(credentialsInput); + const providerData = credentialsInput.providerSpecificData; + const host = DEFAULT_GRPC_HOST; + const languageValue = formData.get("language"); + const language = + typeof languageValue === "string" && languageValue.trim() ? languageValue.trim() : "ru-RU"; + const audio = Buffer.from(await file.arrayBuffer()); + + return await new Promise((resolve, reject) => { + const client = dependencies.createClient(host); + const metadata = new Metadata(); + metadata.set("authorization", `Bearer ${accessToken}`); + const call = client.Recognize(metadata); + const results: string[] = []; + let settled = false; + const finish = (error?: SaluteSpeechError) => { + if (settled) return; + settled = true; + client.close(); + if (error) reject(error); + else resolve(results.join(" ").trim()); + }; + + call.on("data", (response) => { + const text = extractFinalText(response); + if (text) results.push(text); + }); + call.on("error", (error) => finish(mapGrpcError(error))); + call.on("end", () => finish()); + + call.write({ + options: { + audio_encoding: encoding, + sample_rate: resolveSampleRate(formData, encoding), + channels_count: 1, + language, + model: modelId || "general", + enable_partial_results: { enable: false }, + normalization_options: { enable: { enable: true } }, + }, + }); + for (let offset = 0; offset < audio.length; offset += AUDIO_CHUNK_BYTES) { + call.write({ audio_chunk: audio.subarray(offset, offset + AUDIO_CHUNK_BYTES) }); + } + call.end(); + }); +} + +export function isSaluteSpeechError(error: unknown): error is SaluteSpeechError { + return error instanceof SaluteSpeechError; +} diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index d2e634e12ae4..bc25c9a4096e 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -819,6 +819,9 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { }), usage: picked.usage || null, status: picked.status || statusFallback, + ...(picked.incomplete_details && typeof picked.incomplete_details === "object" + ? { incomplete_details: picked.incomplete_details } + : {}), created_at: picked.created_at || Math.floor(Date.now() / 1000), metadata: picked.metadata || {}, }; diff --git a/open-sse/package.json b/open-sse/package.json index fd014cab8558..be98020c5920 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -11,7 +11,7 @@ "./*": "./*" }, "dependencies": { - "@toon-format/toon": "^2.3.0", + "@toon-format/toon": "^2.3.1", "safe-regex": "^2.1.1" } } diff --git a/open-sse/proto/salutespeech/recognitionv2.proto b/open-sse/proto/salutespeech/recognitionv2.proto new file mode 100644 index 000000000000..a7d9d1cbb051 --- /dev/null +++ b/open-sse/proto/salutespeech/recognitionv2.proto @@ -0,0 +1,171 @@ +syntax = "proto3"; + +package smartspeech.recognition.v2; + +import "google/protobuf/duration.proto"; + +option go_package = "./;protocol"; +option java_package = "TODO"; + +service SmartSpeech { + rpc Recognize (stream RecognitionRequest) returns (stream RecognitionResponse); +} + +message RecognitionRequest { + oneof request { + RecognitionOptions options = 1; + bytes audio_chunk = 2; + } +} + +enum EouReason { + UNSPECIFIED = 0; // Not an EOU + ORGANIC = 1; // Proper EOU + NO_SPEECH_TIMEOUT = 2; // EOU because there was no speech for at least no_speech_timeout seconds + MAX_SPEECH_TIMEOUT = 3; // EOU because there was continuous speech with no breaks for at least max_speech_timeout seconds +} + +message RecognitionResponse { + oneof response { + Transcription transcription = 1; + BackendInfo backend_info = 2; + InsightResult insight = 3; + VADResult vad = 4; + } +} + +message Transcription { + int32 channel = 1; + repeated Hypothesis results = 2; + bool eou = 3; // marks final result for this utterance + EouReason eou_reason = 4; // what caused the end of this utterance (eou must be true) + google.protobuf.Duration processed_audio_start = 5; // starting position of processed audio + google.protobuf.Duration processed_audio_end = 6; // ending position of processed audio + Emotions emotions_result = 7; // may be set on end of utterance + SpeakerInfo speaker_info = 8; + PersonIdentity person_identity = 9; +} + +message InsightResult { + string insight_result = 10; +} + +message VADResult { + int32 channel = 1; + google.protobuf.Duration processed_audio_time = 2; // VAD time mark in audio + google.protobuf.Duration utterance_detection_time = 3; // VAD time since utterance start +} + +message OptionalBool { + bool enable = 1; +} + +message RecognitionOptions { + enum AudioEncoding { + AUDIO_ENCODING_UNSPECIFIED = 0; + PCM_S16LE = 1; // 16-bit signed little-endian (Linear PCM) + OPUS = 2; // mime audio/ogg; codecs=opus + MP3 = 3; // MPEG-1/2 Layer-3 + FLAC = 4; + ALAW = 5; + MULAW = 6; + G729 = 7; + } + + AudioEncoding audio_encoding = 1; + int32 sample_rate = 2; // For PCM_16LE, ALAW, MULAW audio encodings + int32 channels_count = 3; + string language = 4; // Language code in RFC-3066 format, i.e.: ru-RU + string model = 5; + OptionalBool enable_multi_utterance = 6; + OptionalBool enable_partial_results = 7; + int32 hypotheses_count = 8; + google.protobuf.Duration no_speech_timeout = 9; + google.protobuf.Duration max_speech_timeout = 10; + Hints hints = 11; + SpeakerSeparationOptions speaker_separation_options = 12; + + NormalizationOptions normalization_options = 13; + repeated string insight_models = 14; + OptionalBool enable_vad = 15; + OptionalBool custom_ws_flow_control = 16; + OptionalBool enable_long_utterances = 17; + repeated string truncate_last_punctuation = 18; +} + +message NormalizationOptions { + OptionalBool enable = 1; + OptionalBool profanity_filter = 2; + OptionalBool punctuation = 3; + OptionalBool capitalization = 4; + OptionalBool question = 5; + OptionalBool force_cyrillic = 6; +} + +message Hints { + repeated string words = 1; + bool enable_letters = 2; + google.protobuf.Duration eou_timeout = 3; +} + +message SpeakerSeparationOptions { + bool enable = 1; + bool enable_only_main_speaker = 2; // return only main speaker + int32 count = 3; // number of expected speakers +} + +message Hypothesis { + message WordAlignment { + string word = 1; // single word + google.protobuf.Duration start = 2; // starting position of the word in audio stream + google.protobuf.Duration end = 3; // ending position of the word in audio stream + } + + string text = 1; // non-normalized text result + string normalized_text = 2; // normalized text result + google.protobuf.Duration start = 3; // hypothesis starting position from current utterance start + google.protobuf.Duration end = 4; // hypothesis final position from current utterance start + repeated WordAlignment word_alignments = 5; // alignments of single words in audio stream +} + +message Emotions { + float positive = 1; // confidence [0.0 - 1.0] + float neutral = 2; + float negative = 3; + float positive_a = 4; + float neutral_a = 5; + float negative_a = 6; + float positive_t = 7; + float neutral_t = 8; + float negative_t = 9; +} + +message BackendInfo { + string model_name = 1; + string model_version = 2; + string server_version = 3; +} + +message SpeakerInfo { + int32 speaker_id = 1; + float main_speaker_confidence = 2; // main speaker feature [0.0 - 1.0] +} + +enum AgeType { // age type + AGE_NONE = 0; // age not requested / not possible to determine + CHILD = 1; // child + ADULT = 2; // adult +} + +enum GenderType { // gender type + GENDER_NONE = 0; // gender not requested / not possible to determine + MALE = 1; // male + FEMALE = 2; // female +} + +message PersonIdentity { + AgeType age = 1; // age status based on model`s age_threshold + GenderType gender = 2; // gender status based on model`s gender_threshold + float age_score = 3; // age score from 0.0 to 1.0 + float gender_score = 4; // gender score from 0.0 to 1.0 +} diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 275ecbab411e..a97d9c5a6e2f 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -30,6 +30,7 @@ import { looksLikeQuotaExhausted, type FailureKind, } from "../../src/shared/utils/classify429"; +import { recordProviderSuccess as resetCooldownFailureCount } from "./providerCooldownTracker.ts"; import { resolveProviderId } from "../../src/shared/constants/providers"; import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints"; import { getCodexModelScope } from "../config/codexQuotaScopes.ts"; @@ -110,6 +111,15 @@ const CONNECTION_FAILURE_DEDUP_MS = 5000; const MAX_CONNECTION_FAILURE_DEDUP_ENTRIES = 10_000; const lastConnectionFailure = new Map(); +// Per-provider network-error dedup: several combo targets on the SAME provider can +// fail the same single network event (a VPN blip) in the same request. Without this, +// each target counts once and one transient blip opens the whole-provider breaker +// while the provider is healthy. A genuinely dead proxy persists ACROSS requests +// (past the window) and still accumulates to its threshold. +const NETWORK_ERROR_DEDUP_MS = 10_000; +const MAX_NETWORK_ERROR_DEDUP_ENTRIES = 1000; +const lastNetworkErrorByProvider = new Map(); + function pruneConnectionFailureDedupeEntries(): void { while (lastConnectionFailure.size > MAX_CONNECTION_FAILURE_DEDUP_ENTRIES) { const oldestKey = lastConnectionFailure.keys().next().value; @@ -175,6 +185,14 @@ export const CREDITS_EXHAUSTED_SIGNALS = [ "insufficient balance", "insufficient_balance", "insufficient account balance", + "insufficient credit balance", + // Command Code returns 400 "You have insufficient credits to make this + // request. Please purchase more credits to continue using the service." + // when the account's billing credits run out. Without this signal the + // error stays unclassified (errorType=null), so the connection is never + // marked credits_exhausted and keeps being re-selected on every request. + "insufficient credits", + "insufficient credit", ]; // T11: Signals that indicate OAuth token is invalid/expired (not permanent deactivation) @@ -250,7 +268,7 @@ const MODEL_ACCESS_DENIED_PATTERNS = [ // across every target, masking the real "fix your credential" error. When the // text clearly indicates a bad credential, the regex-based model-access detection // is suppressed (structured codes/types like model_not_found are unaffected). -const AUTH_CREDENTIAL_ERROR_PATTERNS = [ +export const AUTH_CREDENTIAL_ERROR_PATTERNS = [ /\b(?:invalid|incorrect|expired|missing|revoked)\s+api[\s_-]?key\b/i, /\bapi[\s_-]?key\s+(?:is\s+)?(?:invalid|incorrect|expired|missing|revoked|not\s+valid)\b/i, /\bauthentication\s+(?:failed|error|required)\b/i, @@ -259,6 +277,45 @@ const AUTH_CREDENTIAL_ERROR_PATTERNS = [ /\bnot\s+authenticated\b/i, ]; +// #10460: strict subset of MODEL_ACCESS_DENIED_PATTERNS that is unambiguously +// PROVIDER-wide — the model does not exist / is not served by this provider at all, so +// no account of that provider could serve it (e.g. "The requested model is not +// supported", "model not found"). Deliberately EXCLUDES the "access"/"permission" +// patterns from MODEL_ACCESS_DENIED_PATTERNS (e.g. "does not have permission to access +// this model", "access denied ... model"): those commonly indicate an ACCOUNT-scoped +// entitlement gap (e.g. PRO vs free tier) where a *different* account of the same +// provider may still have access, so they must keep rotating through the normal +// account-cooldown path — not be treated as provider-wide unsupported. +const PROVIDER_MODEL_UNSUPPORTED_PATTERNS = [ + /\binvalid model\b/i, + /\bmodel.*not.*(?:available|found|supported|accessible)\b/i, + /\bmodel.*(?:does not exist|doesn't exist)\b/i, + /\bmodel\b[\s\S]{0,80}?\b(?:does\s+not\s+support|doesn't\s+support|unsupported)\b/i, + /\b(?:does\s+not\s+support|doesn't\s+support|unsupported)\b[\s\S]{0,80}?\bmodel\b/i, + /\bunsupported\s+model\b/i, + /\bplease select a different model\b/i, +]; + +/** + * #10460: is this 400 an unambiguous, PROVIDER-wide "model not supported" response — + * i.e. would retrying a *different account* of the same provider also fail for the + * same reason? Reuses AUTH_CREDENTIAL_ERROR_PATTERNS (the same bad-credential + * exclusion `checkFallbackError`'s 400 branch applies) so a message like "invalid api + * key for model X" is never misclassified as model-wide. Also excludes the broader, + * ambiguous MODEL_ACCESS_DENIED_PATTERNS access/permission phrasing — those can be + * account-scoped entitlement gaps, not a provider-wide unsupported model — so account + * rotation for those keeps working normally via the regular cooldown path. + * + * Callers that want "should combo keep trying other targets" (not "should this + * specific account keep rotating") should use MODEL_ACCESS_DENIED_PATTERNS / + * isModelScoped400() instead — this helper is deliberately narrower. + */ +export function isProviderModelUnsupported400(status: number, errorText: string): boolean { + if (status !== HTTP_STATUS.BAD_REQUEST) return false; + if (AUTH_CREDENTIAL_ERROR_PATTERNS.some((p) => p.test(errorText))) return false; + return PROVIDER_MODEL_UNSUPPORTED_PATTERNS.some((p) => p.test(errorText)); +} + // Malformed request patterns — the model rejected the message format but a different // provider/model in the combo may accept it. const MALFORMED_REQUEST_PATTERNS = [ @@ -881,9 +938,30 @@ export function recordProviderFailure( provider: string | null | undefined, log?: { warn?: (...args: unknown[]) => void }, connectionId?: string | null, - profile?: ProviderBreakerProfile | null + profile?: ProviderBreakerProfile | null, + opts?: { isQueueTimeout?: boolean; isNetworkError?: boolean } ): void { if (!provider) return; + // OmniRoute's own rate-limit queue timeout is backpressure we applied, not a + // provider failure — the provider never saw the request, so it must not count + // toward the provider breaker. + if (opts?.isQueueTimeout) return; + + // Network-layer errors (proxy_unreachable) get a separate SAME-PROVIDER dedup, so a + // single transient network event is not counted once per combo target (see the + // declaration). A dead proxy persists across requests and still accumulates. + if (opts?.isNetworkError) { + const now = Date.now(); + const last = lastNetworkErrorByProvider.get(provider); + if (last && now - last < NETWORK_ERROR_DEDUP_MS) return; + lastNetworkErrorByProvider.delete(provider); + lastNetworkErrorByProvider.set(provider, now); + while (lastNetworkErrorByProvider.size > MAX_NETWORK_ERROR_DEDUP_ENTRIES) { + const oldestKey = lastNetworkErrorByProvider.keys().next().value; + if (typeof oldestKey !== "string") break; + lastNetworkErrorByProvider.delete(oldestKey); + } + } // Deduplicate rapid-fire failures from the same connection if (connectionId) { @@ -910,6 +988,47 @@ export function recordProviderFailure( } } +/** + * Record a successful request for a provider. + * Symmetric counterpart of recordProviderFailure: + * - Resets cooldown failureCount (exponential backoff) for all non-OPEN states. + * - HALF_OPEN -> CLOSED (probe success), CLOSED/DEGRADED -> decay failureCount. + * + * When the breaker is OPEN (provider is failing), this is a no-op -- the + * cooldown stays intact and the breaker keeps its cooldown period. + * + * Matches execute()'s behavior: _onSuccess() is called for all non-OPEN states. + */ +export function recordProviderSuccess( + provider: string | null | undefined, + connectionId?: string | null +): void { + if (!provider || provider === "unknown") return; + + const breaker = getProviderBreaker(provider); + if (!breaker) return; + const breakerState = breaker.getStatus().state; + + // When breaker is OPEN, the provider is failing -- do not reset cooldown + // even if one request slipped through (dispatched before the open). + // The cooldown resets when the breaker reaches HALF_OPEN and the probe + // succeeds below. + if (breakerState === "OPEN") return; + + // Reset cooldown failureCount (exponential backoff) -- symmetric with + // recordProviderCooldown which increments it on each failure. + resetCooldownFailureCount(provider, connectionId ?? undefined); + + // Clear failure-dedup window so the next genuine failure is not suppressed. + if (connectionId) { + lastConnectionFailure.delete(`${provider}:${connectionId}`); + } + + // Transition breaker on success, matching execute()'s behavior: + // HALF_OPEN -> CLOSED (probe success), CLOSED/DEGRADED -> decay failureCount. + breaker._onSuccess(); +} + /** * Reset the shared provider breaker. */ diff --git a/open-sse/services/bottleneckPatch.ts b/open-sse/services/bottleneckPatch.ts new file mode 100644 index 000000000000..42312f33a354 --- /dev/null +++ b/open-sse/services/bottleneckPatch.ts @@ -0,0 +1,82 @@ +/** + * Monkey-patch for Bottleneck v2.19.5 doExpire bug. + * + * Bug (Job.js:162): + * `this._states.jobStatus(this.options.id === "RUNNING")` + * compares job ID to "RUNNING" (always false) instead of checking status. + * Should be: `this._states.jobStatus(this.options.id) === "RUNNING"` + * + * Impact: when a job's execution time exceeds `expiration`, doExpire fires but + * fails to advance the job from RUNNING to EXECUTING. The _assertStatus throws + * in a setTimeout (uncaught), and the job is permanently stuck in RUNNING state. + * Bottleneck's internal _running counter never decrements -> capacity leak. + * + * This patch intercepts Bottleneck's _run method to fix job.doExpire before + * the expiration timeout fires. + */ + +import Bottleneck from "bottleneck"; + +/** Bottleneck Job instance (internal, not exported). */ +interface BottleneckJob { + options: { id?: string; expiration?: number }; + doExpire: (clearGlobalState: () => void, run: () => void, free: () => void) => void; + _states: { jobStatus: (id: string) => string | null; next: (id: string) => void }; +} + +let patched = false; + +export function applyBottleneckDoExpirePatch(): void { + if (patched) return; + patched = true; + + const proto = Bottleneck.prototype as Record; + const originalRun = proto._run as + ((index: string, job: BottleneckJob, wait: number) => unknown) | undefined; + if (typeof originalRun !== "function") { + console.warn("[bottleneck-patch] _run not found on prototype, patch skipped"); + return; + } + + proto._run = function patchedRun(this: unknown, index: string, job: BottleneckJob, wait: number) { + // Patch job.doExpire BEFORE calling originalRun. + // originalRun passes job.doExpire to setTimeout by reference -- once captured, + // reassigning the property later has no effect on the queued timer callback. + // + // Guard: _run is called twice for jobs with wait > 0 (first with the delay, + // then with wait=0 when the timer fires). Without the flag, fixedDoExpire + // would wrap itself recursively on the second call. + if (typeof job?.doExpire === "function" && !(job as Record)._doExpirePatched) { + (job as Record)._doExpirePatched = true; + const originalDoExpire = job.doExpire.bind(job); + // Bottleneck registers the job in _states under options.id (Job.js + // states.start(this.options.id)); a bare `job.id` does not exist and + // reading it makes the RUNNING check below always miss. options.id is + // stable on the job and is the key the state machine uses. + const jobId = job.options.id; + + job.doExpire = function fixedDoExpire( + clearGlobalState: () => void, + run: () => void, + free: () => void + ) { + // Fix: check job status, not compare ID to string "RUNNING" + const states = job._states; + const currentStatus = states?.jobStatus?.(jobId); + if (currentStatus === "RUNNING") { + states?.next?.(jobId); + console.warn( + `[bottleneck-patch] doExpire bug triggered: job ${jobId} stuck in RUNNING, ` + + `advanced to EXECUTING before expiry. This is the Bottleneck v2.19.5 capacity leak.` + ); + } + return originalDoExpire(clearGlobalState, run, free); + }; + } + + // Now call original _run which captures the (now-patched) job.doExpire. + return originalRun.call(this, index, job, wait); + }; + + console.log("[bottleneck-patch] Applied doExpire fix for Bottleneck v2.19.5"); +} diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 21735e31a457..e9d9ee71790c 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -5,6 +5,7 @@ * context-optimized, context-relay, and fusion strategies */ +import { EMPTY_RESPONSE_RETRY_EXHAUSTED } from "./combo/emptyResponseRetryBudget.ts"; import { checkFallbackError, classifyLockoutReason, @@ -17,10 +18,12 @@ import { isModelLocked, recordModelLockoutFailure, recordProviderFailure, + recordProviderSuccess, selectLockoutCooldownMs, } from "./accountFallback.ts"; import { errorResponse, + createErrorResult, unavailableResponse, errorResponseWithComboDiagnostics, } from "../utils/error.ts"; @@ -67,6 +70,7 @@ import { estimateTokens } from "./contextManager.ts"; import { getSessionConnection } from "./sessionManager.ts"; import { applySessionStickiness, + normalizeStickinessMessages, recordStickyBinding, clearStickyBinding, peekStickyConnectionId, @@ -78,11 +82,7 @@ import { acquireQuotaShareConcurrencySlot } from "./combo/quotaShareConcurrency. import { orderTargetsByEvalScores } from "./evalRouting.ts"; import type { CompressionMode } from "./compression/types.ts"; import { getProviderConnections } from "../../src/lib/db/providers"; -import { - isProviderInCooldown, - recordProviderCooldown, - recordProviderSuccess, -} from "./providerCooldownTracker.ts"; +import { isProviderInCooldown, recordProviderCooldown } from "./providerCooldownTracker.ts"; import { resolveResilienceSettings, type ResilienceSettings, @@ -137,10 +137,12 @@ import { clampComboDepth, shouldSkipForPredictedTtft, shouldRecordProviderBreakerFailure, + shouldSkipConnDisable, resolveDelayMs, comboModelNotFoundResponse, isStreamReadinessFailureErrorBody, isTokenLimitBreachErrorBody, + isLocalQueueCapacityErrorBody, toRecordedTarget, getExhaustedTargetSkipReason, } from "./combo/comboPredicates.ts"; @@ -152,10 +154,10 @@ import { expandProviderWildcardsInCollection, } from "./combo/providerWildcard.ts"; import { resolveShadowTargets, scheduleShadowRouting } from "./combo/shadowRouting.ts"; -import { attemptCompatRejectedFallback } from "./combo/comboCompatFallback.ts"; import { applyContextRequirements } from "./combo/contextRequirements.ts"; import { filterTargetsByRequestCompatibility, + isModelRequestCompatible, resolveComboRuntimeUnits, resolveComboTargets, resolveWeightedTargets, @@ -197,7 +199,12 @@ export { QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty }; export { scoreAutoTargets, expandAutoComboCandidatePool }; export type { SingleModelTarget, ResolvedComboTarget }; export { validateResponseQuality }; -export { clampComboDepth, shouldSkipForPredictedTtft, shouldRecordProviderBreakerFailure }; +export { + clampComboDepth, + shouldSkipForPredictedTtft, + shouldRecordProviderBreakerFailure, + shouldSkipConnDisable, +}; export { resolveShadowTargets, scheduleShadowRouting }; export { preScreenTargets }; export { resolveComboRuntimeUnits, resolveComboTargets, filterTargetsByRequestCompatibility }; @@ -712,7 +719,19 @@ export async function handleComboChat({ body = comboCtx.body; const handleSingleModelWithTimeout = buildTargetTimeoutRunner({ - handleSingleModel, + // Pins and nested/pipeline/fusion dispatches bypass the ordered-target filter. + // Check their actual body immediately before sending it to a provider. + handleSingleModel: async (dispatchBody, modelStr, target) => { + if (!isModelRequestCompatible(modelStr, dispatchBody)) { + return createErrorResult( + 400, + "No combo target supports the request's output or capabilities", + null, + "no_compatible_target" + ).response; + } + return handleSingleModel(dispatchBody, modelStr, target); + }, comboTargetTimeoutMs, log, }); @@ -742,7 +761,8 @@ export async function handleComboChat({ // and credits_exhausted accounts never failing over. A transient cooldown is // tolerated (pin kept) so an unstable provider does not churn the pin. const pinDurablyDown = pinInCombo ? await isPinnedModelDurablyUnhealthy(pinnedModel) : false; - if (pinInCombo && !pinDurablyDown) { + const pinCompatible = isModelRequestCompatible(pinnedModel, body); + if (pinInCombo && !pinDurablyDown && pinCompatible) { log.info( "COMBO", `Bypassing strategy — routing directly to pinned context model: ${pinnedModel}` @@ -780,7 +800,7 @@ export async function handleComboChat({ ); } else { const pinnedStatus = pinnedResult.status || 500; - if (![408, 429, 500, 502, 503, 504].includes(pinnedStatus)) { + if (![408, 429, 500, 502, 503, 504, 524].includes(pinnedStatus)) { return pinnedResult; } log.warn( @@ -794,9 +814,11 @@ export async function handleComboChat({ } log.warn( "COMBO", - pinInCombo - ? `Context-cache pin "${pinnedModel}" provider durably unhealthy — dropping pin, using strategy` - : `Stale context-cache pin "${pinnedModel}" not in combo "${combo.name}" targets — dropping pin, using strategy` + !pinCompatible + ? `Context-cache pin "${pinnedModel}" is incompatible with this request — using strategy` + : pinInCombo + ? `Context-cache pin "${pinnedModel}" provider durably unhealthy — dropping pin, using strategy` + : `Stale context-cache pin "${pinnedModel}" not in combo "${combo.name}" targets — dropping pin, using strategy` ); // Fall through to the normal target iteration loop below — the pin is // dropped, so the combo strategy picks the best available target. @@ -1207,11 +1229,21 @@ export async function handleComboChat({ ? ({ targets: orderedTargets, messageHash: null, stuck: false } as const) : await applySessionStickiness( orderedTargets, - body.messages as Array<{ role?: string; content?: unknown }> + normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }), + combo.name ); orderedTargets = _sticky.targets; orderedTargets = orderTargetsByEvalScores(orderedTargets, config.evalRouting, log); + const hadRequestTargets = orderedTargets.length > 0; orderedTargets = filterTargetsByRequestCompatibility(orderedTargets, body, log); + if (hadRequestTargets && orderedTargets.length === 0) { + return createErrorResult( + 400, + "No combo target supports the request's output or capabilities", + null, + "no_compatible_target" + ).response; + } orderedTargets = applyContextRequirements(orderedTargets, config.contextRequirements, log); // Task-aware reordering: only active for strategies ["smart","task","task-aware","task_aware","auto"]. @@ -1985,6 +2017,7 @@ export async function handleComboChat({ // FIX 5: a local per-API-key token-limit 429 must not cool shared accounts. const isTokenLimitBreach = result.status === 429 && isTokenLimitBreachErrorBody(errorBody); + const isLocalQueueCapacity = isLocalQueueCapacityErrorBody(errorBody); // Fix #1681: Status 499 means client disconnected — stop combo loop immediately. // There is no point trying fallback models when nobody is listening. @@ -2003,6 +2036,22 @@ export async function handleComboChat({ // so the combo would wrongly fall through to the next model after a 499. return { ok: false, response: result }; } + if (isLocalQueueCapacity) { + log.info( + "COMBO", + `Local rate-limit queue capacity reached for ${modelStr} — returning without upstream fallback` + ); + recordComboRequest(combo.name, modelStr, { + success: false, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy, + target: toRecordedTarget(target), + }); + recordedAttempts++; + if (i > 0) fallbackCount++; + return { ok: false, response: result }; + } // Combo fallback is target-level orchestration: a non-ok target response is // treated as local to that target and the combo continues to the next target. @@ -2141,7 +2190,13 @@ export async function handleComboChat({ skipProviderBreaker: fallbackResult.skipProviderBreaker, }) ) { - recordProviderFailure(provider, log, targetWithConnection.connectionId, profile); + const isQueueTimeout = + errorText.includes("RATE_LIMIT_QUEUE_TIMEOUT") || + errorText.includes("RATE_LIMIT_QUEUE_WEDGED"); + recordProviderFailure(provider, log, targetWithConnection.connectionId, profile, { + isQueueTimeout, + isNetworkError: structuredError?.code === "proxy_unreachable", + }); } // Check if this is a transient error worth retrying on same model. @@ -2149,8 +2204,25 @@ export async function handleComboChat({ const isTransient = !isStreamReadinessFailure && !isTokenLimitBreach && - [408, 429, 500, 502, 503, 504].includes(result.status); - if (retry < maxRetries && isTransient && !providerExhausted) { + [408, 429, 500, 502, 503, 504, 524].includes(result.status); + // failoverBeforeRetry means what it says: prefer the next sibling + // target over hammering this one again. Without this check, a + // transient error always re-hit the SAME model up to maxRetries + // times regardless of the setting — config.failoverBeforeRetry was + // threaded through to skipUpstreamRetry (a different, lower-level + // retry mechanism) but never consulted here, so a rate-limited + // model got maxRetries+1 back-to-back attempts on itself before + // this loop's own fallback-to-next-target ever ran (#2417). Only + // skip the same-model retry when `nextTarget` (computed above) + // actually gives us somewhere to fail over to — with no sibling + // left, skipping just burns the last attempt for nothing. + if ( + retry < maxRetries && + isTransient && + structuredError?.code !== EMPTY_RESPONSE_RETRY_EXHAUSTED && + !providerExhausted && + (!config.failoverBeforeRetry || !nextTarget) + ) { if ( provider && rawModel && @@ -2201,6 +2273,24 @@ export async function handleComboChat({ strategy, target: toRecordedTarget(target), }); + // LKGP (#919) mirror of the success-path set below: a just-failed target + // must not keep re-pinning itself as the "last known good" choice for the + // *next* separate request. Circuit breaker / model lockout deliberately + // don't react to request-scoped failure classes (see scopedFailure below), + // so nothing else clears this stale pin. + void (async () => { + try { + const { clearLKGP } = await import("../../src/lib/localDb"); + await Promise.all([ + clearLKGP(combo.name, target.executionKey), + clearLKGP(combo.name, combo.id || combo.name), + ]); + } catch (err) { + log.warn("COMBO", "Failed to clear Last Known Good Provider. This is non-fatal.", { + err, + }); + } + })(); recordedAttempts++; lastError = errorText || String(result.status); if (!lastStatus) lastStatus = result.status; @@ -2278,8 +2368,12 @@ export async function handleComboChat({ const abortController = new AbortController(); abortControllers.set(i, abortController); - const onClientAbort = () => abortController.abort(); - signal?.addEventListener("abort", onClientAbort); + const onClientAbort = () => abortController.abort(signal?.reason); + if (signal?.aborted) { + onClientAbort(); + } else { + signal?.addEventListener("abort", onClientAbort); + } const task = (async () => { try { @@ -2514,13 +2608,14 @@ async function handleRoundRobinCombo({ log, "Context-aware round-robin fallback" ); - // #6238: keep the targets the compat pre-filter rejected so they can serve as a - // last-resort fallback tier. The pre-filter drops request-incompatible targets - // BEFORE availability is known; if every compat-kept target then turns out to be - // runtime-unavailable, we must reconsider these before returning 503, instead of - // permanently dropping a compat-rejected-but-healthy provider. - const compatKeptSet = new Set(filteredTargets); - const compatRejectedTargets = evalRankedTargets.filter((target) => !compatKeptSet.has(target)); + if (evalRankedTargets.length > 0 && filteredTargets.length === 0) { + return createErrorResult( + 400, + "No combo target supports the request's output or capabilities", + null, + "no_compatible_target" + ).response; + } const modelCount = filteredTargets.length; if (modelCount === 0) { return comboModelNotFoundResponse("Round-robin combo has no executable targets"); @@ -2631,7 +2726,8 @@ async function handleRoundRobinCombo({ ? ({ targets: filteredTargets, messageHash: null, stuck: false } as const) : await applySessionStickiness( filteredTargets, - body?.messages as Array<{ role?: string; content?: unknown }> + normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }), + combo.name ); let rrStartIndex = startIndex; if (_rrSessionSticky.stuck) { @@ -2978,6 +3074,23 @@ async function handleRoundRobinCombo({ // FIX 5: a local per-API-key token-limit 429 must not cool shared accounts. const isTokenLimitBreach = result.status === 429 && isTokenLimitBreachErrorBody(errorBody); + const isLocalQueueCapacity = isLocalQueueCapacityErrorBody(errorBody); + + if (isLocalQueueCapacity) { + log.info( + "COMBO-RR", + `Local rate-limit queue capacity reached for ${modelStr} — returning without upstream fallback` + ); + recordComboRequest(combo.name, modelStr, { + success: false, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy: "round-robin", + target: toRecordedTarget(target), + }); + recordedAttempts++; + return result; + } // Round-robin uses the same target-level fallback rule as other combo // strategies: non-ok target responses fall through to the next target. @@ -3072,8 +3185,20 @@ async function handleRoundRobinCombo({ const isTransient = !isStreamReadinessFailure && !isTokenLimitBreach && - [408, 429, 500, 502, 503, 504].includes(result.status); - if (retry < maxRetries && isTransient && !providerExhausted) { + [408, 429, 500, 502, 503, 504, 524].includes(result.status); + // See the same guard's comment in the "auto" strategy loop above — + // failoverBeforeRetry must prevent this same-model retry too, not + // just the lower-level skipUpstreamRetry mechanism. Only skip when + // `offset + 1 < modelCount` means a sibling target is actually left + // in this rotation; with none left, skipping just wastes the attempt. + const hasNextRrTarget = offset + 1 < modelCount; + if ( + retry < maxRetries && + isTransient && + structuredError?.code !== EMPTY_RESPONSE_RETRY_EXHAUSTED && + !providerExhausted && + (!config.failoverBeforeRetry || !hasNextRrTarget) + ) { continue; } @@ -3085,6 +3210,22 @@ async function handleRoundRobinCombo({ strategy: "round-robin", target: toRecordedTarget(target), }); + // LKGP (#919) mirror of handleComboChat's failure-path clear above — see + // that comment for why this must happen (nothing else clears a pin left + // by a request-scoped failure class like a stream-readiness timeout). + void (async () => { + try { + const { clearLKGP } = await import("../../src/lib/localDb"); + await Promise.all([ + clearLKGP(combo.name, target.executionKey), + clearLKGP(combo.name, combo.id || combo.name), + ]); + } catch (err) { + log.warn("COMBO-RR", "Failed to clear Last Known Good Provider. This is non-fatal.", { + err, + }); + } + })(); recordedAttempts++; lastError = errorText || String(result.status); if (!lastStatus) lastStatus = result.status; @@ -3141,36 +3282,6 @@ async function handleRoundRobinCombo({ // All models exhausted const latencyMs = Date.now() - startTime; - // #6238: every compat-kept target was skipped as unavailable and NONE was ever - // attempted (recordedAttempts === 0). Before crystallizing 503, probe the targets - // the compat pre-filter rejected — a compat-rejected-but-healthy provider is a - // valid last-resort fallback tier, not a permanently dropped target. - if (recordedAttempts === 0 && compatRejectedTargets.length > 0) { - const compatFallbackResult = await attemptCompatRejectedFallback(compatRejectedTargets, body, { - handleSingleModel, - isModelAvailable, - isProviderInCooldown: (target) => - resilienceSettings.providerCooldown.enabled && - Boolean(target.provider && target.provider !== "unknown") && - isProviderInCooldown( - target.provider as string, - target.connectionId as string | undefined, - resilienceSettings - ), - log, - strategy: "round-robin", - }); - if (compatFallbackResult) { - recordComboRequest(combo.name, null, { - success: true, - latencyMs: Date.now() - startTime, - fallbackCount, - strategy: "round-robin", - }); - return compatFallbackResult; - } - } - if (recordedAttempts === 0) { recordComboRequest(combo.name, null, { success: false, diff --git a/open-sse/services/combo/comboAbortReasons.ts b/open-sse/services/combo/comboAbortReasons.ts new file mode 100644 index 000000000000..0f44eff6a65c --- /dev/null +++ b/open-sse/services/combo/comboAbortReasons.ts @@ -0,0 +1,37 @@ +/** + * Shared abort reasons for combo target dispatch. + * + * `buildTargetTimeoutRunner` aborts a stalled target with `new Error(...)` as the + * abort reason, and hedged targets are cancelled with a different one. Consumers + * downstream (session-affinity eviction in src/sse/handlers/chat.ts) must be able + * to tell those two apart from an ordinary client disconnect: only the per-model + * TIMEOUT means "this account stalled", while a hedge cancellation means "a + * sibling target won" and says nothing about the account's health. + * + * Kept as a dependency-free leaf so src/** can import it without pulling in the + * combo dispatcher. + */ + +/** Abort reason used when a combo target exceeds `comboTargetTimeoutMs`. */ +export const COMBO_PER_MODEL_TIMEOUT_REASON = "combo-per-model-timeout"; + +/** Abort reason used when a hedged sibling target won the race. */ +export const COMBO_HEDGE_CANCELLED_REASON = "hedge-cancelled"; + +function abortReasonMessage(signal: AbortSignal): string { + const reason: unknown = signal.reason; + if (typeof reason === "string") return reason; + if (reason && typeof reason === "object" && typeof (reason as Error).message === "string") { + return (reason as Error).message; + } + return ""; +} + +/** + * True only when `signal` was aborted by the combo per-model timeout. A client + * disconnect, a hedge cancellation, or a non-aborted signal all return false. + */ +export function isComboPerModelTimeoutAbort(signal: AbortSignal | null | undefined): boolean { + if (!signal?.aborted) return false; + return abortReasonMessage(signal) === COMBO_PER_MODEL_TIMEOUT_REASON; +} diff --git a/open-sse/services/combo/comboCompatFallback.ts b/open-sse/services/combo/comboCompatFallback.ts deleted file mode 100644 index 127f2c354cbd..000000000000 --- a/open-sse/services/combo/comboCompatFallback.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ComboLogger, HandleSingleModel, IsModelAvailable, ResolvedComboTarget } from "./types"; - -/** - * Last-resort fallback tier for combo routing (#6238). - * - * `filterTargetsByRequestCompatibility` drops targets that look request-incompatible - * (tool/vision/structured-output unsupported, or below the required context window) - * BEFORE any runtime availability check runs. Its only safety net triggers when ALL - * targets are filtered — not when the kept targets are later all runtime-unavailable - * (circuit-open / cooldown / no credentials). In that case a combo would return - * `503 ALL_ACCOUNTS_INACTIVE` without ever reconsidering a compat-rejected-but-healthy - * target. - * - * This helper makes those compat-rejected targets a genuine fallback tier: only after - * the primary (compat-kept) targets were all skipped without a single real attempt do - * we probe the rejected set. A compat-rejected target is used only if it is actually - * available and returns a successful upstream response; otherwise the caller keeps its - * original error surface unchanged. - */ -export interface CompatFallbackContext { - handleSingleModel: HandleSingleModel; - isModelAvailable?: IsModelAvailable; - /** Returns true when the target's provider connection is in resilience cooldown. */ - isProviderInCooldown?: (target: ResolvedComboTarget) => boolean; - log: ComboLogger; - /** Effective combo strategy, threaded into the single-model dispatch for telemetry. */ - strategy: string; -} - -/** - * Attempt each compatibility-rejected target in order, returning the first successful - * upstream `Response`. Targets that are unavailable, in cooldown, or return a non-ok - * response are skipped. Returns `null` when no rejected target yields a success, so the - * caller can fall through to its existing error/503 path with an unchanged error surface. - */ -export async function attemptCompatRejectedFallback( - rejectedTargets: ResolvedComboTarget[], - body: Record, - ctx: CompatFallbackContext -): Promise { - if (rejectedTargets.length === 0) return null; - - for (const target of rejectedTargets) { - if (ctx.isModelAvailable) { - const available = await ctx.isModelAvailable(target.modelStr, target); - if (!available) { - ctx.log.debug( - "COMBO", - `Last-resort compat fallback: ${target.modelStr} still unavailable — skipping` - ); - continue; - } - } - - if (ctx.isProviderInCooldown?.(target)) { - ctx.log.debug( - "COMBO", - `Last-resort compat fallback: ${target.modelStr} provider in cooldown — skipping` - ); - continue; - } - - ctx.log.info( - "COMBO", - `Last-resort compat fallback → ${target.modelStr} (all compat-kept targets were unavailable)` - ); - const result = await ctx.handleSingleModel(body, target.modelStr, { - ...target, - effectiveComboStrategy: ctx.strategy, - }); - if (result.ok) { - ctx.log.info("COMBO", `Last-resort compat fallback succeeded via ${target.modelStr}`); - return result; - } - ctx.log.debug( - "COMBO", - `Last-resort compat fallback: ${target.modelStr} failed (${result.status}) — trying next` - ); - } - - return null; -} diff --git a/open-sse/services/combo/comboPredicates.ts b/open-sse/services/combo/comboPredicates.ts index 887709522faf..1eaef8d8e3d9 100644 --- a/open-sse/services/combo/comboPredicates.ts +++ b/open-sse/services/combo/comboPredicates.ts @@ -7,6 +7,7 @@ */ import { errorResponse } from "../../utils/error.ts"; +import { isSelfInflictedUpstreamTimeout } from "../../handlers/chatCore/cooldownClassification.ts"; import { parseModel } from "../model.ts"; import type { ResolvedComboTarget } from "./types.ts"; @@ -142,6 +143,7 @@ const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]); * - G-02 / #2743: when the fallback result carries `skipProviderBreaker` (an embedded * service supervisor outage signalled via `X-Omni-Fallback-Hint: connection_cooldown`) * apply connection cooldown ONLY — never trip the whole-provider breaker. + * - Client aborts have already been normalized to 499 before this predicate. * * Pure predicate so the breaker decision is unit-testable without the full combo harness. */ @@ -159,6 +161,27 @@ export function shouldRecordProviderBreakerFailure(args: { ); } +/** Local/request-scoped failures must not cool down a healthy provider account. */ +export function shouldSkipConnDisable( + result: { + status: number; + errorCode?: string | null; + errorType?: string | null; + error?: unknown; + }, + is401: boolean, + hasExtraKeys: boolean, + provider: string +): boolean { + return ( + result.status === 499 || + result.errorCode === "client_disconnected" || + result.errorType === "client_disconnected" || + (is401 && hasExtraKeys) || + isSelfInflictedUpstreamTimeout(result.status, result.errorType, provider) + ); +} + export function resolveDelayMs(value: unknown, fallback: number): number { const numericValue = Number(value); if (!Number.isFinite(numericValue) || numericValue < 0) return fallback; @@ -213,6 +236,21 @@ export function isTokenLimitBreachErrorBody(errorBody: unknown): boolean { return (error as Record).code === "TOKEN_LIMIT_EXCEEDED"; } +/** Local limiter capacity is not an upstream/provider failure and must not cascade. */ +export function isLocalQueueCapacityErrorBody(errorBody: unknown): boolean { + if (!errorBody || typeof errorBody !== "object") return false; + const error = (errorBody as Record).error; + if (!error || typeof error !== "object") return false; + const code = String((error as Record).code || "").toUpperCase(); + const type = String((error as Record).type || "").toLowerCase(); + return ( + code === "RATE_LIMIT_QUEUE_TIMEOUT" || + code === "RATE_LIMIT_QUEUE_FULL" || + code === "RATE_LIMIT_QUEUE_WEDGED" || + type === "local_queue_capacity" + ); +} + export function toRecordedTarget(target: ResolvedComboTarget) { return { executionKey: target.executionKey, diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index a98c8e820bab..f461923a006b 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -11,7 +11,10 @@ * No barrel import — depends only on sibling leaves. */ -import { getModelContextLimit } from "../../../src/lib/modelCapabilities"; +import { + getModelContextLimit, + getResolvedModelContextOverride, +} from "../../../src/lib/modelCapabilities"; import { getComboModelString, normalizeComboStep } from "../../../src/lib/combos/steps.ts"; import { estimateTokens } from "../contextManager.ts"; import { getResolvedModelCapabilities } from "../modelCapabilities.ts"; @@ -466,7 +469,8 @@ function deriveRequestCompatibilityRequirements( const estimatedInputTokens = estimateRequestInputTokens(body); const requestedOutputTokens = Math.max( getPositiveTokenCount(body.max_tokens), - getPositiveTokenCount(body.max_completion_tokens) + getPositiveTokenCount(body.max_completion_tokens), + getPositiveTokenCount(body.max_output_tokens) ); return { requiresTools: requestRequiresTools(body), @@ -486,48 +490,34 @@ function exceedsKnownOutputLimit( return maxOutputTokens < requestedOutputTokens; } -function getKnownContextLimit(capabilities: { - maxInputTokens?: number | null; - contextWindow?: number | null; -}): number | null { - return capabilities.maxInputTokens ?? capabilities.contextWindow ?? null; -} - -function hasKnownCompatibleContextLimit( +/** Approximate context fit is a routing preference, never a dispatch prohibition. */ +function getEstimatedContextFit( target: ResolvedComboTarget, - requiredContextTokens: number -): boolean { - if (requiredContextTokens <= 0) return false; - const capabilities = getResolvedModelCapabilities(target.modelStr); - const contextLimit = getKnownContextLimit(capabilities); - return contextLimit !== null && contextLimit >= requiredContextTokens; -} - -function hasOnlyContextWindowFailures(reasons: string[]): boolean { - return reasons.length > 0 && reasons.every((reason) => reason === "context_window"); + requirements: RequestCompatibilityRequirements +): boolean | null { + const override = getResolvedModelContextOverride(target.modelStr); + if (override !== null) return override >= requirements.requiredContextTokens; + const { maxInputTokens, contextWindow } = getResolvedModelCapabilities(target.modelStr); + if (maxInputTokens === null && contextWindow === null) return null; + return ( + (maxInputTokens === null || maxInputTokens >= requirements.estimatedInputTokens) && + (contextWindow === null || contextWindow >= requirements.requiredContextTokens) + ); } function getTargetCompatibilityFailures( - target: ResolvedComboTarget, + target: Pick, requirements: RequestCompatibilityRequirements ): string[] { const capabilities = getResolvedModelCapabilities(target.modelStr); const failures: string[] = []; - if ( - requirements.requiresTools && - (capabilities.supportsTools === false || !capabilities.toolCalling) - ) { + if (requirements.requiresTools && capabilities.supportsTools === false) { failures.push("tools"); } - // For a request that carries an image, only route to a target whose vision - // support is *confirmed* (`=== true`). Treat `false` AND `null` (unknown) as - // incompatible: an unknown-capability model receiving the image is exactly how - // a text-only model (e.g. ministral) ended up answering "image not provided". - // The caller keeps all targets when none qualify, so combos with no - // confirmed-vision member still behave as before. - if (requirements.requiresVision && capabilities.supportsVision !== true) { + // Missing metadata is unknown, not an explicit denial of a capability. + if (requirements.requiresVision && capabilities.supportsVision === false) { failures.push("vision"); } @@ -539,18 +529,17 @@ function getTargetCompatibilityFailures( failures.push("output_tokens"); } - const contextLimit = getKnownContextLimit(capabilities); - if ( - requirements.requiredContextTokens > 0 && - contextLimit !== null && - contextLimit < requirements.requiredContextTokens - ) { - failures.push("context_window"); - } - return failures; } +/** Check the actual dispatch body, including strategy-specific transformations. */ +export function isModelRequestCompatible(modelStr: string, body: Record): boolean { + return ( + getTargetCompatibilityFailures({ modelStr }, deriveRequestCompatibilityRequirements(body)) + .length === 0 + ); +} + export function filterTargetsByRequestCompatibility( targets: ResolvedComboTarget[], body: Record, @@ -574,77 +563,25 @@ export function filterTargetsByRequestCompatibility( return false; }); - // Unknown context limits are safe only as a fallback. If this request already - // filtered at least one known-too-small target and known-good targets remain, - // prefer the known-good set over unknown metadata gaps. If no known-good - // context target remains, fall back to the strategy order for context-only - // candidates instead of letting unknown metadata be the only survivors. - const rejectedForContextWindow = rejected.some((entry) => - entry.reasons.includes("context_window") - ); - if (requirements.requiredContextTokens > 0 && rejectedForContextWindow) { - const knownContextCompatible = compatible.filter((target) => - hasKnownCompatibleContextLimit(target, requirements.requiredContextTokens) + // Prefer known-fitting targets, retaining unknown/stale context entries in their + // original relative order for runtime fallback. Never restore hard rejections. + if (requirements.requiredContextTokens > 0 && compatible.length > 1) { + const contextFits = new Map( + compatible.map((target) => [target, getEstimatedContextFit(target, requirements)]) ); - - if (knownContextCompatible.length > 0 && knownContextCompatible.length < compatible.length) { - const knownContextCompatibleTargets = new Set(knownContextCompatible); - for (const target of compatible) { - if (!knownContextCompatibleTargets.has(target)) { - rejected.push({ target, reasons: ["context_window_unknown"] }); - } - } - - log.info( - "COMBO", - `${label}: kept ${knownContextCompatible.length}/${targets.length} targets for request requirements` - ); + const knownFits = compatible.filter((target) => contextFits.get(target) === true); + // Preserve strategy/composite-tier order unless a known window appears too small. + if (knownFits.length > 0 && [...contextFits.values()].includes(false)) { + const preferred = new Set(knownFits); log.debug?.( "COMBO", - `${label}: rejected targets ${rejected - .map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`) - .join(", ")}` + `${label}: preferring ${knownFits.length}/${compatible.length} targets by estimated context fit` ); - return knownContextCompatible; - } - - if (knownContextCompatible.length === 0 && compatible.length > 0) { - const rejectedByTarget = new Map(rejected.map((entry) => [entry.target, entry.reasons])); - const contextOnlyFallback = targets.filter((target) => { - const reasons = rejectedByTarget.get(target); - return !reasons || hasOnlyContextWindowFailures(reasons); - }); - - if (contextOnlyFallback.length > compatible.length) { - log.warn( - "COMBO", - `${label}: no known-compatible context target remains; preserving strategy order for context-only candidates` - ); - log.debug?.( - "COMBO", - `${label}: rejected targets ${rejected - .map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`) - .join(", ")}` - ); - return contextOnlyFallback; - } + return [...knownFits, ...compatible.filter((target) => !preferred.has(target))]; } } if (compatible.length === targets.length) return targets; - if (compatible.length === 0) { - log.warn( - "COMBO", - `${label}: all ${targets.length} targets were filtered by request requirements; preserving strategy order` - ); - log.debug?.( - "COMBO", - `${label}: rejected targets ${rejected - .map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`) - .join(", ")}` - ); - return targets; - } log.info( "COMBO", diff --git a/open-sse/services/combo/emptyResponseRetryBudget.ts b/open-sse/services/combo/emptyResponseRetryBudget.ts new file mode 100644 index 000000000000..1bec58d706ba --- /dev/null +++ b/open-sse/services/combo/emptyResponseRetryBudget.ts @@ -0,0 +1,52 @@ +/** Shared by account retries and every combo dispatch within one client request. */ +export const EMPTY_RESPONSE_ATTEMPT_LIMIT = 3; +export const EMPTY_RESPONSE_RETRY_EXHAUSTED = "empty_response_retry_exhausted"; + +type EmptyResponseFailure = { + status: number; + errorCode?: string; + error?: string; +}; + +/** Stable classification for structured errors and legacy call-log summaries. */ +export function getEmptyResponseErrorCode( + result: EmptyResponseFailure +): "empty_response" | typeof EMPTY_RESPONSE_RETRY_EXHAUSTED | null { + if (result.status !== 502) return null; + if ( + result.errorCode === "empty_response" || + result.errorCode === EMPTY_RESPONSE_RETRY_EXHAUSTED + ) { + return result.errorCode; + } + if ( + /returned empty output on \d+ attempts; stopping retries for this provider\/model/i.test( + result.error ?? "" + ) + ) { + return EMPTY_RESPONSE_RETRY_EXHAUSTED; + } + return /empty content|an empty response/i.test(result.error ?? "") ? "empty_response" : null; +} + +/** Empty output is a model failure, not evidence of a broken provider connection. */ +export function isModelEmptyResponseFailure(result: EmptyResponseFailure): boolean { + return getEmptyResponseErrorCode(result) !== null; +} + +export class EmptyResponseRetryBudget { + private failures = new Map(); + + isExhausted(provider: string, model: string): boolean { + return ( + (this.failures.get(JSON.stringify([provider, model])) ?? 0) >= EMPTY_RESPONSE_ATTEMPT_LIMIT + ); + } + + recordFailure(provider: string, model: string, result: EmptyResponseFailure): boolean { + if (!isModelEmptyResponseFailure(result)) return false; + const key = JSON.stringify([provider, model]); + this.failures.set(key, (this.failures.get(key) ?? 0) + 1); + return this.isExhausted(provider, model); + } +} diff --git a/open-sse/services/combo/sessionStickiness.ts b/open-sse/services/combo/sessionStickiness.ts index 2b9f74613399..b821331bfafb 100644 --- a/open-sse/services/combo/sessionStickiness.ts +++ b/open-sse/services/combo/sessionStickiness.ts @@ -8,7 +8,8 @@ * * Design * ────── - * • Hash key: SHA-256 of the FIRST user message → first 16 hex chars. + * • Hash key: SHA-256 of the FIRST user message, namespaced by Combo identity + * at production call sites → first 16 hex chars. * Using only the first message gives a stable key that does not change as * the conversation grows, yet still identifies the conversation reliably. * • Headroom gate: before reusing the sticky connection we re-check that its @@ -73,9 +74,7 @@ interface StickyEntry { * Injectable saturation fetcher seam (for unit tests). * Returns HeadroomSaturation or undefined when unknown. */ -export type SaturationFetcher = ( - connectionId: string -) => Promise; +export type SaturationFetcher = (connectionId: string) => Promise; // ─── Saturation fetcher seam ───────────────────────────────────────────────── @@ -200,6 +199,34 @@ const stickyMap = new Map(); // ─── Helpers ───────────────────────────────────────────────────────────────── +/** + * Normalize the two OpenAI request shapes into the message view used to derive + * session stickiness. Chat Completions carries turns in `messages`; Responses + * carries them in `input`, which may be a string, message objects, or strings. + * + * Combo routing happens before per-target translation, so this normalization + * must happen here. `messages` retains precedence for backward compatibility. + */ +export function normalizeStickinessMessages( + body: { messages?: unknown; input?: unknown } | null | undefined +): Array<{ role?: string; content?: unknown }> | null { + if (!body || typeof body !== "object") return null; + + const { messages, input } = body; + if (Array.isArray(messages) && messages.length > 0) { + return messages as Array<{ role?: string; content?: unknown }>; + } + if (typeof input === "string" && input.length > 0) { + return [{ role: "user", content: input }]; + } + if (Array.isArray(input) && input.length > 0) { + return input.map((item) => + typeof item === "string" ? { role: "user", content: item } : item + ) as Array<{ role?: string; content?: unknown }>; + } + return null; +} + /** * Derive a stable 16-hex-char session key from the first user message content. * Returns null when the message cannot be extracted (fail-open). @@ -229,6 +256,23 @@ export function deriveMessageHash( return createHash("sha256").update(text).digest("hex").slice(0, 16); } +/** + * Keep one conversation's prompt-cache affinity local to the Combo that learned + * it. Without this namespace, two different Combos receiving the same first + * user message share a binding and can silently reorder each other's targets. + * The unscoped form remains available for direct callers and backwards-compatible + * unit seams; production dispatchers always provide their Combo name. + */ +function scopeMessageHash(messageHash: string, namespace?: string): string { + if (!namespace) return messageHash; + return createHash("sha256") + .update(namespace) + .update("\0") + .update(messageHash) + .digest("hex") + .slice(0, 16); +} + /** Evict expired entries and enforce the hard cap. */ function evict(): void { const now = Date.now(); @@ -336,19 +380,22 @@ export interface ApplyStickinessResult { * * @param orderedTargets Targets already ordered by the combo strategy. * @param messages Request body.messages. + * @param namespace Combo identity that owns this sticky binding. * @returns Result with (possibly reordered) targets. */ export async function applySessionStickiness( orderedTargets: ResolvedComboTarget[], - messages: Array<{ role?: string; content?: unknown }> | null | undefined + messages: Array<{ role?: string; content?: unknown }> | null | undefined, + namespace?: string ): Promise { const noOp: ApplyStickinessResult = { targets: orderedTargets, messageHash: null, stuck: false }; try { if (orderedTargets.length <= 1) return noOp; - const messageHash = deriveMessageHash(messages); - if (!messageHash) return noOp; + const rawMessageHash = deriveMessageHash(messages); + if (!rawMessageHash) return noOp; + const messageHash = scopeMessageHash(rawMessageHash, namespace); const existing = stickyMap.get(messageHash); if (!existing) return { targets: orderedTargets, messageHash, stuck: false }; diff --git a/open-sse/services/combo/targetExhaustion.ts b/open-sse/services/combo/targetExhaustion.ts index 7091b483ef17..88a3530107bc 100644 --- a/open-sse/services/combo/targetExhaustion.ts +++ b/open-sse/services/combo/targetExhaustion.ts @@ -22,20 +22,16 @@ import { } from "../accountFallback.ts"; import { RateLimitReason } from "../../config/constants.ts"; import { isProviderCircuitOpenResult } from "./comboPredicates.ts"; +import { isModelEmptyResponseFailure } from "./emptyResponseRetryBudget.ts"; import type { ComboLogger, ResolvedComboTarget } from "./types.ts"; // Connection-level failure statuses: the provider connection itself is likely bad (upstream // unreachable, proxy/gateway error), so remaining same-connection targets are skipped. const CONNECTION_LEVEL_ERROR_STATUSES = [408, 500, 502, 503, 504, 524]; -// #5085: an "empty content" 502 is the synthetic status chatCore assigns to a provider that -// answered HTTP 200 with no usable completion (isEmptyContentResponse). The connection is -// HEALTHY — it just returned an empty body — so this must NOT be classified as a connection -// failure (which would exhaust the whole provider/connection and skip every remaining -// same-provider leg via #1731v2). It is a model-level transient failure: advance to the next -// leg, leaving the rest of that provider's legs eligible. -function isEmptyContentFailure(status: number, errorText: string): boolean { - return status === 502 && /empty content/i.test(errorText); +/** A local combo deadline, not an upstream/provider-wide 524 response. */ +function isComboTargetTimeout(status: number, errorText: string): boolean { + return status === 524 && /^Model .+ timed out$/i.test(errorText.trim()); } export type ComboExhaustionSets = { @@ -84,6 +80,13 @@ export function applyComboTargetExhaustion( const { exhaustedProviders, exhaustedConnections, transientRateLimitedProviders } = sets; const provider = target.provider; + const modelEmptyResponse = isModelEmptyResponseFailure({ + status: result.status, + errorCode: structuredError?.code, + error: errorText, + }); + if (modelEmptyResponse) return false; + // #1731: full provider quota exhausted → skip remaining same-provider targets this request. // Passthrough/per-model-quota providers multiplex models behind one connection, so a quota // 429 for one model must NOT skip fallback targets for another model on the same provider. @@ -130,10 +133,10 @@ function markConnectionLevelExhaustion( provider === "unknown" || !CONNECTION_LEVEL_ERROR_STATUSES.includes(result.status) || isProviderCircuitOpenResult(result, errorText) || - // #5085: empty-content 502 is a healthy connection returning no body — model-level, not - // connection-level. Don't exhaust the provider; let the remaining legs (incl. same-provider) - // be tried in-request. - isEmptyContentFailure(result.status, errorText) || + // The combo timeout runner generated this locally and separately aborts the selected + // connection. Without a response header we do not know its connection id, so treating it + // as provider-wide exhaustion would prevent the configured same-model retry/fallback. + isComboTargetTimeout(result.status, errorText) || // Per-model-quota providers (gemini, github, passthrough, compatible) multiplex models // behind one connection. A model-level 500 (e.g. Gemini "Internal error encountered") // must NOT exhaust the connection — other models on the same connection may still succeed. diff --git a/open-sse/services/combo/targetTimeoutRunner.ts b/open-sse/services/combo/targetTimeoutRunner.ts index a1479b8e077c..b7b0b4e72486 100644 --- a/open-sse/services/combo/targetTimeoutRunner.ts +++ b/open-sse/services/combo/targetTimeoutRunner.ts @@ -10,6 +10,9 @@ * See _tasks/superpowers/plans/2026-07-03-blocoJ-combo-hotpath-decomposition.md (Task 1). */ import { errorResponse } from "../../utils/error.ts"; +import { + COMBO_PER_MODEL_TIMEOUT_REASON, +} from "./comboAbortReasons.ts"; import type { HandleSingleModel, SingleModelTarget, ComboLogger } from "./types.ts"; export function buildTargetTimeoutRunner(deps: { @@ -43,7 +46,7 @@ export function buildTargetTimeoutRunner(deps: { "COMBO", `Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back` ); - timeoutController.abort(new Error("combo-per-model-timeout")); + timeoutController.abort(new Error(COMBO_PER_MODEL_TIMEOUT_REASON)); resolve( new Response(JSON.stringify({ error: { message: `Model ${modelStr} timed out` } }), { status: 524, @@ -60,10 +63,10 @@ export function buildTargetTimeoutRunner(deps: { let onParentHedgeAbort: (() => void) | null = null; if (parentHedgeSignal) { if (parentHedgeSignal.aborted) { - timeoutController.abort(new Error("hedge-cancelled")); + timeoutController.abort(parentHedgeSignal.reason); } else { onParentHedgeAbort = () => { - timeoutController.abort(new Error("hedge-cancelled")); + timeoutController.abort(parentHedgeSignal.reason); }; parentHedgeSignal.addEventListener("abort", onParentHedgeAbort, { once: true }); } diff --git a/open-sse/services/opencodeOllamaUsage.ts b/open-sse/services/opencodeOllamaUsage.ts index 25cd3c3cc98f..c5a230f69262 100644 --- a/open-sse/services/opencodeOllamaUsage.ts +++ b/open-sse/services/opencodeOllamaUsage.ts @@ -13,33 +13,18 @@ type UsageQuota = { currency?: string; }; -const OPENCODE_GO_QUOTA_URL = - process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit"; -const OPENCODE_GO_DASHBOARD_BASE_URL = - process.env.OMNIROUTE_OPENCODE_GO_DASHBOARD_URL ?? "https://opencode.ai/workspace"; -const OPENCODE_GO_QUOTA_TOTALS = { session: 12, weekly: 30, mcp_monthly: 60 } as const; -const OPENCODE_GO_QUOTA_ORDER = ["session", "weekly", "mcp_monthly"] as const; -const OPENCODE_GO_SCRAPED_NUMBER = String.raw`(-?\d+(?:\.\d+)?)`; const OLLAMA_CLOUD_USAGE_URL = process.env.OMNIROUTE_OLLAMA_CLOUD_USAGE_URL ?? "https://ollama.com/settings"; const OLLAMA_CLOUD_SESSION_COOKIE = "__Secure-session"; -type OpenCodeGoQuotaName = (typeof OPENCODE_GO_QUOTA_ORDER)[number]; -type DashboardWindow = { usagePercent: number; resetAt: string | null }; -type OpenCodeGoDashboardUsage = Partial>; -type OpenCodeGoDashboardConfig = - | { state: "configured"; workspaceId: string; authCookie: string } - | { state: "incomplete"; missing: string } - | { state: "none" }; +type OllamaUsageWindow = { usagePercent: number; resetAt: string | null }; type OllamaCloudUsage = { - session?: DashboardWindow; - weekly?: DashboardWindow; + session?: OllamaUsageWindow; + weekly?: OllamaUsageWindow; planTier?: string | null; }; type OllamaCloudConfig = - | { state: "configured"; cookie: string } - | { state: "invalid"; error: string } - | { state: "none" }; + { state: "configured"; cookie: string } | { state: "invalid"; error: string } | { state: "none" }; function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; @@ -59,36 +44,6 @@ function toPercentage(value: unknown): number { return Math.max(0, Math.min(100, toNumber(value, 0))); } -function toTitleCase(value: string): string { - return value - .trim() - .split(/[\s_-]+/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join(" "); -} - -function roundCurrency(value: number): number { - return Math.round(value * 100) / 100; -} - -function safeToIsoString(time: number): string | null { - if (!Number.isFinite(time) || time < 0 || time > 8.64e15) return null; - try { - return new Date(time).toISOString(); - } catch { - return null; - } -} - -function parseResetTime(resetValue: unknown): string | null { - const numeric = toNumber(resetValue, Number.NaN); - if (Number.isFinite(numeric) && numeric > 0) return safeToIsoString(numeric); - if (typeof resetValue !== "string" || !resetValue.trim()) return null; - const parsed = Date.parse(resetValue); - return Number.isFinite(parsed) ? safeToIsoString(parsed) : null; -} - function getProviderSpecificString(data: JsonRecord | undefined, keys: string[]): string { const obj = toRecord(data); for (const key of keys) { @@ -98,349 +53,6 @@ function getProviderSpecificString(data: JsonRecord | undefined, keys: string[]) return ""; } -function resolveOpenCodeGoDashboardConfig( - providerSpecificData?: JsonRecord -): OpenCodeGoDashboardConfig { - const workspaceId = - process.env.OMNIROUTE_OPENCODE_GO_WORKSPACE_ID?.trim() || - process.env.OPENCODE_GO_WORKSPACE_ID?.trim() || - getProviderSpecificString(providerSpecificData, [ - "openCodeGoWorkspaceId", - "opencodeGoWorkspaceId", - "workspaceId", - ]); - const authCookie = - process.env.OMNIROUTE_OPENCODE_GO_AUTH_COOKIE?.trim() || - process.env.OPENCODE_GO_AUTH_COOKIE?.trim() || - getProviderSpecificString(providerSpecificData, [ - "openCodeGoAuthCookie", - "opencodeGoAuthCookie", - "authCookie", - ]); - - if (!workspaceId && !authCookie) return { state: "none" }; - if (workspaceId && authCookie) return { state: "configured", workspaceId, authCookie }; - return { - state: "incomplete", - missing: workspaceId ? "OPENCODE_GO_AUTH_COOKIE" : "OPENCODE_GO_WORKSPACE_ID", - }; -} - -function normalizeOpenCodeGoAuthCookie(value: string): string { - return value - .trim() - .replace(/^auth=/i, "") - .trim(); -} - -function buildBearerAuthorization(value: string): string { - const token = value - .trim() - .replace(/^Bearer\s+/i, "") - .trim(); - return token ? `Bearer ${token}` : ""; -} - -function getOpenCodeGoTokenQuotaName( - limit: JsonRecord, - existingQuotas: Record -): "session" | "weekly" { - const unit = toNumber(limit.unit, 0); - const number = toNumber(limit.number, 0); - if (unit === 3 && number === 5) return "session"; - if (unit === 6 && number === 1) return "weekly"; - if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly"; - return existingQuotas.session ? "weekly" : "session"; -} - -function buildOpenCodeGoDollarQuota( - quotaName: OpenCodeGoQuotaName, - percentage: unknown, - resetAt: string | null, - usedOverride?: unknown, - details?: UsageQuota["details"] -): UsageQuota { - const total = OPENCODE_GO_QUOTA_TOTALS[quotaName]; - const percentUsed = toPercentage(percentage); - const rawUsed = toNumber(usedOverride, Number.NaN); - const used = roundCurrency( - Number.isFinite(rawUsed) ? Math.max(0, Math.min(total, rawUsed)) : (total * percentUsed) / 100 - ); - const remaining = roundCurrency(Math.max(0, total - used)); - return { - used, - total, - remaining, - remainingPercentage: - total > 0 ? Math.max(0, Math.min(100, Math.round((remaining / total) * 100))) : 100, - resetAt, - unlimited: false, - displayName: - quotaName === "session" ? "5-hour rolling" : quotaName === "weekly" ? "Weekly" : "Monthly", - currency: "USD", - details, - }; -} - -function orderOpenCodeGoQuotas(quotas: Record): Record { - const ordered: Record = {}; - for (const key of OPENCODE_GO_QUOTA_ORDER) if (quotas[key]) ordered[key] = quotas[key]; - for (const [key, quota] of Object.entries(quotas)) if (!ordered[key]) ordered[key] = quota; - return ordered; -} - -function parseOpenCodeGoSsrWindow(html: string, field: string): DashboardWindow | null { - for (const candidate of [ - { - usageIndex: 1, - resetIndex: 2, - pattern: String.raw`${field}:\$R\[\d+\]=\{[^}]*usagePercent:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*resetInSec:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*\}`, - }, - { - usageIndex: 2, - resetIndex: 1, - pattern: String.raw`${field}:\$R\[\d+\]=\{[^}]*resetInSec:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*usagePercent:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*\}`, - }, - ]) { - const match = new RegExp(candidate.pattern).exec(html); - if (!match) continue; - const usagePercent = toNumber(match[candidate.usageIndex], Number.NaN); - const resetInSec = toNumber(match[candidate.resetIndex], Number.NaN); - if (Number.isFinite(usagePercent) && Number.isFinite(resetInSec)) { - return { - usagePercent, - resetAt: safeToIsoString(Date.now() + Math.max(0, resetInSec) * 1000), - }; - } - } - return null; -} - -function parseOpenCodeGoHumanReset(value: string): number | null { - const text = value.toLowerCase().replace(/\s+/g, " ").trim(); - if (["reset-now", "reset now", "now", "resets now"].includes(text)) return 0; - const days = text.match(/(\d+(?:\.\d+)?)\s*days?/); - const hours = text.match(/(\d+(?:\.\d+)?)\s*hours?/); - const minutes = text.match(/(\d+(?:\.\d+)?)\s*minutes?/); - const seconds = text.match(/(\d+(?:\.\d+)?)\s*seconds?/); - if (!days && !hours && !minutes && !seconds) return null; - return ( - toNumber(days?.[1], 0) * 86_400 + - toNumber(hours?.[1], 0) * 3_600 + - toNumber(minutes?.[1], 0) * 60 + - toNumber(seconds?.[1], 0) - ); -} - -function parseOpenCodeGoDashboardHtml(html: string): OpenCodeGoDashboardUsage | null { - const usage: OpenCodeGoDashboardUsage = { - session: parseOpenCodeGoSsrWindow(html, "rollingUsage") ?? undefined, - weekly: parseOpenCodeGoSsrWindow(html, "weeklyUsage") ?? undefined, - mcp_monthly: parseOpenCodeGoSsrWindow(html, "monthlyUsage") ?? undefined, - }; - if (usage.session || usage.weekly || usage.mcp_monthly) return usage; - - for (const content of html.split(/data-slot="usage-item"/).slice(1)) { - const label = content - .match(/data-slot="usage-label">([^<]+)[^0-9]*(\d+(?:\.\d+)?)/)?.[1], - Number.NaN - ); - const resetMatch = content.match(/data-slot="(reset-time|reset-now)">([\s\S]*?)<\/span>/); - if (!label || !Number.isFinite(usagePercent) || !resetMatch) continue; - const resetInSec = - resetMatch[1] === "reset-now" - ? 0 - : parseOpenCodeGoHumanReset( - // Strip any HTML comment, INCLUDING an unterminated one (React SSR emits - // / hydration markers). The `(?:-->|$)` arm consumes a - // trailing "" too, so no partial "|$)/g, "").replace(/Resets?\s*in\s*/i, "") - ); - if (resetInSec === null || !Number.isFinite(resetInSec)) continue; - const window = { - usagePercent, - resetAt: safeToIsoString(Date.now() + Math.max(0, resetInSec) * 1000), - }; - if (label.includes("rolling")) usage.session = window; - else if (label.includes("weekly")) usage.weekly = window; - else if (label.includes("monthly")) usage.mcp_monthly = window; - } - return usage.session || usage.weekly || usage.mcp_monthly ? usage : null; -} - -async function fetchOpenCodeGoDashboardUsage( - config: Extract -) { - const url = `${OPENCODE_GO_DASHBOARD_BASE_URL.replace(/\/+$/, "")}/${encodeURIComponent( - config.workspaceId - )}/go`; - const response = await fetch(url, { - headers: { - Accept: "text/html", - Cookie: `auth=${normalizeOpenCodeGoAuthCookie(config.authCookie)}`, - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/152.0", - }, - signal: AbortSignal.timeout(10_000), - }); - if (!response.ok) - return { usage: null, message: `OpenCode Go dashboard error (${response.status}).` }; - const usage = parseOpenCodeGoDashboardHtml(await response.text()); - return { - usage, - message: usage ? undefined : "OpenCode Go dashboard response did not contain quota windows.", - }; -} - -export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?: JsonRecord) { - const dashboardConfig = resolveOpenCodeGoDashboardConfig(providerSpecificData); - if (dashboardConfig.state === "incomplete") { - return { - message: `OpenCode Go dashboard quota config is incomplete. Missing ${dashboardConfig.missing}.`, - }; - } - if (dashboardConfig.state === "configured") { - try { - const dashboard = await fetchOpenCodeGoDashboardUsage(dashboardConfig); - if (!dashboard.usage) { - return { message: dashboard.message || "OpenCode Go dashboard quota data unavailable." }; - } - const quotas: Record = {}; - for (const quotaName of OPENCODE_GO_QUOTA_ORDER) { - const usage = dashboard.usage[quotaName]; - if (usage) { - quotas[quotaName] = buildOpenCodeGoDollarQuota( - quotaName, - usage.usagePercent, - usage.resetAt - ); - } - } - return { plan: "OpenCode Go", quotas: orderOpenCodeGoQuotas(quotas) }; - } catch (error) { - return { message: `OpenCode Go dashboard quota error: ${sanitizeErrorMessage(error)}` }; - } - } - - const token = apiKey.trim().replace(/^Bearer\s+/i, ""); - if (!token) { - return { - message: - "OpenCode Go quota requires OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE. " + - "The API key can be used for chat/models, but OpenCode Go does not expose quota via API key.", - }; - } - - try { - const res = await fetch(OPENCODE_GO_QUOTA_URL, { - headers: { - Authorization: buildBearerAuthorization(token), - "Accept-Language": "en-US,en", - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - if (!res.ok) { - if (res.status === 401 || res.status === 403) { - return { - message: - "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + - "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.", - }; - } - return { - message: - `OpenCode Go quota API error (${res.status}). ` + - "Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " + - "https://github.com/anomalyco/opencode/issues/16017 for upstream status.", - }; - } - - let json: unknown; - try { - json = await res.json(); - } catch { - return { message: "OpenCode Go quota response parsing failed." }; - } - const root = toRecord(json); - if ( - toNumber(root.code, 200) === 401 || - toNumber(root.code, 200) === 403 || - root.success === false - ) { - return { - message: - "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + - "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.", - }; - } - - const data = toRecord(root.data); - const quotas: Record = {}; - for (const limit of Array.isArray(data.limits) ? data.limits : []) { - const src = toRecord(limit); - const type = String(src.type || "").toUpperCase(); - const resetAt = parseResetTime(src.nextResetTime); - if (type === "TOKENS_LIMIT" || type === "TOKEN_LIMIT") { - const quotaName = getOpenCodeGoTokenQuotaName(src, quotas); - quotas[quotaName] = buildOpenCodeGoDollarQuota( - quotaName, - src.percentage, - resetAt, - undefined, - Array.isArray(src.models) - ? src.models.map((model) => { - const modelInfo = toRecord(model); - return { - name: String(modelInfo.model || modelInfo.modelCode || "usage"), - used: toNumber(modelInfo.percentage, 0), - }; - }) - : undefined - ); - } else if (type === "TIME_LIMIT" || type === "TIME_USAGE_LIMIT") { - quotas.mcp_monthly = buildOpenCodeGoDollarQuota( - "mcp_monthly", - src.percentage, - resetAt, - src.currentValue, - Array.isArray(src.usageDetails) - ? src.usageDetails.map((item) => { - const detail = toRecord(item); - return { - name: String(detail.modelCode || detail.name || "usage"), - used: toNumber(detail.usage, 0), - }; - }) - : undefined - ); - } - } - - const levelRaw = - typeof data.planName === "string" - ? data.planName - : typeof data.level === "string" - ? data.level - : ""; - const planLabel = toTitleCase(levelRaw.replace(/\s*plan$/i, "")); - return { - plan: planLabel - ? /^opencode\s+go\b/i.test(planLabel) - ? planLabel - : `OpenCode Go ${planLabel}` - : null, - quotas: orderOpenCodeGoQuotas(quotas), - }; - } catch (error) { - return { message: `OpenCode Go quota API error: ${sanitizeErrorMessage(error)}` }; - } -} - function resolveOllamaCloudConfig(providerSpecificData?: JsonRecord): OllamaCloudConfig { const cookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE?.trim() || diff --git a/open-sse/services/opencodeQuotaFetcher.ts b/open-sse/services/opencodeQuotaFetcher.ts index 79d38b81c2f6..1add4330fe27 100644 --- a/open-sse/services/opencodeQuotaFetcher.ts +++ b/open-sse/services/opencodeQuotaFetcher.ts @@ -1,65 +1,23 @@ /** - * opencodeQuotaFetcher.ts — OpenCode Go / OpenCode / OpenCode Zen Quota Fetcher + * OpenCode Go / OpenCode / OpenCode Zen quota fetcher. * - * Implements QuotaFetcher for the opencode-go, opencode, and opencode-zen providers - * (quotaPreflight.ts + quotaMonitor.ts). - * - * OpenCode Go has THREE independent quota windows per subscription: - * - 5-hour (rolling): $12 of usage - * - Weekly: $30 of usage - * - Monthly: $60 of usage - * - * Upstream endpoint (defensive — no public API exists yet): - * GET https://opencode.ai/zen/go/v1/quota + * The official API exposes rolling, weekly, and monthly usage percentages: + * GET https://opencode.ai/zen/go/v1/usage * Authorization: Bearer * - * Expected response shape: - * { - * quota: { - * window_5h: { used: number, limit: number, reset_at: number | null }, - * window_weekly: { used: number, limit: number, reset_at: number | null }, - * window_monthly: { used: number, limit: number, reset_at: number | null } - * } - * } - * - * NOTE: As of 2026, no public quota API exists for OpenCode Go / OpenCode Zen - * (tracked upstream in anomalyco/opencode#16017, #18648, #31084). The default - * endpoint currently returns HTTP 404. This fetcher is implemented defensively - * so that the dashboard shows "No quota data" gracefully rather than crashing, - * and so that when an endpoint is finally published, only the URL constant - * needs to change. - * - * On a 404 response we log ONE console.warn (latched per process — not per - * request) pointing at the upstream tracking issues, then cache the - * "endpoint unavailable" result for 5 minutes to avoid hammering. On any other - * non-200 / parse failure we return null (fail-open) silently. The first - * call from each server boot is what the operator is most likely to see, so - * we make it count. - * - * Cache: in-memory TTL (60s for success, 5 min for 404). - * - * Override: set OMNIROUTE_OPENCODE_QUOTA_URL to a working endpoint. If - * OpenCode ships a public endpoint (likely in the form of the merged PR - * #16513), the maintainer can update the default. - * - * Registration: call registerOpencodeQuotaFetcher() once at server startup. + * Successful responses are cached for 60 seconds. Network, HTTP, and response + * parsing failures remain fail-open so quota checks never block on missing data. */ import { registerQuotaFetcher, registerQuotaWindows, type QuotaInfo } from "./quotaPreflight.ts"; import { registerMonitorFetcher } from "./quotaMonitor.ts"; import { throttleQuotaFetch } from "./quotaFetchThrottle.ts"; -// OpenCode quota endpoint — same key works across opencode, opencode-go, opencode-zen -// Default points at /zen/go/v1/quota which returns 404 today (no public quota API yet, -// tracked in anomalyco/opencode#16017). Set OMNIROUTE_OPENCODE_QUOTA_URL to override. const OPENCODE_QUOTA_URL = - process.env.OMNIROUTE_OPENCODE_QUOTA_URL ?? "https://opencode.ai/zen/go/v1/quota"; + process.env.OMNIROUTE_OPENCODE_QUOTA_URL ?? "https://opencode.ai/zen/go/v1/usage"; // Cache TTL — matches Codex / DeepSeek / Bailian pattern (60s) const CACHE_TTL_MS = 60_000; -// TTL for cached "endpoint unavailable" results (404) — longer to avoid hammering -// a non-existent endpoint -const NO_ENDPOINT_TTL_MS = 5 * 60_000; // 5 minutes // Window keys as surfaced to the dashboard and quota-window registry export const OPENCODE_WINDOW_5H = "window_5h"; @@ -75,10 +33,9 @@ export interface OpencodeTripleWindowQuota extends QuotaInfo { } interface CacheEntry { - quota: OpencodeTripleWindowQuota | null; + quota: OpencodeTripleWindowQuota; fetchedAt: number; - /** true when quota is null because the upstream endpoint returned 404 */ - noEndpoint?: boolean; + apiKey: string; } // In-memory cache: connectionId → { quota, fetchedAt } @@ -119,109 +76,79 @@ if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) { // ─── Helpers ────────────────────────────────────────────────────────────────── -function toNumber(value: unknown, fallback = 0): number { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string") { - const parsed = parseFloat(value); - if (Number.isFinite(parsed)) return parsed; - } - return fallback; -} - function toRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } -function parseWindowResetAt(window: Record): string | null { - const resetAt = toNumber(window["reset_at"] ?? window["resetAt"], 0); - if (resetAt > 0) { - // Unix timestamp in seconds (< 1e12) or milliseconds (>= 1e12) - return new Date(resetAt < 1e12 ? resetAt * 1000 : resetAt).toISOString(); - } - const resetAfterSeconds = toNumber( - window["reset_after_seconds"] ?? window["resetAfterSeconds"], - 0 - ); - if (resetAfterSeconds > 0) { - return new Date(Date.now() + resetAfterSeconds * 1000).toISOString(); +// ─── Response Parser ────────────────────────────────────────────────────────── + +type ParsedUsageWindow = { + percentUsed: number; + resetAt: string; + limitReached: boolean; +}; + +function parseUsageWindow(value: unknown): ParsedUsageWindow | null { + const window = toRecord(value); + const { status, percent, resetsAt } = window; + if ( + (status !== "ok" && status !== "rate-limited") || + typeof percent !== "number" || + !Number.isFinite(percent) || + percent < 0 || + percent > 100 || + typeof resetsAt !== "string" || + !resetsAt || + !Number.isFinite(Date.parse(resetsAt)) + ) { + return null; } - return null; -} -function parseWindowPercent(window: Record): number { - const used = toNumber(window["used"] ?? window["used_amount"], 0); - const limit = toNumber(window["limit"] ?? window["limit_amount"], 0); - if (limit <= 0) return 0; - return Math.max(0, Math.min(1, used / limit)); + return { + percentUsed: status === "rate-limited" ? 1 : percent / 100, + resetAt: resetsAt, + limitReached: status === "rate-limited" || percent === 100, + }; } -// ─── Response Parser ────────────────────────────────────────────────────────── - function parseOpencodeQuotaResponse(data: unknown): OpencodeTripleWindowQuota | null { - const obj = toRecord(data); - const quotaObj = toRecord(obj["quota"] ?? obj["data"] ?? obj["usage"]); - - // Look for windows under various possible keys - const w5h = toRecord( - quotaObj[OPENCODE_WINDOW_5H] ?? quotaObj["5h"] ?? quotaObj["hourly"] ?? quotaObj["short"] - ); - const wWeekly = toRecord( - quotaObj[OPENCODE_WINDOW_WEEKLY] ?? quotaObj["weekly"] ?? quotaObj["week"] ?? quotaObj["wk"] + const usage = toRecord(toRecord(data).usage); + const rolling = parseUsageWindow(usage.rolling); + const weekly = parseUsageWindow(usage.weekly); + const monthly = parseUsageWindow(usage.monthly); + if (!rolling || !weekly || !monthly) return null; + + const window5h = { percentUsed: rolling.percentUsed, resetAt: rolling.resetAt }; + const windowWeekly = { percentUsed: weekly.percentUsed, resetAt: weekly.resetAt }; + const windowMonthly = { percentUsed: monthly.percentUsed, resetAt: monthly.resetAt }; + const worstPercent = Math.max( + window5h.percentUsed, + windowWeekly.percentUsed, + windowMonthly.percentUsed ); - const wMonthly = toRecord( - quotaObj[OPENCODE_WINDOW_MONTHLY] ?? quotaObj["monthly"] ?? quotaObj["month"] ?? quotaObj["mo"] - ); - - const has5h = Object.keys(w5h).length > 0; - const hasWeekly = Object.keys(wWeekly).length > 0; - const hasMonthly = Object.keys(wMonthly).length > 0; - - // Need at least one window to be meaningful - if (!has5h && !hasWeekly && !hasMonthly) return null; - - const percent5h = has5h ? parseWindowPercent(w5h) : 0; - const percentWeekly = hasWeekly ? parseWindowPercent(wWeekly) : 0; - const percentMonthly = hasMonthly ? parseWindowPercent(wMonthly) : 0; - - const resetAt5h = has5h ? parseWindowResetAt(w5h) : null; - const resetAtWeekly = hasWeekly ? parseWindowResetAt(wWeekly) : null; - const resetAtMonthly = hasMonthly ? parseWindowResetAt(wMonthly) : null; - - const worstPercent = Math.max(percent5h, percentWeekly, percentMonthly); - const limitReached = - Boolean(obj["limit_reached"] ?? quotaObj["limit_reached"]) || worstPercent >= 1; - - // Dominant reset: pick the window with the worst usage - let dominantResetAt: string | null = null; - if (worstPercent === percent5h) { - dominantResetAt = resetAt5h ?? resetAtWeekly ?? resetAtMonthly; - } else if (worstPercent === percentWeekly) { - dominantResetAt = resetAtWeekly ?? resetAt5h ?? resetAtMonthly; - } else { - dominantResetAt = resetAtMonthly ?? resetAtWeekly ?? resetAt5h; - } - - const window5h = { percentUsed: percent5h, resetAt: resetAt5h }; - const windowWeekly = { percentUsed: percentWeekly, resetAt: resetAtWeekly }; - const windowMonthly = { percentUsed: percentMonthly, resetAt: resetAtMonthly }; - - const windows: Record = {}; - if (has5h) windows[OPENCODE_WINDOW_5H] = window5h; - if (hasWeekly) windows[OPENCODE_WINDOW_WEEKLY] = windowWeekly; - if (hasMonthly) windows[OPENCODE_WINDOW_MONTHLY] = windowMonthly; + const dominantResetAt = + worstPercent === window5h.percentUsed + ? window5h.resetAt + : worstPercent === windowWeekly.percentUsed + ? windowWeekly.resetAt + : windowMonthly.resetAt; return { used: worstPercent * 100, total: 100, percentUsed: worstPercent, resetAt: dominantResetAt, - windows, + windows: { + [OPENCODE_WINDOW_5H]: window5h, + [OPENCODE_WINDOW_WEEKLY]: windowWeekly, + [OPENCODE_WINDOW_MONTHLY]: windowMonthly, + }, window5h, windowWeekly, windowMonthly, - limitReached, + limitReached: rolling.limitReached || weekly.limitReached || monthly.limitReached, }; } @@ -242,26 +169,18 @@ export async function fetchOpencodeQuota( connectionId: string, connection?: Record ): Promise { - // Check cache first - const cached = quotaCache.get(connectionId); - if (cached) { - // 404 sentinel — use longer TTL to avoid hammering a non-existent endpoint - if (cached.noEndpoint && Date.now() - cached.fetchedAt < NO_ENDPOINT_TTL_MS) { - return null; - } - if (cached.quota !== null && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { - return cached.quota; - } - } - - // Extract API key from connection const apiKey = - typeof connection?.apiKey === "string" && connection.apiKey.trim().length > 0 + typeof connection?.apiKey === "string" ? connection.apiKey - : null; + .trim() + .replace(/^Bearer\s+/i, "") + .trim() + : ""; + if (!apiKey) return null; - if (!apiKey) { - return null; + const cached = quotaCache.get(connectionId); + if (cached && cached.apiKey === apiKey && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.quota; } try { @@ -278,25 +197,12 @@ export async function fetchOpencodeQuota( }); if (!response.ok) { - if (response.status === 404) { - // Upstream doesn't expose this endpoint. Warn once per URL per process so - // operators know the dashboard will be empty for opencode-go connections. - // Cache a 404 sentinel for NO_ENDPOINT_TTL_MS to avoid hammering. - // See opencode issues #10448, #16017, #18648, #31084. - if (!_warned404Urls.has(OPENCODE_QUOTA_URL)) { - _warned404Urls.add(OPENCODE_QUOTA_URL); - console.warn( - `[opencodeQuotaFetcher] ${OPENCODE_QUOTA_URL} returned 404 — opencode-go usage API is not yet public. ` + - `Set OMNIROUTE_OPENCODE_QUOTA_URL to a working endpoint, or follow ` + - `https://github.com/anomalyco/opencode/issues/16017 for upstream status.` - ); - } - quotaCache.set(connectionId, { - quota: null, - fetchedAt: Date.now(), - noEndpoint: true, - }); - return null; + if (response.status === 404 && !_warned404Urls.has(OPENCODE_QUOTA_URL)) { + _warned404Urls.add(OPENCODE_QUOTA_URL); + console.warn( + `[opencodeQuotaFetcher] Official usage endpoint ${OPENCODE_QUOTA_URL} returned 404. ` + + "Verify OMNIROUTE_OPENCODE_QUOTA_URL when using a relay or test server." + ); } if (response.status === 401 || response.status === 403) { quotaCache.delete(connectionId); @@ -315,8 +221,7 @@ export async function fetchOpencodeQuota( const quota = parseOpencodeQuotaResponse(data); if (!quota) return null; - // Store in cache - quotaCache.set(connectionId, { quota, fetchedAt: Date.now() }); + quotaCache.set(connectionId, { quota, fetchedAt: Date.now(), apiKey }); return quota; } catch { // Network error, timeout, etc. — fail open diff --git a/open-sse/services/providerCooldownTracker.ts b/open-sse/services/providerCooldownTracker.ts index f5a14d200626..13fbd17af530 100644 --- a/open-sse/services/providerCooldownTracker.ts +++ b/open-sse/services/providerCooldownTracker.ts @@ -171,6 +171,11 @@ export function getRemainingCooldownMs( /** * Record a successful request for a provider/connection. * Resets the failure count (but keeps the entry for reference). + * + * @deprecated Use accountFallback.recordProviderSuccess instead -- it also + * transitions the circuit breaker from HALF_OPEN to CLOSED. This function + * only resets the cooldown failureCount without touching the breaker, which + * leaves the breaker stuck in HALF_OPEN after repeated failures. */ export function recordProviderSuccess(provider: string, connectionId: string | undefined): void { if (!provider || provider === "unknown") return; diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 2a667028af41..0eb242949f29 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -9,6 +9,7 @@ */ import Bottleneck from "bottleneck"; +import { applyBottleneckDoExpirePatch } from "./bottleneckPatch.ts"; import { parseRetryAfterFromBody } from "./accountFallback.ts"; import { getProviderCategory } from "../config/providerRegistry.ts"; import { getCodexRateLimitKey } from "../executors/codex.ts"; @@ -338,6 +339,8 @@ function trackAsyncOperation(promise: Promise): Promise { export async function initializeRateLimits() { if (initialized) return; initialized = true; + // Fix Bottleneck v2.19.5 doExpire bug before any limiter is created. + applyBottleneckDoExpirePatch(); try { const { getProviderConnections, getSettings } = await import("@/lib/localDb"); diff --git a/open-sse/services/reasoningCache.ts b/open-sse/services/reasoningCache.ts index fee766ec654c..71e1c2493017 100644 --- a/open-sse/services/reasoningCache.ts +++ b/open-sse/services/reasoningCache.ts @@ -13,6 +13,8 @@ * @see Issue #1628 */ +import { createHash } from "node:crypto"; + import { clearAllReasoningCache, cleanupExpiredReasoning, @@ -120,6 +122,8 @@ type AssistantMessageLike = { }; type AssistantMessageCacheContext = { + /** Authenticated identity supplied by the server, never from request JSON. */ + apiKeyId?: string | null; requestId?: string; messageIndex?: number; }; @@ -128,6 +132,26 @@ type ToolCallLike = { id?: unknown; }; +/** Build an opaque, versioned key. Legacy unscoped rows are never queried. */ +export function buildReasoningCacheKey( + key: string, + apiKeyId: string | null | undefined, + provider: string | null | undefined +): string | null { + if ( + !key || + typeof apiKeyId !== "string" || + !apiKeyId.trim() || + typeof provider !== "string" || + !provider.trim() + ) + return null; + const digest = createHash("sha256") + .update(JSON.stringify([apiKeyId, provider.trim().toLowerCase(), key])) + .digest("hex"); + return `reasoning:v2:${digest}`; +} + const memoryCache = new Map(); const MAX_MEMORY_ENTRIES = 200; const MAX_ENTRY_BYTES = 10000; @@ -176,18 +200,21 @@ export function cacheReasoning( toolCallId: string, provider: string, model: string, - reasoning: string + reasoning: string, + apiKeyId?: string | null ): void { - cacheReasoningByKey(toolCallId, provider, model, reasoning); + cacheReasoningByKey(toolCallId, provider, model, reasoning, apiKeyId); } export function cacheReasoningByKey( key: string, provider: string, model: string, - reasoning: string + reasoning: string, + apiKeyId?: string | null ): void { - if (!key || !reasoning) return; + const scopedKey = buildReasoningCacheKey(key, apiKeyId, provider); + if (!scopedKey || !reasoning) return; if (reasoning.length > MAX_ENTRY_BYTES) { reasoning = reasoning.slice(0, MAX_ENTRY_BYTES); @@ -199,7 +226,7 @@ export function cacheReasoningByKey( if (memoryCache.size >= MAX_MEMORY_ENTRIES) { evictOldest(); } - memoryCache.set(key, { + memoryCache.set(scopedKey, { reasoning, provider, model, @@ -208,7 +235,7 @@ export function cacheReasoningByKey( }); try { - setReasoningCache(key, provider, model, reasoning, TTL_MS); + setReasoningCache(scopedKey, provider, model, reasoning, TTL_MS); } catch { // DB persistence failure is non-fatal; memory cache still serves the hot path. } @@ -225,10 +252,11 @@ export function cacheReasoningBatch( toolCallIds: string[], provider: string, model: string, - reasoning: string + reasoning: string, + apiKeyId?: string | null ): void { for (const id of toolCallIds) { - if (id) cacheReasoning(id, provider, model, reasoning); + if (id) cacheReasoning(id, provider, model, reasoning, apiKeyId); } } @@ -242,7 +270,11 @@ export function cacheReasoningFromAssistantMessage( model: string, context?: AssistantMessageCacheContext ): number { - if (!message || message.role !== "assistant") { + if ( + !message || + message.role !== "assistant" || + !buildReasoningCacheKey("capture", context?.apiKeyId, provider) + ) { return 0; } @@ -270,12 +302,13 @@ export function cacheReasoningFromAssistantMessage( buildAssistantMessageCacheKey(requestId, messageIndex), provider, model, - reasoning + reasoning, + context?.apiKeyId ); return 1; } - cacheReasoningBatch(toolCallIds, provider, model, reasoning); + cacheReasoningBatch(toolCallIds, provider, model, reasoning, context?.apiKeyId); return toolCallIds.length; } @@ -283,27 +316,32 @@ export function cacheReasoningFromAssistantMessage( * Look up cached reasoning_content by tool_call_id. * Memory first → DB fallback → null (miss). */ -export function lookupReasoning(toolCallId: string): string | null { - if (!toolCallId) { +export function lookupReasoning( + toolCallId: string, + apiKeyId?: string | null, + provider?: string | null +): string | null { + const scopedKey = buildReasoningCacheKey(toolCallId, apiKeyId, provider); + if (!scopedKey) { misses++; return null; } // 1. Check memory - const mem = memoryCache.get(toolCallId); + const mem = memoryCache.get(scopedKey); if (mem) { if (Date.now() < mem.expiresAt) { hits++; return mem.reasoning; } // Expired in memory — remove - memoryCache.delete(toolCallId); + memoryCache.delete(scopedKey); } // 2. Fallback to DB let dbResult: { reasoning: string; provider: string; model: string } | null = null; try { - dbResult = getReasoningCache(toolCallId); + dbResult = getReasoningCache(scopedKey); } catch { // DB lookup failure is non-fatal; treat it as a cache miss. } @@ -314,7 +352,7 @@ export function lookupReasoning(toolCallId: string): string | null { promotedReasoning = promotedReasoning.slice(0, MAX_ENTRY_BYTES); } // Promote back to memory for fast subsequent lookups - memoryCache.set(toolCallId, { + memoryCache.set(scopedKey, { reasoning: promotedReasoning, provider: dbResult.provider, model: dbResult.model, diff --git a/open-sse/services/requestDedup.ts b/open-sse/services/requestDedup.ts index 5ccde19529a3..fe0bad567122 100644 --- a/open-sse/services/requestDedup.ts +++ b/open-sse/services/requestDedup.ts @@ -32,26 +32,44 @@ export interface DedupResult { const inflight = new Map>(); +const NON_OUTPUT_TOP_LEVEL_FIELDS = new Set(["stream", "user", "metadata"]); + +function stableCanonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableCanonicalize); + if (!value || typeof value !== "object") return value; + + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .filter((key) => record[key] !== undefined) + .map((key) => [key, stableCanonicalize(record[key])]) + ); +} + /** * Compute a deterministic hash for a request body. - * Includes: model, messages, temperature, tools, tool_choice, max_tokens, response_format - * Excludes: stream, user, metadata (don't affect LLM output) + * Hashes the complete translated upstream body except top-level transport/client + * metadata that cannot affect model output. The handler calls this after translation, + * where prompt-bearing fields vary by provider (`messages`, `input`, `contents`, and + * nested envelopes); projecting only Chat Completions fields can therefore collide + * different prompts or structured-output schemas onto one shared response. + * + * `tenantId` namespaces the hash because a shared in-flight execution uses the + * owner's provider connection, policy, and billing context. Anonymous local mode + * keeps the un-namespaced form. */ -export function computeRequestHash(requestBody: unknown): string { - const body = requestBody as Record; - const canonical = { - model: body.model ?? null, - messages: body.messages ?? null, - temperature: typeof body.temperature === "number" ? body.temperature : 1.0, - tools: body.tools ?? null, - tool_choice: body.tool_choice ?? null, - max_tokens: body.max_tokens ?? null, - response_format: body.response_format ?? null, - top_p: body.top_p ?? null, - frequency_penalty: body.frequency_penalty ?? null, - presence_penalty: body.presence_penalty ?? null, - }; - return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16); +export function computeRequestHash(requestBody: unknown, tenantId?: string | null): string { + const body = + requestBody && typeof requestBody === "object" && !Array.isArray(requestBody) + ? (requestBody as Record) + : {}; + const outputAffectingBody = Object.fromEntries( + Object.entries(body).filter(([key]) => !NON_OUTPUT_TOP_LEVEL_FIELDS.has(key)) + ); + const canonical = JSON.stringify(stableCanonicalize(outputAffectingBody)); + const digest = createHash("sha256").update(canonical).digest("hex").slice(0, 16); + return tenantId ? `${tenantId}.${digest}` : digest; } /** Determine whether a request should be deduplicated */ @@ -82,8 +100,15 @@ export async function deduplicate( const existing = inflight.get(hash); if (existing) { - const result = (await existing) as T; - return { result, wasDeduplicated: true, hash }; + try { + const result = (await existing) as T; + return { result, wasDeduplicated: true, hash }; + } catch { + // The owner failed before producing a response (fetch rejection, timeout, + // abort, etc.). A waiter must make its own attempt instead of inheriting + // the same transport failure. + return { result: await fn(), wasDeduplicated: false, hash }; + } } if (inflight.size >= MAX_INFLIGHT) { @@ -91,12 +116,7 @@ export async function deduplicate( if (oldestKey !== undefined) inflight.delete(oldestKey); } - let resolve!: (value: T) => void; - let reject!: (reason: unknown) => void; - const sharedPromise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); + const sharedPromise = Promise.resolve().then(fn); inflight.set(hash, sharedPromise as Promise); const timer = setTimeout(() => { @@ -104,12 +124,8 @@ export async function deduplicate( }, config.timeoutMs); try { - const result = await fn(); - resolve(result); + const result = await sharedPromise; return { result, wasDeduplicated: false, hash }; - } catch (err) { - reject(err); - throw err; } finally { clearTimeout(timer); if (inflight.get(hash) === sharedPromise) inflight.delete(hash); diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index b479daf2789a..7b0f9b02614f 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -5,12 +5,10 @@ import { getGitHubCopilotRefreshHeaders } from "../config/providerHeaderProfiles import { pbkdf2Sync } from "node:crypto"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; import { serializeRefresh, wasRefreshTokenRotated } from "./refreshSerializer.ts"; -import { - buildExternalIdpRefreshParams, - isExternalIdpAuthMethod, -} from "./kiroExternalIdp.ts"; +import { buildExternalIdpRefreshParams, isExternalIdpAuthMethod } from "./kiroExternalIdp.ts"; import { WINDSURF_CONFIG } from "@/lib/oauth/constants/oauth"; import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab"; +import { deriveCodexWorkspaceInfo } from "@/lib/oauth/providers/codex"; // Default token expiry buffer (refresh if expires within 5 minutes). // Used as fallback for providers without an explicit lead time in @@ -1186,10 +1184,22 @@ export async function refreshCodexToken(refreshToken, log, proxyConfig: unknown expiresIn: tokens.expires_in, }); + // A ChatGPT subscription that lapsed after the account was connected only + // shows up in the refreshed id_token's plan claim. Re-derive the tier so a + // stale "plus" captured at OAuth time cannot outlive the subscription that + // paid for it. Only the plan is patched: the workspace binding stays as the + // user selected it during OAuth, because re-running the team-vs-personal + // heuristic here could silently re-point an established connection. + const workspaceInfo = deriveCodexWorkspaceInfo(tokens.id_token); + const refreshedPlanType = workspaceInfo?.workspacePlanType; + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, + ...(refreshedPlanType + ? { providerSpecificDataPatch: { workspacePlanType: refreshedPlanType } } + : {}), }; } catch (error) { log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`); diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 39f82960827d..c752d33afa50 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -7,7 +7,7 @@ import { getDbInstance } from "@/lib/db/core"; import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts"; import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts"; import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts"; -import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts"; +import { getOllamaCloudUsage } from "./opencodeOllamaUsage.ts"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts"; import { extractCodeAssistOnboardTierId, @@ -372,42 +372,37 @@ async function getOpencodeUsage(connectionId: string, apiKey: string) { const { window5h, windowWeekly, windowMonthly, limitReached } = quota; - const quotas: Record = {}; - - // $12 / 5-hour rolling window - quotas["window_5h"] = { - used: window5h.percentUsed * 12, - total: 12, - remaining: (1 - window5h.percentUsed) * 12, - remainingPercentage: (1 - window5h.percentUsed) * 100, - resetAt: window5h.resetAt, - unlimited: false, - displayName: "$12 / 5-hour", - currency: "USD", - }; - - // $30 / weekly window - quotas["window_weekly"] = { - used: windowWeekly.percentUsed * 30, - total: 30, - remaining: (1 - windowWeekly.percentUsed) * 30, - remainingPercentage: (1 - windowWeekly.percentUsed) * 100, - resetAt: windowWeekly.resetAt, - unlimited: false, - displayName: "$30 / week", - currency: "USD", - }; - - // $60 / monthly window - quotas["window_monthly"] = { - used: windowMonthly.percentUsed * 60, - total: 60, - remaining: (1 - windowMonthly.percentUsed) * 60, - remainingPercentage: (1 - windowMonthly.percentUsed) * 100, - resetAt: windowMonthly.resetAt, - unlimited: false, - displayName: "$60 / month", - currency: "USD", + const quotas: Record = { + session: { + used: window5h.percentUsed * 12, + total: 12, + remaining: (1 - window5h.percentUsed) * 12, + remainingPercentage: (1 - window5h.percentUsed) * 100, + resetAt: window5h.resetAt, + unlimited: false, + displayName: "$12 / 5-hour", + currency: "USD", + }, + weekly: { + used: windowWeekly.percentUsed * 30, + total: 30, + remaining: (1 - windowWeekly.percentUsed) * 30, + remainingPercentage: (1 - windowWeekly.percentUsed) * 100, + resetAt: windowWeekly.resetAt, + unlimited: false, + displayName: "$30 / week", + currency: "USD", + }, + mcp_monthly: { + used: windowMonthly.percentUsed * 60, + total: 60, + remaining: (1 - windowMonthly.percentUsed) * 60, + remainingPercentage: (1 - windowMonthly.percentUsed) * 100, + resetAt: windowMonthly.resetAt, + unlimited: false, + displayName: "$60 / month", + currency: "USD", + }, }; return { @@ -597,7 +592,7 @@ export async function getUsageForProvider( ...(provider === "glm-cn" ? { apiRegion: "china" } : {}), }); case "opencode-go": - return await getOpenCodeGoUsage(apiKey || "", providerSpecificData); + return await getOpencodeUsage(id || "", apiKey || ""); case "ollama-cloud": return await getOllamaCloudUsage(providerSpecificData); case "minimax": diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 6d8c8edb81c2..fa44bf17f37c 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -9,10 +9,7 @@ import { getModelTargetFormat } from "../../config/providerModels.ts"; // dispatching Claude-shape requests to these providers. Anthropic Claude and // other Claude-compatible upstreams that do accept it are unaffected. // Ported from upstream decolua/9router#820 by @hiepau1231. -const CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG = new Set([ - "minimax", - "minimax-cn", -]); +const CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG = new Set(["minimax", "minimax-cn"]); // Placeholder thinking text used as last-resort fallback when: // - Target upstream is a non-Anthropic Claude-shape provider @@ -222,7 +219,8 @@ export function prepareClaudeRequest( body: ClaudeRequestBody, provider: string | null = null, preserveCacheControl = false, - model: string | null = null + model: string | null = null, + reasoningCacheApiKeyId?: string | null ): ClaudeRequestBody { // 0. Strip Anthropic `output_config` for providers that reject it on their // Claude-compatible endpoints (MiniMax). Must run before any downstream @@ -488,7 +486,11 @@ export function prepareClaudeRequest( if (!text) { const pairedToolUseId = toolUseIds[thinkingBlockIdx]; if (pairedToolUseId) { - const cached = lookupReasoning(pairedToolUseId); + const cached = lookupReasoning( + pairedToolUseId, + reasoningCacheApiKeyId, + provider + ); if (cached) { text = cached; recordReplay(); @@ -525,7 +527,7 @@ export function prepareClaudeRequest( let text = ""; const firstToolUseId = toolUseIds[0]; if (firstToolUseId) { - const cached = lookupReasoning(firstToolUseId); + const cached = lookupReasoning(firstToolUseId, reasoningCacheApiKeyId, provider); if (cached) { text = cached; recordReplay(); diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 7f45cf329da3..2d71d9dcad60 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -371,6 +371,26 @@ function inlineLocalSchemaRefs( ); } +// Visit schema-map entries without treating user-defined names as schema keywords (#13059). +function forEachSubschema(record: JsonRecord, visitor: (sub: unknown) => void): void { + for (const [key, value] of Object.entries(record)) { + if (!value || typeof value !== "object") continue; + if ( + (key === "properties" || + key === "patternProperties" || + key === "$defs" || + key === "definitions") && + !Array.isArray(value) + ) { + for (const subSchema of Object.values(value as JsonRecord)) { + visitor(subSchema); + } + } else { + visitor(value); + } + } +} + // Helper: Remove unsupported keywords recursively from object/array function removeUnsupportedKeywords(obj: unknown, keywords: Set): void { if (!obj || typeof obj !== "object") return; @@ -389,21 +409,7 @@ function removeUnsupportedKeywords(obj: unknown, keywords: Set): void { delete record[key]; } } - // Recurse into remaining values. `properties` is a map keyed by arbitrary, - // user-defined property NAMES — a tool may legitimately declare a property - // called `pattern`, `enum`, `minLength`, etc. Descend into each property's - // subschema, but never run keyword-deletion against the property names - // themselves, or glob/grep-style tools lose their `pattern` argument (#1368). - for (const [key, value] of Object.entries(record)) { - if (!value || typeof value !== "object") continue; - if (key === "properties" && !Array.isArray(value)) { - for (const subSchema of Object.values(value as JsonRecord)) { - removeUnsupportedKeywords(subSchema, keywords); - } - } else { - removeUnsupportedKeywords(value, keywords); - } - } + forEachSubschema(record, (sub) => removeUnsupportedKeywords(sub, keywords)); } function normalizeAdditionalProperties(obj: unknown): void { @@ -425,28 +431,27 @@ function normalizeAdditionalProperties(obj: unknown): void { delete record.additionalProperties; } - for (const value of Object.values(record)) { - if (value && typeof value === "object") { - normalizeAdditionalProperties(value); - } - } + forEachSubschema(record, normalizeAdditionalProperties); } // Convert const to enum function convertConstToEnum(obj: unknown): void { if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) { + convertConstToEnum(item); + } + return; + } + const record = obj as JsonRecord; if (record.const !== undefined && !record.enum) { record.enum = [record.const]; delete record.const; } - for (const value of Object.values(record)) { - if (value && typeof value === "object") { - convertConstToEnum(value); - } - } + forEachSubschema(record, convertConstToEnum); } // Convert enum values to strings (Gemini requires string enum values) @@ -454,6 +459,13 @@ function convertConstToEnum(obj: unknown): void { function convertEnumValuesToStrings(obj: unknown): void { if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) { + convertEnumValuesToStrings(item); + } + return; + } + const record = obj as JsonRecord; if (record.enum && Array.isArray(record.enum)) { // Gemini only supports enum for string types, not integer @@ -467,17 +479,20 @@ function convertEnumValuesToStrings(obj: unknown): void { } } - for (const value of Object.values(record)) { - if (value && typeof value === "object") { - convertEnumValuesToStrings(value); - } - } + forEachSubschema(record, convertEnumValuesToStrings); } // Merge allOf schemas function mergeAllOf(obj: unknown): void { if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) { + mergeAllOf(item); + } + return; + } + const record = obj as JsonRecord; if (record.allOf && Array.isArray(record.allOf)) { const merged: { properties?: JsonRecord; required?: string[] } = {}; @@ -510,11 +525,7 @@ function mergeAllOf(obj: unknown): void { } } - for (const value of Object.values(record)) { - if (value && typeof value === "object") { - mergeAllOf(value); - } - } + forEachSubschema(record, mergeAllOf); } // Select best schema from anyOf/oneOf @@ -548,6 +559,13 @@ function selectBest(items: unknown[]): number { function flattenAnyOfOneOf(obj: unknown): void { if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) { + flattenAnyOfOneOf(item); + } + return; + } + const record = obj as JsonRecord; if (record.anyOf && Array.isArray(record.anyOf) && record.anyOf.length > 0) { const nonNullSchemas = record.anyOf.filter((s) => s && toRecord(s).type !== "null"); @@ -569,28 +587,27 @@ function flattenAnyOfOneOf(obj: unknown): void { } } - for (const value of Object.values(record)) { - if (value && typeof value === "object") { - flattenAnyOfOneOf(value); - } - } + forEachSubschema(record, flattenAnyOfOneOf); } // Flatten type arrays function flattenTypeArrays(obj: unknown): void { if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) { + flattenTypeArrays(item); + } + return; + } + const record = obj as JsonRecord; if (record.type && Array.isArray(record.type)) { const nonNullTypes = record.type.filter((t) => t !== "null"); record.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string"; } - for (const value of Object.values(record)) { - if (value && typeof value === "object") { - flattenTypeArrays(value); - } - } + forEachSubschema(record, flattenTypeArrays); } // Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively @@ -620,6 +637,13 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown { function cleanupRequired(obj: unknown): void { if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) { + cleanupRequired(item); + } + return; + } + const record = obj as JsonRecord; if (record.required && Array.isArray(record.required) && record.properties) { const properties = toRecord(record.properties); @@ -635,11 +659,7 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown { } // Recurse into nested objects - for (const value of Object.values(record)) { - if (value && typeof value === "object") { - cleanupRequired(value); - } - } + forEachSubschema(record, cleanupRequired); } cleanupRequired(cleaned); @@ -648,6 +668,13 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown { function addPlaceholders(obj: unknown): void { if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) { + addPlaceholders(item); + } + return; + } + const record = obj as JsonRecord; if (record.type === "object") { if (!record.properties || Object.keys(toRecord(record.properties)).length === 0) { @@ -662,11 +689,7 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown { } // Recurse into nested objects - for (const value of Object.values(record)) { - if (value && typeof value === "object") { - addPlaceholders(value); - } - } + forEachSubschema(record, addPlaceholders); } addPlaceholders(cleaned); diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index 9be3d930b6e7..92d10af8be77 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -282,6 +282,8 @@ const SCHEMA_SLOT_KEYS = [ "else", "unevaluatedProperties", "additionalItems", + "contentSchema", + "unevaluatedItems", ]; function coerceIndexedObjectToArray(value: unknown): unknown[] | null { diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 12406078ece6..87e2771b3ded 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -160,6 +160,8 @@ export function translateRequest( preserveDeveloperRole?: boolean; preserveCacheControl?: boolean; signatureNamespace?: string | null; + /** Authenticated server-side API key identity; never read from body/credentials. */ + reasoningCacheApiKeyId?: string | null; preCompressionBody?: Record | null; /** UA-detected GitHub Copilot client. Forwarded to translators via the * transient `_copilotClient` credential flag (see openai-responses → openai). */ @@ -309,7 +311,13 @@ export function translateRequest( if (targetFormat === FORMATS.CLAUDE) { const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE; const preserveCache = isClaudePassthrough || options?.preserveCacheControl === true; - result = prepareClaudeRequest(result, provider, preserveCache, model); + result = prepareClaudeRequest( + result, + provider, + preserveCache, + model, + options?.reasoningCacheApiKeyId + ); } // Normalize openai-responses input shape for providers that require list input. @@ -415,7 +423,7 @@ export function translateRequest( // Try reasoning cache first if (firstToolUseId) { - const cached = lookupReasoning(firstToolUseId); + const cached = lookupReasoning(firstToolUseId, options?.reasoningCacheApiKeyId, provider); if (cached) { msg.content.splice(firstToolUseIdx, 0, { type: "thinking", @@ -443,7 +451,7 @@ export function translateRequest( ? msg.tool_calls[0]?.id : getAssistantMessageCacheKey(result, 0); if (cacheKey) { - const cached = lookupReasoning(cacheKey); + const cached = lookupReasoning(cacheKey, options?.reasoningCacheApiKeyId, provider); if (cached) { msg.reasoning_content = cached; recordReplay(); diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts index bda9113d08d8..48eaea8533bc 100644 --- a/open-sse/translator/request/openai-responses/toResponses.ts +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -49,6 +49,32 @@ function mapChatResponseFormatToResponsesText(body: JsonRecord, result: JsonReco result.text = { ...existingText, format }; } +// Convert a Chat-Completions content block (string or text-part array) into the +// Responses API `input_text` part array used by message input items. +function buildResponsesTextParts(content: unknown): unknown[] { + if (typeof content === "string") { + return [{ type: "input_text", text: content }]; + } + if (Array.isArray(content)) { + const parts: unknown[] = []; + for (const partValue of content) { + // A bare string inside the content array is a real text instruction + // (e.g. a harness-injected system reminder), not a structured part. + // Silently dropping it lost the instruction (#6954 follow-up). + if (typeof partValue === "string") { + parts.push({ type: "input_text", text: partValue }); + continue; + } + const part = toRecord(partValue); + if (part.type === "text" || typeof part.text === "string") { + parts.push({ type: "input_text", text: toString(part.text) }); + } + } + return parts.length > 0 ? parts : [{ type: "input_text", text: "" }]; + } + return [{ type: "input_text", text: "" }]; +} + export function openaiToOpenAIResponsesRequest( model: unknown, body: unknown, @@ -83,7 +109,18 @@ export function openaiToOpenAIResponsesRequest( if (!hasSystemMessage) { result.instructions = typeof msg.content === "string" ? msg.content : ""; hasSystemMessage = true; + continue; } + // Mid-conversation system/developer turns (e.g. harness-injected reminders + // from Claude Code) must survive as developer-role input items. The + // Responses API supports the `developer` role for exactly this; mapping + // them to `assistant` misattributes harness instructions as model output, + // and silently dropping them loses them entirely (#6954). + input.push({ + type: "message", + role: "developer", + content: buildResponsesTextParts(msg.content), + }); continue; } diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 3da0a59340c3..3f94c420866b 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -10,8 +10,11 @@ import { parseToolInput, normalizeKiroToolSchema, serializeToolResultContent, + wrapSystemReminder, } from "./openai-to-kiro/messageHelpers.ts"; import { supportsKiroAdaptiveThinking } from "./openai-to-kiro/adaptiveThinking.ts"; +import { appendKiroJsonFormatInstruction } from "./openai-to-kiro/responseFormat.ts"; +import { createToolResultGrouping } from "./openai-to-kiro/toolResultGrouping.ts"; /** * Anthropic's direct-provider `[1m]` context-1m beta suffix. Kiro is AWS @@ -34,19 +37,6 @@ export function hasUnsupportedKiroContextSuffix(model: unknown): boolean { ); } -/** - * Wrap system-prompt content in tags before it is merged into - * a Kiro user message. Kiro/CodeWhisperer has no `system` role, so without this - * the system prompt would appear as raw user text (issue #2306). - */ -function wrapSystemReminder(text: string): string { - return `\n${text}\n`; -} - -/** - * Convert OpenAI messages to Kiro format - * Rules: system/tool/user -> user role, merge consecutive same roles - */ function convertMessages(messages, tools, model) { let history = []; let currentMessage = null; @@ -58,12 +48,18 @@ function convertMessages(messages, tools, model) { let currentRole = null; let toolsAttached = false; - // Only Claude models support images in Kiro. Kiro also routes non-Claude - // models (deepseek, minimax, glm, qwen3-coder-next) that do not accept image - // attachments — gate image extraction behind a Claude check so we never - // attach images those models would reject. + // Only Claude models accept image attachments through Kiro. const supportsImages = typeof model === "string" && model.toLowerCase().includes("claude"); - + const grouping = createToolResultGrouping(messages); + const pushCurrentMessage = (message) => { + history.push(message); + currentMessage = message; + }; + const resetPendingUser = () => { + pendingUserContent = []; + pendingToolResults = []; + pendingImages = []; + }; const flushPending = () => { if (currentRole === "user") { // Kiro accepts an empty user `content` when the turn carries toolResults or @@ -151,11 +147,9 @@ function convertMessages(messages, tools, model) { toolsAttached = true; } - history.push(userMsg); - currentMessage = userMsg; - pendingUserContent = []; - pendingToolResults = []; - pendingImages = []; + pushCurrentMessage(userMsg); + resetPendingUser(); + grouping.flushInto(history); } else if (currentRole === "assistant") { const content = pendingAssistantContent.join("\n\n").trim() || "(empty)"; const assistantMsg = { @@ -166,6 +160,7 @@ function convertMessages(messages, tools, model) { history.push(assistantMsg); pendingAssistantContent = []; } + currentRole = null; }; for (let i = 0; i < messages.length; i++) { @@ -177,6 +172,7 @@ function convertMessages(messages, tools, model) { role = "user"; } + if (grouping.handle(msg, i, currentRole, pendingToolResults.length, flushPending)) continue; // If role changes, flush pending if (role !== currentRole && currentRole !== null) { flushPending(); @@ -759,6 +755,9 @@ export function buildKiroPayload(model, body, stream, credentials) { finalContent = `# Tool Documentation\n\n${toolDocs}\n\n---\n\n${finalContent}`; } + // Appended last: output-format instructions hold far better at the prompt tail. + finalContent = appendKiroJsonFormatInstruction(finalContent, body.response_format); + const payload: { conversationState: { chatTriggerType: string; diff --git a/open-sse/translator/request/openai-to-kiro/messageHelpers.ts b/open-sse/translator/request/openai-to-kiro/messageHelpers.ts index 2102023fa7dd..629cb80f6bf9 100644 --- a/open-sse/translator/request/openai-to-kiro/messageHelpers.ts +++ b/open-sse/translator/request/openai-to-kiro/messageHelpers.ts @@ -1,6 +1,15 @@ // Pure message/tool helpers for the OpenAI -> Kiro request translator. // Extracted verbatim from openai-to-kiro.ts (no host imports). +/** + * Wrap system-prompt content in tags before it is merged into + * a Kiro user message. Kiro/CodeWhisperer has no `system` role, so without this + * the system prompt would appear as raw user text (issue #2306). + */ +export function wrapSystemReminder(text: string): string { + return `\n${text}\n`; +} + export function parseToolInput(value: unknown) { if (value && typeof value === "object" && !Array.isArray(value)) { return value; diff --git a/open-sse/translator/request/openai-to-kiro/responseFormat.ts b/open-sse/translator/request/openai-to-kiro/responseFormat.ts new file mode 100644 index 000000000000..5fc29df21022 --- /dev/null +++ b/open-sse/translator/request/openai-to-kiro/responseFormat.ts @@ -0,0 +1,71 @@ +// OpenAI `response_format` support for the OpenAI -> Kiro request translator. +import { wrapSystemReminder } from "./messageHelpers.ts"; + +/** + * Marker the Kiro executor detects to learn that this request carries a JSON + * contract. chatCore hands the executor the already-translated Kiro payload, so + * `response_format` is no longer readable there — the prompt is the only channel + * left, the same one `enabled` travels on. + */ +export const KIRO_JSON_CONTRACT_MARKER = "json"; + +/** The subset of OpenAI's `response_format` that asks for a JSON-only reply. */ +type KiroResponseFormat = { + type?: unknown; + json_schema?: { schema?: unknown } | null; +}; + +/** + * Kiro/CodeWhisperer has no Structured Output request parameter, so an OpenAI + * `response_format` cannot be forwarded and used to be dropped without a trace: + * the model saw nothing but the prompt and invented its own property names, + * which fails every strict-schema caller closed. Describe the contract in the + * prompt instead — the same fallback `openai-to-claude.ts` and + * `DefaultExecutor.applyJsonSchemaFallback()` already apply on their routes. + * + * This is best-effort JSON, never constrained decoding: Kiro's only schema- + * carrying channel is a tool schema, and `kiroSanitizer` must strip exactly the + * keywords a strict schema depends on (`additionalProperties`, `$ref`/`$defs`, + * `anyOf`) or Kiro 400s the request. Callers that need enforcement must route + * to a provider with native Structured Output. + * + * Returns `null` when the request asks for no JSON contract. + */ +export function buildKiroJsonFormatInstruction(responseFormat: unknown): string | null { + if (!responseFormat || typeof responseFormat !== "object" || Array.isArray(responseFormat)) { + return null; + } + const fmt = responseFormat as KiroResponseFormat; + const jsonOnly = + "Respond with the JSON value only: no prose, no explanation, no markdown code fences."; + + if (fmt.type === "json_schema") { + const schema = fmt.json_schema?.schema; + if (!schema || typeof schema !== "object") return null; + return ( + `${KIRO_JSON_CONTRACT_MARKER}\n` + + "You must respond with valid JSON that strictly follows this JSON schema:\n" + + "```json\n" + + JSON.stringify(schema, null, 2) + + "\n```\n" + + "Use exactly the property names the schema defines — do not rename them, do not add " + + "properties it does not define, and do not omit required ones. " + + jsonOnly + ); + } + + if (fmt.type === "json_object") { + return `${KIRO_JSON_CONTRACT_MARKER}\nYou must respond with valid JSON. ${jsonOnly}`; + } + + return null; +} + +/** + * Append the `response_format` contract to a Kiro user-message body, or return + * the content untouched when the request asks for no JSON contract. + */ +export function appendKiroJsonFormatInstruction(content: string, responseFormat: unknown): string { + const instruction = buildKiroJsonFormatInstruction(responseFormat); + return instruction ? `${content}\n\n${wrapSystemReminder(instruction)}` : content; +} diff --git a/open-sse/translator/request/openai-to-kiro/toolResultGrouping.ts b/open-sse/translator/request/openai-to-kiro/toolResultGrouping.ts new file mode 100644 index 000000000000..4f6d87c69824 --- /dev/null +++ b/open-sse/translator/request/openai-to-kiro/toolResultGrouping.ts @@ -0,0 +1,148 @@ +type ContentBlock = { + type?: string; + text?: string; +}; + +type Message = { + role?: string; + content?: string | ContentBlock[] | null; + tool_calls?: unknown[]; +}; + +/** True when an Anthropic-style user message belongs to a tool-result batch. */ +function carriesToolResults(message: Message): boolean { + return ( + Array.isArray(message.content) && message.content.some((block) => block.type === "tool_result") + ); +} + +function isTextOnlyAssistant(message: Message): boolean { + return ( + message.role === "assistant" && + (!message.tool_calls || message.tool_calls.length === 0) && + !(Array.isArray(message.content) && message.content.some((block) => block.type === "tool_use")) + ); +} + +type Lookahead = "result" | "skip" | "stop"; + +function classifyLookahead(message: Message): Lookahead { + if (message.role === "tool" || (message.role === "user" && carriesToolResults(message))) { + return "result"; + } + if (isTextOnlyAssistant(message)) return "skip"; + return "stop"; +} + +/** + * Determine whether assistant prose is genuinely sandwiched inside one + * tool-result batch, rather than being the ordinary final assistant reply. + */ +function hasFollowingToolResult(messages: Message[], index: number): boolean { + for (let nextIndex = index + 1; nextIndex < messages.length; nextIndex++) { + const classification = classifyLookahead(messages[nextIndex]); + if (classification === "skip") continue; + return classification === "result"; + } + return false; +} + +function assistantText(message: Message): string { + if (typeof message.content === "string") return message.content.trim(); + if (!Array.isArray(message.content)) return ""; + return message.content + .filter((block) => block.type === "text" || block.text) + .map((block) => block.text || "") + .join("\n") + .trim(); +} + +function isAccompanyingText(block: ContentBlock): boolean { + return block.type !== "tool_result" && (block.type === "text" || Boolean(block.text)); +} + +function takeAccompanyingUserText(message: Message): string { + if (message.role !== "user" || !Array.isArray(message.content)) return ""; + const textBlocks = message.content.filter(isAccompanyingText); + message.content = message.content.filter((block) => !isAccompanyingText(block)); + return textBlocks + .map((block) => block.text || "") + .join("\n") + .trim(); +} + +type DeferredTurn = { role: "assistant" | "user"; content: string }; + +function prependTrailingUserText(message: Message, turns: DeferredTurn[]): void { + const trailing = turns[turns.length - 1]; + if (message.role !== "user" || trailing?.role !== "user") return; + + if (typeof message.content === "string") { + message.content = `${trailing.content}\n\n${message.content}`; + } else if (Array.isArray(message.content)) { + message.content.unshift({ type: "text", text: trailing.content }); + } else { + message.content = trailing.content; + } + turns.pop(); +} + +function toHistoryEntry(turn: DeferredTurn): Record { + if (turn.role === "assistant") { + return { assistantResponseMessage: { content: turn.content } }; + } + return { + userInputMessage: { content: turn.content, modelId: "", origin: "AI_EDITOR" }, + }; +} + +/** + * Holds text-only assistant messages while all results for one parallel tool + * call are collected, then emits the prose immediately after that batch. + */ +export function createToolResultGrouping(messages: Message[]) { + let deferredTurns: DeferredTurn[] = []; + + return { + handle( + message: Message, + index: number, + currentRole: unknown, + pendingToolResultCount: number, + flushPending: () => void + ): boolean { + if ( + isTextOnlyAssistant(message) && + currentRole === "user" && + pendingToolResultCount > 0 && + hasFollowingToolResult(messages, index) + ) { + const text = assistantText(message); + if (text) deferredTurns.push({ role: "assistant", content: text }); + return true; + } + + if (deferredTurns.length > 0 && carriesToolResults(message)) { + const text = takeAccompanyingUserText(message); + if (text) deferredTurns.push({ role: "user", content: text }); + } + + if ( + deferredTurns.length > 0 && + currentRole === "user" && + message.role !== "tool" && + !carriesToolResults(message) + ) { + prependTrailingUserText(message, deferredTurns); + flushPending(); + } + return false; + }, + + flushInto(history: Array>): void { + if (deferredTurns.length === 0) return; + history.push(...deferredTurns.map(toHistoryEntry)); + deferredTurns = []; + }, + }; +} diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index f2c96bf137b5..c2d9fa16c4ce 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -149,11 +149,18 @@ export function geminiToClaudeResponse(chunk, state) { const thoughtsTokens = typeof usageMeta.thoughtsTokenCount === "number" ? usageMeta.thoughtsTokenCount : 0; const cachedTokens = - typeof usageMeta.cachedContentTokenCount === "number" ? usageMeta.cachedContentTokenCount : 0; - + typeof usageMeta.cachedContentTokenCount === "number" && + Number.isFinite(usageMeta.cachedContentTokenCount) && + usageMeta.cachedContentTokenCount > 0 + ? usageMeta.cachedContentTokenCount + : 0; + + // Internal accounting expects cache-inclusive prompt_tokens. Keep the + // Anthropic split at the wire boundary so cache-only usage is not estimated + // and stream metadata, call logs and pricing retain the full prompt count. state.usage = { - input_tokens: inputTokens, - output_tokens: candidatesTokens + thoughtsTokens, + prompt_tokens: inputTokens, + completion_tokens: candidatesTokens + thoughtsTokens, }; if (cachedTokens > 0) { state.usage.cache_read_input_tokens = cachedTokens; @@ -191,10 +198,15 @@ export function geminiToClaudeResponse(chunk, state) { stopReason = "end_turn"; } + const cachedTokens = state.usage?.cache_read_input_tokens || 0; results.push({ type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null }, - usage: state.usage || { input_tokens: 0, output_tokens: 0 }, + usage: { + input_tokens: Math.max(0, (state.usage?.prompt_tokens || 0) - cachedTokens), + output_tokens: state.usage?.completion_tokens || 0, + ...(cachedTokens > 0 ? { cache_read_input_tokens: cachedTokens } : {}), + }, }); results.push({ type: "message_stop" }); diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts index 094d7dc58d03..cbe8fb1f75e0 100644 --- a/open-sse/translator/response/openai-responses/pureHelpers.ts +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -129,28 +129,44 @@ export function normalizeUpstreamFailure(data, fallbackType = "server_error") { : "Upstream failure"; // Preserve upstream error semantics: + // - invalid_prompt / usage-policy rejection → 400 (request content is not accepted) // - context_length_exceeded → 400 (client can retry with smaller context) // - rate_limit_exceeded → 429 (client should back off) // - Everything else → 502 (upstream failure) - const isContextOverflow = code === "context_length_exceeded"; - const isRateLimit = code === "rate_limit_exceeded" || code === "rate_limited"; + const normalizedCode = code.toLowerCase(); + const normalizedMessage = message.toLowerCase(); + const isInvalidPrompt = + normalizedCode === "invalid_prompt" || + normalizedCode === "content_policy_violation" || + (normalizedMessage.includes("prompt was flagged") && + normalizedMessage.includes("usage policy")); + const isContextOverflow = normalizedCode === "context_length_exceeded"; + const isRateLimit = normalizedCode === "rate_limit_exceeded" || normalizedCode === "rate_limited"; let status: number; let type: string; + let fallbackCode: string; if (isRateLimit) { status = 429; type = "rate_limit_error"; + fallbackCode = "rate_limit_exceeded"; + } else if (isInvalidPrompt) { + status = 400; + type = "invalid_request_error"; + fallbackCode = "invalid_prompt"; } else if (isContextOverflow) { status = 400; type = "invalid_request_error"; + fallbackCode = "context_length_exceeded"; } else { status = 502; type = fallbackType; + fallbackCode = "bad_gateway"; } return { status, type, - code: code || (isRateLimit ? "rate_limit_exceeded" : "bad_gateway"), + code: code || fallbackCode, message, }; } diff --git a/open-sse/utils/aiSdkCompat.ts b/open-sse/utils/aiSdkCompat.ts index fb26394a4b11..2daf206b3019 100644 --- a/open-sse/utils/aiSdkCompat.ts +++ b/open-sse/utils/aiSdkCompat.ts @@ -27,8 +27,28 @@ function normalizeResolveStreamFlagOptions(optionsOrUserAgent?: unknown): Resolv return { userAgent: optionsOrUserAgent }; } +const STREAM_DEFAULT_MODE_ENV_VAR = "OMNIROUTE_STREAM_DEFAULT_MODE"; + +/** + * Normalizes the per-key stream default, falling back to + * {@link STREAM_DEFAULT_MODE_ENV_VAR} when the key does not set one. + * + * The legacy default routes a client that omits `stream` and sends a wildcard + * Accept header (python-httpx, most machine clients) through the streaming path. + * There the response is committed as a 200 event-stream, so an upstream failure + * arrives as an in-band error frame the client reads as an empty success rather + * than a retryable status. The env var lets a deployment flip that default for + * every key at once instead of patching each key individually. + */ export function normalizeStreamDefaultMode(value: unknown): StreamDefaultMode { - return value === "json" ? "json" : "legacy"; + if (value === "json") return "json"; + + // `legacy` is NOT treated as an explicit opt-out: every key row is created with + // stream_default_mode = "legacy" (see POST /api/keys), so it is indistinguishable from + // "unset" and honouring it would make the deployment-wide env var dead code on any + // real database. A caller that genuinely needs SSE still opts in per request with + // `stream: true` or `Accept: text/event-stream` — both outrank this default. + return process.env[STREAM_DEFAULT_MODE_ENV_VAR] === "json" ? "json" : "legacy"; } /** diff --git a/open-sse/utils/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts index c70180f927d4..deb710d4e35b 100644 --- a/open-sse/utils/earlyStreamKeepalive.ts +++ b/open-sse/utils/earlyStreamKeepalive.ts @@ -23,7 +23,8 @@ * heartbeats are emitted every `intervalMs` until the handler resolves; its * body is then forwarded. If the handler ultimately fails, a structured * `event: error` frame is emitted in-band (the response is already committed - * to 200, so the HTTP status can no longer change). + * to 200, so the HTTP status can no longer change) — carrying `error.status` + * so a client can still tell a retryable 5xx/429 from a terminal 4xx. */ const ENCODER = new TextEncoder(); @@ -56,10 +57,181 @@ export type EarlyStreamKeepaliveOptions = { keepaliveFrame?: Uint8Array; /** Extra headers to include in the keepalive response (e.g. X-Correlation-Id). */ extraHeaders?: Record; + /** + * Shape AND event name of the in-band error frame. Each client family parses only + * its own: Anthropic clients (/v1/messages) branch on `error.type` and ignore an + * OpenAI-shaped `{"error":{...}}` frame, while Responses API clients (/v1/responses, + * notably Codex CLI) act only on a `response.failed` event and ignore `event: error` + * whatever its payload. Sending the wrong one makes a failure look to the client like + * a stream that simply stopped. + */ + errorFrameFormat?: ErrorFrameFormat; }; type SettledHandler = { ok: true; response: Response } | { ok: false; error: unknown }; +export type ErrorFrameFormat = "openai" | "anthropic" | "responses"; + +/** Generic in-band failure text used when no upstream message exists. */ +const GENERIC_FAILURE_MESSAGE = "Upstream stream failed before completion."; + +/** + * SSE event name each client family listens on for an in-band failure. Responses API + * clients terminate a stream on `response.failed` and ignore `event: error` entirely, + * so the event name is part of the format, not just the payload. + */ +const ERROR_FRAME_EVENT_NAMES: Record = { + openai: "error", + anthropic: "error", + responses: "response.failed", +}; + +export function errorFrameEventName(format: ErrorFrameFormat = "openai"): string { + return ERROR_FRAME_EVENT_NAMES[format] ?? "error"; +} + +/** + * Maps an HTTP status onto an Anthropic error type. Anthropic clients (Claude Code, + * the Anthropic SDK) branch on `error.type` inside the stream, not on the status line + * they can no longer see — `overloaded_error` and `rate_limit_error` are the two types + * they treat as retryable, so an upstream capacity failure must arrive as one of them + * or the client gives up on a transient error. + * + * Mirrors the status→claude mapping in ./streamHandler.ts (getStreamErrorStatusMapping); + * duplicated deliberately rather than imported, because that module pulls in the usage + * DB and this helper runs on every streaming route. + */ +function anthropicErrorType(status: number, message: string): string { + if (status === 429) return "rate_limit_error"; + if (status === 401) return "authentication_error"; + if (status === 403) return "permission_error"; + if (status === 404) return "not_found_error"; + if (status === 413) return "request_too_large"; + if (status >= 400 && status < 500) return "invalid_request_error"; + // Provider capacity failures reach us as 502/503 (and 529 upstream); the message is + // the only discriminator when a gateway flattens them all onto 502. + if (status === 502 || status === 503 || status === 529) return "overloaded_error"; + if (looksLikeCapacityFailure(message)) return "overloaded_error"; + return "api_error"; +} + +/** True when the message reads as a transient provider capacity failure. */ +function looksLikeCapacityFailure(message: string): boolean { + return /overload|at capacity|capacity constraint|try again later/i.test(message); +} + +/** + * Maps an HTTP status onto a Responses API error code. Codex CLI reads the failure off + * the `response.failed` event's `error.code` / `error.status_code`, so a transient + * capacity failure must arrive with a code it recognizes as retryable rather than the + * generic `server_error` it treats as terminal. + */ +function responsesErrorCode(status: number, message: string): string { + if (status === 429) return "rate_limit_exceeded"; + if (status === 401) return "authentication_error"; + if (status === 403) return "permission_error"; + if (status === 404) return "not_found"; + if (status === 413) return "request_too_large"; + if (status >= 400 && status < 500) return "invalid_request_error"; + // Provider capacity failures reach us as 502/503 (529 upstream); when a gateway + // flattens them all onto 502 the message is the only discriminator left. + if (status === 502 || status === 503 || status === 529) return "server_overloaded"; + if (looksLikeCapacityFailure(message)) return "server_overloaded"; + return "server_error"; +} + +/** Best-effort extraction of a human-readable message from either error shape. */ +function readErrorMessage(bodyText: string): string { + const trimmed = bodyText.trim(); + if (!trimmed.startsWith("{")) return trimmed; + + try { + const parsed = JSON.parse(trimmed) as { error?: { message?: unknown }; message?: unknown }; + const nested = parsed?.error?.message; + if (typeof nested === "string" && nested.trim()) return nested.trim(); + if (typeof parsed?.message === "string" && parsed.message.trim()) return parsed.message.trim(); + } catch { + /* fall through — unparseable body */ + } + + return trimmed; +} + +/** + * Builds the `data:` payload for an in-band error frame in the format the route's + * clients actually parse. An OpenAI-shaped `{"error":{...}}` frame is silently ignored + * by both other families — an Anthropic client branches on `error.type`, and a Responses + * API client acts only on a `response.failed` envelope — so the wrong shape makes a + * /v1/messages or /v1/responses caller see the stream simply end instead of learning it + * failed. Pair this with {@link errorFrameEventName} for the matching event name. + */ +export function buildErrorFrameData( + bodyText: string, + status: number, + format: ErrorFrameFormat = "openai" +): string { + if (format === "openai") return annotateErrorFrameStatus(bodyText, status); + + const message = readErrorMessage(bodyText) || GENERIC_FAILURE_MESSAGE; + + if (format === "anthropic") { + return JSON.stringify({ + type: "error", + error: { type: anthropicErrorType(status, message), message, status }, + }); + } + + // Responses API envelope, mirroring the shape the codex executor already emits for + // upstream failures (open-sse/executors/codex.ts) so clients see one consistent + // failure event: the status travels in `status_code`, as it does there. + return JSON.stringify({ + type: "response.failed", + response: { + id: null, + status: "failed", + error: { + type: status >= 400 && status < 500 ? "invalid_request_error" : "server_error", + code: responsesErrorCode(status, message), + message, + status_code: status, + }, + }, + }); +} + +/** + * Annotates an in-band error payload with the HTTP status the handler wanted to + * return. Once the keepalive stream commits to 200 the status line is gone, so the + * only place a client can still learn "this was a 502, retry it" is the error frame + * itself. Without this, a transient upstream failure is indistinguishable from a + * terminal one and client retry logic keyed on the status never runs. + * + * The body is already sanitized by the handler; this only adds `error.status` (and + * never overwrites one the handler set). Non-JSON or unexpected shapes are passed + * through untouched — an unparseable body must still reach the client verbatim. + */ +export function annotateErrorFrameStatus(bodyText: string, status: number): string { + if (!Number.isInteger(status) || status < 400 || status > 599) return bodyText; + + const trimmed = bodyText.trim(); + if (!trimmed.startsWith("{")) return bodyText; + + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return bodyText; + + const error = (parsed as { error?: unknown }).error; + if (!error || typeof error !== "object" || Array.isArray(error)) return bodyText; + + if ((error as { status?: unknown }).status !== undefined) return bodyText; + + (error as { status?: number }).status = status; + return JSON.stringify(parsed); + } catch { + return bodyText; + } +} + export async function withEarlyStreamKeepalive( handlerPromise: Promise, options: EarlyStreamKeepaliveOptions = {} @@ -69,6 +241,20 @@ export async function withEarlyStreamKeepalive( const signal = options.signal ?? null; const keepaliveFrame = options.keepaliveFrame ?? KEEPALIVE_FRAME; const extraHeaders = options.extraHeaders ?? {}; + const errorFrameFormat = options.errorFrameFormat ?? "openai"; + const errorEventName = errorFrameEventName(errorFrameFormat); + // Handler-rejection frame: no upstream status exists, so report a generic 502 + // (never the raw error/stack) in the format this route's clients parse. + const genericErrorFrame = + errorFrameFormat === "openai" + ? ERROR_FRAME + : ENCODER.encode( + `event: ${errorEventName}\ndata: ${buildErrorFrameData( + GENERIC_FAILURE_MESSAGE, + 502, + errorFrameFormat + )}\n\n` + ); // Settle into a tagged result so neither race branch leaves an unhandled // rejection when the threshold timer wins. @@ -148,7 +334,7 @@ export async function withEarlyStreamKeepalive( if (!result.ok) { // Handler rejected — emit a generic error frame (never the raw error/stack). - controller.enqueue(ERROR_FRAME); + controller.enqueue(genericErrorFrame); } else { const response = result.response; const contentType = (response.headers.get("content-type") || "").toLowerCase(); @@ -174,7 +360,7 @@ export async function withEarlyStreamKeepalive( // the SSE stream. Silently close instead; the client will see // the stream end naturally. if (bytesForwarded === 0) { - controller.enqueue(ERROR_FRAME); + controller.enqueue(genericErrorFrame); } } } else { @@ -183,17 +369,19 @@ export async function withEarlyStreamKeepalive( // change. Frame the (already-sanitized) body as an in-band error event // instead of forwarding raw JSON, which would be malformed SSE. const text = response.body ? await response.text().catch(() => "") : ""; - const dataLine = - text.trim() || - JSON.stringify({ error: { message: "stream_error", type: "stream_error" } }); - controller.enqueue(ENCODER.encode(`event: error\ndata: ${dataLine}\n\n`)); + const dataLine = buildErrorFrameData( + text.trim() || "stream_error", + response.status, + errorFrameFormat + ); + controller.enqueue(ENCODER.encode(`event: ${errorEventName}\ndata: ${dataLine}\n\n`)); } } } catch { // Defensive: never surface a raw error/stack to the client. if (!aborted) { try { - controller.enqueue(ERROR_FRAME); + controller.enqueue(genericErrorFrame); } catch { /* consumer gone */ } diff --git a/open-sse/utils/keepaliveThreshold.ts b/open-sse/utils/keepaliveThreshold.ts index 9dd6d3293870..4c82d9a58fdb 100644 --- a/open-sse/utils/keepaliveThreshold.ts +++ b/open-sse/utils/keepaliveThreshold.ts @@ -17,8 +17,36 @@ import { APIKEY_PROVIDERS } from "@/shared/constants/providers"; import { WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers"; import { WEB_SESSION_CREDENTIAL_REQUIREMENTS } from "@/shared/providers/webSessionCredentials"; -const DEFAULT_THRESHOLD_MS = 2_000; +// The only hard ceiling here is the strictest client idle-read timeout we must beat +// (Codex CLI's reqwest, ~5 s). Everything below that is a tradeoff, and it is not a +// latency tradeoff — it decides whether an upstream failure reaches the client as a +// real HTTP status or as an in-band `event: error` inside an already-committed 200. +// A handler that fails after the threshold can no longer set a status, so client +// retry logic keyed on 429/5xx never fires. 4 s keeps a 1 s safety margin under the +// reqwest timeout while giving the handler (routing + combo failover + upstream call) +// twice as long to produce a real status. +const DEFAULT_THRESHOLD_MS = 4_000; const SLOW_THRESHOLD_MS = 15_000; +const THRESHOLD_ENV_VAR = "OMNIROUTE_KEEPALIVE_THRESHOLD_MS"; + +/** + * Reads the default threshold from {@link THRESHOLD_ENV_VAR} so a deployment can + * retune (or revert to the historical 2000) without a rebuild. Invalid or + * out-of-range values fall back to {@link DEFAULT_THRESHOLD_MS}; the upper bound + * stays under the reqwest idle-read timeout the keepalive exists to beat. + */ +function resolveDefaultThresholdMs(): number { + const raw = process.env[THRESHOLD_ENV_VAR]; + if (typeof raw !== "string" || raw.trim() === "") return DEFAULT_THRESHOLD_MS; + + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return DEFAULT_THRESHOLD_MS; + + const rounded = Math.trunc(parsed); + if (rounded < 0 || rounded > 4_500) return DEFAULT_THRESHOLD_MS; + + return rounded; +} const SLOW_PROVIDER_IDS: Set = new Set(); @@ -50,13 +78,14 @@ for (const id of Object.keys(WEB_SESSION_CREDENTIAL_REQUIREMENTS)) { export const SLOW_KEEPALIVE_PROVIDERS: ReadonlySet = SLOW_PROVIDER_IDS; export function resolveKeepaliveThreshold(model: string | undefined | null): number { - if (!model || typeof model !== "string") return DEFAULT_THRESHOLD_MS; + const defaultThresholdMs = resolveDefaultThresholdMs(); + if (!model || typeof model !== "string") return defaultThresholdMs; const slashIndex = model.indexOf("/"); - if (slashIndex <= 0) return DEFAULT_THRESHOLD_MS; + if (slashIndex <= 0) return defaultThresholdMs; const prefix = model.slice(0, slashIndex); if (SLOW_PROVIDER_IDS.has(prefix)) return SLOW_THRESHOLD_MS; - return DEFAULT_THRESHOLD_MS; + return defaultThresholdMs; } diff --git a/open-sse/utils/passthroughTailProcessor.ts b/open-sse/utils/passthroughTailProcessor.ts index 82b57da8aab6..845bc6e3528e 100644 --- a/open-sse/utils/passthroughTailProcessor.ts +++ b/open-sse/utils/passthroughTailProcessor.ts @@ -37,7 +37,6 @@ export type PassthroughTailProcessorContext = { appendPassthroughReasoning: (value: string) => void; getResponsesReasoningKey: (payload: Record) => string | null; markResponsesReasoningSummarySeen: (key: string) => void; - ensureVisibleResponsesReasoningSummary: (payload: Record) => boolean; emitSyntheticResponsesReasoningSummary: (payload: Record) => void; passthroughResponsesOutputItems: unknown[]; passthroughResponsesPendingFunctionCalls: Map; @@ -136,12 +135,8 @@ function handleResponsesTailPayload( } } if (parsed.type === "response.output_item.done" && parsed.item) { - const reasoningSummaryInjected = context.ensureVisibleResponsesReasoningSummary(parsed); context.emitSyntheticResponsesReasoningSummary(parsed); pushUniqueResponsesOutputItems(context.passthroughResponsesOutputItems, [parsed.item]); - if (reasoningSummaryInjected) { - output = `data: ${JSON.stringify(parsed)}\n\n`; - } const item = asRecord(parsed.item); if (item.type === "function_call") { const pendingKey = getFunctionCallPendingKey(item); diff --git a/open-sse/utils/requestLogger.ts b/open-sse/utils/requestLogger.ts index 560c65a1a68a..d3c8415bb71d 100644 --- a/open-sse/utils/requestLogger.ts +++ b/open-sse/utils/requestLogger.ts @@ -77,7 +77,11 @@ function maskSensitiveHeaders(headers: HeaderInput): Record { if (lowerKey.startsWith("x-ratelimit-")) { continue; } - if (!sensitiveKeys.some((candidate) => lowerKey.includes(candidate))) { + const compactKey = lowerKey.replace(/[^a-z0-9]/g, ""); + if ( + !compactKey.includes("apikey") && + !sensitiveKeys.some((candidate) => lowerKey.includes(candidate)) + ) { continue; } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 7ec442163e8f..ba23b25ad97b 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -1021,34 +1021,6 @@ export function createSSEStream(options: StreamOptions = {}) { : ""; }; - const ensureVisibleResponsesReasoningSummary = (payload: Record): boolean => { - const item = - payload.item && typeof payload.item === "object" && !Array.isArray(payload.item) - ? (payload.item as Record) - : null; - if (!item || item.type !== "reasoning") { - return false; - } - - if (getResponsesReasoningSummaryText(item)) { - return false; - } - - const hasEncryptedReasoning = - typeof item.encrypted_content === "string" && item.encrypted_content.length > 0; - if (!hasEncryptedReasoning) { - return false; - } - - item.summary = [ - { - type: "summary_text", - text: "Codex is reasoning, but the upstream Responses API exposed this reasoning block only as encrypted state. OmniRoute cannot recover the private reasoning text.", - }, - ]; - return true; - }; - const emitSyntheticResponsesReasoningSummary = ( controller: TransformStreamDefaultController, payload: Record @@ -1061,7 +1033,6 @@ export function createSSEStream(options: StreamOptions = {}) { return; } - ensureVisibleResponsesReasoningSummary(payload); const visibleSummary = getResponsesReasoningSummaryText(item); if (!visibleSummary) { @@ -1485,13 +1456,8 @@ export function createSSEStream(options: StreamOptions = {}) { // response.completed snapshot can be backfilled when upstream // returns an empty `output` (happens with store: false). if (parsed.type === "response.output_item.done" && parsed.item) { - const reasoningSummaryInjected = ensureVisibleResponsesReasoningSummary(parsed); emitSyntheticResponsesReasoningSummary(controller, parsed); pushUniqueResponsesOutputItems(passthroughResponsesOutputItems, [parsed.item]); - if (reasoningSummaryInjected) { - output = `data: ${JSON.stringify(parsed)}\n\n`; - injectedUsage = true; - } if (parsed.item?.type === "function_call") { const pendingKey = typeof parsed.item.id === "string" @@ -2181,7 +2147,6 @@ export function createSSEStream(options: StreamOptions = {}) { markResponsesReasoningSummarySeen: (key: string) => { passthroughResponsesReasoningSummarySeen.add(key); }, - ensureVisibleResponsesReasoningSummary, emitSyntheticResponsesReasoningSummary: (payload: Record) => emitSyntheticResponsesReasoningSummary(controller, payload), passthroughResponsesOutputItems, diff --git a/open-sse/utils/streamErrorStatus.ts b/open-sse/utils/streamErrorStatus.ts new file mode 100644 index 000000000000..141d771de23b --- /dev/null +++ b/open-sse/utils/streamErrorStatus.ts @@ -0,0 +1,54 @@ +type StreamErrorStatusKind = "rate_limit" | "authentication" | "permission" | "client" | "server"; + +type StreamErrorStatusMapping = { + responses: { + type: string; + code: string; + }; + claude: { + type: string; + }; +}; + +function getStreamErrorStatusKind(statusCode: number): StreamErrorStatusKind { + if (statusCode === 429) return "rate_limit"; + if (statusCode === 401) return "authentication"; + if (statusCode === 403) return "permission"; + if (statusCode >= 400 && statusCode < 500) return "client"; + return "server"; +} + +export function getStreamErrorStatusMapping(statusCode: number): StreamErrorStatusMapping { + switch (getStreamErrorStatusKind(statusCode)) { + case "rate_limit": + return { + responses: { type: "rate_limit_error", code: "rate_limit_exceeded" }, + claude: { type: "rate_limit_error" }, + }; + case "authentication": + return { + responses: { type: "authentication_error", code: "invalid_authentication" }, + claude: { type: "authentication_error" }, + }; + case "permission": + return { + responses: { type: "authentication_error", code: "permission_denied" }, + claude: { type: "permission_error" }, + }; + case "client": + return { + responses: { type: "invalid_request_error", code: "bad_request" }, + claude: { type: "invalid_request_error" }, + }; + case "server": + return { + responses: { type: "server_error", code: "server_error" }, + claude: { type: "api_error" }, + }; + default: + return { + responses: { type: "server_error", code: "server_error" }, + claude: { type: "api_error" }, + }; + } +} diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index 720f361708b1..4848184de188 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -1,7 +1,9 @@ +import { getStreamErrorStatusMapping } from "./streamErrorStatus.ts"; import { trackPendingRequest } from "@/lib/usageDb"; import { STREAM_IDLE_TIMEOUT_MS } from "../config/constants.ts"; import { FORMATS } from "../translator/formats.ts"; import { PENDING_REQUEST_CLEARED_MARKER } from "./stream.ts"; +import { createStreamTerminalGate } from "./streamTerminalGuard.ts"; // Stream handler with disconnect detection - shared for all providers @@ -35,22 +37,15 @@ type StreamControllerOptions = { connectionId?: string | null; clientResponseFormat?: string | null; clientAbortSignal?: AbortSignal | null; + /** + * Request-scoped orchestration cancellation (for example, a combo target timeout). + * Unlike clientAbortSignal this aborts provider work without recording a client disconnect. + */ + upstreamAbortSignal?: AbortSignal | null; }; type StreamController = ReturnType; -type StreamErrorStatusKind = "rate_limit" | "authentication" | "permission" | "client" | "server"; - -type StreamErrorStatusMapping = { - responses: { - type: string; - code: string; - }; - claude: { - type: string; - }; -}; - function isResponsesClientFormat(clientResponseFormat?: string | null): boolean { return ( clientResponseFormat === FORMATS.OPENAI_RESPONSES || @@ -58,49 +53,6 @@ function isResponsesClientFormat(clientResponseFormat?: string | null): boolean ); } -function getStreamErrorStatusKind(statusCode: number): StreamErrorStatusKind { - if (statusCode === 429) return "rate_limit"; - if (statusCode === 401) return "authentication"; - if (statusCode === 403) return "permission"; - if (statusCode >= 400 && statusCode < 500) return "client"; - return "server"; -} - -function getStreamErrorStatusMapping(statusCode: number): StreamErrorStatusMapping { - switch (getStreamErrorStatusKind(statusCode)) { - case "rate_limit": - return { - responses: { type: "rate_limit_error", code: "rate_limit_exceeded" }, - claude: { type: "rate_limit_error" }, - }; - case "authentication": - return { - responses: { type: "authentication_error", code: "invalid_authentication" }, - claude: { type: "authentication_error" }, - }; - case "permission": - return { - responses: { type: "authentication_error", code: "permission_denied" }, - claude: { type: "permission_error" }, - }; - case "client": - return { - responses: { type: "invalid_request_error", code: "bad_request" }, - claude: { type: "invalid_request_error" }, - }; - case "server": - return { - responses: { type: "server_error", code: "server_error" }, - claude: { type: "api_error" }, - }; - default: - return { - responses: { type: "server_error", code: "server_error" }, - claude: { type: "api_error" }, - }; - } -} - function encodeSseEvent( data: unknown, { @@ -176,6 +128,13 @@ function getErrorStatusCode(error: unknown): number { return 502; } +/** + * Trailing characters carried between client chunks so a terminal marker split + * across a chunk boundary is still matched. Only needs to cover the longest + * marker literal plus its leading newline. + */ +const TERMINAL_CARRY_CHARS = 256; + function hasClientTerminalSseMarker(text: string, clientResponseFormat?: string | null): boolean { if (/(?:^|\r?\n)data:\s*\[DONE\]\s*(?:\r?\n|$)/.test(text)) { return true; @@ -184,7 +143,8 @@ function hasClientTerminalSseMarker(text: string, clientResponseFormat?: string if (isResponsesClientFormat(clientResponseFormat)) { return ( /(?:^|\r?\n)event:\s*response\.completed\s*(?:\r?\n|$)/.test(text) || - /"type"\s*:\s*"response\.completed"/.test(text) + /(?:^|\r?\n)event:\s*response\.incomplete\s*(?:\r?\n|$)/.test(text) || + /"type"\s*:\s*"response\.(?:completed|incomplete)"/.test(text) ); } @@ -198,6 +158,20 @@ function hasClientTerminalSseMarker(text: string, clientResponseFormat?: string return false; } +function isClientTerminalSseFrame(frame: string, clientResponseFormat?: string | null): boolean { + if (hasClientTerminalSseMarker(frame, clientResponseFormat)) return true; + + // Some clients stop reading as soon as the completion reason arrives, before + // the trailing [DONE]/message_stop frame. Inspect that frame before it is + // published so a contentless "stop" cannot masquerade as success (#8649). + if (clientResponseFormat === FORMATS.CLAUDE) { + return /"stop_reason"\s*:\s*"[^"]+"/.test(frame); + } + + const finishReason = frame.match(/"finish_reason"\s*:\s*"([^"]+)"/)?.[1]; + return !!finishReason && finishReason !== "error"; +} + /** * Create stream controller with abort and disconnect detection * @param {object} options @@ -215,6 +189,7 @@ export function createStreamController({ connectionId, clientResponseFormat, clientAbortSignal, + upstreamAbortSignal, }: StreamControllerOptions = {}) { const abortController = new AbortController(); const startTime = Date.now(); @@ -222,6 +197,7 @@ export function createStreamController({ let clientTerminalSeen = false; let pendingRequestCleared = false; let cleanupClientAbortSignal: (() => void) | null = null; + let cleanupUpstreamAbortSignal: (() => void) | null = null; const logStream = (status) => { const duration = Date.now() - startTime; @@ -255,6 +231,17 @@ export function createStreamController({ cleanupClientAbortSignal = null; }; + const cleanupUpstreamAbortListener = () => { + if (!cleanupUpstreamAbortSignal) return; + cleanupUpstreamAbortSignal(); + cleanupUpstreamAbortSignal = null; + }; + + const cleanupAbortListeners = () => { + cleanupClientAbortListener(); + cleanupUpstreamAbortListener(); + }; + const getClientAbortReason = () => { const reason = clientAbortSignal?.reason; if (typeof reason === "string" && reason.trim().length > 0) { @@ -280,7 +267,7 @@ export function createStreamController({ return; } disconnected = true; - cleanupClientAbortListener(); + cleanupAbortListeners(); logStream(`disconnect: ${reason}`); @@ -297,7 +284,7 @@ export function createStreamController({ handleComplete: () => { if (disconnected) return; disconnected = true; - cleanupClientAbortListener(); + cleanupAbortListeners(); logStream("complete"); }, @@ -308,7 +295,7 @@ export function createStreamController({ // Call on error handleError: (error: unknown) => { - cleanupClientAbortListener(); + cleanupAbortListeners(); // A client disconnect is not a provider failure. If the client already went away // (disconnected) or the error is a client abort / "Controller is already closed", @@ -352,9 +339,9 @@ export function createStreamController({ logStream("error: unknown"); }, - abort: () => { - cleanupClientAbortListener(); - abortController.abort(); + abort: (reason?: unknown) => { + cleanupAbortListeners(); + abortController.abort(reason); }, clientResponseFormat, }; @@ -373,6 +360,20 @@ export function createStreamController({ } } + if (upstreamAbortSignal && typeof upstreamAbortSignal.addEventListener === "function") { + const handleUpstreamAbort = () => { + controller.abort(upstreamAbortSignal.reason); + }; + if (upstreamAbortSignal.aborted) { + queueMicrotask(handleUpstreamAbort); + } else { + upstreamAbortSignal.addEventListener("abort", handleUpstreamAbort, { once: true }); + cleanupUpstreamAbortSignal = () => { + upstreamAbortSignal.removeEventListener("abort", handleUpstreamAbort); + }; + } + } + return controller; } @@ -455,26 +456,69 @@ export function createDisconnectAwareStream(transformStream, streamController) { const reader = transformStream.readable.getReader(); const writer = transformStream.writable.getWriter(); const terminalDecoder = new TextDecoder(); - let terminalTail = ""; + let terminalCarry = ""; let clientTerminalSeen = false; + let emptyTerminalErrorEmitted = false; + const terminalGate = createStreamTerminalGate((frame) => + isClientTerminalSseFrame(frame, streamController.clientResponseFormat) + ); const noteClientChunk = (chunk: unknown) => { if (clientTerminalSeen) return; if (!(chunk instanceof Uint8Array)) return; - terminalTail += terminalDecoder.decode(chunk, { stream: true }); - if (terminalTail.length > 4096) { - terminalTail = terminalTail.slice(-4096); - } - clientTerminalSeen = hasClientTerminalSseMarker( - terminalTail, - streamController.clientResponseFormat - ); + // Match against the carry-over plus the *whole* decoded chunk. Clamping the + // scanned text to a fixed window before matching dropped the marker whenever + // the terminal SSE event's own payload was larger than that window — every + // Codex CLI turn with real output — so a fully delivered stream was + // finalized as a 499 client disconnect with zero token usage. + const text = terminalCarry + terminalDecoder.decode(chunk, { stream: true }); + clientTerminalSeen = hasClientTerminalSseMarker(text, streamController.clientResponseFormat); + // Keep just enough trailing text for a marker split across a chunk boundary + // to still match once the next chunk arrives. + terminalCarry = text.length > TERMINAL_CARRY_CHARS ? text.slice(-TERMINAL_CARRY_CHARS) : text; if (clientTerminalSeen) { streamController.markClientTerminalSeen?.(); } }; + const forwardChunk = ( + controller: ReadableStreamDefaultController, + chunk: Uint8Array + ) => { + controller.enqueue(chunk); + noteClientChunk(chunk); + }; + + const emitEmptyContentError = (controller: ReadableStreamDefaultController): void => { + if (emptyTerminalErrorEmitted) return; + emptyTerminalErrorEmitted = true; + const message = "Provider returned empty content"; + const error = Object.assign(new Error(message), { statusCode: 502 }); + streamController.handleError(error); + for (const chunk of buildStreamErrorChunks( + message, + 502, + streamController.clientResponseFormat + )) { + controller.enqueue(chunk); + } + clientTerminalSeen = true; + streamController.markClientTerminalSeen?.(); + }; + + const enqueueGateResult = ( + controller: ReadableStreamDefaultController, + result: ReturnType + ): { emitted: boolean; stopped: boolean } => { + for (const chunk of result.chunks) forwardChunk(controller, chunk); + if (result.emptyTerminal) { + emitEmptyContentError(controller); + return { emitted: true, stopped: true }; + } + return { emitted: result.chunks.length > 0, stopped: false }; + }; + return new ReadableStream( { async pull(controller) { @@ -484,14 +528,44 @@ export function createDisconnectAwareStream(transformStream, streamController) { } try { - const { done, value } = await reader.read(); - if (done) { - streamController.handleComplete(); - controller.close(); - return; + // Keep reading until at least one complete SSE frame can be emitted. + // Returning from pull() after buffering only a split frame can leave + // consumers waiting forever because no enqueue happened to schedule + // another pull. + for (;;) { + const { done, value } = await reader.read(); + if (done) { + const gated = enqueueGateResult(controller, terminalGate.finish()); + if (gated.stopped) { + try { + controller.close(); + } catch {} + return; + } + if (!emptyTerminalErrorEmitted) { + streamController.handleComplete(); + } + try { + controller.close(); + } catch {} + return; + } + + if (!(value instanceof Uint8Array)) continue; + const gated = enqueueGateResult(controller, terminalGate.note(value)); + if (gated.stopped) { + try { + controller.close(); + } catch {} + void Promise.allSettled([ + reader.cancel("empty_content_terminal"), + writer.abort("empty_content_terminal"), + ]); + return; + } + + if (gated.emitted) return; } - controller.enqueue(value); - noteClientChunk(value); } catch (error) { if (!streamController.isConnected()) { try { diff --git a/open-sse/utils/streamReadiness.ts b/open-sse/utils/streamReadiness.ts index 0ee108eb6b9f..1e5730c5194e 100644 --- a/open-sse/utils/streamReadiness.ts +++ b/open-sse/utils/streamReadiness.ts @@ -25,9 +25,12 @@ function hasUsefulValue(value: unknown): boolean { for (const key of [ "content", "text", + "output_text", "delta", "reasoning_content", "reasoning", + "summary", + "encrypted_content", // Mistral/Magistral thinking arrays and StepFun/OpenRouter reasoning_details are // valid model output — without these a reasoning-only stream was misclassified as // "no useful content" and turned into a spurious 502 (#2520). @@ -54,6 +57,7 @@ function hasUsefulValue(value: unknown): boolean { "function_call", "function_call_output", "output", + "item", "content_block", "response", "choices", @@ -111,6 +115,129 @@ export function hasUsefulStreamContent(text: string): boolean { return false; } +const LEGIT_EMPTY_TERMINAL_REASONS = new Set([ + "length", + "tool_calls", + "content_filter", + "max_tokens", + "max_output_tokens", + "tool_use", +]); + +const TERMINAL_REASON_PATTERN = /"(?:finish_reason|stop_reason)"\s*:\s*"([^"]+)"/g; +const INCOMPLETE_REASON_PATTERN = /"incomplete_details"\s*:\s*\{[^{}]*"reason"\s*:\s*"([^"]+)"/g; +const SSE_FIELD_LINE = /(?:^|\r?\n)\s*(?:data|event):/; + +function isSubstantiveErrorValue(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim().length > 0; + if (typeof value === "object" && !Array.isArray(value)) { + const record = value as Record; + if (hasNonEmptyString(record.message)) return true; + return Object.keys(record).length > 0; + } + return value === true; +} + +function dataLineHasStructuredStreamError(line: string, eventType: string): boolean { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) return false; + const data = trimmed.slice(5).trim(); + if (!data || data === "[DONE]") return false; + + try { + const parsed: unknown = JSON.parse(data); + if (!isRecord(parsed)) return false; + const type = getPayloadType(parsed, eventType); + if (type === "error" || type === "response.failed" || eventType === "response.failed") { + return true; + } + if (isSubstantiveErrorValue(parsed.error)) return true; + const response = isRecord(parsed.response) ? parsed.response : null; + return response?.status === "failed" && response.error != null; + } catch { + return false; + } +} + +export function frameHasStructuredStreamError(frame: string): boolean { + const lines = frame.split(/\r?\n/); + const eventLine = lines.find((line) => line.trim().startsWith("event:")); + const eventType = eventLine?.trim().slice(6).trim() ?? ""; + if (/^error$/i.test(eventType)) return true; + return lines.some((line) => dataLineHasStructuredStreamError(line, eventType)); +} + +export type StreamContentWatcher = { + note: (text: string) => void; + finish: () => void; + sawContent: () => boolean; + sawLegitEmptyTerminal: () => boolean; + sawSseFrame: () => boolean; + sawError: () => boolean; +}; + +/** + * Incrementally classifies client-facing SSE without confusing lifecycle and + * keepalive frames with model output. Complete frames are inspected together, + * so JSON split across transport chunks remains valid. + */ +export function createStreamContentWatcher(): StreamContentWatcher { + const MAX_BUFFERED = 64 * 1024; + let pending = ""; + let content = false; + let legitEmpty = false; + let sse = false; + let error = false; + + const inspect = (frame: string): void => { + if (!frame) return; + if (!sse && SSE_FIELD_LINE.test(frame)) sse = true; + if (!error && frameHasStructuredStreamError(frame)) error = true; + if (!content && hasUsefulStreamContent(frame)) content = true; + if (legitEmpty) return; + for (const match of frame.matchAll(TERMINAL_REASON_PATTERN)) { + if (LEGIT_EMPTY_TERMINAL_REASONS.has(match[1])) { + legitEmpty = true; + return; + } + } + for (const match of frame.matchAll(INCOMPLETE_REASON_PATTERN)) { + if (LEGIT_EMPTY_TERMINAL_REASONS.has(match[1])) { + legitEmpty = true; + return; + } + } + }; + + return { + note(text: string): void { + if (!text) return; + pending += text; + for (;;) { + const boundary = pending.search(/\r?\n\r?\n/); + if (boundary === -1) break; + const separator = pending.slice(boundary).match(/^\r?\n\r?\n/)?.[0] ?? "\n\n"; + const frameEnd = boundary + separator.length; + inspect(pending.slice(0, frameEnd)); + pending = pending.slice(frameEnd); + } + if (pending.length > MAX_BUFFERED) { + inspect(pending); + pending = ""; + } + }, + finish(): void { + inspect(pending); + pending = ""; + }, + sawContent: () => content, + sawLegitEmptyTerminal: () => legitEmpty, + sawSseFrame: () => sse, + sawError: () => error, + }; +} + type StreamReadinessSignalState = { currentEvent: string; dataLines: string[]; diff --git a/open-sse/utils/streamTerminalGuard.ts b/open-sse/utils/streamTerminalGuard.ts new file mode 100644 index 000000000000..8441c09402bd --- /dev/null +++ b/open-sse/utils/streamTerminalGuard.ts @@ -0,0 +1,104 @@ +import { createStreamContentWatcher } from "./streamReadiness.ts"; + +export type StreamTerminalGateResult = { + chunks: Uint8Array[]; + emptyTerminal: boolean; +}; + +/** + * Bounded client-facing SSE frame gate. Complete non-terminal frames pass + * immediately; a success terminal is held just long enough to prove that the + * stream produced content, a tool/reasoning result, or a legitimate empty end. + */ +export function createStreamTerminalGate(isTerminalFrame: (frame: string) => boolean): { + note: (chunk: Uint8Array) => StreamTerminalGateResult; + finish: () => StreamTerminalGateResult; +} { + const MAX_PENDING_SSE_FRAME_CHARS = 1024 * 1024; + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const watcher = createStreamContentWatcher(); + let pending = ""; + let disabled = false; + let stopped = false; + let validated = false; + + const shouldReplaceTerminal = (): boolean => + watcher.sawSseFrame() && + !watcher.sawContent() && + !watcher.sawLegitEmptyTerminal() && + !watcher.sawError(); + + const processFrame = (frame: string, chunks: Uint8Array[]): boolean => { + watcher.note(frame); + if (isTerminalFrame(frame) && shouldReplaceTerminal()) { + pending = ""; + stopped = true; + return false; + } + validated = watcher.sawContent() || watcher.sawLegitEmptyTerminal() || watcher.sawError(); + chunks.push(encoder.encode(frame)); + return true; + }; + + const drainCompleteFrames = (chunks: Uint8Array[]): boolean => { + for (;;) { + const boundary = pending.search(/\r?\n\r?\n/); + if (boundary === -1) return true; + const separator = pending.slice(boundary).match(/^\r?\n\r?\n/)?.[0] ?? "\n\n"; + const frameEnd = boundary + separator.length; + const frame = pending.slice(0, frameEnd); + pending = pending.slice(frameEnd); + if (!processFrame(frame, chunks)) return false; + } + }; + + const forwardText = (text: string, chunks: Uint8Array[]): void => { + if (text) chunks.push(encoder.encode(text)); + }; + + const process = (text: string, flush: boolean): StreamTerminalGateResult => { + const chunks: Uint8Array[] = []; + if (stopped) return { chunks, emptyTerminal: true }; + + if (disabled || validated) { + forwardText(text, chunks); + return { chunks, emptyTerminal: false }; + } + + pending += text; + if (!drainCompleteFrames(chunks)) return { chunks, emptyTerminal: true }; + if (validated) { + forwardText(pending, chunks); + pending = ""; + return { chunks, emptyTerminal: false }; + } + + if (flush && pending) { + const frame = pending; + pending = ""; + if (!processFrame(frame, chunks)) return { chunks, emptyTerminal: true }; + } else if (pending.length > MAX_PENDING_SSE_FRAME_CHARS) { + // Fail open above the cap: compatibility with unusually large provider + // frames is safer than buffering attacker-controlled input indefinitely. + disabled = true; + watcher.note(pending); + chunks.push(encoder.encode(pending)); + pending = ""; + } + + if (flush) { + watcher.finish(); + if (shouldReplaceTerminal()) { + stopped = true; + return { chunks, emptyTerminal: true }; + } + } + return { chunks, emptyTerminal: false }; + }; + + return { + note: (chunk) => process(decoder.decode(chunk, { stream: true }), false), + finish: () => process(decoder.decode(), true), + }; +} diff --git a/package-lock.json b/package-lock.json index ba6fe54b8b6f..d23286821616 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,14 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@grpc/grpc-js": "^1.14.4", + "@grpc/proto-loader": "^0.8.1", "@lobehub/icons": "^5.8.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", - "@toon-format/toon": "^2.3.0", + "@toon-format/toon": "^2.3.1", "@types/mdx": "^2.0.13", "@xyflow/react": "^12.11.1", "axios": "^1.16.1", @@ -31,7 +33,7 @@ "clsx": "^2.1.1", "commander": "^15.0.0", "csv-stringify": "^6.7.0", - "dompurify": "^3.4.11", + "dompurify": "^3.4.15", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", @@ -45,16 +47,16 @@ "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", "jose": "^6.2.3", - "js-yaml": "^5.0.0", + "js-yaml": "^5.4.1", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", "lucide-react": "^1.21.0", "marked": "^18.0.4", "marked-terminal": "^7.3.0", "material-symbols": "^0.45.2", - "mermaid": "^11.15.0", + "mermaid": "^11.16.1", "monaco-editor": "^0.55.1", - "next": "^16.2.6", + "next": "~16.3.4", "next-intl": "^4.12.0", "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", @@ -79,7 +81,7 @@ "sqlite-vec": "^0.1.9", "tailwind-merge": "^3.6.0", "tsx": "^4.23.0", - "undici": "^8.3.0", + "undici": "^8.10.2", "update-notifier": "^7.3.1", "uuid": "^14.0.0", "ws": "^8.18.0", @@ -116,7 +118,7 @@ "cross-env": "^10.1.0", "dpdm": "^4.2.0", "eslint": "^9.39.4", - "eslint-config-next": "16.2.10", + "eslint-config-next": "16.2.12", "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", "glob": "^13.0.6", @@ -1485,6 +1487,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2408,6 +2411,43 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -2463,9 +2503,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -2577,9 +2617,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -2589,19 +2629,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -2611,19 +2651,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -2637,9 +2696,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -2653,12 +2712,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2669,12 +2731,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2685,12 +2750,15 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2701,12 +2769,15 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2717,12 +2788,15 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2733,12 +2807,15 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2749,12 +2826,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2765,12 +2845,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2781,204 +2864,254 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -2988,16 +3121,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -3007,16 +3140,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -3026,7 +3159,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -3537,6 +3670,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@jscpd/badge-reporter": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/@jscpd/badge-reporter/-/badge-reporter-4.2.5.tgz", @@ -3713,9 +3856,9 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", - "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", "license": "MIT", "dependencies": { "@chevrotain/types": "~11.1.2" @@ -3804,15 +3947,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz", - "integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", + "integrity": "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz", - "integrity": "sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.12.tgz", + "integrity": "sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==", "dev": true, "license": "MIT", "dependencies": { @@ -3820,9 +3963,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz", - "integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.4.tgz", + "integrity": "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==", "cpu": [ "arm64" ], @@ -3836,9 +3979,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz", - "integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.4.tgz", + "integrity": "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==", "cpu": [ "x64" ], @@ -3852,9 +3995,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz", - "integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.4.tgz", + "integrity": "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==", "cpu": [ "arm64" ], @@ -3871,9 +4014,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz", - "integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.4.tgz", + "integrity": "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==", "cpu": [ "arm64" ], @@ -3890,9 +4033,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz", - "integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.4.tgz", + "integrity": "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==", "cpu": [ "x64" ], @@ -3909,9 +4052,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz", - "integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.4.tgz", + "integrity": "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==", "cpu": [ "x64" ], @@ -3928,9 +4071,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz", - "integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.4.tgz", + "integrity": "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==", "cpu": [ "arm64" ], @@ -3944,9 +4087,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz", - "integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.4.tgz", + "integrity": "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==", "cpu": [ "x64" ], @@ -6327,36 +6470,31 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", - "optional": true, "dependencies": { "@protobufjs/aspromise": "^1.1.1" } @@ -6365,29 +6503,25 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/@radix-ui/number": { "version": "1.1.2", @@ -8957,9 +9091,9 @@ } }, "node_modules/@toon-format/toon": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.3.0.tgz", - "integrity": "sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.3.1.tgz", + "integrity": "sha512-sWqEMeAJGMda9qRrfPKrX3C4c33CO9SJuvJzzrcBEn5sbDB+k6pSLkDsfN0rVnLQwR97MV3sDgVbcxQNsq8xVw==", "license": "MIT" }, "node_modules/@tufjs/canonical-json": { @@ -9419,7 +9553,6 @@ "version": "26.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -11142,20 +11275,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -11165,6 +11298,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -12027,7 +12173,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -12042,14 +12187,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/cliui/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12059,7 +12202,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -12074,7 +12216,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -12087,7 +12228,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -12620,9 +12760,9 @@ "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.33.3", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", - "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", + "version": "3.34.2", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.2.tgz", + "integrity": "sha512-Cm2jaj1X/PBNlzV9yH8zcfGOxO7U+CJ/+mxSBVPSchLaugdp4jtlGx5qaHtPRZ6tgiZ5P+o1XoRfJA+ba6KM3g==", "license": "MIT", "engines": { "node": ">=0.10" @@ -13224,9 +13364,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debug": { @@ -13507,9 +13647,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -14204,13 +14344,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz", - "integrity": "sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.12.tgz", + "integrity": "sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.10", + "@next/eslint-plugin-next": "16.2.12", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -15123,9 +15263,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -15148,6 +15288,15 @@ "fast-string-width": "^3.0.2" } }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -16481,9 +16630,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -17189,9 +17338,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", "license": "MIT", "engines": { "node": ">= 12" @@ -18067,9 +18216,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", - "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", + "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", "funding": [ { "type": "github", @@ -18351,9 +18500,9 @@ "license": "MIT" }, "node_modules/katex": { - "version": "0.16.45", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", - "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -19432,6 +19581,12 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -20134,26 +20289,27 @@ } }, "node_modules/mermaid": { - "version": "11.16.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", - "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "version": "11.17.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", + "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.2.0", + "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.3", + "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.20", + "dayjs": "^1.11.21", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", - "katex": "^0.16.45", + "fastdom": "1.0.12", + "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", @@ -21323,9 +21479,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -21422,16 +21578,16 @@ } }, "node_modules/next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz", - "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.4.tgz", + "integrity": "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==", "license": "MIT", "dependencies": { - "@next/env": "16.2.10", - "@swc/helpers": "0.5.15", + "@next/env": "16.3.4", + "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", + "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "bin": { @@ -21441,15 +21597,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.10", - "@next/swc-darwin-x64": "16.2.10", - "@next/swc-linux-arm64-gnu": "16.2.10", - "@next/swc-linux-arm64-musl": "16.2.10", - "@next/swc-linux-x64-gnu": "16.2.10", - "@next/swc-linux-x64-musl": "16.2.10", - "@next/swc-win32-arm64-msvc": "16.2.10", - "@next/swc-win32-x64-msvc": "16.2.10", - "sharp": "^0.34.5" + "@next/swc-darwin-arm64": "16.3.4", + "@next/swc-darwin-x64": "16.3.4", + "@next/swc-linux-arm64-gnu": "16.3.4", + "@next/swc-linux-arm64-musl": "16.3.4", + "@next/swc-linux-x64-gnu": "16.3.4", + "@next/swc-linux-x64-musl": "16.3.4", + "@next/swc-win32-arm64-msvc": "16.3.4", + "@next/swc-win32-x64-msvc": "16.3.4", + "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -21521,15 +21677,6 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/next/node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/node-abi": { "version": "3.89.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", @@ -23014,9 +23161,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -23033,7 +23180,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -23264,12 +23411,11 @@ "license": "ISC" }, "node_modules/protobufjs": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", - "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", "hasInstallScript": true, "license": "BSD-3-Clause", - "optional": true, "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -23291,8 +23437,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0", - "optional": true + "license": "Apache-2.0" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -23540,12 +23685,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -24888,54 +25034,59 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { @@ -24999,14 +25150,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -25018,13 +25169,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -25658,6 +25809,12 @@ "node": ">= 0.4" } }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -26156,9 +26313,9 @@ } }, "node_modules/tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "devOptional": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -26774,17 +26931,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typed-array-buffer": { @@ -26973,9 +27147,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.7.0.tgz", - "integrity": "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==", + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -26985,7 +27159,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "devOptional": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -28304,7 +28477,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -28323,7 +28495,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -28333,14 +28504,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -28350,7 +28519,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -28365,7 +28533,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -28488,7 +28655,7 @@ "name": "@omniroute/open-sse", "version": "3.8.48", "dependencies": { - "@toon-format/toon": "^2.3.0", + "@toon-format/toon": "^2.3.1", "safe-regex": "^2.1.1" } } diff --git a/package.json b/package.json index d90aa5b11e7b..19c555742372 100644 --- a/package.json +++ b/package.json @@ -225,12 +225,14 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@grpc/grpc-js": "^1.14.4", + "@grpc/proto-loader": "^0.8.1", "@lobehub/icons": "^5.8.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", - "@toon-format/toon": "^2.3.0", + "@toon-format/toon": "^2.3.1", "@types/mdx": "^2.0.13", "@xyflow/react": "^12.11.1", "axios": "^1.16.1", @@ -239,7 +241,7 @@ "clsx": "^2.1.1", "commander": "^15.0.0", "csv-stringify": "^6.7.0", - "dompurify": "^3.4.11", + "dompurify": "^3.4.15", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", @@ -253,16 +255,16 @@ "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", "jose": "^6.2.3", - "js-yaml": "^5.0.0", + "js-yaml": "^5.4.1", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", "lucide-react": "^1.21.0", "marked": "^18.0.4", "marked-terminal": "^7.3.0", "material-symbols": "^0.45.2", - "mermaid": "^11.15.0", + "mermaid": "^11.16.1", "monaco-editor": "^0.55.1", - "next": "^16.2.6", + "next": "~16.3.4", "next-intl": "^4.12.0", "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", @@ -287,7 +289,7 @@ "sqlite-vec": "^0.1.9", "tailwind-merge": "^3.6.0", "tsx": "^4.23.0", - "undici": "^8.3.0", + "undici": "^8.10.2", "update-notifier": "^7.3.1", "uuid": "^14.0.0", "ws": "^8.18.0", @@ -330,7 +332,7 @@ "cross-env": "^10.1.0", "dpdm": "^4.2.0", "eslint": "^9.39.4", - "eslint-config-next": "16.2.10", + "eslint-config-next": "16.2.12", "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", "glob": "^13.0.6", @@ -355,7 +357,7 @@ "lint-staged": { "*.{js,jsx,ts,tsx,mjs}": [ "prettier --write", - "eslint --fix --no-error-on-unmatched-pattern --suppressions-location config/quality/eslint-suppressions.json" + "eslint --fix --no-error-on-unmatched-pattern --no-warn-ignored --pass-on-unpruned-suppressions --suppressions-location config/quality/eslint-suppressions.json" ], "*.{json,md,yml,yaml,css}": [ "prettier --write" @@ -372,16 +374,16 @@ ] }, "overrides": { - "dompurify": "^3.4.11", - "postcss": "^8.5.14", - "ip-address": "10.2.0", - "qs": "^6.15.2", + "dompurify": "$dompurify", + "postcss": "^8.5.28", + "ip-address": "10.7.0", + "qs": "^6.16.0", "uuid": "^14.0.0", "form-data": "^4.0.6", "vite": "^8.0.16", - "protobufjs": "^7.6.3", + "protobufjs": "^7.6.6", "@babel/core": "^7.29.6", - "hono": "^4.12.25", + "hono": "^4.12.34", "@yarnpkg/parsers": { "js-yaml": "^4.2.0" }, @@ -390,6 +392,7 @@ }, "node-gyp": { "undici": "^6.27.0" - } + }, + "sharp": "^0.35.4" } } diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index b0d8892b2740..9699de9e7c69 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -176,6 +176,11 @@ const EXTRA_MODULE_ENTRIES = [ src: ["scripts", "dev", "healthcheck.mjs"], dest: ["healthcheck.mjs"], }, + { + label: "SaluteSpeech recognition protocol", + src: ["open-sse", "proto", "salutespeech"], + dest: ["open-sse", "proto", "salutespeech"], + }, { label: "public directory", src: ["public"], dest: ["public"] }, { label: "playwright-core (dynamic import by gemini-web executor)", diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 197d8ddeb9aa..6eb4390de721 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -148,6 +148,9 @@ const IGNORE_FROM_CODE = new Set([ // write-build-sha.mjs to stamp dist/BUILD_SHA — injected by the build, never // configured by users in .env. "OMNIROUTE_BUILD_SHA", + // Build-time only: the fenix007 tagged-image workflow injects the fork release + // into the browser bundle so the admin sidebar can identify the deployed patch set. + "NEXT_PUBLIC_OMNIROUTE_FORK_VERSION", // Listener-owned self-fetch transport signal. The HTTP/HTTPS launchers set // this before application imports; it is not user-configurable product env. "OMNIROUTE_INTERNAL_SCHEME", diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index 3d7bd1f0cae3..30087d2a9fac 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -64,6 +64,7 @@ function deriveLiveWsPath() { } const LIVE_WS_PATH = deriveLiveWsPath(); +const LIVE_STT_PATH = "/v1/audio/transcriptions/live"; function proxyLiveWs(req, socket, head) { const targetPort = parseInt(process.env.LIVE_WS_PORT || "20132", 10); @@ -87,6 +88,28 @@ function proxyLiveWs(req, socket, head) { socket.on("error", () => !targetSocket.destroyed && targetSocket.destroy()); } +function proxyLiveSttWs(req, socket, head) { + const targetPort = parseInt(process.env.LIVE_STT_PORT || "20133", 10); + const targetSocket = net.connect(targetPort, "127.0.0.1", () => { + let rawRequest = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; + for (const [key, val] of Object.entries(req.headers)) { + if (Array.isArray(val)) { + for (const v of val) rawRequest += `${key}: ${v}\r\n`; + } else { + rawRequest += `${key}: ${val}\r\n`; + } + } + rawRequest += "\r\n"; + targetSocket.write(rawRequest); + if (head && head.length > 0) targetSocket.write(head); + targetSocket.pipe(socket); + socket.pipe(targetSocket); + }); + + targetSocket.on("error", () => !socket.destroyed && socket.destroy()); + socket.on("error", () => !targetSocket.destroyed && targetSocket.destroy()); +} + function wrapUpgradeListener(server, listener) { return async function responsesWsAwareUpgrade(req, socket, head) { try { @@ -99,6 +122,10 @@ function wrapUpgradeListener(server, listener) { } const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + if (url.pathname === LIVE_STT_PATH) { + proxyLiveSttWs(req, socket, head); + return; + } if (url.pathname === LIVE_WS_PATH || url.pathname.startsWith(LIVE_WS_PATH + "/")) { proxyLiveWs(req, socket, head); return; diff --git a/scripts/dev/webdav-handler.mjs b/scripts/dev/webdav-handler.mjs index 8dfd97b6ded0..d6bbe02516f9 100644 --- a/scripts/dev/webdav-handler.mjs +++ b/scripts/dev/webdav-handler.mjs @@ -167,6 +167,42 @@ export function resolveVaultPath(vaultRoot, requestPath) { return { absPath: abs, rel: stripped }; } +/** + * Check each existing component against the canonical vault root. Walking from + * the root also catches dangling symlinks above a not-yet-created destination; + * realpath on that destination alone would only report ENOENT. + * + * @param {string} vaultRoot Canonical (realpath) vault root. + * @param {string} absPath Lexically confined request or destination path. + */ +function assertVaultConfinement(vaultRoot, absPath) { + const relative = path.relative(vaultRoot, absPath); + if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) { + throw { status: 403, message: "Forbidden" }; + } + let current = vaultRoot; + for (const segment of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + let stat; + try { + stat = fs.lstatSync(current); + } catch (err) { + if (err.code === "ENOENT") return; // all remaining components are new + throw { status: 403, message: "Forbidden" }; + } + if (!stat.isSymbolicLink()) continue; + let target; + try { + target = fs.realpathSync(current); + } catch { + throw { status: 403, message: "Forbidden" }; // dangling link or cycle + } + if (target !== vaultRoot && !target.startsWith(vaultRoot + path.sep)) { + throw { status: 403, message: "Forbidden" }; + } + } +} + /** * Validate a Destination header value for MOVE/COPY against the same vault root. * @@ -494,8 +530,7 @@ function buildEntryHref(baseHref, relativePath, isDir) { function handleOptions(req, res) { res.writeHead(200, { DAV: "1, 2", - Allow: - "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, LOCK, UNLOCK", + Allow: "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, LOCK, UNLOCK", "MS-Author-Via": "DAV", "Content-Length": "0", }); @@ -509,8 +544,9 @@ function handleOptions(req, res) { * @param {import("node:http").ServerResponse} res * @param {string} absPath * @param {string} requestPath + * @param {string} vaultRoot */ -function handlePropfind(req, res, absPath, requestPath) { +function handlePropfind(req, res, absPath, requestPath, vaultRoot) { const depth = req.headers["depth"] || "1"; let stat; @@ -551,6 +587,7 @@ function handlePropfind(req, res, absPath, requestPath) { const childAbs = path.join(absPath, child); let childStat; try { + assertVaultConfinement(vaultRoot, childAbs); childStat = fs.statSync(childAbs); } catch { continue; @@ -626,8 +663,9 @@ function handleGet(req, res, absPath, headOnly) { * @param {import("node:http").IncomingMessage} req * @param {import("node:http").ServerResponse} res * @param {string} absPath + * @param {string} vaultRoot */ -async function handlePut(req, res, absPath) { +async function handlePut(req, res, absPath, vaultRoot) { // Check if this is an update (204) or create (201) let existed = false; try { @@ -650,7 +688,10 @@ async function handlePut(req, res, absPath) { const tmpPath = absPath + ".omniroute-webdav-tmp-" + Date.now(); let writeStream; try { - writeStream = fs.createWriteStream(tmpPath); + // Open synchronously and exclusively so a pre-existing temporary symlink + // cannot be followed, or renamed over the target after an async open error. + const fd = fs.openSync(tmpPath, "wx", 0o600); + writeStream = fs.createWriteStream(tmpPath, { fd }); } catch { sendError(res, 500, "Could not write file"); return; @@ -697,6 +738,8 @@ async function handlePut(req, res, absPath) { // Atomic rename try { + assertVaultConfinement(vaultRoot, tmpPath); + assertVaultConfinement(vaultRoot, absPath); fs.renameSync(tmpPath, absPath); } catch { try { @@ -746,8 +789,9 @@ function handleDelete(res, absPath) { * @param {import("node:http").IncomingMessage} req * @param {import("node:http").ServerResponse} res * @param {string} absPath + * @param {string} vaultRoot */ -function handleMkcol(req, res, absPath) { +function handleMkcol(req, res, absPath, vaultRoot) { // MKCOL must not have a request body per RFC 4918 §9.3 let bodyReceived = false; req.on("data", () => { @@ -759,6 +803,13 @@ function handleMkcol(req, res, absPath) { return; } + try { + assertVaultConfinement(vaultRoot, absPath); + } catch { + sendError(res, 403, "Forbidden"); + return; + } + let exists = false; try { fs.statSync(absPath); @@ -811,6 +862,7 @@ function handleMove(req, res, absPath, vaultRoot) { try { const resolved = resolveDestinationPath(vaultRoot, destinationHeader); destAbs = resolved.absPath; + assertVaultConfinement(vaultRoot, destAbs); } catch (err) { if (err && err.status) { sendError(res, err.status, err.message || "Forbidden"); @@ -934,7 +986,7 @@ export async function maybeHandleWebdav(req, res) { // Resolve vault root let vaultRoot; try { - vaultRoot = path.resolve(config.vaultPath); + vaultRoot = fs.realpathSync(path.resolve(config.vaultPath)); } catch { sendError(res, 500, "Server configuration error"); return true; @@ -945,6 +997,7 @@ export async function maybeHandleWebdav(req, res) { try { const resolved = resolveVaultPath(vaultRoot, url); absPath = resolved.absPath; + assertVaultConfinement(vaultRoot, absPath); } catch (err) { if (err && err.status) { sendError(res, err.status, "Forbidden"); @@ -961,7 +1014,7 @@ export async function maybeHandleWebdav(req, res) { handleOptions(req, res); break; case "PROPFIND": - handlePropfind(req, res, absPath, url); + handlePropfind(req, res, absPath, url, vaultRoot); break; case "GET": handleGet(req, res, absPath, false); @@ -970,13 +1023,13 @@ export async function maybeHandleWebdav(req, res) { handleGet(req, res, absPath, true); break; case "PUT": - await handlePut(req, res, absPath); + await handlePut(req, res, absPath, vaultRoot); break; case "DELETE": handleDelete(res, absPath); break; case "MKCOL": - handleMkcol(req, res, absPath); + handleMkcol(req, res, absPath, vaultRoot); break; case "MOVE": handleMove(req, res, absPath, vaultRoot); diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 1f376c147aa9..7d3c07228839 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -29,6 +29,8 @@ type UpdateStep = { type VersionInfo = { current: string; latest: string; + latestLabel?: string; + releaseUrl?: string; updateAvailable: boolean; channel: string; autoUpdateSupported: boolean; @@ -158,7 +160,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`, desc: `A new version of the OmniRoute desktop app is available. Please download the respective app format for your system to update (current: v${versionInfo?.current || ""}).`, }; - }, [platform, versionInfo?.latest, versionInfo?.current]); + }, [platform, versionInfo]); // Electron internal auto-updater state and listeners const [electronUpdateStatus, setElectronUpdateStatus] = useState<{ @@ -896,10 +898,16 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {

- Update Available: v{versionInfo.latest} {isElectron && "(Desktop App)"} + {versionInfo.channel === "fork" + ? "Fork Update Available:" + : "Update Available:"}{" "} + {versionInfo.latestLabel || `v${versionInfo.latest}`}{" "} + {isElectron && versionInfo.channel !== "fork" && "(Desktop App)"}

- {isElectron ? ( + {versionInfo.channel === "fork" ? ( + versionInfo.autoUpdateError + ) : isElectron ? ( <> {electronUpdateStatus.status === "checking" && "Checking for updates..."} {electronUpdateStatus.status === "available" && @@ -925,7 +933,15 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {

- {isElectron ? ( + {versionInfo.channel === "fork" && versionInfo.releaseUrl ? ( + + ) : isElectron ? (
{electronUpdateStatus.status === "available" && (
)} + {isSaluteSpeech && ( + setFormData({ ...formData, clientId: e.target.value })} + placeholder="Your SaluteSpeech client ID" + hint="The API key above is the OAuth client secret." + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> + )} {isModal && ( )} + {isSaluteSpeech && ( + setFormData({ ...formData, clientId: e.target.value })} + placeholder="Your SaluteSpeech client ID" + hint="The API key above is the OAuth client secret." + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> + )} {validationResult && ( {validationResult === "success" ? t("valid") : t("invalid")} @@ -1261,7 +1283,11 @@ export default function EditConnectionModal({ diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx index 4ba800708f68..d1a54b6e54c1 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx @@ -4,14 +4,10 @@ import { Input } from "@/shared/components"; import { providerText, type ProviderMessageTranslator } from "../../providerPageHelpers"; export type QuotaScrapingFieldValues = { - opencodeGoWorkspaceId: string; - opencodeGoAuthCookie: string; ollamaCloudUsageCookie: string; }; export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = { - opencodeGoWorkspaceId: "", - opencodeGoAuthCookie: "", ollamaCloudUsageCookie: "", }; @@ -20,12 +16,7 @@ export function assignQuotaScrapingProviderData( values: QuotaScrapingFieldValues, target: Record ) { - if (provider === "opencode-go") { - target.opencodeGoWorkspaceId = values.opencodeGoWorkspaceId.trim() || undefined; - if (values.opencodeGoAuthCookie.trim()) { - target.opencodeGoAuthCookie = values.opencodeGoAuthCookie.trim(); - } - } else if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) { + if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) { target.ollamaCloudUsageCookie = values.ollamaCloudUsageCookie.trim(); } } @@ -45,45 +36,6 @@ export default function QuotaScrapingFields({ t, editMode = false, }: QuotaScrapingFieldsProps) { - if (provider === "opencode-go") { - return ( -
- onChange({ opencodeGoWorkspaceId: e.target.value })} - placeholder="workspace_..." - hint={providerText( - t, - "opencodeGoWorkspaceIdHint", - "Required for quota scraping. Copy it from the OpenCode Go workspace URL." - )} - autoComplete="off" - spellCheck={false} - /> - onChange({ opencodeGoAuthCookie: e.target.value })} - placeholder="auth=..." - hint={providerText( - t, - "opencodeGoAuthCookieHint", - editMode - ? "Leave blank to keep the stored cookie. Paste auth=... or only the cookie value to replace it." - : "Paste the auth cookie value from opencode.ai. The auth= prefix is accepted." - )} - autoComplete="off" - spellCheck={false} - autoCapitalize="off" - /> -
- ); - } - if (provider === "ollama-cloud") { return (
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts index 677b646df627..fc14a3046a35 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts @@ -20,6 +20,7 @@ type FormData = QuotaScrapingFieldValues & ccCompatibleContext1m: boolean; ccCompatibleRedactThinking: boolean; ccCompatibleSummarizeThinking: boolean; + clientId: string; consoleApiKey: string; customUserAgent: string; cx: string; @@ -74,6 +75,9 @@ export function buildAddProviderSpecificData(options: { if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) { data.consoleApiKey = formData.consoleApiKey.trim(); } + if (provider === "salutespeech" && formData.clientId.trim()) { + data.clientId = formData.clientId.trim(); + } assignQuotaScrapingProviderData(provider, formData, data); if (isGooglePse && formData.cx.trim()) data.cx = formData.cx.trim(); if (usesBaseUrl) data.baseUrl = validatedBaseUrl; @@ -116,6 +120,9 @@ export function assignEditApiKeyProviderSpecificData(options: { if (o.provider === "bailian-coding-plan") { o.target.consoleApiKey = o.formData.consoleApiKey.trim() || undefined; } + if (o.provider === "salutespeech") { + o.target.clientId = o.formData.clientId.trim() || undefined; + } assignQuotaScrapingProviderData(o.provider, o.formData, o.target); if (o.formData.validationModelId) o.target.validationModelId = o.formData.validationModelId; if (o.isGooglePse) o.target.cx = o.formData.cx.trim() || undefined; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx index 7c7833b7f91d..754264b12e97 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx @@ -62,13 +62,22 @@ export default function QuotaCard({ const tierMeta = useMemo( () => normalizePlanTier( - resolvePlanValue(quota?.plan ?? null, connection.providerSpecificData ?? null) + resolvePlanValue( + quota?.plan ?? null, + connection.providerSpecificData ?? null, + connection.provider ?? null + ) ), - [quota?.plan, connection.providerSpecificData] + [quota?.plan, connection.providerSpecificData, connection.provider] ); const resolvedPlan = useMemo( - () => resolvePlanValue(quota?.plan ?? null, connection.providerSpecificData ?? null), - [quota?.plan, connection.providerSpecificData] + () => + resolvePlanValue( + quota?.plan ?? null, + connection.providerSpecificData ?? null, + connection.provider ?? null + ), + [quota?.plan, connection.providerSpecificData, connection.provider] ); const accountLabel = useMemo( () => diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 9ca661017c7e..17d9e9d79c96 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -532,7 +532,11 @@ export default function ProviderLimits({ const resolvedPlanByConnection = useMemo(() => { const out: Record = {}; for (const conn of sortedConnections) { - out[conn.id] = resolvePlanValue(quotaData[conn.id]?.plan, conn.providerSpecificData); + out[conn.id] = resolvePlanValue( + quotaData[conn.id]?.plan, + conn.providerSpecificData, + conn.provider + ); } return out; }, [sortedConnections, quotaData]); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 77058cdc8294..b874648aedda 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -175,13 +175,29 @@ export function calculatePercentage(used, total) { return Math.round(((total - used) / total) * 100); } +/** + * Providers whose live usage payload falls back to "Free" when the tier could + * not be read at all — antigravity's mapCodeAssistSubscriptionToPlanLabel ends + * in `return "Free"`, and its error path returns a literal Free plan. For those + * a live "free" is ambiguous, so persisted metadata still wins. + * + * Every other provider reports free because the account really is free. Most + * importantly Codex, where the usage API returns "unknown" when it cannot tell: + * a live "free" there means the ChatGPT subscription lapsed, and it must beat + * the workspacePlanType captured when the account was first connected. + */ +const PROVIDERS_WITH_AMBIGUOUS_FREE_PLAN = new Set(["antigravity", "agy"]); + /** * Resolve the best available plan label using live usage first, then persisted * provider-specific connection metadata. */ -export function resolvePlanValue(plan, providerSpecificData) { +export function resolvePlanValue(plan, providerSpecificData, provider = null) { const psd = toRecord(providerSpecificData); const livePlan = normalizePlanCandidate(plan); + const freeIsAmbiguous = + typeof provider === "string" && + PROVIDERS_WITH_AMBIGUOUS_FREE_PLAN.has(provider.trim().toLowerCase()); const persistedCandidates = [ psd.workspacePlanType, psd.plan, @@ -195,7 +211,7 @@ export function resolvePlanValue(plan, providerSpecificData) { psd.organizationType, ]; - if (livePlan && normalizePlanTier(livePlan).key !== "free") { + if (livePlan && !(freeIsAmbiguous && normalizePlanTier(livePlan).key === "free")) { return livePlan; } diff --git a/src/app/api/auth/dashboard-access/route.ts b/src/app/api/auth/dashboard-access/route.ts new file mode 100644 index 000000000000..72b47acefa29 --- /dev/null +++ b/src/app/api/auth/dashboard-access/route.ts @@ -0,0 +1,14 @@ +import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; + +export const dynamic = "force-dynamic"; + +/** Cookie-only authorization subrequest for the public dashboard ingress. */ +export async function GET(request: Request) { + const authenticated = await isDashboardSessionAuthenticated(request); + return new Response(null, { + status: authenticated ? 204 : 401, + headers: { "Cache-Control": "no-store", Vary: "Cookie" }, + }); +} + +export const HEAD = GET; diff --git a/src/app/api/cache/route.ts b/src/app/api/cache/route.ts index 01d310432cd5..7924ebda127f 100644 --- a/src/app/api/cache/route.ts +++ b/src/app/api/cache/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { + configureSemanticCache, getCacheStats, + getSemanticCacheRuntimeConfig, clearCache, invalidateByModel, invalidateBySignature, @@ -8,7 +10,7 @@ import { } from "@/lib/semanticCache"; import { getIdempotencyStats } from "@/lib/idempotencyLayer"; import { getCacheMetrics, getCacheTrend } from "@/lib/db/settings"; -import { getCachedSettings } from "@/lib/localDb"; +import { getCachedDatabaseCacheSettings } from "@/lib/db/readCache"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; @@ -26,11 +28,16 @@ export async function GET(req: NextRequest) { const rawHours = parseInt(searchParams.get("trendHours") || "24", 10); const trendHours = Math.min(720, Math.max(1, Number.isNaN(rawHours) ? 24 : rawHours)); + const settings = await getCachedDatabaseCacheSettings(); + configureSemanticCache({ + maxSize: settings.semanticCacheMaxSize, + ttlMs: settings.semanticCacheTTL, + }); const cacheStats = getCacheStats(); const idempotencyStats = await getIdempotencyStats(); const promptCacheMetrics = await getCacheMetrics(); const trend = await getCacheTrend(trendHours); - const settings = await getCachedSettings().catch(() => ({})); + const runtimeConfig = getSemanticCacheRuntimeConfig(); return NextResponse.json({ semanticCache: cacheStats, @@ -39,6 +46,8 @@ export async function GET(req: NextRequest) { idempotency: idempotencyStats, config: { semanticCacheEnabled: settings.semanticCacheEnabled !== false, + semanticCacheMaxSize: runtimeConfig.maxSize, + semanticCacheTTL: runtimeConfig.ttlMs, }, }); } catch (error) { diff --git a/src/app/api/codex/connect/[token]/route.ts b/src/app/api/codex/connect/[token]/route.ts index 0219213d8e04..27b69c01fc50 100644 --- a/src/app/api/codex/connect/[token]/route.ts +++ b/src/app/api/codex/connect/[token]/route.ts @@ -23,10 +23,7 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; const PROVIDER = "codex"; // GET — validate the ticket so the public page can show "ready" vs "expired". -export async function GET( - _request: Request, - { params }: { params: Promise<{ token: string }> } -) { +export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) { const { token } = await params; const ticket = peekDeviceFlowTicket(token); if (!ticket || ticket.provider !== PROVIDER || ticket.status !== "pending") { @@ -43,10 +40,7 @@ export async function GET( } // POST — the browser finished the device flow; consume the ticket and persist. -export async function POST( - request: Request, - { params }: { params: Promise<{ token: string }> } -) { +export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) { const { token } = await params; let rawBody: any; @@ -87,7 +81,13 @@ export async function POST( expires_in: expiresIn, }); - const connection = await persistOAuthConnection(PROVIDER, tokenData, ticket.connectionId); + // The public ticket holder supplies JWT-shaped claims. Until this flow + // verifies the Codex id_token itself, claims must not select and overwrite + // an existing connection implicitly. A server-bound connection id remains + // authoritative for explicit reauthorization. + const connection = await persistOAuthConnection(PROVIDER, tokenData, ticket.connectionId, { + allowImplicitMatch: false, + }); // Record completion so the dashboard poll can notify + refresh. completeDeviceFlowTicket(token, { diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 270372eb0e3d..60c1339a38e9 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -12,6 +12,7 @@ import { import { persistOAuthConnection, buildOAuthConnectionCreatePayload, + findExistingOAuthConnectionMatch, } from "@/lib/oauth/connectionPersistence"; import { createDeviceFlowTicket, getDeviceFlowTicketStatus } from "@/lib/oauth/deviceFlowTickets"; import { @@ -33,21 +34,17 @@ import { oauthPollSchema, } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { keychainImportOnlyGuard } from "./keychainImportOnly"; -// Use globalThis to persist callback server state across Next.js HMR reloads -if (!globalThis.__codexCallbackState) { - globalThis.__codexCallbackState = null; -} -// Windsurf / Devin CLI PKCE callback server state (separate from Codex) -if (!globalThis.__windsurfCallbackState) { - globalThis.__windsurfCallbackState = null; +// Persist one callback server per provider across Next.js HMR reloads. +if (!globalThis.__pkceCallbackStates) { + globalThis.__pkceCallbackStates = {}; } /** Providers that use the PKCE browser callback flow (like Codex). */ -const PKCE_CALLBACK_PROVIDERS = new Set(["codex"]); +const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "xai-oauth"]); /** * Providers whose device flow runs in the user's browser (auth.openai.com blocks @@ -95,12 +92,6 @@ function resolvePublicBaseUrl(request: Request): string { return new URL(request.url).origin; } -async function requireOAuthRouteAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); -} - /** * Dynamic OAuth API Route * Handles: authorize, exchange, device-code, poll, start-callback-server, poll-callback @@ -145,7 +136,7 @@ export async function GET( /* fall through to normal handling */ } - const authResponse = await requireOAuthRouteAuth(request); + const authResponse = await requireManagementAuth(request, { invalidApiKeyStatus: 401 }); if (authResponse) return authResponse; try { @@ -264,8 +255,7 @@ export async function GET( } /** - * Start PKCE callback server for Codex, Windsurf, or Devin CLI. - * Codex uses fixed port 1455; Windsurf/Devin CLI use a random free port (port 0). + * Start a provider-configured PKCE callback server. * Returns the auth URL and stores codeVerifier for later exchange. */ async function handleStartCallbackServer(provider: string, searchParams: URLSearchParams) { @@ -276,50 +266,52 @@ async function handleStartCallbackServer(provider: string, searchParams: URLSear ); } - const isWindsurf = provider === "windsurf" || provider === "devin-cli"; - const stateKey = isWindsurf ? "__windsurfCallbackState" : "__codexCallbackState"; + const callbackStates = globalThis.__pkceCallbackStates; // Clean up existing server if any - if (globalThis[stateKey]?.close) { + if (callbackStates[provider]?.close) { try { - globalThis[stateKey].close(); + callbackStates[provider].close(); } catch (e) { /* ignore */ } } - globalThis[stateKey] = null; + delete callbackStates[provider]; try { - // Codex: fixed port 1455. Windsurf/Devin CLI: OS-assigned random port (0) - const serverPort = isWindsurf ? 0 : 1455; + const providerData = getProvider(provider); + const serverPort = providerData.fixedPort || 0; + const callbackPath = providerData.callbackPath || "/callback"; + const callbackHost = providerData.callbackHost || "localhost"; const { port, close } = await startLocalServer((params) => { - if (globalThis[stateKey]) { - globalThis[stateKey].callbackParams = params; + if (callbackStates[provider]) { + callbackStates[provider].callbackParams = params; } }, serverPort); - const redirectUri = `http://localhost:${port}/auth/callback`; + const redirectUri = `http://${callbackHost}:${port}${callbackPath}`; const authData = generateAuthData(provider, redirectUri); - globalThis[stateKey] = { + callbackStates[provider] = { callbackParams: null, close, port, redirectUri, codeVerifier: authData.codeVerifier, + state: authData.state, startedAt: Date.now(), }; // Auto-cleanup after 5 minutes const startedAt = Date.now(); setTimeout(() => { - if (globalThis[stateKey]?.startedAt === startedAt) { + if (callbackStates[provider]?.startedAt === startedAt) { try { close(); } catch (e) { /* ignore */ } - globalThis[stateKey] = null; + delete callbackStates[provider]; } }, 300000); @@ -368,7 +360,7 @@ export async function POST( /* fall through to normal handling */ } - const authResponse = await requireOAuthRouteAuth(request); + const authResponse = await requireManagementAuth(request, { invalidApiKeyStatus: 401 }); if (authResponse) return authResponse; try { @@ -486,17 +478,7 @@ export async function POST( let connection: any; if (tokenData.email) { const existing = await getProviderConnections({ provider }); - const match = existing.find((c: any) => { - if (c.id && safeEqual(connectionId, c.id)) return true; - // safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-6/7) - if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false; - // For Codex, also check workspaceId to avoid overwriting different workspace connections - if (provider === "codex" && tokenData.providerSpecificData?.workspaceId) { - const existingWorkspace = c.providerSpecificData?.workspaceId; - return safeEqual(existingWorkspace, tokenData.providerSpecificData.workspaceId); - } - return true; - }); + const match = findExistingOAuthConnectionMatch(existing, provider, tokenData, connectionId); const matchId = typeof match?.id === "string" ? match.id : null; if (matchId) { connection = await updateProviderConnection(matchId, { @@ -574,17 +556,12 @@ export async function POST( let connection: any; if (result.tokens.email) { const existing = await getProviderConnections({ provider }); - const match = existing.find((c: any) => { - if (c.id && safeEqual(connectionId, c.id)) return true; - // safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-8/9) - if (!safeEqual(c.email, result.tokens.email) || c.authType !== "oauth") return false; - // For Codex, also check workspaceId to avoid overwriting different workspace connections - if (provider === "codex" && result.tokens.providerSpecificData?.workspaceId) { - const existingWorkspace = c.providerSpecificData?.workspaceId; - return safeEqual(existingWorkspace, result.tokens.providerSpecificData.workspaceId); - } - return true; - }); + const match = findExistingOAuthConnectionMatch( + existing, + provider, + result.tokens, + connectionId + ); const matchId = typeof match?.id === "string" ? match.id : null; if (matchId) { connection = await updateProviderConnection(matchId, { @@ -638,10 +615,9 @@ export async function POST( ); } - // Windsurf and Devin CLI share __windsurfCallbackState; Codex uses its own slot - const stateKey = provider === "codex" ? "__codexCallbackState" : "__windsurfCallbackState"; + const callbackStates = globalThis.__pkceCallbackStates; - if (!globalThis[stateKey]) { + if (!callbackStates[provider]) { return NextResponse.json({ success: false, error: "no_server", @@ -649,13 +625,13 @@ export async function POST( }); } - if (!globalThis[stateKey].callbackParams) { + if (!callbackStates[provider].callbackParams) { return NextResponse.json({ success: false, pending: true }); } // Callback received! Extract code and exchange for tokens - const params = globalThis[stateKey].callbackParams; - const { redirectUri, codeVerifier, close } = globalThis[stateKey]; + const params = callbackStates[provider].callbackParams; + const { redirectUri, codeVerifier, state, close } = callbackStates[provider]; // Clean up server try { @@ -663,7 +639,7 @@ export async function POST( } catch (e) { /* ignore */ } - globalThis[stateKey] = null; + delete callbackStates[provider]; if (params.error) { return NextResponse.json({ @@ -681,6 +657,14 @@ export async function POST( }); } + if (!safeEqual(params.state, state)) { + return NextResponse.json({ + success: false, + error: "invalid_state", + errorDescription: "OAuth state mismatch", + }); + } + try { // Resolve proxy for this provider const proxy = await resolveProxyForProvider(provider); @@ -703,17 +687,12 @@ export async function POST( let connection: any; if (tokenData.email) { const existing = await getProviderConnections({ provider }); - const match = existing.find((c: any) => { - if (c.id && safeEqual(connectionId, c.id)) return true; - // safeEqual: constant-time comparison to prevent timing attacks (CWE-208, finding #258-6/7) - if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false; - // For Codex, also check workspaceId to avoid overwriting different workspace connections - if (provider === "codex" && tokenData.providerSpecificData?.workspaceId) { - const existingWorkspace = c.providerSpecificData?.workspaceId; - return safeEqual(existingWorkspace, tokenData.providerSpecificData.workspaceId); - } - return true; - }); + const match = findExistingOAuthConnectionMatch( + existing, + provider, + tokenData, + connectionId + ); const matchId = typeof match?.id === "string" ? match.id : null; if (matchId) { connection = await updateProviderConnection(matchId, { diff --git a/src/app/api/oauth/[provider]/paste-credentials/route.ts b/src/app/api/oauth/[provider]/paste-credentials/route.ts index 026d6f3ac3cc..6208e7707b68 100644 --- a/src/app/api/oauth/[provider]/paste-credentials/route.ts +++ b/src/app/api/oauth/[provider]/paste-credentials/route.ts @@ -4,7 +4,7 @@ import { persistOAuthConnection } from "@/lib/oauth/connectionPersistence"; import { parsePastedCredentials } from "@/lib/oauth/pasteCredentials"; import { oauthPasteCredentialsSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; /** @@ -27,10 +27,8 @@ export async function POST( request: Request, { params }: { params: Promise<{ provider: string }> } ) { - // Creating a connection is owner-only — gate behind dashboard auth. - if ((await isAuthRequired(request)) && !(await isAuthenticated(request))) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 }); + if (authError) return authError; try { const { provider } = await params; diff --git a/src/app/api/oauth/cliproxy-import/route.ts b/src/app/api/oauth/cliproxy-import/route.ts index 7c60ba97756e..503d7e42aa1c 100644 --- a/src/app/api/oauth/cliproxy-import/route.ts +++ b/src/app/api/oauth/cliproxy-import/route.ts @@ -3,12 +3,9 @@ import path from "path"; import { NextResponse } from "next/server"; import { createProviderConnection } from "@/models"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; -import { - scanCliProxyAuthDir, - toConnectionPayload, -} from "@/lib/oauth/utils/cliProxyAuthImport"; +import { scanCliProxyAuthDir, toConnectionPayload } from "@/lib/oauth/utils/cliProxyAuthImport"; /** * #1934: import OAuth credentials saved by CLIProxyAPI (~/.cli-proxy-api/) so users @@ -23,16 +20,17 @@ function cliProxyConfigDir(): string { } async function requireImportAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } export async function GET(request: Request) { const authResponse = await requireImportAuth(request); if (authResponse) return authResponse; try { - const { candidates, skipped, scanned } = await scanCliProxyAuthDir(cliProxyConfigDir(), Date.now()); + const { candidates, skipped, scanned } = await scanCliProxyAuthDir( + cliProxyConfigDir(), + Date.now() + ); // Sanitize: never return access/refresh tokens to the client. const accounts = candidates.map((c) => ({ provider: c.provider, @@ -52,7 +50,10 @@ export async function POST(request: Request) { const authResponse = await requireImportAuth(request); if (authResponse) return authResponse; try { - const { candidates, skipped, scanned } = await scanCliProxyAuthDir(cliProxyConfigDir(), Date.now()); + const { candidates, skipped, scanned } = await scanCliProxyAuthDir( + cliProxyConfigDir(), + Date.now() + ); let imported = 0; const results: Array<{ provider: string; email: string | null; ok: boolean; error?: string }> = []; diff --git a/src/app/api/oauth/codex/import-token/route.ts b/src/app/api/oauth/codex/import-token/route.ts index 6706f2b5a53a..e801006a2aed 100644 --- a/src/app/api/oauth/codex/import-token/route.ts +++ b/src/app/api/oauth/codex/import-token/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { extractCodexAccountInfo } from "@/lib/oauth/services/codexImport"; import { createProviderConnection } from "@/models"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; /** @@ -28,10 +28,8 @@ const bodySchema = z.object({ name: z.string().trim().min(1).optional(), }); -async function requireAuth(request: Request): Promise { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json(buildErrorBody(401, "Unauthorized"), { status: 401 }); +async function requireAuth(request: Request): Promise { + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } export async function POST(request: Request) { diff --git a/src/app/api/oauth/codex/import/route.ts b/src/app/api/oauth/codex/import/route.ts index 5e378036965a..47001a31e07f 100644 --- a/src/app/api/oauth/codex/import/route.ts +++ b/src/app/api/oauth/codex/import/route.ts @@ -1,8 +1,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { normalizeCodexImportRecord, flattenCodexImportPayload } from "@/lib/oauth/services/codexImport"; +import { + normalizeCodexImportRecord, + flattenCodexImportPayload, +} from "@/lib/oauth/services/codexImport"; import { createProviderConnection } from "@/models"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; /** @@ -25,10 +28,8 @@ const bodySchema = z.object({ }), }); -async function requireAuth(request: Request): Promise { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +async function requireAuth(request: Request): Promise { + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } export async function POST(request: Request) { @@ -39,17 +40,14 @@ export async function POST(request: Request) { try { rawBody = await request.json(); } catch { - return NextResponse.json( - { error: "Invalid or empty JSON body" }, - { status: 400 }, - ); + return NextResponse.json({ error: "Invalid or empty JSON body" }, { status: 400 }); } const parsed = bodySchema.safeParse(rawBody); if (!parsed.success) { return NextResponse.json( { error: parsed.error.errors[0]?.message ?? "Invalid request body" }, - { status: 400 }, + { status: 400 } ); } @@ -58,10 +56,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: flat.error }, { status: 400 }); } if (flat.records.length === 0) { - return NextResponse.json( - { error: "No accounts found in payload" }, - { status: 400 }, - ); + return NextResponse.json({ error: "No accounts found in payload" }, { status: 400 }); } const results: Array< diff --git a/src/app/api/oauth/cursor/auto-import/route.ts b/src/app/api/oauth/cursor/auto-import/route.ts index 85f9c8baa81b..d74622161c0a 100755 --- a/src/app/api/oauth/cursor/auto-import/route.ts +++ b/src/app/api/oauth/cursor/auto-import/route.ts @@ -4,7 +4,7 @@ import { homedir } from "os"; import { join } from "path"; import { execFile } from "child_process"; import { promisify } from "util"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const execFileAsync = promisify(execFile); @@ -40,9 +40,7 @@ export interface CursorInstallProbe { * Port of decolua/9router#313 — only the linux probe is added; macOS/Windows * keep their existing behavior (no install probe). */ -export async function verifyLinuxCursorInstalled( - probe: CursorInstallProbe = {} -): Promise { +export async function verifyLinuxCursorInstalled(probe: CursorInstallProbe = {}): Promise { const exec = probe.execFile ?? execFileAsync; const canAccess = probe.access ?? access; const home = probe.home ?? homedir(); @@ -330,11 +328,8 @@ async function tryIdeAuth(): Promise<{ * 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4). */ export async function GET(request: Request) { - if (await isAuthRequired(request)) { - if (!(await isAuthenticated(request))) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - } + const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 }); + if (authError) return authError; try { // Try Cursor IDE first (has both accessToken and machineId) diff --git a/src/app/api/oauth/cursor/import/route.ts b/src/app/api/oauth/cursor/import/route.ts index 026cfc51cd84..a2827f15877d 100755 --- a/src/app/api/oauth/cursor/import/route.ts +++ b/src/app/api/oauth/cursor/import/route.ts @@ -5,13 +5,11 @@ import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { cursorImportSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; async function requireOAuthImportAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } /** diff --git a/src/app/api/oauth/kiro/api-key/route.ts b/src/app/api/oauth/kiro/api-key/route.ts index 0564d0937f08..c46835f75f93 100644 --- a/src/app/api/oauth/kiro/api-key/route.ts +++ b/src/app/api/oauth/kiro/api-key/route.ts @@ -5,14 +5,12 @@ import { syncToCloud } from "@/lib/cloudSync"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { kiroApiKeyImportSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { buildKiroImportError } from "../import/route"; import { buildKiroApiKeyConnectionName, isKiroApiKeyImportClientError } from "./helpers"; async function requireKiroApiKeyImportAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } /** diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index c3df55477e54..a13682f5fc95 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { homedir } from "os"; import { join } from "path"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { createProviderConnection, getProviderConnections, @@ -30,11 +30,8 @@ import { * 🔒 Auth-guarded: requires JWT cookie or Bearer API key. */ export async function GET(request: Request) { - if (await isAuthRequired(request)) { - if (!(await isAuthenticated(request))) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - } + const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 }); + if (authError) return authError; const { searchParams } = new URL(request.url); const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro"; @@ -111,8 +108,7 @@ async function tryKiroCliSqlite(): Promise<{ for (const table of ["auth_kv", "ItemTable", "storage"]) { try { const row = db.prepare(`SELECT value FROM ${table} WHERE key = ?`).get(key) as - | { value: string } - | undefined; + { value: string } | undefined; if (row?.value) { try { tokenData = JSON.parse(row.value); @@ -139,8 +135,7 @@ async function tryKiroCliSqlite(): Promise<{ for (const table of ["auth_kv", "ItemTable", "storage"]) { try { const row = db.prepare(`SELECT value FROM ${table} WHERE key = ?`).get(key) as - | { value: string } - | undefined; + { value: string } | undefined; if (row?.value) { try { regData = JSON.parse(row.value); diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index 4e58bfcbd3bb..53d062d136ff 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -5,7 +5,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { kiroImportSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { @@ -31,9 +31,7 @@ export function buildKiroImportError(error: unknown): string { } async function requireOAuthImportAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } /** diff --git a/src/app/api/oauth/kiro/social-authorize/route.ts b/src/app/api/oauth/kiro/social-authorize/route.ts index 0afd321ef2f6..5229bc75e639 100755 --- a/src/app/api/oauth/kiro/social-authorize/route.ts +++ b/src/app/api/oauth/kiro/social-authorize/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { KIRO_CONFIG } from "@/lib/oauth/constants/oauth"; /** @@ -8,9 +8,8 @@ import { KIRO_CONFIG } from "@/lib/oauth/constants/oauth"; * Returns a verification URL for the user to open in their browser. */ export async function GET(request) { - if ((await isAuthRequired(request)) && !(await isAuthenticated(request))) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 }); + if (authError) return authError; try { const { searchParams } = new URL(request.url); diff --git a/src/app/api/oauth/kiro/social-exchange/route.ts b/src/app/api/oauth/kiro/social-exchange/route.ts index d668ed02eb76..dcc9cf5ca470 100755 --- a/src/app/api/oauth/kiro/social-exchange/route.ts +++ b/src/app/api/oauth/kiro/social-exchange/route.ts @@ -4,7 +4,7 @@ import { KiroService } from "@/lib/oauth/services/kiro"; import { createProviderConnection, isCloudEnabled } from "@/models"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { KIRO_CONFIG } from "@/lib/oauth/constants/oauth"; @@ -20,9 +20,8 @@ const socialExchangeSchema = z.object({ * Frontend calls this repeatedly until authorization completes. */ export async function POST(request: Request) { - if ((await isAuthRequired(request)) && !(await isAuthenticated(request))) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 }); + if (authError) return authError; let rawBody; try { diff --git a/src/app/api/oauth/trae/callback-state/route.ts b/src/app/api/oauth/trae/callback-state/route.ts new file mode 100644 index 000000000000..2ebd008515c4 --- /dev/null +++ b/src/app/api/oauth/trae/callback-state/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { issueTraeCallbackState } from "@/lib/oauth/traeCallbackState"; + +export async function POST(request: Request) { + const authResponse = await requireManagementAuth(request); + if (authResponse) return authResponse; + + const { state, expiresAt } = issueTraeCallbackState(); + return NextResponse.json( + { state, expiresAt: new Date(expiresAt).toISOString() }, + { headers: { "Cache-Control": "no-store" } } + ); +} diff --git a/src/app/api/oauth/trae/import/route.ts b/src/app/api/oauth/trae/import/route.ts index 9f3fba5bacba..801930ef6e44 100644 --- a/src/app/api/oauth/trae/import/route.ts +++ b/src/app/api/oauth/trae/import/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { createProviderConnection } from "@/models"; import { traeImportSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; /** * POST /api/oauth/trae/import @@ -21,14 +21,8 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; * tenant — optional, default "marscode" * region — optional, default "US-East" */ -async function requireOAuthImportAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); -} - export async function POST(request: Request) { - const authResponse = await requireOAuthImportAuth(request); + const authResponse = await requireManagementAuth(request); if (authResponse) return authResponse; let rawBody; @@ -93,7 +87,7 @@ export async function POST(request: Request) { * Returns field metadata so a generic dashboard UI can render the paste form. */ export async function GET(request: Request) { - const authResponse = await requireOAuthImportAuth(request); + const authResponse = await requireManagementAuth(request); if (authResponse) return authResponse; return NextResponse.json({ diff --git a/src/app/api/providers/[id]/models/discovery/codex.ts b/src/app/api/providers/[id]/models/discovery/codex.ts index 7992d36b6720..b6c67d7be96d 100644 --- a/src/app/api/providers/[id]/models/discovery/codex.ts +++ b/src/app/api/providers/[id]/models/discovery/codex.ts @@ -164,14 +164,16 @@ function buildCodexDiscoveryModel(record: JsonRecord): CodexDiscoveryModel | nul apiFormat: "responses", supportedEndpoints: ["responses"], }; + // The live Codex catalog may report both a lower pricing-tier window and the + // actual usable maximum. Prefer max_context_window whenever it is available. const inputTokenLimit = firstPositiveNumber( record.inputTokenLimit, record.maxInputTokens, record.max_input_tokens, record.contextLength, record.context_length, - record.context_window, record.max_context_window, + record.context_window, topProvider.context_length, limits.input_tokens, limits.inputTokenLimit, diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index c92a7a2c6758..90512ac948ba 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -23,8 +23,11 @@ import { isClaudeExtraUsageBlockEnabled, } from "@/lib/providers/claudeExtraUsage"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure"; -import { refreshConnectionRateLimits, enableRateLimitProtection } from "@/../open-sse/services/rateLimitManager"; +import { isApiKeyRevealEnabledForRequest, maskStoredApiKey } from "@/lib/apiKeyExposure"; +import { + refreshConnectionRateLimits, + enableRateLimitProtection, +} from "@/../open-sse/services/rateLimitManager"; function normalizeCodexLimitPolicy( incoming: unknown, @@ -63,7 +66,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } - const revealKeys = isApiKeyRevealEnabled(); + const revealKeys = isApiKeyRevealEnabledForRequest(request); // Hide or mask sensitive fields const result: Record = { ...connection }; diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index bf63734ffdec..ce5fcc0221af 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -31,7 +31,7 @@ import { } from "@/lib/providers/requestDefaults"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { isManagedProviderConnectionId } from "@/lib/providers/catalog"; -import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure"; +import { isApiKeyRevealEnabledForRequest, maskStoredApiKey } from "@/lib/apiKeyExposure"; import { buildModelSyncInternalHeaders, fetchModelSyncInternal, @@ -45,7 +45,7 @@ export async function GET(request: Request) { try { const connections = await getProviderConnections(); - const revealKeys = isApiKeyRevealEnabled(); + const revealKeys = isApiKeyRevealEnabledForRequest(request); // Hide or mask sensitive fields const safeConnections = connections.map((c) => ({ diff --git a/src/app/api/providers/validate/route.ts b/src/app/api/providers/validate/route.ts index 1af2cdffb370..11a58e4610c5 100644 --- a/src/app/api/providers/validate/route.ts +++ b/src/app/api/providers/validate/route.ts @@ -57,6 +57,7 @@ export async function POST(request) { baseUrl: bodyBaseUrl, region, cx, + clientId, } = validation.data; let providerSpecificData: any = { validationModelId }; @@ -72,6 +73,9 @@ export async function POST(request) { if (cx) { providerSpecificData.cx = cx; } + if (clientId) { + providerSpecificData.clientId = clientId; + } if (isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider)) { const node: any = await getProviderNodeById(provider); diff --git a/src/app/api/settings/cache-config/route.ts b/src/app/api/settings/cache-config/route.ts index 67b300690b18..9ada17e19f56 100644 --- a/src/app/api/settings/cache-config/route.ts +++ b/src/app/api/settings/cache-config/route.ts @@ -1,13 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { getSettings, updateSettings } from "@/lib/localDb"; +import { getUserDatabaseSettings, updateDatabaseSettings } from "@/lib/db/databaseSettings"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { z } from "zod"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; const cacheConfigUpdateSchema = z.object({ semanticCacheEnabled: z.boolean().optional(), - semanticCacheMaxSize: z.number().positive().optional(), - semanticCacheTTL: z.number().positive().optional(), + semanticCacheMaxSize: z.number().int().min(10).max(1000).optional(), + semanticCacheTTL: z.number().int().min(60000).optional(), promptCacheEnabled: z.boolean().optional(), promptCacheStrategy: z.enum(["auto", "system-only", "manual"]).optional(), alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(), @@ -41,9 +42,13 @@ export async function GET(request: NextRequest) { try { const settings = await getSettings(); + const databaseCache = getUserDatabaseSettings().cache; const config: Record = {}; for (const key of CACHE_CONFIG_KEYS) { - config[key] = settings[key] ?? DEFAULTS[key]; + config[key] = + key === "idempotencyWindowMs" || key === "alwaysPreserveClientCache" + ? (settings[key] ?? DEFAULTS[key]) + : (databaseCache[key] ?? settings[key] ?? DEFAULTS[key]); } return NextResponse.json(config); } catch (error) { @@ -69,32 +74,40 @@ export async function PUT(request: NextRequest) { return validation.response; } - const updates: Record = {}; + const settingsUpdates: Record = {}; + const cacheUpdates: Partial["cache"]> = {}; const body = validation.data; if (body.semanticCacheEnabled !== undefined) { - updates.semanticCacheEnabled = body.semanticCacheEnabled; + cacheUpdates.semanticCacheEnabled = body.semanticCacheEnabled; } if (body.semanticCacheMaxSize !== undefined) { - updates.semanticCacheMaxSize = body.semanticCacheMaxSize; + cacheUpdates.semanticCacheMaxSize = body.semanticCacheMaxSize; } if (body.semanticCacheTTL !== undefined) { - updates.semanticCacheTTL = body.semanticCacheTTL; + cacheUpdates.semanticCacheTTL = body.semanticCacheTTL; } if (body.promptCacheEnabled !== undefined) { - updates.promptCacheEnabled = body.promptCacheEnabled; + cacheUpdates.promptCacheEnabled = body.promptCacheEnabled; } if (body.promptCacheStrategy !== undefined) { - updates.promptCacheStrategy = body.promptCacheStrategy; + cacheUpdates.promptCacheStrategy = body.promptCacheStrategy; } if (body.alwaysPreserveClientCache !== undefined) { - updates.alwaysPreserveClientCache = body.alwaysPreserveClientCache; + settingsUpdates.alwaysPreserveClientCache = body.alwaysPreserveClientCache; } if (body.idempotencyWindowMs !== undefined) { - updates.idempotencyWindowMs = body.idempotencyWindowMs; + settingsUpdates.idempotencyWindowMs = body.idempotencyWindowMs; } - await updateSettings(updates); + if (Object.keys(cacheUpdates).length > 0) { + updateDatabaseSettings({ + cache: { ...getUserDatabaseSettings().cache, ...cacheUpdates }, + }); + } + if (Object.keys(settingsUpdates).length > 0) { + await updateSettings(settingsUpdates); + } return NextResponse.json({ ok: true }); } catch (error) { return NextResponse.json({ error: String(error) }, { status: 500 }); diff --git a/src/app/api/system/version/route.ts b/src/app/api/system/version/route.ts index 6032f70159a3..900f653d328b 100644 --- a/src/app/api/system/version/route.ts +++ b/src/app/api/system/version/route.ts @@ -1,9 +1,9 @@ /** - * GET /api/system/version — Returns current version and latest available on npm + * GET /api/system/version — Checks published fork builds or upstream npm releases * POST /api/system/version — Triggers a deployment-aware background update * * Security: Requires admin authentication (same as other management routes). - * Safety: Update only runs if a newer version is available on npm. + * Safety: Fork builds require Docker deployment; the npm updater is upstream-only. */ import { NextRequest, NextResponse } from "next/server"; import { execFile } from "child_process"; @@ -18,6 +18,11 @@ import { } from "@/lib/system/autoUpdate"; import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes"; import { isNewer, resolveLatestVersion } from "@/lib/system/versionCheck"; +import { + FORK_UPDATE_MESSAGE, + getForkBuildVersion, + resolveForkVersionInfo, +} from "@/lib/system/forkVersionCheck"; import { resolveGlobalOmniroutePath } from "@/lib/system/globalPackagePath"; // #5542 — On Windows npm is `npm.cmd`; Node ≥24 refuses to execFile a `.cmd` without // a shell (nodejs/node#52554 → "spawn npm ENOENT"). buildNpmExecOptions enables the @@ -52,6 +57,11 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const forkVersion = getForkBuildVersion(); + if (forkVersion) { + return NextResponse.json(await resolveForkVersionInfo(forkVersion)); + } + const current = getCurrentVersion(); const config = getAutoUpdateConfig(); @@ -79,6 +89,13 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (getForkBuildVersion()) { + return NextResponse.json( + { success: false, channel: "fork", error: FORK_UPDATE_MESSAGE }, + { status: 409 } + ); + } + const current = getCurrentVersion(); const latest = await resolveLatestVersion(); @@ -301,11 +318,11 @@ export async function POST(req: NextRequest) { return; } send({ step: "install", status: "running", message: `Installing omniroute@${latest}...` }); - await execFileAsync( - "npm", - ["install", "-g", `omniroute@${latest}`, "--ignore-scripts", "--legacy-peer-deps"], - buildNpmExecOptions(process.platform, { cwd: PROJECT_ROOT, timeoutMs: 300_000 }) - ); + await execFileAsync( + "npm", + ["install", "-g", `omniroute@${latest}`, "--ignore-scripts", "--legacy-peer-deps"], + buildNpmExecOptions(process.platform, { cwd: PROJECT_ROOT, timeoutMs: 300_000 }) + ); send({ step: "install", status: "done", message: `Installed omniroute@${latest}` }); // Step 2: Rebuild native modules (critical for better-sqlite3) @@ -324,20 +341,20 @@ export async function POST(req: NextRequest) { // Step 3: Restart PM2 send({ step: "restart", status: "running", message: "Restarting service via PM2..." }); - try { - await execFileAsync("pm2", ["restart", "omniroute", "--update-env"], { - timeout: 30000, - cwd: PROJECT_ROOT, - }); - send({ step: "restart", status: "done", message: "Service restarted" }); - } catch { - // PM2 may not be available (Docker/manual setups) - send({ - step: "restart", - status: "skipped", - message: "PM2 not available — manual restart needed", - }); - } + try { + await execFileAsync("pm2", ["restart", "omniroute", "--update-env"], { + timeout: 30000, + cwd: PROJECT_ROOT, + }); + send({ step: "restart", status: "done", message: "Service restarted" }); + } catch { + // PM2 may not be available (Docker/manual setups) + send({ + step: "restart", + status: "skipped", + message: "PM2 not available — manual restart needed", + }); + } send({ step: "complete", diff --git a/src/app/api/usage/[connectionId]/codex-reset-credits/route.ts b/src/app/api/usage/[connectionId]/codex-reset-credits/route.ts new file mode 100644 index 000000000000..39c376e68421 --- /dev/null +++ b/src/app/api/usage/[connectionId]/codex-reset-credits/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { CodexResetCreditError, listCodexResetCredits } from "@/lib/usage/codexResetCredits"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +const ConnectionIdSchema = z.string().trim().min(1).max(256); + +export async function GET( + request: Request, + { params }: { params: Promise<{ connectionId: string }> } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const parsed = ConnectionIdSchema.safeParse((await params).connectionId); + if (!parsed.success) { + return NextResponse.json( + { code: "invalid_connection_id", error: "Invalid connectionId." }, + { status: 400 } + ); + } + + try { + return NextResponse.json(await listCodexResetCredits(parsed.data)); + } catch (error) { + const status = error instanceof CodexResetCreditError ? error.status : 500; + const code = + error instanceof CodexResetCreditError ? error.code : "codex_reset_credit_list_failed"; + const message = + error instanceof CodexResetCreditError + ? sanitizeErrorMessage(error.message) || "Codex reset-credit request failed." + : "Codex reset-credit request failed."; + + console.error("[API] GET /api/usage/[connectionId]/codex-reset-credits failed", { + status, + code, + }); + + return NextResponse.json({ code, error: message }, { status }); + } +} diff --git a/src/app/api/usage/codex-reset-credit/route.ts b/src/app/api/usage/codex-reset-credit/route.ts index ecbae0778238..148473ee880e 100644 --- a/src/app/api/usage/codex-reset-credit/route.ts +++ b/src/app/api/usage/codex-reset-credit/route.ts @@ -1,13 +1,21 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { CodexResetCreditError, consumeCodexResetCredit } from "@/lib/usage/codexResetCredits"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; -const CodexResetCreditBodySchema = z.object({ - connectionId: z.string().optional(), - idempotencyKey: z.string().optional(), -}); +const CodexResetCreditBodySchema = z + .object({ + connectionId: z.string().trim().min(1).max(256), + idempotencyKey: z.string().trim().min(1).max(256), + expectedCreditExpiresAt: z.string().datetime({ offset: true }).optional(), + }) + .strict(); export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const raw = await request.json().catch(() => ({})); const parsed = CodexResetCreditBodySchema.safeParse(raw); @@ -18,16 +26,20 @@ export async function POST(request: Request) { ); } - const connectionId = parsed.data.connectionId ?? ""; - const idempotencyKey = parsed.data.idempotencyKey ?? ""; - - const result = await consumeCodexResetCredit(connectionId, idempotencyKey); + const result = await consumeCodexResetCredit( + parsed.data.connectionId, + parsed.data.idempotencyKey, + parsed.data.expectedCreditExpiresAt + ); return NextResponse.json({ ok: true, ...result }); } catch (error) { const status = error instanceof CodexResetCreditError ? error.status : 500; const code = error instanceof CodexResetCreditError ? error.code : "codex_reset_credit_failed"; - const message = error instanceof Error ? error.message : "Failed to redeem Codex reset credit."; - console.error("[API] POST /api/usage/codex-reset-credit error:", error); + const message = + error instanceof CodexResetCreditError + ? sanitizeErrorMessage(error.message) || "Failed to redeem Codex reset credit." + : "Failed to redeem Codex reset credit."; + console.error("[API] POST /api/usage/codex-reset-credit failed", { status, code }); return NextResponse.json({ ok: false, code, error: message }, { status }); } } diff --git a/src/app/api/v1/audio/transcriptions/live/session/route.ts b/src/app/api/v1/audio/transcriptions/live/session/route.ts new file mode 100644 index 000000000000..acf893b4d3aa --- /dev/null +++ b/src/app/api/v1/audio/transcriptions/live/session/route.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; +import { getProviderCredentialsWithQuotaPreflight } from "@/sse/services/auth"; +import { createLiveSttSession } from "@/lib/liveStt/session"; +import { isValidApiKey } from "@/sse/services/auth"; + +const requestSchema = z.object({ + language: z + .string() + .trim() + .regex(/^[a-z]{2,3}-[A-Z]{2}$/) + .max(16) + .default("ru-RU"), + model: z.enum(["general", "callcenter"]).default("general"), +}); + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + }, + }); +} + +export async function POST(request: Request) { + const parsed = requestSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid live STT session request"); + + const origin = request.headers.get("origin"); + if (!origin) return errorResponse(HTTP_STATUS.FORBIDDEN, "Live STT requires an Origin header"); + try { + new URL(origin); + } catch { + return errorResponse(HTTP_STATUS.FORBIDDEN, "Invalid request origin"); + } + + const policy = await enforceApiKeyPolicy(request, `salutespeech/${parsed.data.model}`); + if (policy.rejection) return policy.rejection; + if (!policy.apiKey) + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "API key is required for live transcription"); + if (!(await isValidApiKey(policy.apiKey))) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + + const credentials = await getProviderCredentialsWithQuotaPreflight( + "salutespeech", + null, + policy.apiKeyInfo?.allowedConnections ?? null, + parsed.data.model + ); + if (!credentials?.connectionId) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "No active SaluteSpeech connection"); + } + + try { + const session = await createLiveSttSession({ + apiKeyId: policy.apiKeyInfo?.id ?? null, + connectionId: credentials.connectionId, + origin, + language: parsed.data.language, + model: parsed.data.model, + }); + return Response.json({ + url: "/v1/audio/transcriptions/live", + token: session.token, + expiresAt: session.expiresAt, + input: { format: "pcm_s16le", channels: 1, sampleRates: [8000, 16000, 48000] }, + }); + } catch { + return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Live STT is unavailable"); + } +} diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 21e525d6dfad..dd53aa7c5fb2 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -223,6 +223,51 @@ async function postHandler(request: Request, context) { ); } + if (providerConfig?.id === "openai") { + const credentials = await getProviderCredentialsWithQuotaPreflight( + parsed.provider, + null, + allowedConnections, + resolvedModel + ); + if (!credentials) { + return errorResponse( + HTTP_STATUS.UNAUTHORIZED, + `No credentials for provider: ${parsed.provider}` + ); + } + if (credentials.allRateLimited) { + return unavailableResponse( + HTTP_STATUS.RATE_LIMITED, + `[${parsed.provider}] All accounts rate limited`, + credentials.retryAfter, + credentials.retryAfterHuman + ); + } + + const result = await handleOpenAIImageEdit({ + provider: parsed.provider, + model: parsed.model, + credentials, + prompt, + imageBytes, + imageMime, + size, + responseFormat, + n: 1, + log, + }); + + if (result.success) { + await clearRecoveredProviderState(credentials); + return jsonResponse((result as any).data); + } + return jsonResponse( + toJsonErrorPayload((result as any).error, "Image edit provider error"), + (result as any).status + ); + } + // Built-in non-chatgpt-web providers do not expose an OpenAI-compatible edit endpoint. if (providerConfig) { return errorResponse( diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 8c5d09034926..8c3ae144b4c7 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -3,8 +3,6 @@ import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; import { getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState, - extractApiKey, - isValidApiKey, } from "@/sse/services/auth"; import { parseImageModel, @@ -12,20 +10,26 @@ import { getImageModelEntry, } from "@omniroute/open-sse/config/imageRegistry.ts"; import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; -import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { lockModel } from "@omniroute/open-sse/services/accountFallback.ts"; +import { COOLDOWN_MS, HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; import * as log from "@/sse/utils/logger"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { v1ImageGenerationSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { getAllCustomModels, resolveProxyForConnection } from "@/lib/localDb"; +import { getAllCustomModels } from "@/lib/db/models"; +import { resolveProxyForConnection } from "@/lib/db/settings"; import { resolveImageRouteModel } from "@/lib/images/imageRouteModel"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { calculateModalCost } from "@/lib/usage/costCalculator"; import { generateRequestId } from "@/shared/utils/requestId"; import { getSpecialtyModelsResponse } from "@/app/api/v1/_shared/specialtyCatalog"; +import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth"; +import { runWithCallLogApiKeyContext } from "@/lib/usage/callLogApiKeyContext"; +import { executeImageWithCredentialFallback } from "@/sse/services/imageCredentialRetry"; export const dynamic = "force-dynamic"; @@ -87,6 +91,45 @@ function publicBaseUrlHeaders(headers: Headers): Record { return out; } +function isCodexChatGptModelAccessError( + result: { status?: unknown; error?: unknown }, + requestedModel: string +): boolean { + if (result.status !== HTTP_STATUS.BAD_REQUEST) return false; + + let error = result.error; + // 3.8.48 base: the codex handler surfaces the upstream body as a raw string + // (no sanitizeImageProviderError parsing), so unwrap JSON before matching. + if (typeof error === "string" && error.trimStart().startsWith("{")) { + try { + error = JSON.parse(error); + } catch { + // keep the raw string + } + } + let detail: string | null = typeof error === "string" ? error : null; + if (error && typeof error === "object") { + if ("detail" in error && typeof error.detail === "string") { + detail = error.detail; + } else if ("message" in error && typeof error.message === "string") { + detail = error.message; + } else if ( + "error" in error && + error.error && + typeof error.error === "object" && + "message" in error.error && + typeof error.error.message === "string" + ) { + detail = error.error.message; + } + } + + return ( + detail === + `The '${requestedModel}' model is not supported when using Codex with a ChatGPT account.` + ); +} + async function postHandler(request, context) { let rawBody; try { @@ -103,6 +146,11 @@ async function postHandler(request, context) { const body = validation.data; const startTime = Date.now(); + // Authenticate before policy enforcement. Policy checks intentionally allow + // keyless local mode and assume the route has already rejected invalid keys. + const authRejection = await enforceClientApiRouteAuth(request); + if (authRejection) return authRejection; + // Enforce API key policies (model restrictions + budget limits) const policy = await enforceApiKeyPolicy(request, body.model); if (policy.rejection) return policy.rejection; @@ -114,7 +162,7 @@ async function postHandler(request, context) { body.model = await resolveImageRouteModel(body.model); // Parse model to get provider - let { provider } = parseImageModel(body.model); + let { provider, model: requestedModel } = parseImageModel(body.model); let isCustomModel = false; // If not in built-in registry, check custom models tagged for images @@ -129,6 +177,7 @@ async function postHandler(request, context) { const fullId = `${providerId}/${model.id}`; if (fullId === body.model) { provider = providerId; + requestedModel = model.id; isCustomModel = true; break; } @@ -171,7 +220,12 @@ async function postHandler(request, context) { // Get credentials — skip for local providers (authType: "none") let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentialsWithQuotaPreflight(provider); + credentials = await getProviderCredentialsWithQuotaPreflight( + provider, + null, + null, + requestedModel + ); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, @@ -187,7 +241,12 @@ async function postHandler(request, context) { ); } } else if (isCustomModel) { - credentials = await getProviderCredentialsWithQuotaPreflight(provider); + credentials = await getProviderCredentialsWithQuotaPreflight( + provider, + null, + null, + requestedModel + ); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, @@ -204,34 +263,82 @@ async function postHandler(request, context) { } } - // Resolve proxy for the connection if credentials exist (#1904) - let proxyInfo = null; - if (credentials?.connectionId) { - try { - proxyInfo = await resolveProxyForConnection(credentials.connectionId); - } catch { - log.debug("PROXY", `Failed to resolve proxy for image provider: ${provider}`); + const executeImageGeneration = async (selectedCredentials) => { + // Resolve proxy per attempt so an account fallback never reuses another + // connection's proxy configuration (#1904). + let proxyInfo = null; + if (selectedCredentials?.connectionId) { + try { + proxyInfo = await resolveProxyForConnection(selectedCredentials.connectionId); + } catch { + log.debug("PROXY", `Failed to resolve proxy for image provider: ${provider}`); + } } - } - const generateImage = () => - handleImageGeneration({ - body, - credentials, - log, - ...(isCustomModel && { resolvedProvider: provider }), - signal: request.signal, - clientHeaders: publicBaseUrlHeaders(request.headers), - }); + const generateImage = () => + runWithCallLogApiKeyContext( + { + apiKeyId: policy.apiKeyInfo?.id ?? null, + apiKeyName: policy.apiKeyInfo?.name ?? null, + }, + () => + handleImageGeneration({ + body, + credentials: selectedCredentials, + log, + ...(isCustomModel && { resolvedProvider: provider }), + signal: request.signal, + clientHeaders: publicBaseUrlHeaders(request.headers), + }) + ); + + return selectedCredentials?.connectionId + ? runWithProxyContext(proxyInfo?.proxy || null, generateImage).catch((err: any) => ({ + success: false, + status: err.statusCode || 500, + error: err.message, + })) + : generateImage(); + }; + + // #9231: keep image requests on the same credential lifecycle as chat — + // refresh OAuth and rotate accounts on upstream 401 before giving up. + const execution = await executeImageWithCredentialFallback({ + provider, + requestedModel, + credentials, + execute: executeImageGeneration, + }); + credentials = execution.credentials; + let result = execution.result; - // Execute with proxy context when available, direct otherwise (#1904) - const result = await (credentials?.connectionId - ? runWithProxyContext(proxyInfo?.proxy || null, generateImage).catch((err: any) => ({ - success: false, - status: err.statusCode || 500, - error: err.message, - })) - : generateImage()); + // Some ChatGPT accounts can use Codex but do not have the requested model + // entitlement. Retry one sibling account for that exact upstream response; + // ordinary client 400s remain single-attempt failures. + if ( + provider === "codex" && + credentials?.connectionId && + requestedModel && + isCodexChatGptModelAccessError(result, requestedModel) + ) { + lockModel( + provider, + credentials.connectionId, + requestedModel, + "model_access_denied", + COOLDOWN_MS.notFound + ); + const fallbackCredentials = await getProviderCredentialsWithQuotaPreflight( + provider, + credentials.connectionId, + null, + requestedModel + ); + if (fallbackCredentials?.connectionId && !isAllRateLimitedCredentials(fallbackCredentials)) { + credentials = fallbackCredentials; + result = await executeImageGeneration(credentials); + } + } if (result.success) { await clearRecoveredProviderState(credentials); diff --git a/src/app/api/v1/messages/route.ts b/src/app/api/v1/messages/route.ts index 3e2172b1ebeb..5fe634ed27ad 100644 --- a/src/app/api/v1/messages/route.ts +++ b/src/app/api/v1/messages/route.ts @@ -58,7 +58,12 @@ async function postHandler(request: any, context: any, preParsedBody: any = null if (accept.includes("text/event-stream")) { let model; try { - const body = preParsedBody ?? (await request.clone().json().catch(() => null)); + const body = + preParsedBody ?? + (await request + .clone() + .json() + .catch(() => null)); model = body?.model; } catch { // body unavailable / non-JSON — fall back to the default keepalive threshold @@ -67,6 +72,9 @@ async function postHandler(request: any, context: any, preParsedBody: any = null signal: request.signal, thresholdMs: resolveKeepaliveThreshold(model), keepaliveFrame: ANTHROPIC_PING_FRAME, + // Claude clients branch on `error.type` inside the stream; an OpenAI-shaped + // error frame is ignored, so a failure would read as a stream that just ended. + errorFrameFormat: "anthropic", }); } return await handleChat(request, null, preParsedBody); diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 380827f11af0..ead589c8ba9a 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -106,7 +106,10 @@ type CachedCatalog = { status: number; expiresAt: number; }; -const CATALOG_CACHE_TTL_MS = 1500; // ~one request-latency window; safe vs SDK bursts +// The catalog is expensive to serialize and Codex cancels discovery after about +// five seconds. Catalog-affecting writes bump `modelCatalogCacheVersion`, so a +// longer TTL keeps routine refreshes fast without delaying model updates. +const CATALOG_CACHE_TTL_MS = 60_000; const catalogCache = new Map(); const catalogInFlight = new Map>(); diff --git a/src/app/api/v1/providers/[provider]/images/generations/route.ts b/src/app/api/v1/providers/[provider]/images/generations/route.ts index f80f580b785b..5dcd9dbcee68 100644 --- a/src/app/api/v1/providers/[provider]/images/generations/route.ts +++ b/src/app/api/v1/providers/[provider]/images/generations/route.ts @@ -4,8 +4,6 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState, - extractApiKey, - isValidApiKey, } from "@/sse/services/auth"; import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts"; import * as log from "@/sse/utils/logger"; @@ -13,6 +11,9 @@ import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { v1ImageGenerationSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth"; +import { runWithCallLogApiKeyContext } from "@/lib/usage/callLogApiKeyContext"; +import { executeImageWithCredentialFallback } from "@/sse/services/imageCredentialRetry"; /** * Handle CORS preflight @@ -55,6 +56,9 @@ export async function POST(request, { params }) { body.model = `${rawProvider}/${body.model}`; } + const authRejection = await enforceClientApiRouteAuth(request); + if (authRejection) return authRejection; + // Enforce API key policies (model restrictions + budget limits) const policy = await enforceApiKeyPolicy(request, body.model); if (policy.rejection) return policy.rejection; @@ -68,7 +72,13 @@ export async function POST(request, { params }) { ); } - const credentials = await getProviderCredentialsWithQuotaPreflight(rawProvider); + const requestedModel = body.model.slice(rawProvider.length + 1); + let credentials = await getProviderCredentialsWithQuotaPreflight( + rawProvider, + null, + null, + requestedModel + ); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, @@ -84,7 +94,21 @@ export async function POST(request, { params }) { ); } - const result = await handleImageGeneration({ body, credentials, log }); + const execution = await executeImageWithCredentialFallback({ + provider: rawProvider, + requestedModel, + credentials, + execute: (attemptCredentials) => + runWithCallLogApiKeyContext( + { + apiKeyId: policy.apiKeyInfo?.id ?? null, + apiKeyName: policy.apiKeyInfo?.name ?? null, + }, + () => handleImageGeneration({ body, credentials: attemptCredentials, log }) + ), + }); + credentials = execution.credentials; + const result = execution.result; if (result.success) { await clearRecoveredProviderState(credentials); diff --git a/src/app/api/v1/responses/route.ts b/src/app/api/v1/responses/route.ts index 90639e8b04fe..673a20ff60d1 100644 --- a/src/app/api/v1/responses/route.ts +++ b/src/app/api/v1/responses/route.ts @@ -98,6 +98,11 @@ async function postHandler(request: any, context: any, preParsedBody: any = null return await withEarlyStreamKeepalive(handleChat(resolved, null, resolvedBody), { signal: request.signal, thresholdMs, + // Once the keepalive stream commits to 200 the status line is gone and the failure + // can only travel in-band. Responses API clients (Codex CLI) terminate a stream on + // `response.failed` and ignore `event: error`, so an OpenAI-shaped frame here reads + // to them as a stream that just stopped — no error surfaced, no retry. + errorFrameFormat: "responses", }); } return await handleChat(resolved, null, resolvedBody); diff --git a/src/app/authorize/parseCallback.ts b/src/app/authorize/parseCallback.ts index ff86d767ca9b..26038306d7ff 100644 --- a/src/app/authorize/parseCallback.ts +++ b/src/app/authorize/parseCallback.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + /** * Pure parser for the Trae SOLO /authorize callback query string. Extracted * from route.ts so it can be unit-tested without touching the DB layer. @@ -36,58 +38,133 @@ export type ParsedTraeCallback = { export type ParseError = { ok: false; error: string }; -export function parseTraeCallbackQuery(q: URLSearchParams): ParsedTraeCallback | ParseError { - const userJwtRaw = q.get("userJwt"); - if (!userJwtRaw) return { ok: false, error: "Missing userJwt in callback" }; +const MAX_TOKEN_LENGTH = 32 * 1024; +const MAX_JSON_LENGTH = 64 * 1024; +const MAX_DATE_MS = 8_640_000_000_000_000; +const DEFAULT_TRAE_API_HOST = "https://api-us-east.trae.ai"; + +const traeUserJwtSchema = z.object({ + ClientID: z.string().trim().min(1).max(256).optional(), + Token: z.string().min(1).max(MAX_TOKEN_LENGTH), + RefreshToken: z.string().max(MAX_TOKEN_LENGTH).optional(), + TokenExpireAt: z.number().int().nonnegative().max(MAX_DATE_MS).optional(), + RefreshExpireAt: z.number().int().nonnegative().max(MAX_DATE_MS).optional(), +}); + +const traeUserInfoSchema = z.object({ + UserID: z.string().max(512).optional(), + TenantID: z.string().max(512).optional(), + Region: z.string().max(128).optional(), + AIRegion: z.string().max(128).optional(), + ScreenName: z.string().max(512).optional(), + NonPlainTextEmail: z.string().max(512).optional(), +}); + +type JsonObjectResult = { ok: true; value: Record } | ParseError; + +function parseJsonObject(raw: string, label: string): JsonObjectResult { + if (raw.length > MAX_JSON_LENGTH) return { ok: false, error: `${label} payload is too large` }; + try { + const value = JSON.parse(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { ok: false, error: `Malformed ${label} payload` }; + } + return { ok: true, value }; + } catch { + return { ok: false, error: `Malformed ${label} payload` }; + } +} + +function normalizeTraeApiHost(raw: string | null): string | ParseError { + const candidate = raw || DEFAULT_TRAE_API_HOST; + if (candidate.length > 512) return { ok: false, error: "Invalid Trae API host" }; - let userJwt: Record; try { - userJwt = JSON.parse(userJwtRaw); + const url = new URL(candidate); + const hostname = url.hostname.toLowerCase(); + const approvedHostname = + hostname === "api.trae.ai" || /^api-[a-z0-9]+(?:-[a-z0-9]+)*\.trae\.ai$/.test(hostname); + if ( + url.protocol !== "https:" || + !approvedHostname || + url.username || + url.password || + url.port || + (url.pathname !== "/" && url.pathname !== "") || + url.search || + url.hash + ) { + return { ok: false, error: "Invalid Trae API host" }; + } + return url.origin; } catch { - return { ok: false, error: "Malformed userJwt payload" }; + return { ok: false, error: "Invalid Trae API host" }; } +} + +export function parseTraeCallbackQuery(q: URLSearchParams): ParsedTraeCallback | ParseError { + const userJwtRaw = q.get("userJwt"); + if (!userJwtRaw) return { ok: false, error: "Missing userJwt in callback" }; - const token = userJwt.Token as string | undefined; - if (!token) return { ok: false, error: "userJwt.Token missing" }; - const refresh = (userJwt.RefreshToken as string) || q.get("refreshToken") || null; - const tokenExpiresAtMs = Number(userJwt.TokenExpireAt) || 0; - const refreshExpiresAtMs = Number(userJwt.RefreshExpireAt || q.get("refreshExpireAt")) || 0; + const parsedUserJwt = parseJsonObject(userJwtRaw, "userJwt"); + if (!parsedUserJwt.ok) return parsedUserJwt; + const userJwtResult = traeUserJwtSchema.safeParse(parsedUserJwt.value); + if (!userJwtResult.success) return { ok: false, error: "Malformed userJwt payload" }; + const userJwt = userJwtResult.data; - let info: Record = {}; + const flatRefresh = q.get("refreshToken"); + if (flatRefresh && flatRefresh.length > MAX_TOKEN_LENGTH) { + return { ok: false, error: "Malformed refreshToken payload" }; + } + const refresh = userJwt.RefreshToken || flatRefresh || null; + const tokenExpiresAtMs = userJwt.TokenExpireAt || 0; + const flatRefreshExpiresAt = q.get("refreshExpireAt"); + if (flatRefreshExpiresAt && !/^\d{1,16}$/.test(flatRefreshExpiresAt)) { + return { ok: false, error: "Malformed refreshExpireAt payload" }; + } + const refreshExpiresAtMs = userJwt.RefreshExpireAt || Number(flatRefreshExpiresAt) || 0; + if (refreshExpiresAtMs > MAX_DATE_MS) { + return { ok: false, error: "Malformed refreshExpireAt payload" }; + } + + let info: z.infer = {}; const userInfoRaw = q.get("userInfo"); if (userInfoRaw) { - try { - info = JSON.parse(userInfoRaw); - } catch { - // userInfo is best-effort metadata — fall back to defaults silently. - } + const parsedUserInfo = parseJsonObject(userInfoRaw, "userInfo"); + if (!parsedUserInfo.ok) return parsedUserInfo; + const userInfoResult = traeUserInfoSchema.safeParse(parsedUserInfo.value); + if (!userInfoResult.success) return { ok: false, error: "Malformed userInfo payload" }; + info = userInfoResult.data; } - const userId = (info.UserID as string) || ""; - const region = (info.Region as string) || "US-East"; + const host = normalizeTraeApiHost(q.get("host")); + if (typeof host !== "string") return host; + + const userId = info.UserID || ""; + const region = info.Region || "US-East"; return { ok: true, record: { provider: "trae", authType: "oauth", - accessToken: token, + accessToken: userJwt.Token, refreshToken: refresh, expiresAt: tokenExpiresAtMs ? new Date(tokenExpiresAtMs).toISOString() : null, - email: (info.NonPlainTextEmail as string) || null, + email: info.NonPlainTextEmail || null, providerSpecificData: { userId, - tenantId: (info.TenantID as string) || "", + tenantId: info.TenantID || "", bizUserId: userId, userUniqueId: userId, webId: userId, scope: "marscode-us", tenant: "marscode", region, - aiRegion: (info.AIRegion as string) || region, - host: q.get("host") || "https://api-us-east.trae.ai", - screenName: (info.ScreenName as string) || null, - clientId: (userJwt.ClientID as string) || "en1oxy7wnw8j9n", + aiRegion: info.AIRegion || region, + host, + screenName: info.ScreenName || null, + clientId: userJwt.ClientID || "en1oxy7wnw8j9n", refreshExpireAt: refreshExpiresAtMs || null, authMethod: "oauth_callback", }, diff --git a/src/app/authorize/route.ts b/src/app/authorize/route.ts index b2a0954af686..b200baeb642c 100644 --- a/src/app/authorize/route.ts +++ b/src/app/authorize/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { createProviderConnection } from "@/models"; +import { consumeTraeCallbackState } from "@/lib/oauth/traeCallbackState"; import { parseTraeCallbackQuery } from "./parseCallback"; /** @@ -25,9 +26,10 @@ import { parseTraeCallbackQuery } from "./parseCallback"; * opening window before closing itself — that's how TraeAuthModal knows * the import succeeded. * - * State validation: the caller passes its UUID as `login_trace_id` in the - * authorize URL; Trae echoes it back as `loginTraceID`. The modal verifies - * the echoed state before trusting the postMessage. + * State validation: the dashboard obtains a short-lived, single-use UUID from + * the authenticated callback-state endpoint and passes it as `login_trace_id`. + * Trae echoes it back as `loginTraceID`; this handler consumes it atomically + * before any credential is persisted. */ function htmlClose(message: Record): NextResponse { // Embedding values: only emit the small/sanitized status payload — never the @@ -66,6 +68,9 @@ function htmlClose(message: Record): NextResponse { export async function GET(request: Request) { const url = new URL(request.url); const q = url.searchParams; + if (!consumeTraeCallbackState(q.get("loginTraceID"))) { + return htmlClose({ success: false, error: "Invalid or expired callback state" }); + } const parsed = parseTraeCallbackQuery(q); if (!parsed.ok) { return htmlClose({ success: false, error: parsed.error }); diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 4cc92e1d7f41..e1676009f8bb 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -457,5 +457,16 @@ export async function registerNodejs(): Promise { const msg = err instanceof Error ? err.message : String(err); console.warn("[STARTUP] Live dashboard WebSocket daemon failed to start (non-fatal):", msg); } + + try { + await import("@/server/liveStt/server"); + console.log("[STARTUP] Live SaluteSpeech STT WebSocket daemon bootstrap invoked"); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn( + "[STARTUP] Live SaluteSpeech STT WebSocket daemon failed to start (non-fatal):", + msg + ); + } } } diff --git a/src/lib/accessTokens/scopes.ts b/src/lib/accessTokens/scopes.ts index 225ed8f3c9da..de03b09f0493 100644 --- a/src/lib/accessTokens/scopes.ts +++ b/src/lib/accessTokens/scopes.ts @@ -7,8 +7,8 @@ * * Hierarchy (admin ⊃ write ⊃ read): * - read : list/inspect only (models list, providers status, logs, usage, cost) - * - write : read + configure/apply (setup-codex, keys add, config set, combo edit) - * - admin : write + sensitive management (tokens create/revoke, providers add, + * - write : read + configure/apply (config set, combo edit) + * - admin : write + sensitive management (tokens create/revoke, keys add/reveal, providers add, * services install/start, policy, oauth) * * Loopback-only routes that spawn processes (`isLocalOnlyPath`) are NEVER reachable diff --git a/src/lib/apiKeyExposure.ts b/src/lib/apiKeyExposure.ts index bd5a2123d6b3..0e6dea1222c2 100644 --- a/src/lib/apiKeyExposure.ts +++ b/src/lib/apiKeyExposure.ts @@ -1,3 +1,4 @@ +import { evaluateAccessTokenAuth, extractBearer } from "@/server/authz/accessTokenAuth"; import { isApiKeyRevealEnabledFlag } from "@/shared/utils/featureFlags"; const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]); @@ -13,6 +14,15 @@ export function isApiKeyRevealEnabled(): boolean { } } +/** A global reveal toggle never broadens a restricted CLI credential. */ +export function isApiKeyRevealEnabledForRequest(request: Request): boolean { + if (extractBearer(request)?.startsWith("oma_")) { + const verdict = evaluateAccessTokenAuth(request); + if (verdict.kind !== "ok" || verdict.scope !== "admin") return false; + } + return isApiKeyRevealEnabled(); +} + export function maskStoredApiKey(key: unknown): string | null { if (typeof key !== "string") return null; return key.slice(0, 8) + "****" + key.slice(-4); diff --git a/src/lib/db/databaseSettings.ts b/src/lib/db/databaseSettings.ts index fa63534b1856..a78a63188463 100644 --- a/src/lib/db/databaseSettings.ts +++ b/src/lib/db/databaseSettings.ts @@ -227,6 +227,10 @@ export function getUserDatabaseSettings(): UserDatabaseSettings { } mergeTopLevelSections(settings, mainSettings); + // The legacy cache-config endpoint persisted these as flat keys in the main + // settings namespace. Fold them into the canonical section before applying + // the newer databaseSettings namespace, which remains authoritative. + mergeDatabaseSettingsNamespace(settings, mainSettings); mergeDatabaseSettingsNamespace(settings, readNamespace(DATABASE_SETTINGS_NAMESPACE)); mergeRuntimeLogSettings(settings, mainSettings); normalizeOptimizationSettings(settings); @@ -295,11 +299,17 @@ export function updateDatabaseSettings( if (pipelineEnabled !== undefined) { settingsInsert.run("call_log_pipeline_enabled", JSON.stringify(Boolean(pipelineEnabled))); } + + if (updates.cache !== undefined) { + for (const [key, value] of Object.entries(nextSettings.cache)) { + settingsInsert.run(key, JSON.stringify(value)); + } + } }); tx(); backupDbFile("pre-write"); - invalidateDbCache("settings"); + invalidateDbCache("databaseSettings"); if (optimizationUpdated) { applyDatabaseOptimizationSettings(nextSettings.optimization); refreshVacuumScheduler(); diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 986844a5d0ef..ba0932f6251e 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -6,6 +6,7 @@ import { getDbInstance } from "./core"; import { backupDbFile } from "./backup"; +import { invalidateModelCatalogCache } from "./readCache"; import { type JsonRecord, asRecord, toNonEmptyString, getKeyValue } from "./models/shared"; import { readCompatList, @@ -128,6 +129,7 @@ export async function addCustomModel( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)" ).run(providerId, JSON.stringify(models)); backupDbFile("pre-write"); + invalidateModelCatalogCache(); return model; } @@ -231,6 +233,7 @@ export async function replaceCustomModels( } backupDbFile("pre-write"); + invalidateModelCatalogCache(); return merged; } @@ -262,6 +265,7 @@ export async function removeCustomModel(providerId: string, modelId: string) { removeModelCompatOverride(providerId, modelId); backupDbFile("pre-write"); + invalidateModelCatalogCache(); return true; } @@ -474,6 +478,7 @@ export async function replaceSyncedAvailableModelsForConnection( ).run(key, JSON.stringify(normalizedModels)); } backupDbFile("pre-write"); + invalidateModelCatalogCache(); // Return the full unioned list for the provider return getSyncedAvailableModels(providerId); } @@ -528,6 +533,7 @@ export async function removeSyncedAvailableModel( }); removeModel(); + if (removedAny) invalidateModelCatalogCache(); return removedAny; } @@ -545,6 +551,7 @@ export async function deleteSyncedAvailableModelsForConnection( key ); backupDbFile("pre-write"); + invalidateModelCatalogCache(); return getSyncedAvailableModels(providerId); } @@ -561,6 +568,7 @@ export async function deleteSyncedAvailableModelsForProvider(providerId: string) ) .run(keyPrefix.length, keyPrefix); backupDbFile("pre-write"); + if (result.changes) invalidateModelCatalogCache(); return Number(result.changes || 0); } @@ -585,6 +593,7 @@ export async function pruneStaleSyncedAvailableModelsForProvider( ) .run(`${keyPrefix}%`, ...allowedKeys); backupDbFile("pre-write"); + if (result.changes) invalidateModelCatalogCache(); return Number(result.changes || 0); } @@ -671,6 +680,7 @@ export async function updateCustomModel( ); backupDbFile("pre-write"); + invalidateModelCatalogCache(); return next; } @@ -863,6 +873,7 @@ export function setModelIsHidden(providerId: string, modelId: string, hidden: bo if (idx >= 0) list[idx] = next; else list.push(next); writeCompatList(providerId, list); + invalidateModelCatalogCache(); return; } @@ -871,10 +882,12 @@ export function setModelIsHidden(providerId: string, modelId: string, hidden: bo // Only `id` left; drop the entry entirely. const filtered = list.filter((_, i) => i !== idx); writeCompatList(providerId, filtered); + invalidateModelCatalogCache(); return; } delete list[idx].isHidden; writeCompatList(providerId, list); + invalidateModelCatalogCache(); } function readUpstreamFromJsonRecord( diff --git a/src/lib/db/models/aliases.ts b/src/lib/db/models/aliases.ts index b88e15d7c169..2631c3810f87 100644 --- a/src/lib/db/models/aliases.ts +++ b/src/lib/db/models/aliases.ts @@ -2,6 +2,7 @@ import { getDbInstance } from "../core"; import { backupDbFile } from "../backup"; +import { invalidateModelCatalogCache } from "../readCache"; import { getKeyValue } from "./shared"; export async function getModelAliases() { @@ -24,12 +25,14 @@ export async function setModelAlias(alias: string, model: unknown) { "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('modelAliases', ?, ?)" ).run(alias, JSON.stringify(model)); backupDbFile("pre-write"); + invalidateModelCatalogCache(); } export async function deleteModelAlias(alias: string) { const db = getDbInstance(); db.prepare("DELETE FROM key_value WHERE namespace = 'modelAliases' AND key = ?").run(alias); backupDbFile("pre-write"); + invalidateModelCatalogCache(); } /** diff --git a/src/lib/db/models/compat.ts b/src/lib/db/models/compat.ts index 54b05ba3a617..07dce7168ff6 100644 --- a/src/lib/db/models/compat.ts +++ b/src/lib/db/models/compat.ts @@ -2,6 +2,7 @@ import { getDbInstance } from "../core"; import { backupDbFile } from "../backup"; +import { invalidateModelCatalogCache } from "../readCache"; import { MODEL_COMPAT_PROTOCOL_KEYS, type ModelCompatProtocolKey, @@ -150,6 +151,7 @@ export function writeCompatList(providerId: string, list: ModelCompatOverride[]) ); } backupDbFile("pre-write"); + invalidateModelCatalogCache(); } export function getModelCompatOverrides(providerId: string): ModelCompatOverride[] { diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 47a18fee9fd6..7df607709d73 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -133,7 +133,10 @@ function findExistingCookieConnection( return null; } -export async function createProviderConnection(data: JsonRecord) { +export async function createProviderConnection( + data: JsonRecord, + options: { skipOAuthDedup?: boolean } = {} +) { const db = getDbInstance() as unknown as DbLike; const now = new Date().toISOString(); const normalizedProviderSpecificData = normalizeProviderSpecificData( @@ -146,7 +149,7 @@ export async function createProviderConnection(data: JsonRecord) { // We need to check for workspace uniqueness, not just email let existing: JsonRecord | null = null; - if (data.authType === "oauth" && data.email) { + if (!options.skipOAuthDedup && data.authType === "oauth" && data.email) { // For Codex, check for existing connection with same workspace const providerSpecificData = toRecord(data.providerSpecificData); const workspaceId = toStringOrNull(providerSpecificData.workspaceId); @@ -617,8 +620,9 @@ export async function clearConnectionErrorIfUnchanged( } ): Promise { const db = getDbInstance() as unknown as DbLike; - const result = db.prepare( - ` + const result = db + .prepare( + ` UPDATE provider_connections SET test_status = 'active', last_error = NULL, @@ -634,13 +638,14 @@ export async function clearConnectionErrorIfUnchanged( AND IFNULL(last_error_at, '') = ? AND IFNULL(rate_limited_until, '') = ? ` - ).run( - new Date().toISOString(), - id, - expected.testStatus ?? "", - expected.lastErrorAt ?? "", - expected.rateLimitedUntil ?? "" - ); + ) + .run( + new Date().toISOString(), + id, + expected.testStatus ?? "", + expected.lastErrorAt ?? "", + expected.rateLimitedUntil ?? "" + ); const applied = (result.changes ?? 0) > 0; if (applied) { backupDbFile("pre-write"); diff --git a/src/lib/db/readCache.ts b/src/lib/db/readCache.ts index 8ce10f1460d8..653e44c3ef28 100644 --- a/src/lib/db/readCache.ts +++ b/src/lib/db/readCache.ts @@ -58,6 +58,17 @@ const settingsCache = new TTLCache>(SETTINGS_TTL_MS); const pricingCache = new TTLCache>(PRICING_TTL_MS); const connectionsCache = new TTLCache(CONNECTIONS_TTL_MS); +export interface CachedDatabaseCacheSettings { + semanticCacheEnabled: boolean; + semanticCacheMaxSize: number; + semanticCacheTTL: number; + promptCacheEnabled: boolean; + promptCacheStrategy: "auto" | "system-only" | "manual"; + alwaysPreserveClientCache: "auto" | "always" | "never"; +} + +const databaseCacheSettingsCache = new TTLCache(SETTINGS_TTL_MS); + /** * Cached wrapper for getSettings. * Invalidated on every updateSettings() call. @@ -72,6 +83,17 @@ export async function getCachedSettings(): Promise> { return value; } +/** Cached consolidated cache settings from the database-settings namespace and legacy keys. */ +export async function getCachedDatabaseCacheSettings(): Promise { + const cached = databaseCacheSettingsCache.get("cache"); + if (cached) return cached; + + const { getUserDatabaseSettings } = await import("@/lib/db/databaseSettings"); + const value = getUserDatabaseSettings().cache; + databaseCacheSettingsCache.set("cache", value); + return value; +} + /** * Cached wrapper for getPricing. * Longer TTL since pricing rarely changes mid-session. @@ -142,6 +164,11 @@ export async function setCachedLKGP( lkgpCache.invalidate(`lkgp:${comboName}:${modelId}`); } +/** Drop one LKGP pin from the read cache (or all pins when no key is given). */ +export function invalidateCachedLKGP(pinKey?: string): void { + lkgpCache.invalidate(pinKey ? `lkgp:${pinKey}` : undefined); +} + // ──────────────── Combo Cache Invalidation Signal ──────────────── // // The nested-combo expansion caches live in request handlers @@ -186,12 +213,27 @@ export function getModelCatalogCacheVersion(): number { return modelCatalogCacheVersion; } +/** + * Invalidate the serialized `/v1/models` response cache after a direct catalog + * mutation. Model CRUD lives outside the settings/connections/combos caches, so + * those writes need an explicit version bump to make the next catalog request + * rebuild immediately instead of waiting for its response-cache TTL. + */ +export function invalidateModelCatalogCache(): void { + modelCatalogCacheVersion++; +} + /** * Invalidate all caches (call after writes to any of: settings, pricing, * connections, combos). */ -export function invalidateDbCache(scope?: "settings" | "pricing" | "connections" | "combos"): void { +export function invalidateDbCache( + scope?: "settings" | "databaseSettings" | "pricing" | "connections" | "combos" +): void { if (!scope || scope === "settings") settingsCache.invalidate(); + if (!scope || scope === "settings" || scope === "databaseSettings") { + databaseCacheSettingsCache.invalidate(); + } if (!scope || scope === "pricing") pricingCache.invalidate(); if (!scope || scope === "connections") connectionsCache.invalidate(); if (!scope || scope === "combos") combosCacheVersion++; diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 390551d8afc9..e07b54d51d7f 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -27,6 +27,14 @@ type ProxyResolutionCacheEntry = { }; const PROXY_RESOLUTION_CACHE_MAX_ENTRIES = 100; +const DATABASE_CACHE_SETTING_KEYS = new Set([ + "semanticCacheEnabled", + "semanticCacheMaxSize", + "semanticCacheTTL", + "promptCacheEnabled", + "promptCacheStrategy", + "alwaysPreserveClientCache", +]); function isTruthyEnvFlag(value: string | undefined): boolean { return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim()); @@ -138,7 +146,7 @@ export async function getSettings() { idempotencyWindowMs: 5000, wsAuth: false, maxBodySizeMb: requestBodyLimitMbFromEnv(process.env.MAX_BODY_SIZE_BYTES), - debugMode: true, + debugMode: false, // Opt-in diagnostic: when true, the chat handler emits a `log.debug("TOOLS", …)` // line per request summarizing tool count + MCP/hosted/client source breakdown. logToolSources: false, @@ -205,9 +213,15 @@ export async function updateSettings(updates: Record) { const insert = db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)" ); + const insertDatabaseCacheSetting = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('databaseSettings', ?, ?)" + ); const tx = db.transaction(() => { for (const [key, value] of Object.entries(updates)) { insert.run(key, JSON.stringify(value)); + if (DATABASE_CACHE_SETTING_KEYS.has(key)) { + insertDatabaseCacheSetting.run(`cache.${key}`, JSON.stringify(value)); + } } }); tx(); @@ -689,7 +703,7 @@ export { resetAllPricing, } from "./settings/pricing"; -export { type LKGPRecord, getLKGP, setLKGP, clearAllLKGP } from "./settings/lkgp"; +export { type LKGPRecord, getLKGP, setLKGP, clearAllLKGP, clearLKGP } from "./settings/lkgp"; export { type CacheTrendPoint, diff --git a/src/lib/db/settings/lkgp.ts b/src/lib/db/settings/lkgp.ts index b5b990f641b0..569f886aa673 100644 --- a/src/lib/db/settings/lkgp.ts +++ b/src/lib/db/settings/lkgp.ts @@ -47,3 +47,22 @@ export function clearAllLKGP(): void { const db = getDbInstance(); db.prepare("DELETE FROM key_value WHERE namespace = 'lkgp'").run(); } + +/** + * Delete one persisted LKGP pin after its target fails. `setLKGP` is only ever + * called on success — nothing previously invalidated a pin once its provider + * started failing, so a *separate* subsequent request kept re-selecting the + * same just-failed provider via `applyStrategyOrdering.ts`'s LKGP reordering + * (live incident: 3 consecutive requests all picked the same timed-out + * opencode-zen/big-pickle target instead of failing over to another combo + * model). Circuit breaker / model lockout deliberately don't react to this + * failure class (request-scoped timeouts, see comboPredicates.ts), so nothing + * else clears the stale pin. + */ +export async function clearLKGP(comboName: string, modelId: string): Promise { + const db = getDbInstance(); + const key = `${comboName}:${modelId}`; + db.prepare("DELETE FROM key_value WHERE namespace = 'lkgp' AND key = ?").run(key); + const { invalidateCachedLKGP } = await import("../readCache"); + invalidateCachedLKGP(key); +} diff --git a/src/lib/ipUtils.ts b/src/lib/ipUtils.ts index 3bf92d3127d4..6cb3ab535c76 100644 --- a/src/lib/ipUtils.ts +++ b/src/lib/ipUtils.ts @@ -1,4 +1,7 @@ -import { isIP } from "node:net"; +import { BlockList, isIP } from "node:net"; +import { resolveStampedPeer } from "@/server/authz/peerStampValue"; +import { PEER_IP_HEADER } from "@/server/authz/headers"; +import { CLIENT_IP_HEADER, resolveClientIpStamp } from "@/server/authz/clientIpStamp"; /** * T07: Extract the real client IP from X-Forwarded-For header. @@ -36,59 +39,61 @@ function normalizePeer(addr: string | undefined): string { return trimmed.startsWith("::ffff:") ? trimmed.slice("::ffff:".length) : trimmed; } -/** - * Whether the TCP peer is a loopback address (i.e. the request reached us via - * a local reverse proxy such as nginx). Only then is it safe to trust the - * forwarding headers — from a direct public socket those headers are - * attacker-controlled and must be ignored, otherwise per-IP brute-force - * buckets (login lockout, etc.) become spoofable / shareable. - * - * Ported from decolua/9router#1893. - */ -function isLoopbackPeer(addr: string | undefined): boolean { - const ip = normalizePeer(addr); - if (!ip) return false; - if (ip === "::1") return true; - return ip.startsWith("127."); +/** Only configured reverse proxies may supply an X-Forwarded-For chain. */ +function isTrustedProxy(address: string): boolean { + const ip = normalizePeer(address); + if (!isIP(ip)) return false; + const trusted = new BlockList(); + trusted.addSubnet("127.0.0.0", 8, "ipv4"); + trusted.addAddress("::1", "ipv6"); + for (const entry of (process.env.OMNIROUTE_TRUSTED_PROXY_IPS || "").split(",")) { + const [rawAddress, prefix, ...extra] = entry.trim().split("/"); + const candidate = normalizePeer(rawAddress); + const family = isIP(candidate); + if (!family || extra.length) continue; + const type = family === 4 ? "ipv4" : "ipv6"; + try { + if (prefix === undefined) trusted.addAddress(candidate, type); + else if (/^\d+$/.test(prefix)) trusted.addSubnet(candidate, Number(prefix), type); + } catch { + // Invalid configuration never broadens trust. + } + } + return trusted.check(ip, isIP(ip) === 4 ? "ipv4" : "ipv6"); } /** - * Extract client IP from a Request or NextRequest object. + * Resolve the client from a real peer, a server-authenticated peer stamp, or + * the pipeline's authenticated client-IP stamp. Header-only requests without + * these proofs share the unknown bucket; arbitrary forwarding headers cannot + * create fresh login buckets. CF-Connecting-IP is deliberately never trusted. * - * Behind a local reverse proxy (TCP peer is loopback) we trust the standard - * forwarding headers in priority order: CF-Connecting-IP > X-Forwarded-For > - * X-Real-IP. Directly from a public socket those headers are spoofable, so we - * key by the unspoofable TCP peer address instead. When no peer is known - * (edge runtime / fetch path with no socket) we fall back to the headers so - * we don't regress to "unknown" for every request in that path. + * Reverse proxies must overwrite XFF or append their immediate client to it. + * Walk from the socket towards the client, stopping at the first untrusted hop. */ export function getClientIpFromRequest(req: { headers?: Headers | { get?: (n: string) => string | null }; socket?: { remoteAddress?: string }; ip?: string; }): string { - // Helper to get header value from either Headers object or plain object - const getHeader = (name: string): string | null => { - if (!req.headers) return null; - if (typeof (req.headers as Headers).get === "function") { - return (req.headers as Headers).get(name); - } - return null; - }; - - const remoteAddress = req.ip ?? req.socket?.remoteAddress; - const hasPeer = Boolean(normalizePeer(remoteAddress)); - const trustForwardingHeaders = !hasPeer || isLoopbackPeer(remoteAddress); - - if (trustForwardingHeaders) { - const cfIp = getHeader("cf-connecting-ip"); - if (cfIp && isIP(cfIp.trim()) !== 0) return cfIp.trim(); - - const xff = getHeader("x-forwarded-for"); - const realIp = getHeader("x-real-ip"); - return extractClientIp(xff ?? realIp, remoteAddress); + const getHeader = (name: string): string | null => req.headers?.get?.(name) ?? null; + const token = process.env.OMNIROUTE_PEER_STAMP_TOKEN; + const rawPeer = + req.socket?.remoteAddress ?? req.ip ?? resolveStampedPeer(getHeader(PEER_IP_HEADER), token); + const peer = normalizePeer(rawPeer ?? undefined); + if (!isIP(peer)) { + return resolveClientIpStamp(getHeader(CLIENT_IP_HEADER), token) ?? "unknown"; } + if (!isTrustedProxy(peer)) return peer; - // Direct public peer — forwarding headers are attacker-controlled, ignore. - return normalizePeer(remoteAddress); + const forwarded = getHeader("x-forwarded-for") ?? getHeader("x-real-ip"); + if (!forwarded) return peer; + const hops = forwarded.split(",").map((part) => normalizePeer(part)); + let current = peer; + for (let i = hops.length - 1; i >= 0 && isTrustedProxy(current); i--) { + // A malformed nearest hop must not let us skip to attacker-controlled data. + if (!isIP(hops[i])) return current; + current = hops[i]; + } + return current; } diff --git a/src/lib/liveStt/session.ts b/src/lib/liveStt/session.ts new file mode 100644 index 000000000000..3cbe56cdce38 --- /dev/null +++ b/src/lib/liveStt/session.ts @@ -0,0 +1,107 @@ +import { randomUUID } from "node:crypto"; +import { SignJWT, jwtVerify } from "jose"; + +const LIVE_STT_AUDIENCE = "omniroute-salutespeech-live-stt"; +const DEFAULT_TTL_SECONDS = 60; +const MAX_TTL_SECONDS = 300; + +export type LiveSttSessionInput = { + apiKeyId: string | null; + connectionId: string; + origin: string; + language: string; + model: "general" | "callcenter"; +}; + +export type VerifiedLiveSttSession = LiveSttSessionInput & { + jti: string; + expiresAt: string; +}; + +const consumedSessions = new Map(); + +function getSecret(): Uint8Array { + const value = process.env.LIVE_STT_TOKEN_SECRET?.trim(); + if (!value) throw new Error("Live STT session signing secret is not configured"); + return new TextEncoder().encode(value); +} + +function getTtlSeconds(): number { + const value = Number.parseInt(process.env.LIVE_STT_SESSION_TTL_SECONDS ?? "", 10); + if (!Number.isSafeInteger(value) || value <= 0) return DEFAULT_TTL_SECONDS; + return Math.min(value, MAX_TTL_SECONDS); +} + +function cleanupConsumedSessions(now: number): void { + for (const [jti, expiresAt] of consumedSessions) { + if (expiresAt <= now) consumedSessions.delete(jti); + } +} + +export async function createLiveSttSession(input: LiveSttSessionInput): Promise<{ + token: string; + expiresAt: string; +}> { + const parsedOrigin = new URL(input.origin); + const ttlSeconds = getTtlSeconds(); + const expiresAt = new Date(Date.now() + ttlSeconds * 1000); + const jti = randomUUID(); + const token = await new SignJWT({ + connectionId: input.connectionId, + origin: parsedOrigin.origin, + language: input.language, + model: input.model, + apiKeyId: input.apiKeyId, + }) + .setProtectedHeader({ alg: "HS256" }) + .setAudience(LIVE_STT_AUDIENCE) + .setJti(jti) + .setIssuedAt() + .setExpirationTime(`${ttlSeconds}s`) + .sign(getSecret()); + + return { token, expiresAt: expiresAt.toISOString() }; +} + +export async function consumeLiveSttSession( + token: string, + origin: string +): Promise { + const { payload } = await jwtVerify(token, getSecret(), { + algorithms: ["HS256"], + audience: LIVE_STT_AUDIENCE, + }); + const connectionId = typeof payload.connectionId === "string" ? payload.connectionId : ""; + const sessionOrigin = typeof payload.origin === "string" ? payload.origin : ""; + const language = typeof payload.language === "string" ? payload.language : ""; + const model = + payload.model === "general" || payload.model === "callcenter" ? payload.model : null; + const jti = typeof payload.jti === "string" ? payload.jti : ""; + const exp = typeof payload.exp === "number" ? payload.exp : 0; + const apiKeyId = typeof payload.apiKeyId === "string" ? payload.apiKeyId : null; + if (!connectionId || !sessionOrigin || !language || !model || !jti || sessionOrigin !== origin) { + throw new Error("Invalid live STT session"); + } + + const now = Date.now(); + cleanupConsumedSessions(now); + const expiresAtMs = exp * 1000; + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now || consumedSessions.has(jti)) { + throw new Error("Live STT session is unavailable"); + } + consumedSessions.set(jti, expiresAtMs); + + return { + apiKeyId, + connectionId, + origin: sessionOrigin, + language, + model, + jti, + expiresAt: new Date(expiresAtMs).toISOString(), + }; +} + +export function resetLiveSttSessionsForTests(): void { + consumedSessions.clear(); +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 46f2eb67cf74..9dc826a52b94 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -142,6 +142,7 @@ export { // LKGP (Last Known Good Provider) (#919) getLKGP, setLKGP, + clearLKGP, // Pricing getPricing, diff --git a/src/lib/logPayloads.ts b/src/lib/logPayloads.ts index 12bda3c16940..1fe8fb5abf38 100644 --- a/src/lib/logPayloads.ts +++ b/src/lib/logPayloads.ts @@ -47,7 +47,8 @@ export function redactPayload(payload: unknown): unknown { const redacted: JsonRecord = {}; for (const [key, value] of Object.entries(payload)) { - if (SENSITIVE_KEYS.has(key)) { + const compactKey = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + if (SENSITIVE_KEYS.has(key) || compactKey.includes("apikey")) { redacted[key] = "[REDACTED]"; } else if (typeof value === "string" && value.startsWith("Bearer ")) { redacted[key] = "Bearer [REDACTED]"; diff --git a/src/lib/memory/extraction.ts b/src/lib/memory/extraction.ts index 48617e14acba..10ca887ff323 100644 --- a/src/lib/memory/extraction.ts +++ b/src/lib/memory/extraction.ts @@ -49,6 +49,105 @@ const MAX_FACT_LENGTH = 500; // Minimum content length to avoid noise const MIN_FACT_LENGTH = 3; const MAX_EXTRACTION_TEXT_LENGTH = 64 * 1024; +const DEFAULT_EXTRACTION_CONCURRENCY = 1; +const DEFAULT_EXTRACTION_QUEUE_MAX = 100; +const MAX_EXTRACTION_CONCURRENCY = 4; +const MAX_EXTRACTION_QUEUE_MAX = 1_000; + +type ExtractionJob = () => Promise; + +function readBoundedInteger( + value: string | undefined, + fallback: number, + minimum: number, + maximum: number +): number { + const parsed = Number(value); + if (!Number.isInteger(parsed)) return fallback; + return Math.min(Math.max(parsed, minimum), maximum); +} + +const extractionConcurrency = readBoundedInteger( + process.env.OMNIROUTE_MEMORY_EXTRACTION_CONCURRENCY, + DEFAULT_EXTRACTION_CONCURRENCY, + 1, + MAX_EXTRACTION_CONCURRENCY +); +const extractionQueueMax = readBoundedInteger( + process.env.OMNIROUTE_MEMORY_EXTRACTION_QUEUE_MAX, + DEFAULT_EXTRACTION_QUEUE_MAX, + 1, + MAX_EXTRACTION_QUEUE_MAX +); + +export interface MemoryExtractionQueueStats { + active: number; + queued: number; + concurrency: number; + maxQueue: number; +} + +export class MemoryExtractionQueue { + private readonly pending: ExtractionJob[] = []; + private active = 0; + private drainScheduled = false; + + constructor( + private readonly concurrency: number, + private readonly maxQueue: number, + private readonly schedule: (callback: () => void) => void = setImmediate + ) {} + + enqueue(job: ExtractionJob): boolean { + if (this.pending.length >= this.maxQueue) return false; + + this.pending.push(job); + this.scheduleDrain(); + return true; + } + + stats(): MemoryExtractionQueueStats { + return { + active: this.active, + queued: this.pending.length, + concurrency: this.concurrency, + maxQueue: this.maxQueue, + }; + } + + private scheduleDrain(): void { + if (this.drainScheduled || this.active >= this.concurrency || this.pending.length === 0) return; + + this.drainScheduled = true; + this.schedule(() => { + this.drainScheduled = false; + this.drain(); + }); + } + + private drain(): void { + while (this.active < this.concurrency && this.pending.length > 0) { + const job = this.pending.shift(); + if (!job) return; + + this.active += 1; + void job() + .catch((error) => { + log.error("memory.extraction.background.failed", { err: error?.message }); + }) + .finally(() => { + this.active -= 1; + this.scheduleDrain(); + }); + } + } +} + +const extractionQueue = new MemoryExtractionQueue(extractionConcurrency, extractionQueueMax); + +export function getMemoryExtractionQueueStats(): MemoryExtractionQueueStats { + return extractionQueue.stats(); +} // ─── Types ────────────────────────────────────────────────────────────────── @@ -162,34 +261,44 @@ export function extractFacts(response: string, apiKeyId: string, sessionId: stri if (!response || !apiKeyId || !sessionId) return; const cappedResponse = capExtractionText(response); + const accepted = extractionQueue.enqueue(async () => { + log.info("memory.extraction.start", { apiKeyId }); - log.info("memory.extraction.start", { apiKeyId }); - - // Non-blocking: schedule after current event loop tick - setImmediate(() => { const facts = extractFactsFromText(cappedResponse); - if (facts.length === 0) return; + if (facts.length === 0) { + log.info("memory.extraction.complete", { apiKeyId, factCount: 0 }); + return; + } for (const fact of facts) { log.debug("memory.extraction.fact_found", { key: fact.key, category: fact.category }); - createMemory({ - apiKeyId, - sessionId, - type: fact.type, - key: fact.key, - content: fact.content, - metadata: { - category: fact.category, - extractedAt: new Date().toISOString(), - source: "llm_response", - }, - expiresAt: null, - }).catch((err) => { - log.error("memory.extraction.background.failed", { err: err?.message, apiKeyId }); - }); + try { + await createMemory({ + apiKeyId, + sessionId, + type: fact.type, + key: fact.key, + content: fact.content, + metadata: { + category: fact.category, + extractedAt: new Date().toISOString(), + source: "llm_response", + }, + expiresAt: null, + }); + } catch (error) { + log.error("memory.extraction.background.failed", { err: error?.message, apiKeyId }); + } } log.info("memory.extraction.complete", { apiKeyId, factCount: facts.length }); }); + + if (!accepted) { + log.warn("memory.extraction.dropped", { + apiKeyId, + ...extractionQueue.stats(), + }); + } } diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index e3e6052f9d20..a25a6fd39b12 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -510,6 +510,21 @@ export function capThinkingBudget(input: CapabilityInput, budget: number): numbe return Math.min(budget, cap); } +/** Resolve operator/discovery context overrides without changing catalog capabilities. */ +export function getResolvedModelContextOverride(input: CapabilityInput): number | null { + const resolved = resolveCapabilityInput(input); + // An exact effort-specific override wins; otherwise inherit the base model's window. + const candidates = new Set([resolved.rawModel, resolved.model]); + for (const model of [...candidates]) { + if (model) candidates.add(model.replace(/-(xhigh|high|medium|low|none|minimal|max)$/i, "")); + } + for (const model of candidates) { + const override = getModelContextOverride(resolved.provider, model); + if (override !== null) return override; + } + return null; +} + export function getModelContextLimit( providerOrInput: CapabilityInput, modelId?: string @@ -521,6 +536,9 @@ export function getModelContextLimit( // Feature 5004: a persisted override (operator-set or auto-discovered) wins over the // static catalog / models.dev sync. `getResolvedModelCapabilities` stays override-free // so the reconciler can compare the catalog value against provider-declared windows. - const override = getModelContextOverride(resolved.provider, resolved.model); + const override = getResolvedModelContextOverride({ + provider: resolved.provider, + model: resolved.rawModel ?? resolved.model, + }); return override ?? resolved.contextWindow; } diff --git a/src/lib/oauth/connectionPersistence.ts b/src/lib/oauth/connectionPersistence.ts index 5ecc4e6b9016..db6fd8ec3cc6 100644 --- a/src/lib/oauth/connectionPersistence.ts +++ b/src/lib/oauth/connectionPersistence.ts @@ -19,7 +19,7 @@ import { syncToCloud } from "@/lib/cloudSync"; * Constant-time string comparison to prevent timing-oracle attacks (CWE-208). * Handles null/undefined safely and different-length strings. */ -function safeEqual(a: string | null | undefined, b: string | null | undefined): boolean { +export function safeEqual(a: string | null | undefined, b: string | null | undefined): boolean { if (a == null || b == null) return a === b; const ba = Buffer.from(String(a)); const bb = Buffer.from(String(b)); @@ -27,6 +27,49 @@ function safeEqual(a: string | null | undefined, b: string | null | undefined): return timingSafeEqual(ba, bb); } +/** + * Decide whether two Codex OAuth payloads identify the same account. Team + * accounts are keyed by workspace; personal accounts without a workspace must + * agree on ChatGPT user id because email alone is not a unique account key. + */ +function isSameCodexAccount( + existingProviderData: Record | null | undefined, + incomingProviderData: Record | null | undefined +): boolean { + const incomingWorkspace = incomingProviderData?.workspaceId; + const existingWorkspace = existingProviderData?.workspaceId; + if (incomingWorkspace || existingWorkspace) { + return safeEqual(existingWorkspace, incomingWorkspace); + } + + const incomingUserId = incomingProviderData?.chatgptUserId; + const existingUserId = existingProviderData?.chatgptUserId; + return Boolean(incomingUserId) && safeEqual(existingUserId, incomingUserId); +} + +/** + * Find the OAuth connection that should receive refreshed credentials. An + * explicit connection id remains authoritative; otherwise matching falls back + * to provider-specific account identity rules. + */ +export function findExistingOAuthConnectionMatch( + existing: Array>, + provider: string, + tokenData: Record, + connectionId?: string +): Record | undefined { + return existing.find((connection) => { + if (connection.id && safeEqual(connectionId, connection.id)) return true; + if (!safeEqual(connection.email, tokenData.email) || connection.authType !== "oauth") { + return false; + } + if (provider === "codex") { + return isSameCodexAccount(connection.providerSpecificData, tokenData.providerSpecificData); + } + return true; + }); +} + /** * Build the create payload for a brand-new OAuth connection. * @@ -69,7 +112,8 @@ async function syncToCloudIfEnabled(): Promise { export async function persistOAuthConnection( provider: string, tokenData: any, - connectionId?: string + connectionId?: string, + options: { allowImplicitMatch?: boolean } = {} ) { // Normalize: if name is missing, use email or displayName as fallback label. if (!tokenData.name && (tokenData.email || tokenData.displayName)) { @@ -83,16 +127,12 @@ export async function persistOAuthConnection( let connection: any; if (tokenData.email) { const existing = await getProviderConnections({ provider }); - const match = existing.find((c: any) => { - if (c.id && safeEqual(connectionId, c.id)) return true; - if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false; - // For Codex, also check workspaceId to avoid overwriting a different workspace. - if (provider === "codex" && tokenData.providerSpecificData?.workspaceId) { - const existingWorkspace = c.providerSpecificData?.workspaceId; - return safeEqual(existingWorkspace, tokenData.providerSpecificData.workspaceId); - } - return true; - }); + const match = + options.allowImplicitMatch === false + ? existing.find((candidate: any) => + candidate.id ? safeEqual(connectionId, candidate.id) : false + ) + : findExistingOAuthConnectionMatch(existing, provider, tokenData, connectionId); const matchId = typeof match?.id === "string" ? match.id : null; if (matchId) { connection = await updateProviderConnection(matchId, { @@ -105,7 +145,8 @@ export async function persistOAuthConnection( } if (!connection) { connection = await createProviderConnection( - buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt) + buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt), + { skipOAuthDedup: options.allowImplicitMatch === false } ); } diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index b9c36adc26b6..081333f4436d 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -126,6 +126,21 @@ export const GROK_CLI_CONFIG = { tokenUrl: "https://auth.x.ai/oauth2/token", }; +// xAI API OAuth Configuration (Authorization Code Flow with PKCE) +// This intentionally uses a separate provider from Grok Build: both use the +// public Grok CLI OAuth client, but their inference endpoints and model +// entitlements differ (`api.x.ai` vs `cli-chat-proxy.grok.com`). +export const XAI_OAUTH_CONFIG = { + clientId: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"), + authorizeUrl: "https://auth.x.ai/oauth2/authorize", + tokenUrl: "https://auth.x.ai/oauth2/token", + scope: "openid profile email offline_access grok-cli:access api:access", + codeChallengeMethod: "S256", + loopbackPort: 56121, + callbackPath: "/callback", + callbackHost: "127.0.0.1", +}; + // Kimi Coding OAuth Configuration (Device Code Flow) export const KIMI_CODING_CONFIG = { clientId: resolvePublicCred("kimi_id", "KIMI_CODING_OAUTH_CLIENT_ID"), @@ -490,6 +505,7 @@ export const PROVIDERS = { TRAE: "trae", CODEBUDDY_CN: "codebuddy-cn", GROK_CLI: "grok-cli", + XAI_OAUTH: "xai-oauth", ZED: "zed", ZED_HOSTED: "zed-hosted", }; diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index cc6ab06aec8f..38cdba9823f4 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -110,7 +110,7 @@ export function getProvider(name) { */ export function generateAuthData(providerName, redirectUri) { const provider = getProvider(providerName); - const pkce = generatePKCE(); + const pkce = generatePKCE(provider.pkceVerifierBytes || 32); let codeVerifier = pkce.codeVerifier; const { codeChallenge, state } = pkce; @@ -136,6 +136,7 @@ export function generateAuthData(providerName, redirectUri) { flowType: provider.flowType, fixedPort: provider.fixedPort, callbackPath: provider.callbackPath || "/callback", + callbackHost: provider.callbackHost || "localhost", supported: false, error, }; @@ -176,6 +177,7 @@ export function generateAuthData(providerName, redirectUri) { flowType: provider.flowType, fixedPort: provider.fixedPort, callbackPath: provider.callbackPath || "/callback", + callbackHost: provider.callbackHost || "localhost", }; } diff --git a/src/lib/oauth/providers/codex.ts b/src/lib/oauth/providers/codex.ts index 6829f0c5601c..d9d0aad702c8 100644 --- a/src/lib/oauth/providers/codex.ts +++ b/src/lib/oauth/providers/codex.ts @@ -77,6 +77,86 @@ function parseIdToken(idToken: string): { email: string | null; authInfo: CodexA } } +/** + * Workspace binding and plan tier carried by a Codex id_token. + */ +export interface CodexWorkspaceInfo { + workspaceId: string | null; + workspacePlanType: string; + chatgptUserId: string | null; + organizations: CodexAuthInfo["organizations"] | null; +} + +/** + * Build the persisted workspace record from the id_token auth claim. + * + * IMPORTANT: A user can have both Team and Personal workspaces. + * The JWT's chatgpt_account_id may not always reflect the workspace + * the user selected during OAuth. We need to be smart about selection. + * + * Selection logic: + * 1. If plan_type indicates team/business, use chatgpt_account_id + * 2. If plan_type is "free" but organizations has team workspace, use team + * 3. Otherwise use chatgpt_account_id as fallback + */ +function buildWorkspaceInfo(authInfo: CodexAuthInfo | null): CodexWorkspaceInfo { + let workspaceId = authInfo?.chatgpt_account_id || null; + let planType = (authInfo?.chatgpt_plan_type || "").toLowerCase(); + + // Check if we should use a team workspace instead + const organizations = authInfo?.organizations || []; + if (organizations.length > 0) { + // Find team/business workspace (non-default usually means team) + const teamOrg = organizations.find((org) => { + const title = (org.title || "").toLowerCase(); + const role = (org.role || "").toLowerCase(); + // Team workspaces typically have role like "member" or "admin" and non-personal titles + return ( + !org.is_default && + (title.includes("team") || + title.includes("business") || + title.includes("workspace") || + title.includes("org") || + role === "admin" || + role === "member") + ); + }); + + // If user's plan_type is "team" or we found a team org, prefer it + if (planType.includes("team") || planType.includes("chatgptteam")) { + // User authenticated via Team, use the chatgpt_account_id from JWT + } else if (teamOrg && (planType === "free" || planType === "")) { + // User has a team org but plan_type shows free - use team org instead + workspaceId = teamOrg.id; + planType = "team"; + } + } + + return { + workspaceId, + workspacePlanType: planType, + // Also store the full authInfo for future reference + chatgptUserId: authInfo?.chatgpt_user_id || null, + organizations: organizations.length > 0 ? organizations : null, + }; +} + +/** + * Re-derive workspace info from an id_token handed back by a later grant + * (notably the refresh_token grant, whose response carries a fresh id_token). + * + * Returns null when the token carries no usable auth claim, so callers keep the + * record they already persisted instead of overwriting it with empty defaults. + */ +export function deriveCodexWorkspaceInfo( + idToken: string | null | undefined +): CodexWorkspaceInfo | null { + if (!idToken) return null; + const { authInfo } = parseIdToken(idToken); + if (!authInfo) return null; + return buildWorkspaceInfo(authInfo); +} + export const codex = { config: CODEX_CONFIG, flowType: "authorization_code_pkce", @@ -139,8 +219,8 @@ export const codex = { mapTokens: (tokens, extra) => { // Parse id_token for email and auth info - let email = null; - let authInfo = extra?.authInfo || null; + let email: string | null = null; + let authInfo: CodexAuthInfo | null = extra?.authInfo || null; if (tokens.id_token) { const parsed = parseIdToken(tokens.id_token); @@ -151,55 +231,7 @@ export const codex = { } } - // Determine the correct workspace to use - // - // IMPORTANT: A user can have both Team and Personal workspaces. - // The JWT's chatgpt_account_id may not always reflect the workspace - // the user selected during OAuth. We need to be smart about selection. - // - // Selection logic: - // 1. If plan_type indicates team/business, use chatgpt_account_id - // 2. If plan_type is "free" but organizations has team workspace, use team - // 3. Otherwise use chatgpt_account_id as fallback - let workspaceId = authInfo?.chatgpt_account_id || null; - let planType = (authInfo?.chatgpt_plan_type || "").toLowerCase(); - - // Check if we should use a team workspace instead - const organizations = authInfo?.organizations || []; - if (organizations.length > 0) { - // Find team/business workspace (non-default usually means team) - const teamOrg = organizations.find((org) => { - const title = (org.title || "").toLowerCase(); - const role = (org.role || "").toLowerCase(); - // Team workspaces typically have role like "member" or "admin" and non-personal titles - return ( - !org.is_default && - (title.includes("team") || - title.includes("business") || - title.includes("workspace") || - title.includes("org") || - role === "admin" || - role === "member") - ); - }); - - // If user's plan_type is "team" or we found a team org, prefer it - if (planType.includes("team") || planType.includes("chatgptteam")) { - // User authenticated via Team, use the chatgpt_account_id from JWT - } else if (teamOrg && (planType === "free" || planType === "")) { - // User has a team org but plan_type shows free - use team org instead - workspaceId = teamOrg.id; - planType = "team"; - } - } - - const providerSpecificData = { - workspaceId, - workspacePlanType: planType, - // Also store the full authInfo for future reference - chatgptUserId: authInfo?.chatgpt_user_id || null, - organizations: organizations.length > 0 ? organizations : null, - }; + const providerSpecificData = buildWorkspaceInfo(authInfo); return { accessToken: tokens.access_token, diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index e2f9e10847b7..a1a82c18dd4c 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -26,6 +26,7 @@ import { kilocode } from "./kilocode"; import { cline } from "./cline"; import { windsurf } from "./windsurf"; import { grokCli } from "./grok-cli"; +import { xaiOauth } from "./xai-oauth"; import { codebuddyCn } from "./codebuddy-cn"; import { zed } from "./zed"; import { zedHosted } from "./zed-hosted"; @@ -54,6 +55,7 @@ export const PROVIDERS = { // devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login) "devin-cli": windsurf, "grok-cli": grokCli, + "xai-oauth": xaiOauth, "codebuddy-cn": codebuddyCn, // Zed IDE credential bridge — uses keychain import, not standard OAuth zed, diff --git a/src/lib/oauth/providers/xai-oauth.ts b/src/lib/oauth/providers/xai-oauth.ts new file mode 100644 index 000000000000..fb7e829596ea --- /dev/null +++ b/src/lib/oauth/providers/xai-oauth.ts @@ -0,0 +1,102 @@ +import crypto from "node:crypto"; + +import { XAI_OAUTH_CONFIG } from "../constants/oauth"; + +const BASE64_BLOCK_SIZE = 4; + +/** + * Extract display metadata from an id_token already returned by xAI's token + * endpoint. This is not used to authorize requests; xAI validates the access + * token upstream. + */ +export function decodeXaiIdTokenIdentity(idToken: unknown): { + email: string | null; + name: string | null; +} { + if (typeof idToken !== "string") return { email: null, name: null }; + const parts = idToken.split("."); + if (parts.length !== 3) return { email: null, name: null }; + + try { + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE; + const payload = JSON.parse( + Buffer.from(base64 + "=".repeat(padding), "base64").toString("utf8") + ); + return { + email: payload.email || payload.preferred_username || null, + name: payload.name || null, + }; + } catch { + return { email: null, name: null }; + } +} + +export const xaiOauth = { + config: XAI_OAUTH_CONFIG, + flowType: "authorization_code_pkce" as const, + fixedPort: XAI_OAUTH_CONFIG.loopbackPort, + callbackPath: XAI_OAUTH_CONFIG.callbackPath, + callbackHost: XAI_OAUTH_CONFIG.callbackHost, + // The official xAI flow uses a 96-byte random verifier (128 base64url chars). + pkceVerifierBytes: 96, + + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = { + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state, + nonce: crypto.randomBytes(16).toString("hex"), + plan: "generic", + referrer: "cli-proxy-api", + }; + const query = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + return `${config.authorizeUrl}?${query}`; + }, + + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`xAI token exchange failed: ${error}`); + } + + return response.json(); + }, + + mapTokens: (tokens) => { + const identity = decodeXaiIdTokenIdentity(tokens.id_token); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + email: identity.email, + name: identity.name || identity.email, + providerSpecificData: { + scope: tokens.scope || XAI_OAUTH_CONFIG.scope, + tokenType: tokens.token_type || "Bearer", + }, + }; + }, +}; diff --git a/src/lib/oauth/traeCallbackState.ts b/src/lib/oauth/traeCallbackState.ts new file mode 100644 index 000000000000..cdf8ef1fbf87 --- /dev/null +++ b/src/lib/oauth/traeCallbackState.ts @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; + +const STATE_TTL_MS = 10 * 60 * 1000; +const MAX_PENDING_STATES = 128; +const STORE_KEY = "__omnirouteTraeCallbackStates"; + +type TraeCallbackStateStore = Map; + +function store(): TraeCallbackStateStore { + const globals = globalThis as unknown as { + [STORE_KEY]?: TraeCallbackStateStore; + }; + if (!globals[STORE_KEY]) globals[STORE_KEY] = new Map(); + return globals[STORE_KEY]!; +} + +function prune(now = Date.now()): void { + const states = store(); + for (const [state, expiresAt] of states) { + if (expiresAt <= now) states.delete(state); + } + while (states.size >= MAX_PENDING_STATES) { + const oldest = states.keys().next().value; + if (typeof oldest !== "string") break; + states.delete(oldest); + } +} + +export function issueTraeCallbackState(): { state: string; expiresAt: number } { + prune(); + const state = randomUUID(); + const expiresAt = Date.now() + STATE_TTL_MS; + store().set(state, expiresAt); + return { state, expiresAt }; +} + +/** Atomically consume a live state. Missing, expired, and replayed states fail closed. */ +export function consumeTraeCallbackState(state: string | null): boolean { + if (!state) return false; + const states = store(); + const expiresAt = states.get(state); + if (expiresAt === undefined) return false; + states.delete(state); + return expiresAt > Date.now(); +} + +export function resetTraeCallbackStatesForTests(): void { + store().clear(); +} diff --git a/src/lib/oauth/utils/pkce.ts b/src/lib/oauth/utils/pkce.ts index 7c258caccb39..6cefd195f51c 100644 --- a/src/lib/oauth/utils/pkce.ts +++ b/src/lib/oauth/utils/pkce.ts @@ -3,8 +3,8 @@ import crypto from "crypto"; /** * Generate PKCE code verifier (43-128 characters) */ -export function generateCodeVerifier() { - return crypto.randomBytes(32).toString("base64url"); +export function generateCodeVerifier(bytes = 32) { + return crypto.randomBytes(bytes).toString("base64url"); } /** @@ -24,8 +24,8 @@ export function generateState() { /** * Generate complete PKCE pair */ -export function generatePKCE() { - const codeVerifier = generateCodeVerifier(); +export function generatePKCE(verifierBytes = 32) { + const codeVerifier = generateCodeVerifier(verifierBytes); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateState(); diff --git a/src/lib/providers/codexFastTier.ts b/src/lib/providers/codexFastTier.ts index 4f8887c7878d..ecb865eef949 100644 --- a/src/lib/providers/codexFastTier.ts +++ b/src/lib/providers/codexFastTier.ts @@ -14,6 +14,7 @@ export type CodexFastTierValue = CodexServiceTier; export type CodexGlobalServiceMode = "none" | CodexServiceTier; export const CODEX_FAST_TIER_DEFAULT_SUPPORTED_MODELS: readonly string[] = [ + "gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", diff --git a/src/lib/providers/staticModels.ts b/src/lib/providers/staticModels.ts index 88bb6d85d8ba..69c5b9bf5c7a 100644 --- a/src/lib/providers/staticModels.ts +++ b/src/lib/providers/staticModels.ts @@ -31,6 +31,7 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str { id: "universal-3-pro", name: "Universal 3 Pro (Transcription)" }, { id: "universal-2", name: "Universal 2 (Transcription)" }, ], + salutespeech: () => [{ id: "general", name: "General (Russian speech recognition)" }], antigravity: () => ANTIGRAVITY_PUBLIC_MODELS.map((model) => ({ ...model })), claude: () => [ { id: "claude-fable-5", name: "Claude Fable 5" }, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 01a13c7339e4..13b3e88f37dd 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -18,10 +18,7 @@ import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; import { validateImageProviderApiKey } from "@/lib/providers/imageValidation"; import { KiroService } from "@/lib/oauth/services/kiro"; import { usesCcWireImage } from "@omniroute/open-sse/services/ccWireImageBuiltins.ts"; -import { - buildProviderHeaders, - buildProviderUrl, -} from "@omniroute/open-sse/services/provider.ts"; +import { buildProviderHeaders, buildProviderUrl } from "@omniroute/open-sse/services/provider.ts"; import { OPENAI_LIKE_FORMATS, @@ -68,7 +65,9 @@ import { import { validateDeepgramProvider, validateAssemblyAIProvider, + validateSaluteSpeechProvider, validateElevenLabsProvider, + validateInferenceShProvider, validateInworldProvider, validateKieProvider, validateAwsPollyProvider, @@ -478,6 +477,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi bytez: validateBytezProvider, deepgram: validateDeepgramProvider, assemblyai: validateAssemblyAIProvider, + salutespeech: validateSaluteSpeechProvider, "fal-ai": ({ apiKey, providerSpecificData }: any) => validateImageProviderApiKey({ provider: "fal-ai", apiKey, providerSpecificData }), "stability-ai": ({ apiKey, providerSpecificData }: any) => @@ -489,6 +489,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi topaz: ({ apiKey, providerSpecificData }: any) => validateImageProviderApiKey({ provider: "topaz", apiKey, providerSpecificData }), elevenlabs: validateElevenLabsProvider, + "inference-sh": validateInferenceShProvider, inworld: validateInworldProvider, kie: validateKieProvider, "aws-polly": validateAwsPollyProvider, diff --git a/src/lib/providers/validation/audioMiscProviders.ts b/src/lib/providers/validation/audioMiscProviders.ts index 2672fce0c19c..9f54cb20366d 100644 --- a/src/lib/providers/validation/audioMiscProviders.ts +++ b/src/lib/providers/validation/audioMiscProviders.ts @@ -13,9 +13,17 @@ import { } from "./headers"; import { toValidationErrorResult, validationRead, validationWrite } from "./transport"; import { validateDirectChatProvider } from "./directChatProbe"; -import { buildRunwayApiUrl, buildRunwayHeaders, normalizeRunwayBaseUrl } from "@omniroute/open-sse/config/runway.ts"; -import { buildMaritalkChatUrl, buildMaritalkModelsUrl } from "@omniroute/open-sse/config/maritalk.ts"; +import { + buildRunwayApiUrl, + buildRunwayHeaders, + normalizeRunwayBaseUrl, +} from "@omniroute/open-sse/config/runway.ts"; +import { + buildMaritalkChatUrl, + buildMaritalkModelsUrl, +} from "@omniroute/open-sse/config/maritalk.ts"; import { signAwsRequest } from "@omniroute/open-sse/utils/awsSigV4.ts"; +import { randomUUID } from "node:crypto"; export async function validateDeepgramProvider({ apiKey, providerSpecificData = {} }: any) { try { @@ -55,6 +63,47 @@ export async function validateAssemblyAIProvider({ apiKey, providerSpecificData } } +export async function validateSaluteSpeechProvider({ apiKey, providerSpecificData = {} }: any) { + const clientId = getAwsProviderString(providerSpecificData.clientId); + if (!clientId) return { valid: false, error: "Missing SaluteSpeech OAuth client ID" }; + if (!getAwsProviderString(apiKey)) { + return { valid: false, error: "Missing SaluteSpeech OAuth client secret" }; + } + + const oauthUrl = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"; + const oauthScope = "SALUTE_SPEECH_PERS"; + + try { + const response = await validationWrite(oauthUrl, { + method: "POST", + headers: applyCustomUserAgent( + { + Authorization: `Basic ${Buffer.from(`${clientId}:${apiKey}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + RqUID: randomUUID(), + }, + providerSpecificData + ), + body: new URLSearchParams({ scope: oauthScope }), + }); + const payload = await response.json().catch(() => null); + if ( + response.ok && + payload && + typeof payload === "object" && + typeof (payload as { access_token?: unknown }).access_token === "string" + ) { + return { valid: true, error: null }; + } + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid SaluteSpeech OAuth credentials" }; + } + return { valid: false, error: `Validation failed: ${response.status}` }; + } catch (error: unknown) { + return toValidationErrorResult(error); + } +} + export async function validateElevenLabsProvider({ apiKey, providerSpecificData = {} }: any) { try { // Lightweight auth check endpoint @@ -80,6 +129,29 @@ export async function validateElevenLabsProvider({ apiKey, providerSpecificData } } +export async function validateInferenceShProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const response = await validationRead("https://api.inference.sh/v1/tasks", { + method: "GET", + headers: applyCustomUserAgent( + { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + providerSpecificData + ), + }); + + if (response.ok) return { valid: true, error: null }; + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + return { valid: false, error: `Validation failed: ${response.status}` }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + export async function validateInworldProvider({ apiKey, providerSpecificData = {} }: any) { try { // Inworld TTS lacks a simple key-introspection endpoint. @@ -227,7 +299,10 @@ export async function validateAwsPollyProvider({ apiKey, providerSpecificData = } } -export async function validateBailianCodingPlanProvider({ apiKey, providerSpecificData = {} }: any) { +export async function validateBailianCodingPlanProvider({ + apiKey, + providerSpecificData = {}, +}: any) { try { const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || @@ -579,4 +654,3 @@ export async function validatePoeProvider({ apiKey, providerSpecificData = {} }: return { valid: false, error: "Connection failed while testing Poe" }; } - diff --git a/src/lib/semanticCache.ts b/src/lib/semanticCache.ts index b185b19963ae..15a100c23d7b 100644 --- a/src/lib/semanticCache.ts +++ b/src/lib/semanticCache.ts @@ -6,7 +6,7 @@ * are cached after assembly; cache hits always return JSON. * Two-tier: in-memory LRU (fast) + SQLite (persistent across restarts). * - * Cache key = SHA-256(model + normalized messages + temperature + top_p) + * Cache key = SHA-256(version + model + complete request identity) * Bypass: X-OmniRoute-No-Cache: true * * @module lib/semanticCache @@ -18,6 +18,27 @@ import { getDbInstance } from "./db/core"; type JsonRecord = Record; +export interface SemanticCacheRuntimeConfig { + maxSize: number; + ttlMs: number; +} + +const DEFAULT_SEMANTIC_CACHE_CONFIG: SemanticCacheRuntimeConfig = { + maxSize: 100, + ttlMs: 1_800_000, +}; + +const CACHE_SIGNATURE_VERSION = 2; +const REQUEST_TRANSPORT_FIELDS = new Set([ + "model", + "messages", + "input", + "temperature", + "top_p", + "stream", + "stream_options", +]); + function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -31,6 +52,27 @@ function toNumber(value: unknown, fallback = 0): number { return fallback; } +function toPositiveInteger(value: unknown, fallback: number): number { + const parsed = toNumber(value, fallback); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map((entry) => canonicalize(entry)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value as JsonRecord) + .sort((left, right) => left.localeCompare(right)) + .map((key) => [key, canonicalize((value as JsonRecord)[key])]) + ); + } + return value; +} + +function isoNow(): string { + return new Date().toISOString(); +} + function ensureCacheMetricsTable() { try { const db = getDbInstance(); @@ -92,14 +134,41 @@ function getHeaderValue( // ─── Singleton ───────────────── let memoryCache: LRUCache | null = null; +let configuredCache = { ...DEFAULT_SEMANTIC_CACHE_CONFIG }; +let activeMemoryConfig = ""; + +function resolvedCacheConfig(): SemanticCacheRuntimeConfig & { maxBytes: number } { + return { + maxSize: toPositiveInteger(process.env.SEMANTIC_CACHE_MAX_SIZE, configuredCache.maxSize), + ttlMs: toPositiveInteger(process.env.SEMANTIC_CACHE_TTL_MS, configuredCache.ttlMs), + maxBytes: toPositiveInteger(process.env.SEMANTIC_CACHE_MAX_BYTES, 2 * 1024 * 1024), + }; +} + +/** Apply dashboard cache settings to the runtime. Environment variables remain explicit overrides. */ +export function configureSemanticCache(config: Partial): void { + configuredCache = { + maxSize: toPositiveInteger(config.maxSize, configuredCache.maxSize), + ttlMs: toPositiveInteger(config.ttlMs, configuredCache.ttlMs), + }; +} + +export function getSemanticCacheRuntimeConfig(): SemanticCacheRuntimeConfig { + const { maxSize, ttlMs } = resolvedCacheConfig(); + return { maxSize, ttlMs }; +} function getMemoryCache() { - if (!memoryCache) { + const config = resolvedCacheConfig(); + const configKey = `${config.maxSize}:${config.maxBytes}:${config.ttlMs}`; + if (!memoryCache || activeMemoryConfig !== configKey) { + memoryCache?.clear(); memoryCache = new LRUCache({ - maxSize: parseInt(process.env.SEMANTIC_CACHE_MAX_SIZE || "50", 10), - maxBytes: parseInt(process.env.SEMANTIC_CACHE_MAX_BYTES || String(2 * 1024 * 1024), 10), - defaultTTL: parseInt(process.env.SEMANTIC_CACHE_TTL_MS || "1800000", 10), + maxSize: config.maxSize, + maxBytes: config.maxBytes, + defaultTTL: config.ttlMs, }); + activeMemoryConfig = configKey; ensureCacheMetricsTable(); } return memoryCache; @@ -121,14 +190,19 @@ export function generateSignature( conversation, temperature = 0, topP = 1, - apiKeyId?: string + apiKeyId?: string, + requestVariant?: unknown ) { - const payload = JSON.stringify({ - model, - messages: normalizeConversation(conversation), - temperature, - top_p: topP, - }); + const payload = JSON.stringify( + canonicalize({ + version: CACHE_SIGNATURE_VERSION, + model, + messages: normalizeConversation(conversation), + temperature, + top_p: topP, + ...(requestVariant != null ? { request: requestVariant } : {}), + }) + ); const digest = crypto.createHash("sha256").update(payload).digest("hex"); // Per-key cache isolation (#3740) namespaces the signature with the apiKeyId as a // PLAINTEXT prefix instead of folding it into the digest. The apiKeyId is an internal @@ -140,13 +214,28 @@ export function generateSignature( return apiKeyId ? `${apiKeyId}.${digest}` : digest; } -function stringifyForSignature(value: unknown): string { - if (typeof value === "string") return value; - try { - return JSON.stringify(value); - } catch { - return String(value); +/** + * Capture every non-transport request field that may affect the returned answer. + * Unknown future fields are included automatically, preferring harmless misses over + * serving a response produced under a different output contract. + */ +export function requestVariantOf(body: unknown, context?: Record): unknown { + const record = asRecord(body); + const variant: JsonRecord = { + conversationField: Array.isArray(record.messages) + ? "messages" + : record.input !== undefined + ? "input" + : "none", + }; + + for (const key of Object.keys(record).sort()) { + if (!REQUEST_TRANSPORT_FIELDS.has(key)) variant[key] = record[key]; + } + if (context && Object.values(context).some((value) => value != null)) { + variant.context = context; } + return canonicalize(variant); } /** @@ -159,10 +248,14 @@ function normalizeConversation(conversation: unknown) { } if (!Array.isArray(conversation)) return []; - return conversation.map((item: Record) => ({ - role: typeof item?.role === "string" && item.role.trim().length > 0 ? item.role : "user", - content: stringifyForSignature(item?.content), - })); + return conversation.map((item: Record) => { + const normalized = { + ...item, + role: typeof item?.role === "string" && item.role.trim().length > 0 ? item.role : "user", + content: canonicalize(item?.content), + }; + return canonicalize(normalized); + }); } // ─── Cache Operations ───────────────── @@ -187,9 +280,9 @@ export function getCachedResponse(signature) { const db = getDbInstance(); const row = db .prepare( - "SELECT response, tokens_saved FROM semantic_cache WHERE signature = ? AND expires_at > datetime('now')" + "SELECT response, tokens_saved FROM semantic_cache WHERE signature = ? AND expires_at > ?" ) - .get(signature); + .get(signature, isoNow()); if (row) { const record = asRecord(row); @@ -228,10 +321,10 @@ export function getCachedResponse(signature) { * @param {string} model * @param {object} response - The API response to cache * @param {number} tokensSaved - Estimated tokens saved - * @param {number} [ttlMs] - TTL in ms (default: 1 hour) + * @param {number} [ttlMs] - Optional per-write TTL override */ -export function setCachedResponse(signature, model, response, tokensSaved = 0, ttlMs = 3600000) { - const ttl = parseInt(process.env.SEMANTIC_CACHE_TTL_MS || String(ttlMs), 10); +export function setCachedResponse(signature, model, response, tokensSaved = 0, ttlMs?: number) { + const ttl = toPositiveInteger(ttlMs, resolvedCacheConfig().ttlMs); // 1. Memory cache getMemoryCache().set(signature, { response, tokensSaved }, ttl); @@ -241,8 +334,9 @@ export function setCachedResponse(signature, model, response, tokensSaved = 0, t const db = getDbInstance(); const id = crypto.randomUUID(); const promptHash = signature.slice(0, 16); - const now = new Date().toISOString(); - const expiresAt = new Date(Date.now() + ttl).toISOString(); + const nowMs = Date.now(); + const now = new Date(nowMs).toISOString(); + const expiresAt = new Date(nowMs + ttl).toISOString(); db.prepare( `INSERT OR REPLACE INTO semantic_cache (id, signature, model, prompt_hash, response, tokens_saved, hit_count, created_at, expires_at) @@ -326,8 +420,8 @@ export function getCacheStats() { try { const db = getDbInstance(); const row = db - .prepare("SELECT COUNT(*) as count FROM semantic_cache WHERE expires_at > datetime('now')") - .get(); + .prepare("SELECT COUNT(*) as count FROM semantic_cache WHERE expires_at > ?") + .get(isoNow()); dbSize = toNumber(asRecord(row).count, 0); } catch { // DB not available diff --git a/src/lib/system/forkVersionCheck.ts b/src/lib/system/forkVersionCheck.ts new file mode 100644 index 000000000000..49df35b12716 --- /dev/null +++ b/src/lib/system/forkVersionCheck.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; + +const REPOSITORY = "fenix007/OmniRoute"; +const API_BASE = `https://api.github.com/repos/${REPOSITORY}`; +export const FORK_UPDATE_MESSAGE = + "Update this maintained fork by deploying its published Docker image."; + +const publishedRunsSchema = z.object({ + workflow_runs: z.array( + z.object({ + id: z.number().int().positive(), + head_sha: z.string().regex(/^[a-f0-9]{40}$/), + head_branch: z.literal("stable"), + status: z.literal("completed"), + conclusion: z.literal("success"), + }) + ), +}); +const comparisonSchema = z.object({ + status: z.enum(["ahead", "behind", "identical", "diverged"]), +}); + +export function getForkBuildVersion(): string | null { + return process.env.NEXT_PUBLIC_OMNIROUTE_FORK_VERSION?.trim() || null; +} + +function getBuildRef(version: string): string | null { + const sha = /^fork · sha-([a-f0-9]{7,40})$/.exec(version); + if (sha) return sha[1]; + return /^v?\d+\.\d+\.\d+-fork\.\d+$/.test(version) ? version : null; +} + +let cachedCheck: + | { current: string; expiresAt: number; result: ReturnType } + | undefined; + +export function resolveForkVersionInfo(current: string, fetchImpl?: typeof fetch) { + if (fetchImpl) return lookupForkVersionInfo(current, fetchImpl); + // The version route is force-dynamic. Cache the complete check here, including + // in-flight requests and outages, instead of relying on Next's fetch cache. + if (cachedCheck?.current === current && cachedCheck.expiresAt > Date.now()) { + return cachedCheck.result; + } + const result = lookupForkVersionInfo(current, fetch); + cachedCheck = { current, expiresAt: Date.now() + 300_000, result }; + return result; +} + +async function lookupForkVersionInfo(current: string, fetchImpl: typeof fetch) { + const unavailable = { + current, + latest: "unavailable", + updateAvailable: false, + channel: "fork", + autoUpdateSupported: false, + autoUpdateError: FORK_UPDATE_MESSAGE, + news: null, + checkStatus: "unavailable", + }; + const currentRef = getBuildRef(current); + if (!currentRef) return unavailable; + + const get = async (path: string) => { + const response = await fetchImpl(`${API_BASE}/${path}`, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "omniroute-fork-version-check", + }, + signal: AbortSignal.timeout(10_000), + cache: "no-store", + }); + if (!response.ok) throw new Error("Fork version lookup unavailable"); + return response.json(); + }; + + try { + // A stable branch commit is deployable only after both image builds and + // manifest publication succeed. Never advertise a pending or failed build. + const runs = publishedRunsSchema.parse( + await get( + "actions/workflows/fork-image-fenix007.yml/runs?branch=stable&status=success&per_page=1" + ) + ); + const latestRun = runs.workflow_runs[0]; + if (!latestRun) return unavailable; + + let updateAvailable = false; + if (!latestRun.head_sha.startsWith(currentRef)) { + const comparison = comparisonSchema.parse( + await get(`compare/${encodeURIComponent(currentRef)}...${latestRun.head_sha}?per_page=1`) + ); + // A different SHA alone does not imply an upgrade (rollbacks, newer local + // builds and diverged branches must not produce a downgrade notification). + updateAvailable = comparison.status === "ahead"; + } + + const latest = `sha-${latestRun.head_sha.slice(0, 7)}`; + return { + ...unavailable, + latest, + latestLabel: `fork · ${latest}`, + releaseUrl: `https://github.com/${REPOSITORY}/actions/runs/${latestRun.id}`, + updateAvailable, + checkStatus: "ok", + }; + } catch { + // Registry/GitHub outages must never fall back to an upstream npm release. + return unavailable; + } +} diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index b21b91005c27..39b96c144c4c 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -586,6 +586,7 @@ export async function checkConnection(conn) { expiresAt?: string; expiresIn?: number; providerSpecificData?: Record; + providerSpecificDataPatch?: Record; }; type ConnectionUpdate = Parameters[1]; @@ -621,15 +622,18 @@ export async function checkConnection(conn) { updateData.tokenExpiresAt = expiresAt; } // Merge new providerSpecificData and ALWAYS clear the refresh circuit - // breaker streak on a successful refresh. + // breaker streak on a successful refresh. A patch (e.g. the Codex plan + // tier re-derived from the refreshed id_token) carries only the keys the + // refresh corrected and is applied last. const mergedProviderData = { ...(conn.providerSpecificData || {}), ...(refreshResult.providerSpecificData || {}), + ...(refreshResult.providerSpecificDataPatch || {}), }; const clearedProviderData = clearRefreshCircuit(mergedProviderData); if (clearedProviderData !== undefined) { updateData.providerSpecificData = clearedProviderData; - } else if (refreshResult.providerSpecificData) { + } else if (refreshResult.providerSpecificData || refreshResult.providerSpecificDataPatch) { updateData.providerSpecificData = mergedProviderData; } await updateProviderConnection(conn.id, updateData); diff --git a/src/lib/usage/callLogApiKeyContext.ts b/src/lib/usage/callLogApiKeyContext.ts new file mode 100644 index 000000000000..a5c3c22308a6 --- /dev/null +++ b/src/lib/usage/callLogApiKeyContext.ts @@ -0,0 +1,26 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export interface CallLogApiKeyContext { + apiKeyId: string | null; + apiKeyName: string | null; +} + +const callLogApiKeyContext = new AsyncLocalStorage(); + +/** + * Bind API-key attribution to every call log emitted by one request. + * + * Modal handlers fan out into provider-specific helpers that write their own + * call logs. Request-scoped storage keeps that attribution available without + * threading identity parameters through every provider handler. + */ +export function runWithCallLogApiKeyContext( + context: CallLogApiKeyContext, + callback: () => TResult +): TResult { + return callLogApiKeyContext.run(context, callback); +} + +export function getCallLogApiKeyContext(): CallLogApiKeyContext | null { + return callLogApiKeyContext.getStore() ?? null; +} diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index f5fdb7cca4db..2407a2290177 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -7,11 +7,13 @@ import fs from "node:fs"; import path from "node:path"; +import { getEmptyResponseErrorCode } from "@omniroute/open-sse/services/combo/emptyResponseRetryBudget.ts"; import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts"; import { getDbInstance } from "../db/core"; import { collectReferencedArtifacts, selectCallLogIdsBefore } from "./callLogsBoundedQueries"; import { getRequestDetailLogByCallLogId } from "../db/detailedLogs"; import { shouldPersistToDisk } from "./migrations"; +import { getCallLogApiKeyContext } from "./callLogApiKeyContext"; import { getLoggedInputTokens, getLoggedOutputTokens, @@ -472,6 +474,14 @@ export function trimCallLogsToMaxRows(maxRows = getCallLogsTableMaxRows()) { } function mapSummaryRow(row: CallLogSummaryRow) { + const parsedError = parseInlineError(row.error_summary); + const structuredError = + parsedError && typeof parsedError === "object" ? (parsedError as JsonRecord) : null; + const errorCode = getEmptyResponseErrorCode({ + status: toNumber(row.status), + error: row.error_summary ?? undefined, + errorCode: typeof structuredError?.code === "string" ? structuredError.code : undefined, + }); const detailState = normalizeDetailState(row.detail_state); const provider = row.provider; const nodePrefix = row.provider_node_prefix ?? null; @@ -505,6 +515,7 @@ function mapSummaryRow(row: CallLogSummaryRow) { comboStepId: row.combo_step_id, comboExecutionKey: row.combo_execution_key, error: row.error_summary, + errorCode, detailState, artifactRelPath: row.artifact_relpath, artifactSizeBytes: row.artifact_size_bytes, @@ -550,7 +561,12 @@ export async function saveCallLog(entry: any) { if (!shouldPersistToDisk) return; try { - const apiKeyId = entry.apiKeyId || null; + const apiKeyContext = getCallLogApiKeyContext(); + // `||` (not `??`): an empty-string apiKeyId/apiKeyName is "unattributed", + // same as before this fallback existed — it must not be persisted verbatim + // nor block the request-scoped context. + const apiKeyId = entry.apiKeyId || apiKeyContext?.apiKeyId || null; + const apiKeyName = entry.apiKeyName || apiKeyContext?.apiKeyName || null; const noLogEnabled = Boolean(entry.noLog) || (apiKeyId ? isNoLog(apiKeyId) : false); const protectedRequestBody = noLogEnabled ? null : protectPayloadForLog(entry.requestBody); @@ -597,7 +613,7 @@ export async function saveCallLog(entry: any) { sourceFormat: entry.sourceFormat || null, targetFormat: entry.targetFormat || null, apiKeyId, - apiKeyName: entry.apiKeyName || null, + apiKeyName, comboName: entry.comboName || null, comboStepId: toStringOrNull(entry.comboStepId), comboExecutionKey: diff --git a/src/lib/usage/codexResetCredits.ts b/src/lib/usage/codexResetCredits.ts index 1e770270c04c..cc6aa8402964 100644 --- a/src/lib/usage/codexResetCredits.ts +++ b/src/lib/usage/codexResetCredits.ts @@ -1,4 +1,5 @@ -import { getProviderConnectionById, resolveProxyForConnection } from "@/lib/localDb"; +import { getProviderConnectionById } from "@/lib/db/providers"; +import { resolveProxyForConnection } from "@/lib/db/settings"; import { fetchAndPersistProviderLimits, refreshAndUpdateCredentials, @@ -26,6 +27,11 @@ type CodexConnectionLike = JsonRecord & { export type CodexResetCreditOutcome = "reset" | "alreadyRedeemed"; +export interface CodexResetCreditList { + availableCount: number; + credits: Array<{ expiresAt: string | null }>; +} + export class CodexResetCreditError extends Error { status: number; code: string; @@ -116,16 +122,47 @@ function isUnavailableResetCredit(record: JsonRecord): boolean { const status = normalizeOutcome( record.status ?? record.state ?? record.outcome ?? record.result ?? record.code ); - if (status && ["consumed", "redeemed", "used", "expired", "unavailable"].includes(status)) { + if ( + status && + ["consumed", "redeeming", "redeemed", "used", "expired", "unavailable"].includes(status) + ) { return true; } return record.consumed === true || record.redeemed === true || record.available === false; } -function extractCreditIdFromRecord(value: unknown): string | null { - const record = toRecord(value); - if (!record || Object.keys(record).length === 0 || isUnavailableResetCredit(record)) return null; - return extractStringField(record, ["credit_id", "creditId", "id"]); +function normalizeExpiry(record: JsonRecord): string | null { + const value = record.expires_at ?? record.expiresAt; + if (typeof value !== "string" || !value.trim()) return null; + + const timestamp = Date.parse(value.trim()); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null; +} + +function parseAvailableResetCredits(payload: unknown): Array<{ + id: string; + expiresAt: string | null; +}> { + const now = Date.now(); + + return getResetCreditCandidates(payload) + .flatMap((candidate) => { + const record = toRecord(candidate); + if (Object.keys(record).length === 0 || isUnavailableResetCredit(record)) return []; + + const id = extractStringField(record, ["credit_id", "creditId", "id"]); + if (!id) return []; + + const expiresAt = normalizeExpiry(record); + if (expiresAt && Date.parse(expiresAt) <= now) return []; + + return [{ id, expiresAt }]; + }) + .sort((left, right) => { + if (left.expiresAt === null) return right.expiresAt === null ? 0 : 1; + if (right.expiresAt === null) return -1; + return Date.parse(left.expiresAt) - Date.parse(right.expiresAt); + }); } function getResetCreditCandidates(payload: unknown): unknown[] { @@ -148,13 +185,24 @@ function getResetCreditCandidates(payload: unknown): unknown[] { return []; } -function parseAvailableResetCreditId(payload: unknown): string { - for (const candidate of getResetCreditCandidates(payload)) { - const creditId = extractCreditIdFromRecord(candidate); - if (creditId) return creditId; +function selectAvailableResetCredit( + payload: unknown, + expectedCreditExpiresAt?: string +): { id: string; expiresAt: string | null } { + const credit = parseAvailableResetCredits(payload)[0]; + if (!credit) { + throw new CodexResetCreditError(409, "no_credit", "No Codex reset credits are available."); } - throw new CodexResetCreditError(409, "no_credit", "No Codex reset credits are available."); + if (expectedCreditExpiresAt && credit.expiresAt !== expectedCreditExpiresAt) { + throw new CodexResetCreditError( + 409, + "credit_changed", + "The selected Codex reset credit changed before redemption." + ); + } + + return credit; } async function readResponsePayload(response: Response): Promise { @@ -264,18 +312,29 @@ async function fetchResetCredits(connection: CodexConnectionLike): Promise { let refreshedConnection = await refreshCodexConnectionIfNeeded(connection); - let creditsResponse = await fetchResetCredits(refreshedConnection); + let response = await fetchResetCredits(refreshedConnection); - if (creditsResponse.status === 401 || creditsResponse.status === 403) { + if (response.status === 401 || response.status === 403) { refreshedConnection = await refreshCodexConnectionIfNeeded(refreshedConnection, true); - creditsResponse = await fetchResetCredits(refreshedConnection); + response = await fetchResetCredits(refreshedConnection); } + return { connection: refreshedConnection, response }; +} + +async function consumeWithAuthRetry( + connection: CodexConnectionLike, + idempotencyKey: string, + expectedCreditExpiresAt?: string +): Promise<{ connection: CodexConnectionLike; response: Response }> { + const fetched = await fetchResetCreditsWithAuthRetry(connection); + let refreshedConnection = fetched.connection; + const creditsResponse = fetched.response; + const creditsPayload = await readResponsePayload(creditsResponse); if (!creditsResponse.ok) { throwKnownConsumeError(creditsPayload); @@ -286,8 +345,8 @@ async function consumeWithAuthRetry( ); } - const creditId = parseAvailableResetCreditId(creditsPayload); - let response = await postConsumeResetCredit(refreshedConnection, idempotencyKey, creditId); + const credit = selectAvailableResetCredit(creditsPayload, expectedCreditExpiresAt); + let response = await postConsumeResetCredit(refreshedConnection, idempotencyKey, credit.id); if (response.status === 401 || response.status === 403) { refreshedConnection = await refreshCodexConnectionIfNeeded(refreshedConnection, true); @@ -301,16 +360,57 @@ async function consumeWithAuthRetry( `Codex reset-credit API returned HTTP ${refreshedCreditsResponse.status}.` ); } - const refreshedCreditId = parseAvailableResetCreditId(refreshedCreditsPayload); - response = await postConsumeResetCredit(refreshedConnection, idempotencyKey, refreshedCreditId); + const refreshedCredit = selectAvailableResetCredit( + refreshedCreditsPayload, + expectedCreditExpiresAt + ); + response = await postConsumeResetCredit( + refreshedConnection, + idempotencyKey, + refreshedCredit.id + ); } return { connection: refreshedConnection, response }; } +export async function listCodexResetCredits(connectionId: string): Promise { + if (!connectionId || typeof connectionId !== "string" || !connectionId.trim()) { + throw new CodexResetCreditError(400, "connection_id_required", "connectionId is required."); + } + + try { + const connection = await loadCodexConnection(connectionId.trim()); + const { response } = await fetchResetCreditsWithAuthRetry(connection); + const payload = await readResponsePayload(response); + + if (!response.ok) { + throw new CodexResetCreditError( + 502, + "codex_reset_credit_upstream_error", + "Codex reset-credit API request failed." + ); + } + + const credits = parseAvailableResetCredits(payload); + return { + availableCount: credits.length, + credits: credits.map(({ expiresAt }) => ({ expiresAt })), + }; + } catch (error) { + if (error instanceof CodexResetCreditError) throw error; + throw new CodexResetCreditError( + 500, + "codex_reset_credit_list_failed", + "Failed to load Codex reset credits." + ); + } +} + export async function consumeCodexResetCredit( connectionId: string, - idempotencyKey: string + idempotencyKey: string, + expectedCreditExpiresAt?: string ): Promise<{ outcome: CodexResetCreditOutcome; usage: JsonRecord; @@ -324,7 +424,11 @@ export async function consumeCodexResetCredit( try { const connection = await loadCodexConnection(connectionId); - const { response } = await consumeWithAuthRetry(connection, idempotencyKey.trim()); + const { response } = await consumeWithAuthRetry( + connection, + idempotencyKey.trim(), + expectedCreditExpiresAt + ); const payload = await readResponsePayload(response); if (!response.ok) { diff --git a/src/lib/usage/costCalculator.ts b/src/lib/usage/costCalculator.ts index a8976319ba4b..a1ab8f1d915a 100644 --- a/src/lib/usage/costCalculator.ts +++ b/src/lib/usage/costCalculator.ts @@ -101,6 +101,8 @@ export function getCodexFastCostMultiplier( const modelKey = stripCodexEffortSuffix(normalizeModelName(String(model || "")).toLowerCase()); const compactModelKey = modelKey.replace(/-/g, ""); + // Codex Astra Fast is 2.5x Standard (https://developers.openai.com/codex/pricing). + if (modelKey === "gpt-6-astra" || compactModelKey === "gpt6astra") return 2.5; if ( /^gpt-5\.6-(?:sol|terra|luna)$/.test(modelKey) || /^gpt5\.6(?:sol|terra|luna)$/.test(compactModelKey) diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 6dcafb051f23..2f3e60b39b26 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -13,7 +13,12 @@ import { } from "@/lib/localDb"; import { syncToCloud } from "@/lib/cloudSync"; import { setQuotaCache } from "@/domain/quotaCache"; -import { buildClaudeExtraUsageConnectionUpdate } from "@/lib/providers/claudeExtraUsage"; +import { + buildClaudeExtraUsageConnectionUpdate, + CLAUDE_EXTRA_USAGE_ERROR_SOURCE, + isClaudeExtraUsageBlockEnabled, + isClaudeExtraUsageQueued, +} from "@/lib/providers/claudeExtraUsage"; import { clearRecoveredProviderState } from "@/sse/services/auth"; import { getMachineId } from "@/shared/utils/machine"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; @@ -426,12 +431,67 @@ export function hasUsableQuota(usage: JsonRecord): boolean { return false; } +// A window "still blocks" recovery when it governs quota and is either still +// exhausted with a real reset that hasn't passed yet, or exhausted with no +// parseable real reset at all (unknown-reset windows stay locked, matching +// the pre-existing kimi-coding partial-refresh semantics). +function windowStillExhaustedAfterRealReset(value: unknown, nowMs: number): boolean { + if (!isRecord(value)) return false; + if (value.unlimited === true) return false; + const remaining = + typeof value.remaining === "number" + ? value.remaining + : typeof value.remainingPercentage === "number" + ? value.remainingPercentage + : null; + if (remaining !== null && remaining > 0) return false; + if (value.resetAt == null) return true; + const resetMs = Date.parse(String(value.resetAt)); + if (Number.isNaN(resetMs)) return true; + return resetMs > nowMs; +} + export async function maybeClearRecoveredQuotaState( connection: ProviderConnectionLike, usage: JsonRecord ): Promise { if (!hasUsableQuota(usage)) return connection; if (isTerminalStatusForQuotaRecovery(connection.testStatus)) return connection; + if (connection.lastErrorType === "quota_exhausted") { + if ( + connection.lastErrorSource === CLAUDE_EXTRA_USAGE_ERROR_SOURCE && + isClaudeExtraUsageBlockEnabled(connection.provider, connection.providerSpecificData) && + isClaudeExtraUsageQueued(usage) + ) { + // Claude's pay-as-you-go extra-usage block is orthogonal to the + // session/weekly quota windows checked below: the upstream can report a + // fully recovered quota window while extraUsage.queued is still true. + // Only syncClaudeExtraUsageStateIfNeeded (buildClaudeExtraUsageConnectionUpdate) + // owns clearing this specific state — the general window-recovery logic + // below must not release it just because some quota window looks fresh. + return connection; + } + + const quotas = usage?.quotas; + if (isRecord(quotas)) { + // Honor the REAL per-window resetAt from the freshly fetched quota + // instead of the synthetic cooldown persisted at failure time (e.g. + // Claude's flat 1h SUBSCRIPTION_QUOTA_COOLDOWN_MS when no upstream + // reset was parseable). Only stay locked if some window that governs + // this connection's quota is still demonstrably exhausted. + const anyStillBlocking = Object.values(quotas).some((value) => + windowStillExhaustedAfterRealReset(value, Date.now()) + ); + if (anyStillBlocking) return connection; + } else if ( + connection.rateLimitedUntil && + new Date(connection.rateLimitedUntil).getTime() > Date.now() + ) { + // No quota object at all (degraded/failed fetch shape) — fall back to + // the previous synthetic-cooldown guard. + return connection; + } + } const hasTransientState = connection.testStatus === "unavailable" || diff --git a/src/lib/vscode/reasoningMetadata.ts b/src/lib/vscode/reasoningMetadata.ts index 03780176d57d..0ced6a39dada 100644 --- a/src/lib/vscode/reasoningMetadata.ts +++ b/src/lib/vscode/reasoningMetadata.ts @@ -17,7 +17,11 @@ export type VscodeCatalogModel = { }; const STANDARD_EFFORT_SUFFIX_PATTERN = /-(xhigh|high|medium|low|none)$/i; -const GPT_5_6_EXTENDED_EFFORT_SUFFIX_PATTERN = /^(.*gpt-5\.6-(?:sol|terra|luna))-(max|ultra)$/i; +// Models whose `max`/`ultra` tiers are real catalog entries rather than standard +// effort suffixes. Without a match here the base id never splits off, and VS Code +// discovery expands the alias again into invalid ids like `gpt-6-astra-max-high`. +const EXTENDED_EFFORT_SUFFIX_PATTERN = + /^(.*(?:gpt-5\.6-(?:sol|terra|luna)|gpt-6-astra))-(max|ultra)$/i; const DEFAULT_REASONING_EFFORT = "none"; const KNOWN_REASONING_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh", "max", "ultra"]); @@ -41,7 +45,7 @@ export function getCatalogModelName(model: VscodeCatalogModel) { } function matchReasoningEffortSuffix(modelId: string) { - const extendedMatch = modelId.match(GPT_5_6_EXTENDED_EFFORT_SUFFIX_PATTERN); + const extendedMatch = modelId.match(EXTENDED_EFFORT_SUFFIX_PATTERN); if (extendedMatch?.[1] && extendedMatch[2]) { return { baseModelId: extendedMatch[1], effort: extendedMatch[2].toLowerCase() }; } diff --git a/src/server/authz/accessScopes.ts b/src/server/authz/accessScopes.ts index f5422a746b38..d35a6d02dcb0 100644 --- a/src/server/authz/accessScopes.ts +++ b/src/server/authz/accessScopes.ts @@ -23,6 +23,9 @@ import { type AccessScope } from "@/lib/accessTokens/scopes"; /** Sensitive management surfaces — require `admin` for ALL methods. */ export const ADMIN_SCOPE_PREFIXES: readonly string[] = [ "/api/cli/tokens", // access-token management (create/list/revoke) + "/api/cli-tools/keys", // returns complete inference/management credentials + "/api/cli-tools/backups", // reads/restores configurations containing credentials + "/api/cli-tools/codex-profiles", // profile configs can contain inline credentials "/api/oauth", // OAuth authorization flows "/api/auth", // login / logout / session "/api/policy", // policy engine @@ -33,6 +36,9 @@ export const ADMIN_SCOPE_PREFIXES: readonly string[] = [ /** Require `admin` only for mutating methods; GET/HEAD under these stay `read`. */ export const ADMIN_MUTATION_PREFIXES: readonly string[] = [ "/api/providers", // POST add provider / rotate key = admin; GET status = read + "/api/keys", // key creation, rotation, scope changes and deletion are credential management + "/api/sync/tokens", // configuration-sync credential issuance and revocation + "/api/relay/tokens", // relay credential issuance and revocation "/api/cli-tools/apply", // writes config onto the host filesystem ]; @@ -54,6 +60,8 @@ export function inferRequiredScope(method: string, path: string): AccessScope { const p = path || "/"; if (matchesPrefix(p, ADMIN_SCOPE_PREFIXES)) return "admin"; + if (/^\/api\/keys\/[^/]+\/reveal\/?$/.test(p)) return "admin"; + if (/^\/api\/cli-tools\/[^/]+-settings(?:\/|$)/.test(p)) return "admin"; const isMutation = !READ_METHODS.has(m); if (isMutation && matchesPrefix(p, ADMIN_MUTATION_PREFIXES)) return "admin"; diff --git a/src/server/authz/accessTokenAuth.ts b/src/server/authz/accessTokenAuth.ts index cb7b98959fc2..635bffe66190 100644 --- a/src/server/authz/accessTokenAuth.ts +++ b/src/server/authz/accessTokenAuth.ts @@ -33,7 +33,15 @@ export function extractBearer(request: Request): string | null { function safePathname(url: string): string { try { - return new URL(url).pathname; + const pathname = new URL(url).pathname; + // Route handlers retain Next's configured basePath in request.url, while + // the proxy classification sees the path with it removed. Scope matching + // must use the same application-relative path at both auth gates. + const basePath = (process.env.OMNIROUTE_BASE_PATH || "").replace(/\/+$/, ""); + if (basePath && (pathname === basePath || pathname.startsWith(`${basePath}/`))) { + return pathname.slice(basePath.length) || "/"; + } + return pathname; } catch { return "/"; } diff --git a/src/server/authz/clientIpStamp.ts b/src/server/authz/clientIpStamp.ts new file mode 100644 index 000000000000..8e5c12c2b266 --- /dev/null +++ b/src/server/authz/clientIpStamp.ts @@ -0,0 +1,25 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { isIP } from "node:net"; + +/** Carries an authenticated client address across Next's socket-less boundary. */ +export const CLIENT_IP_HEADER = "x-omniroute-client-ip"; + +function mac(ip: string, token: string): Buffer { + return createHmac("sha256", token).update(`omniroute-client-ip-v1\n${ip}`).digest(); +} + +export function stampClientIp(ip: string, token: string | undefined): string | null { + if (!token || !isIP(ip)) return null; + return `${ip}|${mac(ip, token).toString("hex")}`; +} + +export function resolveClientIpStamp( + value: string | null, + token: string | undefined +): string | null { + if (!value || !token) return null; + const [ip, signature, ...extra] = value.split("|"); + if (extra.length || !isIP(ip) || !/^[a-f0-9]{64}$/.test(signature || "")) return null; + const provided = Buffer.from(signature, "hex"); + return timingSafeEqual(provided, mac(ip, token)) ? ip : null; +} diff --git a/src/server/authz/peerStamp.ts b/src/server/authz/peerStamp.ts index ba80df018969..0b11ed782733 100644 --- a/src/server/authz/peerStamp.ts +++ b/src/server/authz/peerStamp.ts @@ -1,38 +1,8 @@ import { timingSafeEqual } from "node:crypto"; import { classifyHostLocality } from "./routeGuard"; -/** - * Resolve the real peer IP from the trusted `|` stamp that the custom - * Node server writes into PEER_IP_HEADER (see scripts/dev/peer-stamp.mjs). Returns - * the IP ONLY when the token constant-time-matches this process's stamp token; - * any other value (no stamp, wrong/forged token, missing separator, empty IP) - * returns null. - * - * Pure + dependency-free so the auth boundary is directly unit-testable. - * - * SECURITY: this is the ONLY trustworthy locality signal in the Next middleware - * runtime (which has no socket). Never derive locality from the Host header — it - * is fully client-controlled, so `Host: 127.0.0.1` from a remote attacker would - * otherwise bypass the LOCAL_ONLY gate guarding spawn-capable routes. - */ -export function resolveStampedPeer( - headerValue: string | null, - token: string | undefined -): string | null { - if (!headerValue || !token) return null; - const sep = headerValue.indexOf("|"); - if (sep <= 0) return null; - const provided = headerValue.slice(0, sep); - const ip = headerValue.slice(sep + 1); - if (!ip) return null; - if (provided.length !== token.length) return null; - try { - if (!timingSafeEqual(Buffer.from(provided), Buffer.from(token))) return null; - } catch { - return null; - } - return ip; -} +import { resolveStampedPeer } from "./peerStampValue"; +export { resolveStampedPeer } from "./peerStampValue"; /** * Resolve the trusted "request arrived via a reverse proxy" marker stamped by diff --git a/src/server/authz/peerStampValue.ts b/src/server/authz/peerStampValue.ts new file mode 100644 index 000000000000..df099aa94ca1 --- /dev/null +++ b/src/server/authz/peerStampValue.ts @@ -0,0 +1,34 @@ +import { timingSafeEqual } from "node:crypto"; + +/** + * Resolve the real peer IP from the trusted `|` stamp that the custom + * Node server writes into PEER_IP_HEADER (see scripts/dev/peer-stamp.mjs). Returns + * the IP ONLY when the token constant-time-matches this process's stamp token; + * any other value (no stamp, wrong/forged token, missing separator, empty IP) + * returns null. + * + * Pure + dependency-free so the auth boundary is directly unit-testable. + * + * SECURITY: this is the ONLY trustworthy locality signal in the Next middleware + * runtime (which has no socket). Never derive locality from the Host header — it + * is fully client-controlled, so `Host: 127.0.0.1` from a remote attacker would + * otherwise bypass the LOCAL_ONLY gate guarding spawn-capable routes. + */ +export function resolveStampedPeer( + headerValue: string | null, + token: string | undefined +): string | null { + if (!headerValue || !token) return null; + const sep = headerValue.indexOf("|"); + if (sep <= 0) return null; + const provided = headerValue.slice(0, sep); + const ip = headerValue.slice(sep + 1); + if (!ip) return null; + if (provided.length !== token.length) return null; + try { + if (!timingSafeEqual(Buffer.from(provided), Buffer.from(token))) return null; + } catch { + return null; + } + return ip; +} diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index b440bad49fb6..68a1fa9d72d7 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -27,6 +27,8 @@ import { } from "./headers"; import type { AuthSubject, RouteClass, RouteClassification } from "./types"; import type { AuthOutcome, RoutePolicy } from "./context"; +import { getClientIpFromRequest } from "../../lib/ipUtils"; +import { CLIENT_IP_HEADER, stampClientIp } from "./clientIpStamp"; export interface AuthzPipelineOptions { enforce?: boolean; @@ -308,6 +310,12 @@ export async function runAuthzPipeline( // per-process token never reaches route handlers or upstream providers. requestHeaders.delete(PEER_IP_HEADER); requestHeaders.delete(VIA_PROXY_HEADER); + requestHeaders.delete(CLIENT_IP_HEADER); + const clientIpStamp = stampClientIp( + getClientIpFromRequest(request), + process.env.OMNIROUTE_PEER_STAMP_TOKEN + ); + if (clientIpStamp) requestHeaders.set(CLIENT_IP_HEADER, clientIpStamp); requestHeaders.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); requestHeaders.set(AUTHZ_HEADER_REQUEST_ID, requestId); diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index d9479e1e891d..7f9f4672596f 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -45,6 +45,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/headroom/start", // Headroom token-saver proxy lifecycle: spawns headroom-ai python CLI (Hard Rules #15 + #17) "/api/headroom/stop", // Headroom token-saver proxy lifecycle: sends SIGTERM/SIGKILL to managed PID (Hard Rules #15 + #17) "/api/oauth/cursor/auto-import", // spawns `execFile("which", ["cursor"])` to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. + "/api/oauth/kiro/auto-import", // reads Kiro and AWS SSO credentials from the host filesystem + "/api/oauth/cliproxy-import", // reads CLIProxyAPI credentials from the host filesystem "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review). "/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md. ]; @@ -166,9 +168,7 @@ export function isPrivateLanHost(hostHeader: string | null): boolean { * triggers the auto-update flow (spawns git checkout + npm install + pm2). * Hard Rules #15/#17 still apply to POST. */ -export const LOCAL_ONLY_API_GET_EXEMPTIONS: ReadonlySet = new Set([ - "/api/system/version", -]); +export const LOCAL_ONLY_API_GET_EXEMPTIONS: ReadonlySet = new Set(["/api/system/version"]); /** Safe HTTP methods that can be exempted for read-only paths. */ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); diff --git a/src/server/liveStt/server.ts b/src/server/liveStt/server.ts new file mode 100644 index 000000000000..a6da0b726cde --- /dev/null +++ b/src/server/liveStt/server.ts @@ -0,0 +1,237 @@ +import { createServer, type IncomingMessage } from "node:http"; +import { WebSocket, WebSocketServer } from "ws"; +import { consumeLiveSttSession } from "@/lib/liveStt/session"; +import { openSaluteSpeechLiveRecognition } from "@omniroute/open-sse/handlers/saluteSpeechLive.ts"; + +const DEFAULT_PORT = 20133; +const DEFAULT_HOST = "127.0.0.1"; +const MAX_AUDIO_CHUNK_BYTES = 64 * 1024; +const MAX_SESSION_AUDIO_BYTES = 10 * 1024 * 1024; +const MAX_SESSION_DURATION_MS = 300 * 1000; +const MAX_CONCURRENT_SESSIONS = 5; + +let activeSessions = 0; + +function parsePositiveInt(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function getProtocolToken(header: string | string[] | undefined): string | null { + const value = Array.isArray(header) ? header.join(",") : header; + if (!value) return null; + const protocol = value + .split(",") + .map((part) => part.trim()) + .find((part) => part.startsWith("live-stt.")); + const token = protocol?.slice("live-stt.".length) ?? ""; + return /^[A-Za-z0-9._-]{32,4096}$/.test(token) ? token : null; +} + +function send(socket: WebSocket, payload: Record): void { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(payload)); +} + +function close(socket: WebSocket, code: number, reason: string): void { + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { + socket.close(code, reason.slice(0, 123)); + } +} + +function parseStartMessage(data: WebSocket.RawData): { sampleRate: 8000 | 16000 | 48000 } | null { + if (!Buffer.isBuffer(data)) return null; + try { + const payload = JSON.parse(data.toString("utf8")) as { type?: unknown; sampleRate?: unknown }; + if (payload.type !== "start") return null; + if (payload.sampleRate !== 8000 && payload.sampleRate !== 16000 && payload.sampleRate !== 48000) + return null; + return { sampleRate: payload.sampleRate }; + } catch { + return null; + } +} + +async function getConnectionCredentials(connectionId: string) { + const { getProviderCredentialsWithQuotaPreflight } = await import("@/sse/services/auth"); + return getProviderCredentialsWithQuotaPreflight("salutespeech", null, null, null, { + forcedConnectionId: connectionId, + }); +} + +async function handleConnection(socket: WebSocket, request: IncomingMessage): Promise { + const token = getProtocolToken(request.headers["sec-websocket-protocol"]); + const origin = request.headers.origin; + if ( + !token || + !origin || + activeSessions >= + parsePositiveInt(process.env.LIVE_STT_MAX_CONCURRENT_SESSIONS, MAX_CONCURRENT_SESSIONS) + ) { + close(socket, 1008, "Live STT is unavailable"); + return; + } + + let session; + try { + session = await consumeLiveSttSession(token, origin); + } catch { + close(socket, 1008, "Invalid live STT session"); + return; + } + + activeSessions += 1; + let settled = false; + let started = false; + let initializing = false; + let receivedBytes = 0; + let recognition: Awaited> | null = null; + const timeout = setTimeout( + () => close(socket, 1000, "Live STT session duration limit reached"), + parsePositiveInt(process.env.LIVE_STT_MAX_SESSION_SECONDS, MAX_SESSION_DURATION_MS / 1000) * + 1000 + ); + + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + recognition?.close(); + activeSessions = Math.max(0, activeSessions - 1); + }; + + socket.once("close", finish); + socket.once("error", finish); + send(socket, { type: "ready", language: session.language, model: session.model }); + + socket.on("message", async (data, isBinary) => { + if (settled) return; + if (!started) { + if (isBinary) { + close(socket, 1008, "Send start options before audio"); + return; + } + const start = parseStartMessage(data); + if (!start) { + close(socket, 1008, "Invalid start options"); + return; + } + started = true; + initializing = true; + try { + const credentials = await getConnectionCredentials(session.connectionId); + if (!credentials) throw new Error("No active SaluteSpeech connection"); + recognition = await openSaluteSpeechLiveRecognition({ + credentials, + language: session.language, + model: session.model, + sampleRate: start.sampleRate, + onResult: (result) => send(socket, result), + onError: () => { + send(socket, { + type: "error", + code: "provider_error", + message: "Speech recognition provider failed", + }); + close(socket, 1011, "Speech recognition provider failed"); + }, + onEnd: () => close(socket, 1000, "Speech recognition finished"), + }); + initializing = false; + send(socket, { type: "started", sampleRate: start.sampleRate }); + } catch { + initializing = false; + send(socket, { + type: "error", + code: "provider_unavailable", + message: "Live STT is unavailable", + }); + close(socket, 1011, "Live STT is unavailable"); + } + return; + } + + if (!isBinary || !recognition) { + if (!initializing) close(socket, 1008, "Audio frames must be binary PCM"); + return; + } + const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer); + const maxChunkBytes = parsePositiveInt( + process.env.LIVE_STT_MAX_AUDIO_CHUNK_BYTES, + MAX_AUDIO_CHUNK_BYTES + ); + if (chunk.length === 0 || chunk.length > maxChunkBytes || chunk.length % 2 !== 0) { + close(socket, 1009, "Invalid audio frame"); + return; + } + receivedBytes += chunk.length; + if ( + receivedBytes > + parsePositiveInt(process.env.LIVE_STT_MAX_SESSION_AUDIO_BYTES, MAX_SESSION_AUDIO_BYTES) + ) { + close(socket, 1009, "Live STT audio limit reached"); + return; + } + recognition.writeAudio(chunk); + }); +} + +export function startLiveSttServer( + port = DEFAULT_PORT, + host = DEFAULT_HOST +): Promise { + const server = createServer((request, response) => { + if (request.url === "/readyz") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ status: "ok" })); + return; + } + response.writeHead(404, { "Content-Type": "application/json" }); + response.end('{"error":"not_found"}'); + }); + const websocketServer = new WebSocketServer({ + noServer: true, + maxPayload: parsePositiveInt(process.env.LIVE_STT_MAX_AUDIO_CHUNK_BYTES, MAX_AUDIO_CHUNK_BYTES), + }); + + server.on("upgrade", (request, socket, head) => { + if ( + new URL(request.url ?? "/", "http://localhost").pathname !== "/v1/audio/transcriptions/live" + ) { + socket.destroy(); + return; + } + websocketServer.handleUpgrade(request, socket, head, (websocket) => { + void handleConnection(websocket, request); + }); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.off("error", reject); + resolve(server); + }); + }); +} + +function isBuildOrTest(): boolean { + return ( + process.env.NEXT_PHASE === "phase-production-build" || + process.env.NODE_ENV === "test" || + process.argv.some((arg) => arg.includes("test")) + ); +} + +if (!isBuildOrTest() && process.env.OMNIROUTE_ENABLE_LIVE_STT !== "0") { + startLiveSttServer( + Number.parseInt(process.env.LIVE_STT_PORT ?? String(DEFAULT_PORT), 10), + process.env.LIVE_STT_HOST ?? DEFAULT_HOST + ) + .then(() => console.log("[LiveSTT] SaluteSpeech WebSocket server listening on 127.0.0.1:20133")) + .catch((error: unknown) => { + console.warn( + "[LiveSTT] Failed to start SaluteSpeech WebSocket server:", + error instanceof Error ? error.message : error + ); + }); +} diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index f19db09152cf..07bb0a34adaf 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -13,7 +13,7 @@ import { isCredentialBlob, submitCredentialBlob } from "@/shared/components/oaut const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy"]); /** Providers that use a local callback server on a random port (PKCE browser flow). */ -const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex"]); +const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "xai-oauth"]); /** * Phase 1 hotfix (2026-05-29): windsurf & devin-cli only support import-token. @@ -407,6 +407,11 @@ export default function OAuthModal({ let redirectUri: string; if (provider === "codex" || provider === "openai") { redirectUri = "http://localhost:1455/auth/callback"; + } else if (provider === "xai-oauth") { + // xAI registers a fixed native-app loopback callback. On remote installs + // the browser cannot reach OmniRoute there, so the user pastes the + // resulting callback URL into the existing manual-flow input. + redirectUri = "http://127.0.0.1:56121/callback"; } else if (provider === "windsurf" || provider === "devin-cli") { // Remote fallback: use OmniRoute's port with the /auth/callback path Windsurf expects. // On true localhost this code is never reached (callback server handles the flow above). diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index ead99f022446..6f35cdac9fa1 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -520,7 +520,7 @@ export default function Sidebar({

{customAppName || APP_CONFIG.name}

- v{APP_CONFIG.version} + {APP_CONFIG.versionLabel}
)} diff --git a/src/shared/components/TraeAuthModal.tsx b/src/shared/components/TraeAuthModal.tsx index 94d150878794..6516b0b4ca62 100644 --- a/src/shared/components/TraeAuthModal.tsx +++ b/src/shared/components/TraeAuthModal.tsx @@ -7,15 +7,6 @@ import Input from "./Input"; const TRAE_CLIENT_ID = "en1oxy7wnw8j9n"; -function uuid(): string { - const c = (globalThis.crypto || (globalThis as any).crypto) as Crypto | undefined; - if (c?.randomUUID) return c.randomUUID(); - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => { - const r = (Math.random() * 16) | 0; - return (ch === "x" ? r : (r & 0x3) | 0x8).toString(16); - }); -} - function randomHex(bytes: number): string { const buf = new Uint8Array(bytes); (globalThis.crypto || (globalThis as any).crypto).getRandomValues(buf); @@ -139,11 +130,23 @@ export default function TraeAuthModal({ return () => window.removeEventListener("message", onMessage); }, [isOpen, onSuccess, onClose]); - const handleAuthorizeWithBrowser = () => { + const handleAuthorizeWithBrowser = async () => { setError(null); setAuthorizing(true); - const traceId = uuid(); - traceIdRef.current = traceId; + let traceId: string; + try { + const response = await fetch("/api/oauth/trae/callback-state", { method: "POST" }); + const data = await response.json(); + if (!response.ok || typeof data.state !== "string") { + throw new Error(typeof data.error === "string" ? data.error : "Unable to start OAuth"); + } + traceId = data.state; + traceIdRef.current = traceId; + } catch (err) { + setAuthorizing(false); + setError(err instanceof Error ? err.message : "Unable to start OAuth"); + return; + } // Trae's authorize endpoint validates two things about auth_callback_url: // 1. host must be a loopback IP (127.0.0.1) — "localhost" hostname gets // rejected with "Login Failed". diff --git a/src/shared/constants/appConfig.ts b/src/shared/constants/appConfig.ts index f73868a70eee..f8fd30f638c7 100644 --- a/src/shared/constants/appConfig.ts +++ b/src/shared/constants/appConfig.ts @@ -1,9 +1,23 @@ import pkg from "../../../package.json" with { type: "json" }; +export function formatAppVersion(baseVersion: string, forkVersion?: string): string { + const normalizedForkVersion = forkVersion?.trim().replace(/^v/, ""); + if (!normalizedForkVersion || normalizedForkVersion === baseVersion) { + return `v${baseVersion}`; + } + + const forkSuffix = normalizedForkVersion.startsWith(`${baseVersion}-`) + ? normalizedForkVersion.slice(baseVersion.length + 1) + : normalizedForkVersion; + + return `v${baseVersion} · ${forkSuffix}`; +} + export const APP_CONFIG = { name: "OmniRoute", description: "AI Gateway for Multi-Provider LLMs", version: pkg.version, + versionLabel: formatAppVersion(pkg.version, process.env.NEXT_PUBLIC_OMNIROUTE_FORK_VERSION), }; export const THEME_CONFIG = { diff --git a/src/shared/constants/clientIdentityProfiles.ts b/src/shared/constants/clientIdentityProfiles.ts index 3788bd507b96..d0dd57a3efb5 100644 --- a/src/shared/constants/clientIdentityProfiles.ts +++ b/src/shared/constants/clientIdentityProfiles.ts @@ -39,7 +39,7 @@ const CODEX_CLI_PROFILE: ClientIdentityProfile = Object.freeze({ id: "codex-cli", label: "Codex CLI", headers: Object.freeze({ - "User-Agent": "codex_cli_rs/0.144.1", + "User-Agent": "codex_cli_rs/0.153.4", originator: "codex_cli_rs", }), }); diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index e70783b4ef56..61e316852772 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -91,6 +91,12 @@ export const MODEL_SPECS: Record = { aliases: ["openai/gpt-5.6-luna"], }, + // Public model limits; the Codex registry supplies its smaller OAuth window. + "gpt-6-astra": { + ...GPT_5_6_MODEL_SPEC, + aliases: ["openai/gpt-6-astra"], + }, + "gpt-5.5": { maxOutputTokens: 128000, contextWindow: 1050000, diff --git a/src/shared/constants/pricing/frontier-labs.ts b/src/shared/constants/pricing/frontier-labs.ts index 0c7117cb57f3..f4e16e3b206c 100644 --- a/src/shared/constants/pricing/frontier-labs.ts +++ b/src/shared/constants/pricing/frontier-labs.ts @@ -3,6 +3,7 @@ * Pure data; merged by default-pricing.ts via spread (god-file decomposition; semantic split). */ import { + GPT_6_ASTRA_PRICING, GPT_5_5_PRICING, GPT_5_6_LUNA_PRICING, GPT_5_6_SOL_PRICING, @@ -21,6 +22,7 @@ export const DEFAULT_PRICING_FRONTIER = { "gpt-5.6-sol": GPT_5_6_SOL_PRICING, "gpt-5.6-terra": GPT_5_6_TERRA_PRICING, "gpt-5.6-luna": GPT_5_6_LUNA_PRICING, + "gpt-6-astra": GPT_6_ASTRA_PRICING, "gpt-5.5": GPT_5_5_PRICING, // The -pro tier mirrors its base family pricing until OpenAI publishes a // distinct pro rate; without these rows the openai provider's gpt-5.x-pro diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index 19c1f2e991ad..d0338e02f2df 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -3,6 +3,7 @@ * Pure data; merged by default-pricing.ts via spread (god-file decomposition; semantic split). */ import { + GPT_6_ASTRA_PRICING, GPT_5_3_CODEX_PRICING, GPT_5_5_PRICING, GPT_5_6_LUNA_PRICING, @@ -100,6 +101,14 @@ export const DEFAULT_PRICING_OAUTH = { "gpt-5.6-luna-high": GPT_5_6_LUNA_PRICING, "gpt-5.6-luna-medium": GPT_5_6_LUNA_PRICING, "gpt-5.6-luna-low": GPT_5_6_LUNA_PRICING, + // GPT-6 Astra (paid Codex tiers only) + "gpt-6-astra": GPT_6_ASTRA_PRICING, + "gpt-6-astra-ultra": GPT_6_ASTRA_PRICING, + "gpt-6-astra-max": GPT_6_ASTRA_PRICING, + "gpt-6-astra-xhigh": GPT_6_ASTRA_PRICING, + "gpt-6-astra-high": GPT_6_ASTRA_PRICING, + "gpt-6-astra-medium": GPT_6_ASTRA_PRICING, + "gpt-6-astra-low": GPT_6_ASTRA_PRICING, // GPT 5.5 "gpt-5.5": GPT_5_5_PRICING, "gpt5.5": GPT_5_5_PRICING, diff --git a/src/shared/constants/pricing/shared-tiers.ts b/src/shared/constants/pricing/shared-tiers.ts index 596fe1744650..b4254557a96a 100644 --- a/src/shared/constants/pricing/shared-tiers.ts +++ b/src/shared/constants/pricing/shared-tiers.ts @@ -17,6 +17,16 @@ export const GPT_5_5_PRICING = { cache_creation: 5.0, }; +// GPT-6 Astra standard tier (USD per 1M tokens). Cache writes are 1.25x input, +// matching the rest of the OpenAI reasoning lineup. +export const GPT_6_ASTRA_PRICING = { + input: 10.0, + output: 50.0, + cached: 1.0, + reasoning: 50.0, + cache_creation: 12.5, +}; + export const GPT_5_6_SOL_PRICING = { input: 5.0, output: 30.0, diff --git a/src/shared/constants/providers/audio.ts b/src/shared/constants/providers/audio.ts index 9db459152482..266a07933049 100644 --- a/src/shared/constants/providers/audio.ts +++ b/src/shared/constants/providers/audio.ts @@ -21,6 +21,17 @@ export const AUDIO_ONLY_PROVIDERS = { textIcon: "AA", website: "https://assemblyai.com", }, + salutespeech: { + id: "salutespeech", + alias: "salute-speech", + name: "SaluteSpeech", + icon: "mic", + color: "#21A038", + textIcon: "SS", + website: "https://developers.sber.ru/docs/ru/salutespeech/overview", + authHint: + "Use the SaluteSpeech OAuth client secret as API key and enter its client ID below. Supports MP3, FLAC, Opus, and raw PCM audio.", + }, elevenlabs: { id: "elevenlabs", alias: "el", @@ -30,6 +41,16 @@ export const AUDIO_ONLY_PROVIDERS = { textIcon: "EL", website: "https://elevenlabs.io", }, + "inference-sh": { + id: "inference-sh", + alias: "inference", + name: "Inference.sh", + icon: "record_voice_over", + color: "#2563EB", + textIcon: "IS", + website: "https://inference.sh/apps/infsh/omnivoice", + authHint: "Create an Inference.sh API key. This provider uses infsh/omnivoice for TTS.", + }, cartesia: { id: "cartesia", alias: "cartesia", diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index 7eda70e7943c..0551e8938cc6 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -3,6 +3,19 @@ * Pure data literal; re-exported by the providers.ts barrel. No behavior change. */ export const OAUTH_PROVIDERS = { + "xai-oauth": { + id: "xai-oauth", + alias: "xao", + name: "xAI OAuth (Grok)", + icon: "auto_awesome", + color: "#1DA1F2", + textIcon: "XA", + website: "https://x.ai", + subscriptionRisk: true, + riskNoticeVariant: "oauth", + authHint: + "Sign in with xAI to use api.x.ai models such as Grok 4.5. This is separate from Grok Build JWT sessions, which use cli-chat-proxy.grok.com and grok-build model aliases.", + }, "grok-cli": { id: "grok-cli", alias: "gc", diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index a647a376f166..ea80e16245ef 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -34,6 +34,15 @@ const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); +// These routes inspect credentials stored on the OmniRoute host. Exclude them +// from the broad OAuth public prefix so the management policy can enforce its +// local-only gate before the handler runs. +const LOCAL_ONLY_OAUTH_IMPORT_ROUTES = [ + "/api/oauth/cliproxy-import", + "/api/oauth/cursor/auto-import", + "/api/oauth/kiro/auto-import", +]; + const PUBLIC_CLOUD_API_ROUTES = [ { path: "/api/cloud/auth", methods: new Set(["POST", "OPTIONS"]) }, { path: "/api/cloud/model/resolve", methods: new Set(["POST", "OPTIONS"]) }, @@ -52,6 +61,14 @@ function isPublicCloudApiRoute(pathname: string, method: string): boolean { } export function isPublicApiRoute(pathname: string, method = "GET"): boolean { + if ( + LOCAL_ONLY_OAUTH_IMPORT_ROUTES.some( + (route) => pathname === route || pathname.startsWith(`${route}/`) + ) + ) { + return false; + } + if (isPublicCloudApiRoute(pathname, method)) { return true; } diff --git a/src/shared/network/outboundUrlGuard.ts b/src/shared/network/outboundUrlGuard.ts index 1f51145c9de3..9ff7d094bcb9 100644 --- a/src/shared/network/outboundUrlGuard.ts +++ b/src/shared/network/outboundUrlGuard.ts @@ -39,9 +39,15 @@ export class OutboundUrlGuardError extends Error { } function normalizeHost(hostname: string) { - const normalized = hostname.trim().toLowerCase(); + let normalized = hostname.trim().toLowerCase(); if (normalized.startsWith("[") && normalized.endsWith("]")) { - return normalized.slice(1, -1); + normalized = normalized.slice(1, -1); + } + // DNS answers may use expanded IPv6, unlike URL.hostname. Compare the same + // canonical representation in both paths, including IPv4-mapped addresses. + if (isIP(normalized) === 6) { + normalized = normalized.split("%", 1)[0]; + return new URL(`http://[${normalized}]/`).hostname.slice(1, -1); } return normalized; } @@ -54,14 +60,13 @@ export function isPrivateHost(hostname: string) { normalized === "localhost" || normalized === "0.0.0.0" || normalized === "127.0.0.1" || - normalized === "::1" || + normalized.startsWith("::") || // unspecified, loopback, IPv4-compatible/mapped normalized.endsWith(".localhost") || normalized.endsWith(".local") || // `.internal` is reserved for private use (ICANN-style) and is the // hostname suffix used by GCP/Azure metadata probes // (e.g. `metadata.google.internal`). - normalized.endsWith(".internal") || - normalized.startsWith("::ffff:") + normalized.endsWith(".internal") ) { return true; } @@ -75,15 +80,17 @@ export function isPrivateHost(hostname: string) { if (a === 192 && b === 168) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 100 && b >= 64 && b <= 127) return true; + if (a >= 224) return true; // multicast, reserved, and limited broadcast return false; } if (isIP(normalized) === 6) { + const prefix = Number.parseInt(normalized.split(":", 1)[0], 16); return ( - normalized === "::1" || - normalized.startsWith("fc") || - normalized.startsWith("fd") || - normalized.startsWith("fe80:") + (prefix & 0xfe00) === 0xfc00 || // unique-local fc00::/7 + (prefix & 0xffc0) === 0xfe80 || // link-local fe80::/10 + (prefix & 0xffc0) === 0xfec0 || // deprecated site-local fec0::/10 + (prefix & 0xff00) === 0xff00 // multicast ff00::/8 ); } diff --git a/src/shared/utils/circuitBreaker.ts b/src/shared/utils/circuitBreaker.ts index ee898e319bfa..f926e7713b3e 100644 --- a/src/shared/utils/circuitBreaker.ts +++ b/src/shared/utils/circuitBreaker.ts @@ -32,11 +32,19 @@ import type { FailureKind } from "./classify429"; * own ReadableStream controller). It carries no `statusCode`, so it defaults to * HTTP 502 and would otherwise trip the provider circuit breaker — blacklisting * the entire Codex provider for a bug that lives in our bridge, not upstream. - * Use this with the breaker's `isFailure` option so the bridge error is ignored - * by the provider breaker while genuine upstream 5xx failures still count. + * Client-side aborts follow the same policy when they carry the platform's + * AbortError identity. Raw provider-controlled text is deliberately not enough + * to suppress health accounting; request-signal-aware call sites classify + * internal string abort reasons before they reach the breaker. + * + * Use this with the breaker's `isFailure` option so local lifecycle errors are + * ignored by the provider breaker while genuine upstream 5xx failures still count. */ export function isLocalStreamLifecycleError(error: unknown): boolean { if (!error) return false; + const name = + typeof (error as { name?: unknown }).name === "string" ? (error as { name: string }).name : ""; + if (name === "AbortError") return true; const message = typeof error === "string" ? error diff --git a/src/shared/utils/clientApiRouteAuth.ts b/src/shared/utils/clientApiRouteAuth.ts new file mode 100644 index 000000000000..65d12f100d5e --- /dev/null +++ b/src/shared/utils/clientApiRouteAuth.ts @@ -0,0 +1,50 @@ +/** + * Route-level authentication guard for CLIENT_API (`/api/v1/*`) handlers. + * + * Defence-in-depth companion to `clientApiPolicy` + * (src/server/authz/policies/clientApi.ts), which already fronts these routes + * through `src/proxy.ts`. The handler-level check must never be *stricter* + * than the middleware, otherwise callers the pipeline admits get a 401 from + * the route instead: + * + * - a presented key must be valid, but is only rejected while + * `REQUIRE_API_KEY=true`; with enforcement off a stale CLI key degrades to + * anonymous instead of failing the whole request (#2257); + * - a cookie-authenticated dashboard session is accepted in place of a key — + * the dashboard Media page and Playground call these routes with a session + * only (same fix as the preset auth mismatch in + * `src/app/api/playground/presets/route.ts`); + * - anonymous traffic stays allowed while `REQUIRE_API_KEY=false`. + * + * @module shared/utils/clientApiRouteAuth + */ + +import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; +import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; + +/** + * Authenticate a client-API request at the route level. + * + * @param request - The incoming request. + * @returns A 401 `Response` the handler must return, or `null` when the + * request may proceed to policy enforcement. + */ +export async function enforceClientApiRouteAuth(request: Request): Promise { + const apiKeyRaw = extractApiKey(request); + + if (apiKeyRaw) { + if (await isValidApiKey(apiKeyRaw)) return null; + return isRequireApiKeyEnabled() + ? errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key") + : null; + } + + if (await isDashboardSessionAuthenticated(request)) return null; + + return isRequireApiKeyEnabled() + ? errorResponse(HTTP_STATUS.UNAUTHORIZED, "Authentication required") + : null; +} diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index c45bd4fb2d08..72f3af64fe04 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -459,6 +459,7 @@ export const validateProviderApiKeySchema = z baseUrl: z.string().trim().url().optional(), region: z.string().trim().max(64).optional(), cx: z.string().trim().max(500).optional(), + clientId: z.string().trim().max(500).optional(), }) .superRefine((data, ctx) => { if (data.provider === "google-pse-search" && !data.cx) { @@ -468,4 +469,11 @@ export const validateProviderApiKeySchema = z path: ["cx"], }); } + if (data.provider === "salutespeech" && !data.clientId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "SaluteSpeech OAuth client ID is required", + path: ["clientId"], + }); + } }); diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 0bee6caa4245..abf013589783 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -19,12 +19,16 @@ import { } from "@omniroute/open-sse/services/accountFallback.ts"; import { getModelInfo, getComboForModel } from "../services/model"; import { resolveBareModelToConnectionDefault } from "@omniroute/open-sse/services/model.ts"; -import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import { errorResponse, createErrorResult } from "@omniroute/open-sse/utils/error.ts"; +import { + EmptyResponseRetryBudget, + EMPTY_RESPONSE_ATTEMPT_LIMIT, + EMPTY_RESPONSE_RETRY_EXHAUSTED, +} from "@omniroute/open-sse/services/combo/emptyResponseRetryBudget.ts"; import { getImageModelEntry } from "@omniroute/open-sse/config/imageRegistry.ts"; import { acceptHeaderForcesStream } from "@omniroute/open-sse/utils/aiSdkCompat.ts"; -import { isSelfInflictedUpstreamTimeout } from "@omniroute/open-sse/handlers/chatCore/cooldownClassification.ts"; import { applyNoThinkingAlias } from "@omniroute/open-sse/utils/noThinkingAlias.ts"; -import { handleComboChat } from "@omniroute/open-sse/services/combo.ts"; +import { handleComboChat, shouldSkipConnDisable } from "@omniroute/open-sse/services/combo.ts"; import { resolveRequestAutoControls } from "@omniroute/open-sse/services/autoCombo/requestControls.ts"; import { resolveComboConfig } from "@omniroute/open-sse/services/comboConfig.ts"; import { injectHandoffIntoBody } from "@omniroute/open-sse/services/contextHandoff.ts"; @@ -63,6 +67,7 @@ import { getCombosCacheVersion, getSessionAccountAffinity, } from "@/lib/localDb"; +import { dispatchChatWithAffinityEviction } from "./chatDispatch"; import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings"; import { ensureOpenAIStoreSessionFallback, @@ -72,7 +77,6 @@ import { guardrailRegistry, resolveDisabledGuardrails } from "@/lib/guardrails"; import { resolveModelOrError, checkPipelineGates, - executeChatWithBreaker, handleNoCredentials, safeResolveProxy, safeLogEvents, @@ -131,8 +135,16 @@ import { resolveCooldownAwareRetrySettings, waitForCooldownAwareRetry, } from "../services/cooldownAwareRetry"; +import { + shouldRetrySameAccountTransport, + sameAccountTransportRetryDelayMs, +} from "../services/sameAccountTransportRetry"; import { constrainConnectionsToQuota, resolveQuotaKeyScope } from "../../lib/quota/quotaKey"; import { checkConnectionCapacity } from "../utils/backpressure"; +import { + PROVIDER_BREAKER_FAILURE_STATUSES, + shouldTripProviderBreakerForResult, +} from "./chatPredicates"; registerCodexQuotaFetcher(); @@ -205,9 +217,33 @@ function intersectAllowedConnectionIds(primary: unknown, secondary: unknown): st return first || second || null; } -const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]); +function hasNonObjectMessageEntry(messages: unknown[]): boolean { + return messages.some( + (message) => message === null || typeof message !== "object" || Array.isArray(message) + ); +} + const comboPromoteDeps = { updateCombo, info: log.info, warn: log.warn }; +export { shouldTripProviderBreakerForResult } from "./chatPredicates"; + +async function readResponseErrorReason(response: Response): Promise { + try { + const payload = (await response.clone().json()) as { + error?: { message?: unknown } | unknown; + }; + const message = + payload.error && + typeof payload.error === "object" && + typeof (payload.error as { message?: unknown }).message === "string" + ? (payload.error as { message: string }).message + : null; + return message?.trim() || null; + } catch { + return null; + } +} + /** * Handle chat completion request * Supports: OpenAI, Claude, Gemini, OpenAI Responses API formats @@ -222,6 +258,7 @@ export async function handleChat( // Pipeline: Start request telemetry const reqId = correlationId || generateRequestId(); const telemetry = new RequestTelemetry(reqId); + const emptyResponseBudget = new EmptyResponseRetryBudget(); const backpressure = checkConnectionCapacity(); if (backpressure.shouldReject) { @@ -267,6 +304,10 @@ export async function handleChat( log.warn("CHAT", "Rejecting request with empty messages array"); return errorResponse(HTTP_STATUS.BAD_REQUEST, "messages: at least one message is required"); } + if (Array.isArray(b.messages) && hasNonObjectMessageEntry(b.messages)) { + log.warn("CHAT", "Rejecting request with non-object message entries"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "messages: Expected array of objects"); + } if (!("messages" in b) && !("input" in b)) { log.warn("CHAT", "Rejecting request with missing messages"); return errorResponse(HTTP_STATUS.BAD_REQUEST, "messages: Expected array, received undefined"); @@ -809,6 +850,7 @@ export async function handleChat( failoverBeforeRetry?: boolean; providerId?: string | null; effectiveComboStrategy?: string | null; + modelAbortSignal?: AbortSignal | null; } ) => handleSingleModelChat( @@ -835,7 +877,9 @@ export async function handleChat( cachedSettings: settings, providerId: target?.providerId ?? null, correlationId: reqId, + emptyResponseBudget, modelPinned: (target as any)?.modelPinned ?? false, + modelAbortSignal: target?.modelAbortSignal ?? null, }, target?.effectiveComboStrategy ?? combo.strategy, true @@ -913,14 +957,21 @@ export async function handleChat( // (success:false) so gate/breaker-rejected traffic is counted per key — support-mesh 2026-07-08. if (!response.ok) { try { - const { recordRejectedRequestUsage } = await import("./rejectedRequestUsage"); + const { + describeRejectedComboFailure, + recordRejectedRequestUsage, + } = await import("./rejectedRequestUsage"); await recordRejectedRequestUsage({ status: response.status, model: body?.model || resolvedModelStr, requestedModel: body?.model || resolvedModelStr, provider: "-", endpoint: clientRawRequest?.endpoint, - error: `[${response.status}] Combo "${combo.name}" failed — all targets exhausted`, + error: describeRejectedComboFailure({ + status: response.status, + comboName: combo.name, + reason: response.status === 499 ? await readResponseErrorReason(response) : null, + }), comboName: combo.name, apiKeyId: apiKeyInfo?.id ?? null, apiKeyName: apiKeyInfo?.name ?? null, @@ -948,6 +999,7 @@ export async function handleChat( forceLiveComboTest: isComboLiveTest, forcedConnectionId: requestedConnectionId, correlationId: reqId, + emptyResponseBudget, }, null, false @@ -982,6 +1034,7 @@ async function handleSingleModelChat( apiKeyInfo: any = null, telemetry: any = null, runtimeOptions: { + emptyResponseBudget?: EmptyResponseRetryBudget; emergencyFallbackTried?: boolean; forceLiveComboTest?: boolean; sessionId?: string | null; @@ -996,10 +1049,13 @@ async function handleSingleModelChat( cachedSettings?: any; providerId?: string | null; correlationId?: string | null; + modelAbortSignal?: AbortSignal | null; } = {}, comboStrategy: string | null = null, isCombo: boolean = false ) { + const emptyResponseBudget = runtimeOptions.emptyResponseBudget ?? new EmptyResponseRetryBudget(); + // 1. Resolve model → provider/model const resolved = await resolveModelOrError( modelStr, @@ -1055,6 +1111,7 @@ async function handleSingleModelChat( allowRateLimitedConnection: target?.allowRateLimitedConnection === true, providerId: target?.providerId ?? null, correlationId: runtimeOptions?.correlationId ?? null, + emptyResponseBudget, }, target?.effectiveComboStrategy ?? redirectCombo.strategy ?? "priority", false @@ -1096,6 +1153,15 @@ async function handleSingleModelChat( // Intentional override (e.g. providerId points to a different credential pool). return runtimeOptions.providerId; })(); + const emptyResponseLimitResponse = () => + createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + `[${provider}/${model}] returned empty output on ${EMPTY_RESPONSE_ATTEMPT_LIMIT} attempts; stopping retries for this provider/model`, + null, + EMPTY_RESPONSE_RETRY_EXHAUSTED + ).response; + if (emptyResponseBudget.isExhausted(provider, model)) return emptyResponseLimitResponse(); + const forceLiveComboTest = runtimeOptions.forceLiveComboTest === true; const bypassProviderQuotaPolicy = hasProviderQuotaBypassScope(apiKeyInfo?.scopes); const hasForcedConnection = @@ -1212,6 +1278,7 @@ async function handleSingleModelChat( // re-attempt to exactly one for the whole request. Declared outside both retry // loops so it can never reset and loop. let streamEarlyEofRetries = 0; + const sameAccountTransportRetries = new Map(); requestAttemptLoop: while (true) { const excludedConnectionIds = new Set(); @@ -1221,6 +1288,7 @@ async function handleSingleModelChat( let preselectedCredentials = runtimeOptions.preselectedCredentials; while (true) { + if (emptyResponseBudget.isExhausted(provider, model)) return emptyResponseLimitResponse(); const credentials = preselectedCredentials && excludedConnectionIds.size === 0 ? preselectedCredentials @@ -1285,10 +1353,22 @@ async function handleSingleModelChat( } const breakerFailureStatus = Number(lastStatus ?? credentials?.lastErrorCode); + // lastError is a string here — check for the proxy_unreachable tag embedded by + // tagProxyUnreachable (proxyFetch.ts) and OmniRoute's own queue timeouts. Both mean + // we never reached the provider, so they must not trip the provider breaker. + const isNetworkError = + typeof lastError === "string" && + (lastError.includes("proxy_unreachable") || lastError.includes("PROXY_UNREACHABLE")); + const isQueueTimeout = + typeof lastError === "string" && + (lastError.includes("RATE_LIMIT_QUEUE_TIMEOUT") || + lastError.includes("RATE_LIMIT_QUEUE_WEDGED")); if ( !forceLiveComboTest && credentials?.allRateLimited && - PROVIDER_BREAKER_FAILURE_STATUSES.has(breakerFailureStatus) + PROVIDER_BREAKER_FAILURE_STATUSES.has(breakerFailureStatus) && + !isNetworkError && + !isQueueTimeout ) { breaker._onFailure(); } @@ -1379,33 +1459,36 @@ async function handleSingleModelChat( // 4. Execute chat via core after breaker gate checks (with optional TLS tracking) if (telemetry) telemetry.startPhase("connect"); - const { result, tlsFingerprintUsed } = await executeChatWithBreaker({ - bypassCircuitBreaker: forceLiveComboTest || hasForcedConnection, - breaker, - body: requestBody, - provider, - model: effectiveModel, - refreshedCredentials, - proxyInfo, - appliedProxySink, - log, - clientRawRequest, - credentials, - apiKeyInfo, - userAgent, - comboName, - comboStrategy, - isCombo, - comboStepId: runtimeOptions.comboStepId ?? null, - comboExecutionKey: runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null, - extendedContext, - modelApiFormat: apiFormat, - providerProfile, - cachedSettings: runtimeOptions.cachedSettings, - skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false, - correlationId: runtimeOptions?.correlationId ?? null, - modelPinned: runtimeOptions?.modelPinned ?? false, - }); + const { result, tlsFingerprintUsed } = await dispatchChatWithAffinityEviction( + { + bypassCircuitBreaker: forceLiveComboTest || hasForcedConnection, + breaker, + body: requestBody, + provider, + model: effectiveModel, + refreshedCredentials, + proxyInfo, + appliedProxySink, + log, + clientRawRequest, + credentials, + apiKeyInfo, + userAgent, + comboName, + comboStrategy, + isCombo, + comboStepId: runtimeOptions.comboStepId ?? null, + comboExecutionKey: runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null, + extendedContext, + modelApiFormat: apiFormat, + providerProfile, + cachedSettings: runtimeOptions.cachedSettings, + skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false, + correlationId: runtimeOptions?.correlationId ?? null, + modelPinned: runtimeOptions?.modelPinned ?? false, + }, + runtimeOptions + ); if (telemetry) telemetry.endPhase(); const proxyLatency = Date.now() - proxyStartTime; @@ -1444,6 +1527,19 @@ async function handleSingleModelChat( return result.response; } + // Empty model output is request-specific: do not fan it out across the whole + // account pool and then reset that budget on the next combo retry. + if (emptyResponseBudget.recordFailure(provider, model, result)) { + log.warn( + "RETRY", + `${provider}/${model} reached the empty-response attempt limit (${EMPTY_RESPONSE_ATTEMPT_LIMIT})` + ); + return withSelectedConnectionHeader( + emptyResponseLimitResponse(), + credentials?.connectionId + ); + } + const isAntigravityStreamReadinessFailure = provider === "antigravity" && (result.errorCode === "STREAM_READINESS_TIMEOUT" || @@ -1716,6 +1812,35 @@ async function handleSingleModelChat( } } + // #9708: retry a retryable pre-output transport failure once on the same + // account (jittered 2-3s) before cooling the connection. A first 503/507 + // must not rotate away from a still-healthy Codex prompt-cache partition. + const transportAttempts = sameAccountTransportRetries.get(credentials.connectionId) || 0; + if ( + shouldRetrySameAccountTransport({ + status: result.status, + errorText: errorStr, + errorCode: result.errorCode, + errorType: result.errorType, + attempt: transportAttempts, + hasForcedConnection, + }) + ) { + sameAccountTransportRetries.set(credentials.connectionId, transportAttempts + 1); + const waitMs = sameAccountTransportRetryDelayMs(); + log.warn( + "RETRY", + `${provider}/${model} retryable pre-output ${result.status} — retrying same account once after ${waitMs}ms` + ); + const completed = await waitForCooldownAwareRetry(waitMs, requestSignal); + if (!completed) { + releaseOAuthSession(); + return errorResponse(499, "Request aborted"); + } + preselectedCredentials = credentials; + continue; + } + // 8. Fallback to next account // A3 guard: if 401 and connection has extra keys, skip connection-level disable // (key-level failure already recorded in chatCore.ts via T07) @@ -1724,13 +1849,7 @@ async function handleSingleModelChat( ((credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []).length > 0 || connectionHasExtraKeys(credentials.connectionId); const is401 = result.status === 401; - // Our own timeout fired on a slow upstream; don't cool down a healthy account. - const skipConnectionDisable = - result.status === 499 || - result.errorCode === "client_disconnected" || - result.errorType === "client_disconnected" || - (is401 && hasExtraKeys) || - isSelfInflictedUpstreamTimeout(result.status, result.errorType, provider); + const skipConnectionDisable = shouldSkipConnDisable(result, is401, hasExtraKeys, provider); const { shouldFallback, cooldownMs } = skipConnectionDisable ? { shouldFallback: false, cooldownMs: 0 } @@ -1780,11 +1899,7 @@ async function handleSingleModelChat( continue; } - if ( - !forceLiveComboTest && - !isCombo && - PROVIDER_BREAKER_FAILURE_STATUSES.has(Number(result.status)) - ) { + if (shouldTripProviderBreakerForResult(result, isCombo, forceLiveComboTest)) { breaker._onFailure(); } diff --git a/src/sse/handlers/chatDispatch.ts b/src/sse/handlers/chatDispatch.ts new file mode 100644 index 000000000000..4e1a8d6dd5aa --- /dev/null +++ b/src/sse/handlers/chatDispatch.ts @@ -0,0 +1,63 @@ +/** + * Dispatch seam between chat.ts and executeChatWithBreaker, extracted so the + * frozen god-file `chat.ts` does not grow (check:file-size). + * + * Its only job beyond forwarding the call is the #6219 follow-up: when a combo + * per-model timeout abandons the account this session is pinned to, drop the + * pin. See `evictSessionAffinityOnComboTimeout` for why the existing #6219 + * eviction never covers this path. + */ + +import { executeChatWithBreaker } from "./chatHelpers"; +import { evictSessionAffinityOnComboTimeout } from "../services/sessionAffinityPin"; + +/** The dispatch arguments chat.ts already assembles, plus what the eviction reads. */ +type DispatchArgs = { + provider: string; + credentials: { connectionId: string }; + clientRawRequest: any; + [key: string]: unknown; +}; + +/** Runtime fields this seam consults; the rest of runtimeOptions is ignored. */ +type DispatchRuntimeOptions = { + sessionAffinityKey?: string | null; + modelAbortSignal?: AbortSignal | null; +}; + +/** + * Run the upstream dispatch and evict the sticky session pin when a combo + * per-model timeout abandons it. + * + * The abort surfaces two ways: as a rejection out of `executeChatWithBreaker` + * (the common case — `buildTargetTimeoutRunner` then swallows it behind its + * synthetic 524), or as a failed result when an executor catches the abort + * itself. Both are covered. The eviction is a no-op unless this dispatch was + * aborted by the per-model timeout specifically. + */ +export async function dispatchChatWithAffinityEviction( + args: DispatchArgs, + runtimeOptions: DispatchRuntimeOptions +): Promise>> { + const evict = () => + evictSessionAffinityOnComboTimeout({ + sessionKey: runtimeOptions.sessionAffinityKey, + provider: args.provider, + connectionId: args.credentials?.connectionId, + modelAbortSignal: runtimeOptions.modelAbortSignal, + }); + + let dispatched: Awaited>; + try { + dispatched = await executeChatWithBreaker({ + ...args, + modelAbortSignal: runtimeOptions.modelAbortSignal ?? null, + } as any); + } catch (dispatchErr) { + evict(); + throw dispatchErr; + } + + if (!dispatched.result?.success) evict(); + return dispatched; +} diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 603ac78cd6d3..15bc275c5399 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -1,3 +1,8 @@ +export { + withSessionHeader, + withCorrelationId, + withSelectedConnectionHeader, +} from "./chatResponseHeaders.ts"; import { getModelInfo, getComboForModel } from "../services/model"; import { clearAccountError, markAccountUnavailable } from "../services/auth"; import { connectionHasExtraKeys } from "@omniroute/open-sse/services/apiKeyRotator.ts"; @@ -395,6 +400,7 @@ export async function executeChatWithBreaker({ trafficType = "production", correlationId = null, modelPinned = false, + modelAbortSignal = null, }: ExecuteChatWithBreakerOptions): Promise<{ result: any; tlsFingerprintUsed: boolean }> { let tlsFingerprintUsed = false; const normalizedTrafficType: TrafficType = @@ -432,6 +438,7 @@ export async function executeChatWithBreaker({ trafficType: normalizedTrafficType, correlationId, modelPinned, + upstreamAbortSignal: modelAbortSignal, onCredentialsRefreshed: async (newCreds: any) => { await updateProviderCredentials(credentials.connectionId, { accessToken: newCreds.accessToken, @@ -457,12 +464,12 @@ export async function executeChatWithBreaker({ if ( Number(failure?.status) === 499 || failure?.code === "client_disconnected" || - failure?.type === "client_disconnected" + failure?.type === "client_disconnected" || + isLocalStreamLifecycleError(failure) ) { return; } - // A3 guard: if 401 and connection has extra keys, skip connection-level disable - // (key-level failure already recorded in chatCore.ts via T07) + // A3 guard: extra-key 401s were already recorded per key in chatCore.ts (T07). // Check extra keys directly from credentials for reliability across restarts const extraKeys = (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []; @@ -587,12 +594,9 @@ export function handleNoCredentials( return modelCooldownResponse({ model: cooldownModel, retryAfter: credentials.retryAfter, - retryAfterAt: - typeof credentials.retryAfter === "string" ? credentials.retryAfter : null, + retryAfterAt: typeof credentials.retryAfter === "string" ? credentials.retryAfter : null, credentialsCoolingCount: - typeof credentials.connectionsCount === "number" - ? credentials.connectionsCount - : null, + typeof credentials.connectionsCount === "number" ? credentials.connectionsCount : null, }); } @@ -708,7 +712,10 @@ export async function safeResolveProxy(connectionId: string, apiKeyId?: string) // is dead/inactive must fail closed — egressing on the real IP leaks it. Reuse // the existing proxy-resolution-failure policy (blocks by default; PROXY_FAIL_OPEN // opts back into direct). Explicit "proxy off" is not a leak (see the guard). - if (!(resolved as { proxy?: unknown } | null)?.proxy && hasBlockingProxyAssignment(connectionId)) { + if ( + !(resolved as { proxy?: unknown } | null)?.proxy && + hasBlockingProxyAssignment(connectionId) + ) { return decideProxyResolutionFailure( Object.assign( new Error( @@ -819,57 +826,3 @@ export async function safeLogEvents({ }); } catch {} } - -export function withSessionHeader(response: Response, sessionId: string | null): Response { - if (!response || !sessionId) return response; - - try { - response.headers.set("X-OmniRoute-Session-Id", sessionId); - return response; - } catch { - const cloned = new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - cloned.headers.set("X-OmniRoute-Session-Id", sessionId); - return cloned; - } -} - -export function withCorrelationId(response: Response, correlationId: string | null): Response { - if (!response || !correlationId) return response; - - try { - response.headers.set("X-Correlation-Id", correlationId); - return response; - } catch { - const cloned = new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - cloned.headers.set("X-Correlation-Id", correlationId); - return cloned; - } -} - -export function withSelectedConnectionHeader( - response: Response, - connectionId: string | null | undefined -): Response { - if (!response || !connectionId) return response; - - try { - response.headers.set("X-OmniRoute-Selected-Connection-Id", connectionId); - return response; - } catch { - const cloned = new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - cloned.headers.set("X-OmniRoute-Selected-Connection-Id", connectionId); - return cloned; - } -} diff --git a/src/sse/handlers/chatPredicates.ts b/src/sse/handlers/chatPredicates.ts new file mode 100644 index 000000000000..6517bdbbc383 --- /dev/null +++ b/src/sse/handlers/chatPredicates.ts @@ -0,0 +1,70 @@ +import { isLocalStreamLifecycleError } from "../../shared/utils/circuitBreaker"; +import { + COMBO_HEDGE_CANCELLED_REASON, + COMBO_PER_MODEL_TIMEOUT_REASON, +} from "../../../open-sse/services/combo/comboAbortReasons"; + +export const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]); + +export function classifyLocalAbortFailure( + error: unknown, + requestSignalAborted = false, + abortReason?: unknown +): { status: 499; message: string } | null { + const message = + typeof error === "string" + ? error + : error && + typeof error === "object" && + typeof (error as { message?: unknown }).message === "string" + ? (error as { message: string }).message + : ""; + // Fetch may throw a generic AbortError; the local signal retains the actual + // orchestration reason. Keep the 499 control flow while preserving diagnostics. + const localReason = abortReason ?? error; + const reasonMessage = + typeof localReason === "string" + ? localReason + : localReason && typeof localReason === "object" && "message" in localReason + ? String(localReason.message) + : ""; + if (requestSignalAborted && reasonMessage === COMBO_PER_MODEL_TIMEOUT_REASON) { + return { status: 499, message: "Model timeout: combo-per-model-timeout" }; + } + if (requestSignalAborted && reasonMessage === COMBO_HEDGE_CANCELLED_REASON) { + return { status: 499, message: "Request cancelled: hedge-cancelled" }; + } + const isSignalAbortReason = + requestSignalAborted && + (/request_signal_aborted|client disconnected|operation was aborted/i.test(message) || + message === COMBO_PER_MODEL_TIMEOUT_REASON || + message === COMBO_HEDGE_CANCELLED_REASON); + return isLocalStreamLifecycleError(error) || isSignalAbortReason + ? { status: 499, message: "Request aborted" } + : null; +} + +/** + * Decide whether a terminal single-model result should affect the provider-wide + * circuit breaker. Client aborts, local queue capacity and network-path errors + * are local failures even when their fallback status is a breaker-worthy 5xx. + */ +export function shouldTripProviderBreakerForResult( + result: { + status: number; + errorCode?: string | null; + errorType?: string | null; + error?: unknown; + }, + isCombo: boolean, + forceLiveComboTest: boolean +): boolean { + return ( + !forceLiveComboTest && + !isCombo && + result.errorCode !== "proxy_unreachable" && + result.errorCode !== "RATE_LIMIT_QUEUE_TIMEOUT" && + result.errorCode !== "RATE_LIMIT_QUEUE_WEDGED" && + PROVIDER_BREAKER_FAILURE_STATUSES.has(Number(result.status)) + ); +} diff --git a/src/sse/handlers/chatResponseHeaders.ts b/src/sse/handlers/chatResponseHeaders.ts new file mode 100644 index 000000000000..3837c79b6568 --- /dev/null +++ b/src/sse/handlers/chatResponseHeaders.ts @@ -0,0 +1,53 @@ +export function withSessionHeader(response: Response, sessionId: string | null): Response { + if (!response || !sessionId) return response; + + try { + response.headers.set("X-OmniRoute-Session-Id", sessionId); + return response; + } catch { + const cloned = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + cloned.headers.set("X-OmniRoute-Session-Id", sessionId); + return cloned; + } +} + +export function withCorrelationId(response: Response, correlationId: string | null): Response { + if (!response || !correlationId) return response; + + try { + response.headers.set("X-Correlation-Id", correlationId); + return response; + } catch { + const cloned = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + cloned.headers.set("X-Correlation-Id", correlationId); + return cloned; + } +} + +export function withSelectedConnectionHeader( + response: Response, + connectionId: string | null | undefined +): Response { + if (!response || !connectionId) return response; + + try { + response.headers.set("X-OmniRoute-Selected-Connection-Id", connectionId); + return response; + } catch { + const cloned = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + cloned.headers.set("X-OmniRoute-Selected-Connection-Id", connectionId); + return cloned; + } +} diff --git a/src/sse/handlers/rejectedRequestUsage.ts b/src/sse/handlers/rejectedRequestUsage.ts index 7626e83e81d1..af6fe95963c6 100644 --- a/src/sse/handlers/rejectedRequestUsage.ts +++ b/src/sse/handlers/rejectedRequestUsage.ts @@ -37,6 +37,27 @@ export interface RejectedRequestUsageInput { startTime?: number; } +/** + * Keep a combo's terminal reason visible when its response is interrupted. + * A 499 is not exclusively a client cancellation: it can also represent a + * per-target timeout or another local stream lifecycle failure. + */ +export function describeRejectedComboFailure({ + status, + comboName, + reason, +}: { + status: number; + comboName: string; + reason?: string | null; +}): string { + const prefix = `[${status}] Combo "${comboName}"`; + if (status !== 499) return `${prefix} failed — all targets exhausted`; + + const suffix = reason?.trim() ? `: ${reason.trim()}` : ""; + return `${prefix} request interrupted${suffix}`; +} + export async function recordRejectedRequestUsage(input: RejectedRequestUsageInput): Promise { const { status, diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 1d3b3f31a42a..88376c82a222 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -28,6 +28,7 @@ import { hasPerModelQuota, getRuntimeProviderProfile, recordModelLockoutFailure, + isProviderModelUnsupported400, } from "@omniroute/open-sse/services/accountFallback.ts"; import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts"; import { COOLDOWN_MS, RateLimitReason } from "@omniroute/open-sse/config/constants.ts"; @@ -37,6 +38,10 @@ import { } from "@omniroute/open-sse/services/quotaPreflight.ts"; import { resolveResilienceSettings } from "@/lib/resilience/settings"; import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings"; +import { + buildMixedAvailabilityError, + isTransportCooldownErrorCode, +} from "../services/sameAccountTransportRetry"; import { syncHealthFromDB, type KeyHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts"; import { classifyProviderError, @@ -1221,6 +1226,9 @@ export async function getProviderCredentials( let modelLockedCount = 0; let familyLockedCount = 0; + // #9708: connections held out only by a transport cooldown, so an + // all-quota-exhausted verdict can be told apart from a transient one. + const transportCooledIds = new Set(); // Filter out unavailable accounts and excluded connection const availableConnections = connections.filter((c) => { if (excludedConnectionIds.has(c.id)) return false; @@ -1228,7 +1236,10 @@ export async function getProviderCredentials( return false; } if (!allowSuppressedConnections) { - if (!allowRateLimitedConnections && isAccountUnavailable(c.rateLimitedUntil)) return false; + if (!allowRateLimitedConnections && isAccountUnavailable(c.rateLimitedUntil)) { + if (isTransportCooldownErrorCode(c.errorCode)) transportCooledIds.add(c.id); + return false; + } if (isTerminalConnectionStatus(c)) return false; if (provider === "codex" && isCodexScopeUnavailable(c, requestedModel)) return false; // Per-model lockout: if this specific model/family is locked on this connection, skip it @@ -1422,6 +1433,29 @@ export async function getProviderCredentials( } if (policyEligibleConnections.length === 0 && availableConnections.length > 0) { + const transportUnavailable = connections.filter((connection) => + transportCooledIds.has(connection.id) + ); + if (transportUnavailable.length > 0) { + const mixed = buildMixedAvailabilityError({ + provider, + quotaFilteredCount: blockedByPolicy.length, + transportUnavailableCount: transportUnavailable.length, + transportStatus: Number(transportUnavailable[0]?.errorCode) || 503, + }); + const retryAfter = + getEarliestFutureDate( + transportUnavailable.map((connection) => connection.rateLimitedUntil || null) + ) || new Date(Date.now() + 3000).toISOString(); + return { + allRateLimited: true, + retryAfter, + retryAfterHuman: formatRetryAfter(retryAfter), + lastError: mixed.lastError, + lastErrorCode: mixed.lastErrorCode, + }; + } + const earliestResetAt = getEarliestFutureDate(blockedByPolicy.map((entry) => entry.resetAt)); const earliestResetMs = parseFutureDateMs(earliestResetAt); @@ -1482,8 +1516,7 @@ export async function getProviderCredentials( { fallbackStrategy?: string; stickyRoundRobinLimit?: number } >; const providerOverride = providerStrategyOverrides[resolvedId] || {}; - const strategy = - providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first"; + const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first"; let connection; const affinityConnection = await selectSessionAffinityConnection( @@ -1957,6 +1990,26 @@ export async function markAccountUnavailable( } } + // #10460: model-unsupported 400 — the PROVIDER does not serve this model, not + // this account. Cooling down the account and rotating to the next one wastes an + // upstream call because all accounts share the same model catalog. Return + // shouldFallback: false so the error propagates to the combo layer, which already + // has isModelScoped400() (combo.ts:1827) to advance to the next combo target. + // Uses isProviderModelUnsupported400() — the SAME disambiguation + // (AUTH_CREDENTIAL_ERROR_PATTERNS exclusion) checkFallbackError's 400 branch + // applies, narrowed further to exclude the broader/ambiguous + // MODEL_ACCESS_DENIED_PATTERNS access-/permission-phrased matches (e.g. "does not + // have permission to access this model"), which can be an ACCOUNT-scoped + // entitlement gap (PRO vs free tier) rather than a provider-wide unsupported + // model — those must keep rotating to other accounts normally. + if (isProviderModelUnsupported400(status, errorText)) { + log.info( + "AUTH", + `${connectionId.slice(0, 8)} provider_model_unsupported 400 (${provider}/${model ?? "n/a"}) — skipping account cooldown, letting combo advance` + ); + return { shouldFallback: false, cooldownMs: 0, reason: "provider_model_unsupported" }; + } + const effectiveProviderProfile = providerProfile || (provider ? await getRuntimeProviderProfile(provider) : null); // #4530 follow-up: the combo.ts lockout sites forward the admin-configured diff --git a/src/sse/services/imageCredentialRetry.ts b/src/sse/services/imageCredentialRetry.ts new file mode 100644 index 000000000000..c43d033a0243 --- /dev/null +++ b/src/sse/services/imageCredentialRetry.ts @@ -0,0 +1,119 @@ +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +import { getProviderCredentialsWithQuotaPreflight } from "./auth"; +import { checkAndRefreshToken } from "./tokenRefresh"; +import * as log from "../utils/logger"; + +interface ImageGenerationResult { + success: boolean; + status?: number; + error?: unknown; + data?: unknown; +} + +interface ImageCredentialRetryOptions { + provider: string; + requestedModel: string | null; + credentials: any; + execute: (credentials: any) => Promise; +} + +interface ImageCredentialRetryResult { + credentials: any; + result: ImageGenerationResult; +} + +function connectionIdOf(credentials: any): string | null { + const connectionId = credentials?.connectionId; + return typeof connectionId === "string" && connectionId.trim().length > 0 + ? connectionId.trim() + : null; +} + +function isCredentialSentinel(credentials: any): boolean { + return Boolean(credentials?.allRateLimited || credentials?.allExpired); +} + +async function selectNextCredentials( + provider: string, + requestedModel: string | null, + excludedConnectionIds: Set +) { + return getProviderCredentialsWithQuotaPreflight(provider, null, null, requestedModel, { + excludeConnectionIds: Array.from(excludedConnectionIds), + }); +} + +/** + * Keep image requests on the same credential lifecycle as chat requests. + * + * Each connection is attempted at most once. A refresh failure or upstream 401 + * excludes only that connection for the current request; it does not mutate the + * account into a terminal state because another request may refresh it normally. + */ +export async function executeImageWithCredentialFallback({ + provider, + requestedModel, + credentials, + execute, +}: ImageCredentialRetryOptions): Promise { + // Local/no-auth image providers intentionally have no credential row. They + // still need one direct attempt, but there is no account identity to refresh + // or rotate after a 401. + if (!credentials) { + return { credentials, result: await execute(credentials) }; + } + + const excludedConnectionIds = new Set(); + let currentCredentials = credentials; + let lastCredentials = credentials; + let lastResult: ImageGenerationResult | null = null; + + while (currentCredentials && !isCredentialSentinel(currentCredentials)) { + const connectionId = connectionIdOf(currentCredentials); + if (connectionId && excludedConnectionIds.has(connectionId)) break; + if (connectionId) excludedConnectionIds.add(connectionId); + + try { + currentCredentials = await checkAndRefreshToken(provider, currentCredentials); + } catch (error) { + log.warn("IMAGE", "Credential refresh failed; trying another image-provider account", { + provider, + connectionId, + error: sanitizeErrorMessage(error instanceof Error ? error : new Error(String(error))), + }); + if (!connectionId) throw error; + currentCredentials = await selectNextCredentials( + provider, + requestedModel, + excludedConnectionIds + ); + continue; + } + + lastCredentials = currentCredentials; + lastResult = await execute(currentCredentials); + if (lastResult.success || Number(lastResult.status) !== 401 || !connectionId) { + return { credentials: lastCredentials, result: lastResult }; + } + + log.warn("IMAGE", "Image provider rejected credentials; trying another account", { + provider, + connectionId, + }); + currentCredentials = await selectNextCredentials( + provider, + requestedModel, + excludedConnectionIds + ); + } + + return { + credentials: lastCredentials, + result: lastResult || { + success: false, + status: 401, + error: "Authentication failed for all eligible image-provider accounts", + }, + }; +} diff --git a/src/sse/services/sameAccountTransportRetry.ts b/src/sse/services/sameAccountTransportRetry.ts new file mode 100644 index 000000000000..4c82d94765a9 --- /dev/null +++ b/src/sse/services/sameAccountTransportRetry.ts @@ -0,0 +1,108 @@ +/** + * Same-account retry for retryable pre-output transport failures (#9708). + * + * A 503/507 (connection reset, retry-buffer overflow, early EOF before useful + * output) must not immediately cool the account and rotate. One jittered + * same-account retry absorbs brief proxy blips and keeps Codex prompt-cache + * affinity. A second failure then takes a short cooldown and may rotate. + */ + +export const SAME_ACCOUNT_TRANSPORT_RETRY_MAX = 1; +export const SAME_ACCOUNT_TRANSPORT_RETRY_MIN_DELAY_MS = 2000; +export const SAME_ACCOUNT_TRANSPORT_RETRY_JITTER_MS = 1000; + +const RETRYABLE_TRANSPORT_STATUSES = new Set([502, 503, 504, 507]); + +const RETRYABLE_TRANSPORT_TEXT = [ + /upstream connect error/i, + /disconnect\/reset before headers/i, + /remote connection failure/i, + /connection reset/i, + /exceeded request buffer limit/i, + /early eof/i, + /econnreset/i, + /socket hang up/i, + /und_err_socket/i, +]; + +const NON_RETRYABLE_ERROR_TYPES = new Set(["lease_error", "account_semaphore_capacity"]); + +export function isRetryableTransportStatus(status: unknown): boolean { + const numeric = Number(status); + return Number.isFinite(numeric) && RETRYABLE_TRANSPORT_STATUSES.has(numeric); +} + +export function isRetryablePreOutputTransportError( + status: unknown, + errorText: string | null | undefined, + errorCode?: string | null, + errorType?: string | null +): boolean { + if (errorType && NON_RETRYABLE_ERROR_TYPES.has(errorType)) return false; + if (errorCode && String(errorCode).startsWith("LEASE_")) return false; + + const text = String(errorText || ""); + const numericStatus = Number(status); + if (numericStatus === 429 || numericStatus === 401 || numericStatus === 400) return false; + if (/quota (threshold|exhausted)|credits exhausted/i.test(text)) return false; + if (/invalid_request|prompt is too long|context.?length|unsupported model/i.test(text)) { + return false; + } + + const statusRetryable = isRetryableTransportStatus(status); + const textRetryable = RETRYABLE_TRANSPORT_TEXT.some((pattern) => pattern.test(text)); + const codeRetryable = + errorCode === "STREAM_EARLY_EOF" || + errorCode === "proxy_unreachable" || + errorCode === "PROXY_UNREACHABLE"; + + return statusRetryable || textRetryable || codeRetryable; +} + +export function sameAccountTransportRetryDelayMs(random: () => number = Math.random): number { + const draw = random(); + const unit = Number.isFinite(draw) ? Math.min(Math.max(draw, 0), 1) : 0; + return Math.round( + SAME_ACCOUNT_TRANSPORT_RETRY_MIN_DELAY_MS + SAME_ACCOUNT_TRANSPORT_RETRY_JITTER_MS * unit + ); +} + +export function shouldRetrySameAccountTransport(options: { + status: unknown; + errorText?: string | null; + errorCode?: string | null; + errorType?: string | null; + attempt: number; + hasForcedConnection?: boolean; + hasEmittedOutput?: boolean; +}): boolean { + if (options.hasForcedConnection) return false; + if (options.hasEmittedOutput) return false; + if (options.attempt >= SAME_ACCOUNT_TRANSPORT_RETRY_MAX) return false; + return isRetryablePreOutputTransportError( + options.status, + options.errorText, + options.errorCode, + options.errorType + ); +} + +export function isTransportCooldownErrorCode(errorCode: unknown): boolean { + return isRetryableTransportStatus(errorCode); +} + +export function buildMixedAvailabilityError(options: { + provider: string; + quotaFilteredCount: number; + transportUnavailableCount: number; + transportStatus?: number | null; +}): { status: number; lastError: string; lastErrorCode: number } { + const quota = Math.max(0, options.quotaFilteredCount); + const transport = Math.max(0, options.transportUnavailableCount); + const upstreamStatus = options.transportStatus || 503; + return { + status: 503, + lastErrorCode: 503, + lastError: `No ${options.provider} accounts currently available: ${quota} quota-filtered, ${transport} temporarily unavailable after upstream ${upstreamStatus}`, + }; +} diff --git a/src/sse/services/sessionAffinityPin.ts b/src/sse/services/sessionAffinityPin.ts index f8f4749d480c..735f2c5c7e88 100644 --- a/src/sse/services/sessionAffinityPin.ts +++ b/src/sse/services/sessionAffinityPin.ts @@ -28,6 +28,7 @@ import { upsertSessionAccountAffinity, touchSessionAccountAffinity, deleteSessionAccountAffinity, + evictSessionAccountAffinityForConnection, } from "@/lib/db/sessionAccountAffinity"; import { updateProviderConnection } from "@/lib/db/providers"; import { isModelExcludedByConnection } from "@/domain/connectionModelRules"; @@ -36,6 +37,7 @@ import { isAccountUnavailable, isModelLocked, } from "@omniroute/open-sse/services/accountFallback.ts"; +import { isComboPerModelTimeoutAbort } from "@omniroute/open-sse/services/combo/comboAbortReasons.ts"; import * as log from "../utils/logger"; /** Minimal structural view of a provider connection this module reads. */ @@ -127,6 +129,61 @@ export async function selectSessionAffinityConnection entries.push({ level: "info", tag, msg }), + warn: (tag: unknown, msg: unknown) => entries.push({ level: "warn", tag, msg }), + error: (tag: unknown, msg: unknown) => entries.push({ level: "error", tag, msg }), + debug: (tag: unknown, msg: unknown) => entries.push({ level: "debug", tag, msg }), + entries, + }; +} + +export function okResponse(body: unknown = { choices: [{ message: { content: "ok" } }] }) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +export function errorResponse(status: number, message: string = `Error ${status}`) { + return new Response(JSON.stringify({ error: { message } }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +export function waitForBackgroundWork() { + return new Promise((resolve) => setTimeout(resolve, 25)); +} + +export function providerBreakerOpenResponse() { + return new Response( + JSON.stringify({ + error: { + message: "Provider circuit breaker is open", + code: "provider_circuit_open", + }, + }), + { + status: 503, + headers: { + "content-type": "application/json", + "x-omniroute-provider-breaker": "open", + }, + } + ); +} + +export function streamResponse(chunks: unknown[]) { + return new Response(chunks.join(""), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +export function capabilityEntry(limitContext: unknown, overrides: Record = {}) { + return { + tool_call: true, + reasoning: false, + attachment: false, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: false, + limit_context: limitContext, + limit_input: limitContext, + limit_output: 4096, + interleaved_field: null, + ...overrides, + }; +} diff --git a/tests/fixtures/translator-capability.ts b/tests/fixtures/translator-capability.ts new file mode 100644 index 000000000000..210dbfe026ee --- /dev/null +++ b/tests/fixtures/translator-capability.ts @@ -0,0 +1,22 @@ +export function buildCapability(overrides = {}) { + return { + tool_call: null, + reasoning: null, + attachment: null, + structured_output: null, + temperature: null, + modalities_input: "[]", + modalities_output: "[]", + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: null, + limit_context: null, + limit_input: null, + limit_output: null, + interleaved_field: null, + ...overrides, + }; +} diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 152a21a0ee18..88866d21ac85 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -1,3 +1,13 @@ +import { + buildOpenAIResponse, + buildOpenAIToolCallResponse, + buildClaudeResponse, + buildClaudeStreamResponse, + buildGeminiResponse, + buildOpenAIStreamResponse, + buildOpenAIResponsesSSE, + buildOpenAIResponsesJson, +} from "../fixtures/chat-pipeline-responses.ts"; import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; @@ -98,269 +108,6 @@ function buildRequest({ }); } -function buildOpenAIResponse(text = "ok", model = "gpt-4o-mini", usage = null) { - return new Response( - JSON.stringify({ - id: "chatcmpl_json", - object: "chat.completion", - model, - choices: [ - { - index: 0, - message: { role: "assistant", content: text }, - finish_reason: "stop", - }, - ], - usage: usage || { - prompt_tokens: 4, - completion_tokens: 2, - total_tokens: 6, - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); -} - -function buildOpenAIToolCallResponse({ - model = "gpt-4o-mini", - toolName = "lookupWeather@1.0.0", - toolCallId = "call_weather", - argumentsObject = { location: "Sao Paulo" }, -} = {}) { - return new Response( - JSON.stringify({ - id: "chatcmpl_tool", - object: "chat.completion", - model, - choices: [ - { - index: 0, - message: { - role: "assistant", - content: "", - tool_calls: [ - { - id: toolCallId, - type: "function", - function: { - name: toolName, - arguments: JSON.stringify(argumentsObject), - }, - }, - ], - }, - finish_reason: "tool_calls", - }, - ], - usage: { - prompt_tokens: 6, - completion_tokens: 4, - total_tokens: 10, - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); -} - -function buildClaudeResponse(text = "ok", model = "claude-3-5-sonnet-20241022") { - return new Response( - JSON.stringify({ - id: "msg_json", - type: "message", - role: "assistant", - model, - content: [{ type: "text", text }], - stop_reason: "end_turn", - usage: { - input_tokens: 10, - output_tokens: 4, - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); -} - -function buildClaudeStreamResponse(text = "streamed from claude", model = "claude-sonnet-4-6") { - return new Response( - [ - "event: message_start", - `data: ${JSON.stringify({ - type: "message_start", - message: { - id: "msg_stream", - type: "message", - role: "assistant", - model, - usage: { input_tokens: 12, output_tokens: 0 }, - }, - })}`, - "", - "event: content_block_start", - `data: ${JSON.stringify({ - type: "content_block_start", - index: 0, - content_block: { type: "text", text: "" }, - })}`, - "", - "event: content_block_delta", - `data: ${JSON.stringify({ - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text }, - })}`, - "", - "event: message_delta", - `data: ${JSON.stringify({ - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { output_tokens: 3 }, - })}`, - "", - "event: message_stop", - `data: ${JSON.stringify({ type: "message_stop" })}`, - "", - ].join("\n"), - { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - } - ); -} - -function buildGeminiResponse(text = "ok", model = "gemini-2.5-flash") { - return new Response( - JSON.stringify({ - responseId: "resp_gemini", - modelVersion: model, - createTime: "2026-04-05T12:00:00.000Z", - candidates: [ - { - content: { - parts: [{ text }], - }, - finishReason: "STOP", - }, - ], - usageMetadata: { - promptTokenCount: 5, - candidatesTokenCount: 7, - totalTokenCount: 12, - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); -} - -function buildOpenAIStreamResponse(text = "streamed from openai") { - return new Response( - [ - `data: ${JSON.stringify({ - id: "chatcmpl_stream", - object: "chat.completion.chunk", - choices: [{ index: 0, delta: { role: "assistant", content: text } }], - })}`, - "", - "data: [DONE]", - "", - ].join("\n"), - { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - } - ); -} - -function buildOpenAIResponsesSSE({ - text = "responses streamed from codex", - model = "gpt-5.1-codex", - usage = null, -} = {}) { - return new Response( - [ - `data: ${JSON.stringify({ - type: "response.completed", - response: { - id: "resp_stream", - object: "response", - status: "completed", - model, - output: [ - { - id: "msg_stream", - type: "message", - role: "assistant", - content: [{ type: "output_text", text, annotations: [] }], - }, - ], - usage: usage || { - input_tokens: 120, - output_tokens: 30, - prompt_tokens_details: { - cached_tokens: 40, - }, - cache_creation_input_tokens: 11, - completion_tokens_details: { - reasoning_tokens: 13, - }, - }, - }, - })}`, - "", - "data: [DONE]", - "", - ].join("\n"), - { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - } - ); -} - -function buildOpenAIResponsesJson({ - text = "responses compacted from codex", - model = "gpt-5.5", - usage = null, -} = {}) { - return new Response( - JSON.stringify({ - id: "resp_compact", - object: "response", - status: "completed", - model, - output: [ - { - id: "msg_compact", - type: "message", - role: "assistant", - content: [{ type: "output_text", text, annotations: [] }], - }, - ], - output_text: text, - usage: usage || { - input_tokens: 90, - output_tokens: 15, - total_tokens: 105, - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); -} - async function resetStorage() { globalThis.fetch = originalFetch; process.env.REQUIRE_API_KEY = "false"; @@ -698,7 +445,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.144.1 (Windows 10.0.26200; x64)"); + assert.equal(call.headers["User-Agent"], "codex-cli/0.153.4 (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"); @@ -1533,7 +1280,7 @@ test("chat pipeline falls back across combo models when the first provider fails headers: toPlainHeaders(init.headers), }; attempts.push(call); - if (attempts.length === 1) { + if (call.url.endsWith("/chat/completions")) { return new Response(JSON.stringify({ error: { message: "openai combo miss" } }), { status: 503, headers: { "Content-Type": "application/json" }, @@ -1554,9 +1301,19 @@ test("chat pipeline falls back across combo models when the first provider fails const json = (await response.json()) as any; assert.equal(response.status, 200); - assert.equal(attempts.length, 2); + // Retry the pre-output 503 once on the same account before combo fallback. + assert.equal(attempts.length, 3, JSON.stringify(attempts.map((call) => call.url))); assert.match(attempts[0].url, /\/chat\/completions$/); - assert.match(attempts[1].url, /\?beta=true$/); + assert.equal(attempts[1].url, attempts[0].url); + assert.equal( + new Headers(attempts[0].headers).get("authorization"), + "Bearer sk-openai-combo-fail" + ); + assert.equal( + new Headers(attempts[1].headers).get("authorization"), + "Bearer sk-openai-combo-fail" + ); + assert.match(attempts[2].url, /\?beta=true$/); assert.equal(json.choices[0].message.content, "Claude combo fallback"); }); @@ -1598,3 +1355,191 @@ test("chat pipeline deduplicates concurrent identical non-stream requests", asyn assert.equal(jsonA.choices[0].message.content, "Deduplicated response"); assert.equal(jsonB.choices[0].message.content, "Deduplicated response"); }); + +test("X-OmniRoute-No-Cache bypasses concurrent request deduplication", async () => { + clearProviderFailure("codex"); + await seedConnection("codex", { apiKey: "sk-codex-no-cache-dedup" }); + let fetchCount = 0; + const upstreamBodies: Array<{ text?: { format?: { strict?: boolean } } }> = []; + + globalThis.fetch = async (_url, init: RequestInit = {}) => { + const callNumber = ++fetchCount; + upstreamBodies.push(JSON.parse(String(init.body))); + await new Promise((resolve) => setTimeout(resolve, 25)); + return buildOpenAIResponsesSSE({ + model: "gpt-5.6-sol", + text: JSON.stringify({ result: `independent-${callNumber}` }), + }); + }; + + const requests = Array.from({ length: 3 }, () => + buildRequest({ + headers: { "X-OmniRoute-No-Cache": "true" }, + body: { + model: "cx/gpt-5.6-sol", + stream: false, + temperature: 0, + messages: [{ role: "user", content: "Do not deduplicate this request" }], + response_format: { + type: "json_schema", + json_schema: { + name: "dedup_probe", + strict: true, + schema: { + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + additionalProperties: false, + }, + }, + }, + }, + }) + ); + + const responses = await Promise.all(requests.map((request) => handleChat(request))); + const payloads = (await Promise.all(responses.map((response) => response.json()))) as Array<{ + choices: Array<{ message: { content: string } }>; + }>; + + assert.equal(fetchCount, requests.length); + assert.equal(upstreamBodies.length, requests.length); + assert.ok(upstreamBodies.every((body) => body.text?.format?.strict === true)); + assert.deepEqual( + new Set(payloads.map((payload) => payload.choices[0].message.content)).size, + requests.length + ); +}); + +test("concurrent request deduplication does not reuse an upstream error", async () => { + clearProviderFailure("openai"); + await seedConnection("openai", { apiKey: "sk-openai-error-dedup" }); + let fetchCount = 0; + + globalThis.fetch = async () => { + const callNumber = ++fetchCount; + await new Promise((resolve) => setTimeout(resolve, 25)); + if (callNumber === 1) { + return new Response(JSON.stringify({ error: { message: "upstream rejected request" } }), { + status: 422, + headers: { "Content-Type": "application/json" }, + }); + } + return buildOpenAIResponse(`Recovered upstream response ${callNumber}`); + }; + + const requests = Array.from({ length: 3 }, () => + buildRequest({ + body: { + model: "openai/gpt-4o-mini", + stream: false, + temperature: 0, + messages: [{ role: "user", content: "Do not share an upstream error" }], + }, + }) + ); + + const responses = await Promise.all(requests.map((request) => handleChat(request))); + + assert.equal(fetchCount, requests.length); + assert.ok(responses.filter((response) => response.status === 422).length <= 1); + assert.ok(responses.filter((response) => response.status === 200).length >= requests.length - 1); +}); + +test("concurrent request deduplication does not reuse an empty upstream body", async () => { + clearProviderFailure("openai"); + await seedConnection("openai", { apiKey: "sk-openai-empty-dedup" }); + let fetchCount = 0; + + globalThis.fetch = async () => { + const callNumber = ++fetchCount; + await new Promise((resolve) => setTimeout(resolve, 25)); + if (callNumber === 1) { + return buildOpenAIResponse(""); + } + return buildOpenAIResponse(`Recovered upstream response ${callNumber}`); + }; + + const requests = Array.from({ length: 3 }, () => + buildRequest({ + body: { + model: "openai/gpt-4o-mini", + stream: false, + temperature: 0, + messages: [{ role: "user", content: "Do not share an empty upstream body" }], + }, + }) + ); + + const responses = await Promise.all(requests.map((request) => handleChat(request))); + + assert.equal(fetchCount, requests.length); + assert.ok(responses.filter((response) => response.status !== 200).length <= 1); +}); + +test("chat pipeline maps buffered Codex response.incomplete to finish_reason length", async () => { + clearProviderFailure("codex"); + await seedConnection("codex", { apiKey: "sk-codex-incomplete-finish-reason" }); + const partialProfile = JSON.stringify({ requirements: [], domain: null }); + + globalThis.fetch = async () => + new Response( + [ + "event: response.incomplete", + `data: ${JSON.stringify({ + type: "response.incomplete", + response: { + id: "resp_incomplete_profile", + object: "response", + model: "gpt-5.6-sol", + status: "incomplete", + incomplete_details: { reason: "max_output_tokens" }, + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: partialProfile }], + }, + ], + }, + })}`, + "", + ].join("\n"), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + + const response = await handleChat( + buildRequest({ + headers: { "X-OmniRoute-No-Cache": "true" }, + body: { + model: "cx/gpt-5.6-sol", + stream: false, + temperature: 0, + messages: [{ role: "user", content: "Extract this vacancy" }], + response_format: { + type: "json_schema", + json_schema: { + name: "vacancy_profile", + strict: true, + schema: { + type: "object", + properties: { + requirements: { type: "array", items: { type: "string" } }, + domain: { type: ["string", "null"] }, + }, + required: ["requirements", "domain"], + additionalProperties: false, + }, + }, + }, + }, + }) + ); + const payload = (await response.json()) as { + choices: Array<{ finish_reason: string; message: { content: string } }>; + }; + + assert.equal(response.status, 200); + assert.equal(payload.choices[0].message.content, partialProfile); + assert.equal(payload.choices[0].finish_reason, "length"); +}); diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index ec208cafd159..5f2ca5c69fd4 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -966,16 +966,16 @@ "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.144.1 (; )", - "Version": "0.144.1", + "User-Agent": "codex-cli/0.153.4 (; )", + "Version": "0.153.4", "X-Codex-Beta-Features": "responses_websockets" }, "nonStream": { "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.144.1 (; )", - "Version": "0.144.1", + "User-Agent": "codex-cli/0.153.4 (; )", + "Version": "0.153.4", "X-Codex-Beta-Features": "responses_websockets" }, "oauth": { @@ -983,8 +983,8 @@ "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.144.1 (; )", - "Version": "0.144.1", + "User-Agent": "codex-cli/0.153.4 (; )", + "Version": "0.153.4", "X-Codex-Beta-Features": "responses_websockets" } }, diff --git a/tests/unit/access-scopes-infer.test.ts b/tests/unit/access-scopes-infer.test.ts index d9cdc6556709..6a46e9517235 100644 --- a/tests/unit/access-scopes-infer.test.ts +++ b/tests/unit/access-scopes-infer.test.ts @@ -9,10 +9,10 @@ test("read methods default to read", () => { }); test("mutating methods default to write", () => { - assert.equal(inferRequiredScope("POST", "/api/keys"), "write"); + assert.equal(inferRequiredScope("POST", "/api/combos"), "write"); assert.equal(inferRequiredScope("PUT", "/api/config"), "write"); assert.equal(inferRequiredScope("PATCH", "/api/combo/x"), "write"); - assert.equal(inferRequiredScope("DELETE", "/api/keys/abc"), "write"); + assert.equal(inferRequiredScope("DELETE", "/api/combos/abc"), "write"); }); test("admin-prefix routes require admin for ANY method", () => { @@ -46,3 +46,28 @@ test("prefix matching does not over-match unrelated paths", () => { // "/api/services" itself and its children are admin, but a lookalike is not assert.equal(inferRequiredScope("GET", "/api/services-catalog"), "read"); }); + +test("all credential mutation and disclosure operations require admin", () => { + for (const [method, path] of [ + ["POST", "/api/keys"], + ["PATCH", "/api/keys/example"], + ["POST", "/api/keys/example/regenerate"], + ["DELETE", "/api/keys/example"], + ["GET", "/api/keys/example/reveal"], + ["HEAD", "/api/keys/example/reveal/"], + ["GET", "/api/cli-tools/keys"], + ["GET", "/api/cli-tools/codex-settings"], + ["POST", "/api/cli-tools/guide-settings/opencode"], + ["GET", "/api/cli-tools/backups"], + ["GET", "/api/cli-tools/codex-profiles"], + ["POST", "/api/sync/tokens"], + ["DELETE", "/api/relay/tokens/example"], + ["HEAD", "/api/cli-tools/keys/"], + ]) + assert.equal(inferRequiredScope(method, path), "admin", `${method} ${path}`); + assert.equal(inferRequiredScope("GET", "/api/keys"), "read"); + assert.equal(inferRequiredScope("GET", "/api/keys/example"), "read"); + assert.equal(inferRequiredScope("GET", "/api/cli-tools/status"), "read"); + assert.equal(inferRequiredScope("GET", "/api/sync/tokens"), "read"); + assert.equal(inferRequiredScope("GET", "/api/relay/tokens"), "read"); +}); diff --git a/tests/unit/access-token-credential-boundary.test.ts b/tests/unit/access-token-credential-boundary.test.ts new file mode 100644 index 000000000000..bf8934e37563 --- /dev/null +++ b/tests/unit/access-token-credential-boundary.test.ts @@ -0,0 +1,239 @@ +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 { SignJWT } from "jose"; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-credential-boundary-")); +process.env.DATA_DIR = dataDir; +process.env.INITIAL_PASSWORD = "fixture-password"; +process.env.JWT_SECRET = "fixture-session-secret"; +process.env.API_KEY_SECRET = "fixture-api-secret"; +process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.ALLOW_API_KEY_REVEAL = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const accessTokens = await import("../../src/lib/db/accessTokens.ts"); +const apiKeys = await import("../../src/lib/db/apiKeys.ts"); +const settings = await import("../../src/lib/db/settings.ts"); +const keysRoute = await import("../../src/app/api/keys/route.ts"); +const keyRoute = await import("../../src/app/api/keys/[id]/route.ts"); +const revealRoute = await import("../../src/app/api/keys/[id]/reveal/route.ts"); +const cliKeysRoute = await import("../../src/app/api/cli-tools/keys/route.ts"); +const { managementPolicy } = await import("../../src/server/authz/policies/management.ts"); +const { classifyRoute } = await import("../../src/server/authz/classify.ts"); + +await settings.updateSettings({ requireLogin: true, cloudEnabled: false }); +const writeToken = accessTokens.createAccessToken({ name: "write", scope: "write" }).secret; +const readToken = accessTokens.createAccessToken({ name: "read", scope: "read" }).secret; +const adminToken = accessTokens.createAccessToken({ name: "admin", scope: "admin" }).secret; +const existing = await apiKeys.createApiKey("existing", "fixture-machine", ["self:usage"]); +const session = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(new TextEncoder().encode(process.env.JWT_SECRET)); + +function request(method: string, pathname: string, credential: string, body?: unknown) { + return new Request(`https://audit.invalid${pathname}`, { + method, + headers: { + ...(credential === "session" + ? { cookie: `auth_token=${session}` } + : { authorization: `Bearer ${credential}` }), + "content-type": "application/json", + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +for (const credential of [readToken, writeToken]) { + const scope = credential === readToken ? "read" : "write"; + test(`${scope} cannot create or promote a management credential`, async () => { + const before = (await apiKeys.getApiKeys()).length; + const req = request("POST", "/api/keys", credential, { name: "escalated", scopes: ["manage"] }); + const policy = await managementPolicy.evaluate({ + request: req, + classification: classifyRoute("/api/keys", "POST"), + requestId: "fixture", + }); + assert.equal(policy.allow, false); + assert.equal((await keysRoute.POST(req)).status, 403); + assert.equal((await apiKeys.getApiKeys()).length, before); + const patch = await keyRoute.PATCH( + request("PATCH", `/api/keys/${existing.id}`, credential, { scopes: ["admin"] }), + { params: Promise.resolve({ id: existing.id }) } + ); + assert.equal(patch.status, 403); + assert.deepEqual((await apiKeys.getApiKeyById(existing.id))?.scopes, ["self:usage"]); + }); + + test(`${scope} cannot reveal credentials through either handler`, async () => { + assert.equal( + (await cliKeysRoute.GET(request("GET", "/api/cli-tools/keys", credential))).status, + 403 + ); + assert.equal( + ( + await revealRoute.GET(request("GET", `/api/keys/${existing.id}/reveal`, credential), { + params: Promise.resolve({ id: existing.id }), + }) + ).status, + 403 + ); + // Ordinary masked key inventory remains inspectable. + const inventory = await keysRoute.GET(request("GET", "/api/keys", credential)); + assert.equal(inventory.status, 200); + assert.equal( + (await inventory.json()).keys.some((key: { key: string }) => key.key === existing.key), + false + ); + }); +} + +for (const credential of [adminToken, "session"]) { + test(`${credential === "session" ? "dashboard" : "admin"} retains credential management`, async () => { + const created = await keysRoute.POST( + request("POST", "/api/keys", credential, { name: "authorized", scopes: ["manage"] }) + ); + assert.equal(created.status, 201); + const key = await created.json(); + const patch = await keyRoute.PATCH( + request("PATCH", `/api/keys/${key.id}`, credential, { scopes: ["admin"] }), + { params: Promise.resolve({ id: key.id }) } + ); + assert.equal(patch.status, 200); + const reveal = await revealRoute.GET(request("GET", `/api/keys/${key.id}/reveal`, credential), { + params: Promise.resolve({ id: key.id }), + }); + assert.equal(reveal.status, 200); + assert.equal((await reveal.json()).key, key.key); + const raw = await cliKeysRoute.GET(request("GET", "/api/cli-tools/keys", credential)); + assert.equal(raw.status, 200); + assert.equal( + (await raw.json()).keys.some((item: { rawKey: string }) => item.rawKey === key.key), + true + ); + }); +} + +test("basePath deployments cannot downgrade credential routes to default scopes", async () => { + const original = process.env.OMNIROUTE_BASE_PATH; + process.env.OMNIROUTE_BASE_PATH = "/omni"; + try { + assert.equal( + ( + await keysRoute.POST( + request("POST", "/omni/api/keys", writeToken, { + name: "basepath-escalation", + scopes: ["manage"], + }) + ) + ).status, + 403 + ); + assert.equal( + (await cliKeysRoute.GET(request("GET", "/omni/api/cli-tools/keys", readToken))).status, + 403 + ); + assert.equal( + ( + await revealRoute.GET(request("GET", `/omni/api/keys/${existing.id}/reveal`, readToken), { + params: Promise.resolve({ id: existing.id }), + }) + ).status, + 403 + ); + } finally { + if (original === undefined) delete process.env.OMNIROUTE_BASE_PATH; + else process.env.OMNIROUTE_BASE_PATH = original; + } +}); + +test("provider list and detail mask secrets for restricted tokens despite reveal enabled", async () => { + const providers = await import("../../src/lib/db/providers.ts"); + const providerRoute = await import("../../src/app/api/providers/route.ts"); + const detailRoute = await import("../../src/app/api/providers/[id]/route.ts"); + const secret = "fixture-provider-key-secret-value"; + const connection = await providers.createProviderConnection({ + provider: "openai", + name: "fixture", + authType: "apikey", + apiKey: secret, + }); + for (const credential of [readToken, writeToken, adminToken, "session"]) { + const list = await providerRoute.GET(request("GET", "/api/providers", credential)); + assert.equal(list.status, 200); + const listed = (await list.json()).connections.find( + (row: { id: string }) => row.id === connection.id + ); + const detail = await detailRoute.GET( + request("GET", `/api/providers/${connection.id}`, credential), + { + params: Promise.resolve({ id: connection.id }), + } + ); + assert.equal(detail.status, 200); + const detailed = await detail.json(); + const expected = + credential === adminToken || credential === "session" ? secret : "fixture-****alue"; + assert.equal(listed.apiKey, expected); + assert.equal(detailed.connection.apiKey, expected); + } +}); + +test("restricted CLI config handlers reject before reading local credentials", async () => { + const codex = await import("../../src/app/api/cli-tools/codex-settings/route.ts"); + for (const credential of [readToken, writeToken]) { + assert.equal( + (await codex.GET(request("GET", "/api/cli-tools/codex-settings", credential))).status, + 403 + ); + } +}); + +test("write token cannot mint a sync credential", async () => { + const sync = await import("../../src/app/api/sync/tokens/route.ts"); + assert.equal( + (await sync.POST(request("POST", "/api/sync/tokens", writeToken, { name: "escalate" }))).status, + 403 + ); +}); + +test("restricted tokens cannot read raw database exports or replace stored credentials", async () => { + const { NextRequest } = await import("next/server"); + const database = await import("../../src/app/api/settings/database/route.ts"); + const backupExport = await import("../../src/app/api/db-backups/export/route.ts"); + const backupImport = await import("../../src/app/api/db-backups/import/route.ts"); + const jsonExport = await import("../../src/app/api/settings/export-json/route.ts"); + const jsonImport = await import("../../src/app/api/settings/import-json/route.ts"); + for (const credential of [readToken, writeToken]) { + assert.equal( + (await database.GET(new NextRequest(request("GET", "/api/settings/database", credential)))) + .status, + 401 + ); + assert.equal( + (await backupExport.GET(request("GET", "/api/db-backups/export", credential))).status, + 401 + ); + assert.equal( + (await backupImport.POST(request("POST", "/api/db-backups/import", credential))).status, + 401 + ); + assert.equal( + (await jsonExport.GET(request("GET", "/api/settings/export-json", credential))).status, + 401 + ); + assert.equal( + (await jsonImport.POST(request("POST", "/api/settings/import-json", credential, {}))).status, + 401 + ); + } +}); diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 768f21f2311c..67a55af2d902 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -1,11 +1,29 @@ 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"; + +// #10460: DATA_DIR must be assigned BEFORE any transitive DB import. The +// accountFallback.ts import below statically imports `@/lib/db/providers`, which +// imports `src/lib/db/core.ts`, whose `DATA_DIR` is a top-level +// `export const DATA_DIR = resolveWritableDataDir(...)` captured once at module-load +// time. Setting `process.env.DATA_DIR` after that first import is a no-op — the DB +// singleton keeps whatever DATA_DIR it resolved at import time, so an isolated test +// directory assigned later is silently never used and the #10460 tests below would +// actually read/write the shared default DATA_DIR instead. +const TEST_DATA_DIR_10460 = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-10460-")); +process.env.DATA_DIR = TEST_DATA_DIR_10460; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "10460-test-secret"; const accountFallback = await import("../../open-sse/services/accountFallback.ts"); const accountSelector = await import("../../open-sse/services/accountSelector.ts"); const { RateLimitReason, COOLDOWN_MS, PROVIDER_PROFILES } = await import("../../open-sse/config/constants.ts"); const { getCircuitBreaker } = await import("../../src/shared/utils/circuitBreaker.ts"); +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); const { isOAuthInvalidToken, @@ -1152,6 +1170,19 @@ test("isCreditsExhausted returns true for actual credits-exhausted signals", () // #5239: "Insufficient account balance" out-of-credit bodies assert.equal(isCreditsExhausted("Insufficient account balance"), true); assert.equal(isCreditsExhausted("insufficient_balance"), true); + assert.equal(isCreditsExhausted("Insufficient credit balance"), true); + // Command Code returns 400 "You have insufficient credits to make this + // request. Please purchase more credits to continue using the service." + // when the account's billing credits run out. Without this signal the + // error is unclassified (errorType=null), so the connection is never + // marked credits_exhausted and keeps being re-selected on every request. + assert.equal(isCreditsExhausted("insufficient credits"), true); + assert.equal( + isCreditsExhausted( + "You have insufficient credits to make this request. Please purchase more credits to continue using the service." + ), + true + ); }); test("CREDITS_EXHAUSTED_SIGNALS no longer contains generic gRPC resource-exhausted patterns", () => { @@ -1569,3 +1600,382 @@ test("isAccountDeactivated matches a custom signal after setCustomBannedSignals" setCustomBannedSignals([]); // cleanup — restore module state for other tests }); + +// ─── #10460: model-unsupported 400 skips account rotation ──────────────────── +// TEST_DATA_DIR_10460 / DATA_DIR / core / providersDb / auth are set up at the top +// of this file, BEFORE the accountFallback.ts import — see the comment there. + +async function resetStorage10460() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR_10460, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR_10460, { recursive: true }); +} + +async function seedConn10460(provider: string): Promise { + const conn = await providersDb.createProviderConnection({ + provider, + authType: "apikey", + apiKey: `${provider}-key-10460`, + isActive: true, + testStatus: "active", + }); + return (conn as Record).id as string; +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR_10460, { recursive: true, force: true }); +}); + +test("#10460: model-unsupported 400 returns shouldFallback:false (no account cooldown)", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + const result = await auth.markAccountUnavailable( + connId, + 400, + "The requested model is not supported", + "github", + "claude-fable-5" + ); + + // The guard must prevent account cooldown — the error belongs to the combo layer + assert.strictEqual(result.shouldFallback, false, "must not trigger account rotation"); + assert.strictEqual(result.cooldownMs, 0, "must not cool down the account"); + + // Verify the connection was NOT marked unavailable + const after = await providersDb.getProviderConnectionById(connId); + assert.ok(!after.rateLimitedUntil, "connection must not be rate-limited"); + assert.notStrictEqual(after.testStatus, "unavailable", "connection must stay active"); +}); + +test("#10460: model-unsupported 400 handles various phrasings", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + const phrasings = [ + "The requested model is not supported", + "model claude-fable-5 is not supported", + "invalid_request_error: model is not supported", + "unsupported model: gpt-9", + ]; + + for (const errorText of phrasings) { + await resetStorage10460(); + const id = await seedConn10460("github"); + const result = await auth.markAccountUnavailable(id, 400, errorText, "github", "test-model"); + assert.strictEqual(result.shouldFallback, false, `phrasing "${errorText}" must not rotate`); + // Verify connection stays healthy after each iteration + const conn = await providersDb.getProviderConnectionById(id); + assert.ok(!conn.rateLimitedUntil, `"${errorText}" must not rate-limit connection`); + assert.notStrictEqual(conn.testStatus, "unavailable", `"${errorText}" must not mark unavailable`); + } +}); + +test("#10460: body-specific 400 still goes through normal path (not blocked by model guard)", async () => { + await resetStorage10460(); + const connId = await seedConn10460("openai"); + + const result = await auth.markAccountUnavailable( + connId, + 400, + "Invalid message format: the request body is malformed", + "openai", + "gpt-4" + ); + + // Body-specific 400 does NOT match MODEL_ACCESS_DENIED_PATTERNS — normal path applies + assert.strictEqual(result.shouldFallback, true, "body-specific 400 must still allow fallback"); +}); + +test("#10460: non-400 status with model-unsupported text does NOT trigger guard", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + // 429 + model-unsupported text should go through normal path (guard only fires for status===400) + const result = await auth.markAccountUnavailable( + connId, + 429, + "The requested model is not supported", + "github", + "test-model" + ); + + assert.strictEqual(result.shouldFallback, true, "non-400 must not be short-circuited by model guard"); + // The key assertion: guard returns shouldFallback:false. If we get here with + // shouldFallback:true, the guard did NOT fire (correct behavior). +}); + +test("#10460: empty errorText with status 400 does NOT trigger guard", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + const result = await auth.markAccountUnavailable(connId, 400, "", "github", "test-model"); + + // Empty string matches no patterns — normal path applies + assert.strictEqual(result.shouldFallback, false, "empty errorText is generic 400 → no fallback"); +}); + +test("#10460: auth-credential 400 text does NOT match model-unsupported guard", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + // "Invalid API key provided for model gpt-4o" contains "model" but is NOT a + // model-unsupported error — it's a credential issue. The guard must not fire. + const result = await auth.markAccountUnavailable( + connId, + 400, + "Invalid API key provided for model gpt-4o", + "github", + "gpt-4o" + ); + + // This text does NOT match MODEL_ACCESS_DENIED_PATTERNS (verified by regex test) + // so it falls through to checkFallbackError which returns shouldFallback:false for generic 400 + assert.strictEqual(result.shouldFallback, false, "auth-credential 400 must not be caught by model guard"); + // The generic 400 path returns cooldownMs:0 — same as the guard, but the + // connection was NOT touched (no rateLimitedUntil set). This distinguishes + // it from the normal fallback path which would set a cooldown. + const conn = await providersDb.getProviderConnectionById(connId); + assert.ok(!conn.rateLimitedUntil, "generic 400 must not rate-limit connection"); +}); + +test("#10460: guard early return does not touch DB (distinguishes from normal path)", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + // Guard path: model-unsupported 400 → shouldFallback:false, cooldownMs:0, no DB change + const guardResult = await auth.markAccountUnavailable( + connId, 400, "The requested model is not supported", "github", "test-model" + ); + assert.strictEqual(guardResult.shouldFallback, false); + assert.strictEqual(guardResult.cooldownMs, 0); + const guardConn = await providersDb.getProviderConnectionById(connId); + assert.ok(!guardConn.rateLimitedUntil, "guard path must not touch DB"); + assert.strictEqual(guardConn.testStatus, "active", "guard path must keep connection active"); +}); + +test("#10460: returned result exposes a sanitized provider_model_unsupported reason", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + const result = await auth.markAccountUnavailable( + connId, + 400, + "The requested model is not supported", + "github", + "claude-fable-5" + ); + + assert.strictEqual(result.shouldFallback, false); + assert.strictEqual( + (result as { reason?: string }).reason, + "provider_model_unsupported", + "the canonical sanitized reason must be exposed on the returned result, not only in logs" + ); +}); + +test("#10460: account-scoped permission/entitlement 400 keeps rotating (not misclassified as provider-wide unsupported)", async () => { + await resetStorage10460(); + const connIds: string[] = []; + for (let i = 0; i < 3; i++) { + connIds.push(await seedConn10460("github")); + } + let calls = 0; + + // Phrased so it matches the broader/ambiguous MODEL_ACCESS_DENIED_PATTERNS + // ("permission" ... "model") but is NOT an unambiguous provider-wide "model not + // supported" response — it reads as an account/key entitlement gap (e.g. this + // key's plan doesn't include this model), where a DIFFERENT account of the same + // provider may still have access. Rotation through all 3 accounts must continue, + // unlike the unambiguous "model is not supported" case above. + for (const connId of connIds) { + calls += 1; + const result = await auth.markAccountUnavailable( + connId, + 400, + "Your API key does not have permission to use model gpt-4o", + "github", + "gpt-4o" + ); + if (!result.shouldFallback) break; + } + + assert.equal( + calls, + 3, + "an account-scoped permission/entitlement 400 must keep rotating through all accounts, " + + "not be short-circuited by the provider-wide model-unsupported guard" + ); +}); + +test("#10460: account-scoped 401 keeps rotating through all 3 accounts (not short-circuited)", async () => { + await resetStorage10460(); + const connIds: string[] = []; + for (let i = 0; i < 3; i++) { + connIds.push(await seedConn10460("github")); + } + let calls = 0; + + for (const connId of connIds) { + calls += 1; + const result = await auth.markAccountUnavailable( + connId, + 401, + "Unauthorized: invalid credentials", + "github", + "claude-fable-5" + ); + if (!result.shouldFallback) break; + } + + assert.equal( + calls, + 3, + "401 account-scoped errors must keep rotating through every account, unlike model-unsupported 400" + ); +}); + +test("#10460: account-scoped 403 keeps rotating through all 3 accounts", async () => { + await resetStorage10460(); + const connIds: string[] = []; + for (let i = 0; i < 3; i++) { + connIds.push(await seedConn10460("github")); + } + let calls = 0; + + for (const connId of connIds) { + calls += 1; + const result = await auth.markAccountUnavailable( + connId, + 403, + "Forbidden: access denied for this account", + "github", + "claude-fable-5" + ); + if (!result.shouldFallback) break; + } + + assert.equal(calls, 3, "403 account-scoped errors must keep rotating through every account"); +}); + +test("#10460: 429 rate limit keeps rotating through all 3 accounts", async () => { + await resetStorage10460(); + const connIds: string[] = []; + for (let i = 0; i < 3; i++) { + connIds.push(await seedConn10460("github")); + } + let calls = 0; + + for (const connId of connIds) { + calls += 1; + const result = await auth.markAccountUnavailable( + connId, + 429, + "Rate limit exceeded", + "github", + "claude-fable-5" + ); + if (!result.shouldFallback) break; + } + + assert.equal(calls, 3, "429 rate-limit errors must keep rotating through every account"); +}); + +// ─── #10460 acceptance criteria: 3-account rotation + combo target advancement ─ +// +// Reproduces the exact regression from issue #10460: a combo with a +// (github/model, 3 accounts) target followed by a sibling target. When GitHub +// returns an unambiguous "model not supported" 400, the account-rotation loop +// must make exactly ONE upstream call (not one per account) and the combo must +// advance to the next target — not just that markAccountUnavailable() in +// isolation returns shouldFallback:false (covered above), but that a realistic +// rotation loop wired to the REAL markAccountUnavailable()/ +// isProviderModelUnsupported400() gating actually stops after account 1 and lets +// handleComboChat() move on. +// +// The inner loop below is a faithful, minimal reproduction of the account-rotation +// contract in src/sse/handlers/chat.ts::handleSingleModelChat step 8 ("Fallback to +// next account", ~line 1936): call markAccountUnavailable(); continue to the next +// connection only while shouldFallback is true, otherwise stop immediately and +// surface the failure. Reimplemented at this scope (rather than driving the full +// handleChat()/route stack) so the test can assert on upstream-call counts and +// per-connection DB state directly, while still exercising the real gating logic +// that decides whether rotation continues. +test("#10460 acceptance: unambiguous model-unsupported 400 makes exactly ONE upstream call across 3 accounts, then the combo advances to the next target", async () => { + await resetStorage10460(); + const { handleComboChat } = await import("../../open-sse/services/combo.ts"); + + const githubConnIds: string[] = []; + for (let i = 0; i < 3; i++) { + githubConnIds.push(await seedConn10460("github")); + } + + let githubUpstreamCalls = 0; + const triedGithubConnections: string[] = []; + const noopLog = { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} }; + + const handleSingleModel = async (_body: unknown, modelStr: string) => { + if (modelStr.startsWith("github/")) { + for (const connId of githubConnIds) { + triedGithubConnections.push(connId); + githubUpstreamCalls += 1; + const result = await auth.markAccountUnavailable( + connId, + 400, + "The requested model is not supported", + "github", + "claude-fable-5" + ); + if (result.shouldFallback) continue; + return new Response( + JSON.stringify({ error: { message: "The requested model is not supported" } }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + // A test-fixture bug (guard not firing) would otherwise silently exhaust + // every account and mask the regression this test exists to catch. + throw new Error("all 3 github accounts were tried — the guard did not fire"); + } + // Second combo target (a different provider) — succeeds immediately. + return new Response(JSON.stringify({ id: "ok", choices: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }] }, + combo: { + name: "test-combo-10460", + strategy: "priority", + models: [{ model: "github/claude-fable-5" }, { model: "openai/gpt-4o-mini" }], + }, + handleSingleModel, + log: noopLog, + settings: {}, + allCombos: [], + }); + + assert.equal( + githubUpstreamCalls, + 1, + `expected exactly ONE upstream call for github/claude-fable-5 across 3 accounts, got ` + + `${githubUpstreamCalls} (tried: ${triedGithubConnections.join(", ")})` + ); + assert.equal(triedGithubConnections.length, 1, "only the first account should have been tried"); + assert.equal(result.status, 200, "the combo must advance to the next target and succeed"); + + // The two untouched accounts must remain completely unaffected — proves the + // guard did not just avoid an upstream call but also never cooled them down, + // so they stay immediately eligible for the next unrelated request. + for (const connId of githubConnIds.slice(1)) { + const conn = await providersDb.getProviderConnectionById(connId); + assert.ok(!conn.rateLimitedUntil, `untried account ${connId} must not be rate-limited`); + assert.notStrictEqual( + conn.testStatus, + "unavailable", + `untried account ${connId} must stay active` + ); + } +}); diff --git a/tests/unit/app-config.test.ts b/tests/unit/app-config.test.ts new file mode 100644 index 000000000000..6da90366c544 --- /dev/null +++ b/tests/unit/app-config.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { formatAppVersion } from "../../src/shared/constants/appConfig.ts"; + +describe("formatAppVersion", () => { + it("identifies branch builds by fork and commit", () => { + assert.equal(formatAppVersion("3.8.48", "fork · sha-7d05c4f"), "v3.8.48 · fork · sha-7d05c4f"); + }); + + it("keeps the upstream version label for ordinary builds", () => { + assert.equal(formatAppVersion("3.8.48"), "v3.8.48"); + }); + + it("adds the concise fork release suffix for tagged fork builds", () => { + assert.equal(formatAppVersion("3.8.48", "3.8.48-fork.17"), "v3.8.48 · fork.17"); + }); + + it("preserves a mismatched build identifier so version drift stays visible", () => { + assert.equal(formatAppVersion("3.8.48", "v3.8.49-fork.1"), "v3.8.48 · 3.8.49-fork.1"); + }); +}); diff --git a/tests/unit/audio-speech-handler.test.ts b/tests/unit/audio-speech-handler.test.ts index 49d24998c9a0..954ec3f625d1 100644 --- a/tests/unit/audio-speech-handler.test.ts +++ b/tests/unit/audio-speech-handler.test.ts @@ -69,6 +69,139 @@ test("handleAudioSpeech proxies OpenAI-compatible providers with defaults", asyn } }); +test("handleAudioSpeech runs OmniVoice on Inference.sh and fetches its audio without credentials", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; options: RequestInit }> = []; + + globalThis.fetch = async (url, options = {}) => { + requests.push({ url: String(url), options }); + if (String(url) === "https://api.inference.sh/v1/apps/run") { + return new Response( + JSON.stringify({ + data: { output: { audio: "https://cloud.inference.sh/files/omnivoice.wav" } }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (String(url) === "https://cloud.inference.sh/files/omnivoice.wav") { + return new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": "audio/wav" }, + }); + } + throw new Error(`Unexpected URL: ${String(url)}`); + }; + + try { + const response = await handleAudioSpeech({ + body: { + model: "inference-sh/omnivoice@0yb22kbj", + input: "Hello from OmniVoice", + voice: "female, low pitch, British accent", + speed: 1.25, + }, + credentials: { apiKey: "inference-key" }, + }); + + assert.equal(requests.length, 2); + assert.equal(requests[0].url, "https://api.inference.sh/v1/apps/run"); + assert.equal( + (requests[0].options.headers as Record).Authorization, + "Bearer inference-key" + ); + assert.deepEqual(JSON.parse(String(requests[0].options.body)), { + app: "infsh/omnivoice@0yb22kbj", + input: { + text: "Hello from OmniVoice", + instruct: "female, low pitch, British accent", + speed: 1.25, + }, + }); + assert.equal(requests[1].url, "https://cloud.inference.sh/files/omnivoice.wav"); + assert.equal( + (requests[1].options.headers as Record | undefined)?.Authorization, + undefined + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "audio/wav"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleAudioSpeech rejects an Inference.sh task without an HTTPS audio artifact", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ data: { output: { audio: "http://invalid.test/audio.wav" } } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + + try { + const response = await handleAudioSpeech({ + body: { model: "inference-sh/omnivoice@0yb22kbj", input: "test" }, + credentials: { apiKey: "inference-key" }, + }); + const payload = (await response.json()) as { error: { message: string } }; + + assert.equal(response.status, 502); + assert.equal(payload.error.message, "Inference.sh did not return generated audio"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleAudioSpeech polls an Inference.sh task until OmniVoice audio is ready", async () => { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + + globalThis.fetch = async (url) => { + const requestUrl = String(url); + requests.push(requestUrl); + if (requestUrl === "https://api.inference.sh/v1/apps/run") { + return new Response(JSON.stringify({ data: { id: "task-1", status: "queued" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (requestUrl === "https://api.inference.sh/v1/tasks/task-1") { + return new Response( + JSON.stringify({ + data: { + id: "task-1", + status: "completed", + output: { audio: "https://cloud.inference.sh/files/ready.wav" }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (requestUrl === "https://cloud.inference.sh/files/ready.wav") { + return new Response(new Uint8Array([4, 5, 6]), { + status: 200, + headers: { "content-type": "audio/wav" }, + }); + } + throw new Error(`Unexpected URL: ${requestUrl}`); + }; + + try { + const response = await handleAudioSpeech({ + body: { model: "inference-sh/omnivoice@0yb22kbj", input: "wait for audio" }, + credentials: { apiKey: "inference-key" }, + }); + + assert.deepEqual(requests, [ + "https://api.inference.sh/v1/apps/run", + "https://api.inference.sh/v1/tasks/task-1", + "https://cloud.inference.sh/files/ready.wav", + ]); + assert.equal(response.status, 200); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("handleAudioSpeech routes Deepgram with Token auth and model query parameter", async () => { const originalFetch = globalThis.fetch; let capturedUrl; diff --git a/tests/unit/audio-transcription-handler.test.ts b/tests/unit/audio-transcription-handler.test.ts index 8bde9d5426a7..70c509920863 100644 --- a/tests/unit/audio-transcription-handler.test.ts +++ b/tests/unit/audio-transcription-handler.test.ts @@ -2,6 +2,14 @@ import test from "node:test"; import assert from "node:assert/strict"; const { handleAudioTranscription } = await import("../../open-sse/handlers/audioTranscription.ts"); +const { setSaluteSpeechTranscriptionDependenciesForTests } = + await import("../../open-sse/handlers/saluteSpeechTranscription.ts"); + +type ErrorPayload = { error: { message: string } }; + +async function readErrorPayload(response: Response): Promise { + return (await response.json()) as ErrorPayload; +} function buildFile(contents, name, type) { return new File([Buffer.from(contents)], name, { type }); @@ -12,12 +20,53 @@ function immediateTimeout(callback, _ms, ...args) { return 0; } +function createSaluteSpeechClient(onWrite) { + const listeners = new Map(); + return { + Recognize(metadata) { + const writes = []; + return { + write(value) { + writes.push(value); + onWrite?.({ metadata, writes, value }); + return true; + }, + end() { + listeners.get("data")?.({ + transcription: { + eou: false, + results: [{ normalized_text: "partial text" }], + }, + }); + listeners.get("data")?.({ + transcription: { + eou: true, + results: [{ normalized_text: "final transcript" }], + }, + }); + listeners.get("end")?.(); + }, + cancel() {}, + on(event, listener) { + listeners.set(event, listener); + return this; + }, + }; + }, + close() {}, + }; +} + +test.afterEach(() => { + setSaluteSpeechTranscriptionDependenciesForTests(null); +}); + test("handleAudioTranscription requires model", async () => { const formData = new FormData(); formData.append("file", buildFile("abc", "audio.wav", "audio/wav")); const response = await handleAudioTranscription({ formData, credentials: { apiKey: "x" } }); - const payload = (await response.json()) as any; + const payload = await readErrorPayload(response); assert.equal(response.status, 400); assert.equal(payload.error.message, "model is required"); @@ -28,7 +77,7 @@ test("handleAudioTranscription requires a file upload", async () => { formData.append("model", "openai/whisper-1"); const response = await handleAudioTranscription({ formData, credentials: { apiKey: "x" } }); - const payload = (await response.json()) as any; + const payload = await readErrorPayload(response); assert.equal(response.status, 400); assert.equal(payload.error.message, "file is required"); @@ -124,7 +173,7 @@ test("handleAudioTranscription routes Deepgram with binary upload and language p formData, credentials: { apiKey: "dg-key" }, }); - const payload = (await response.json()) as any; + const payload = await readErrorPayload(response); const url = new URL(capturedUrl); assert.equal(url.origin + url.pathname, "https://api.deepgram.com/v1/listen"); @@ -224,7 +273,7 @@ test("handleAudioTranscription rejects invalid HuggingFace model paths", async ( formData, credentials: { apiKey: "hf-key" }, }); - const payload = (await response.json()) as any; + const payload = await readErrorPayload(response); assert.equal(response.status, 400); assert.equal(payload.error.message, "Invalid model ID"); @@ -236,7 +285,7 @@ test("handleAudioTranscription requires credentials for authenticated providers" formData.append("file", buildFile("abc", "clip.wav", "audio/wav")); const response = await handleAudioTranscription({ formData, credentials: null }); - const payload = (await response.json()) as any; + const payload = await readErrorPayload(response); assert.equal(response.status, 401); assert.equal(payload.error.message, "No credentials for transcription provider: openai"); @@ -349,7 +398,7 @@ test("handleAudioTranscription returns an error when AssemblyAI reports a termin formData, credentials: { apiKey: "assembly-key" }, }); - const payload = (await response.json()) as any; + const payload = await readErrorPayload(response); assert.equal(response.status, 500); assert.equal(payload.error.message, "corrupt audio payload"); @@ -399,6 +448,97 @@ test("handleAudioTranscription routes HuggingFace providers with raw audio uploa } }); +test("handleAudioTranscription exchanges SaluteSpeech OAuth credentials and returns the final transcript", async () => { + const oauthCalls = []; + const grpcWrites = []; + setSaluteSpeechTranscriptionDependenciesForTests({ + fetch: async (url, options = {}) => { + oauthCalls.push({ url: String(url), options }); + return new Response(JSON.stringify({ access_token: "salute-access-token" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + createClient: (host) => { + assert.equal(host, "smartspeech.sber.ru:443"); + return createSaluteSpeechClient(({ metadata, writes }) => { + grpcWrites.push({ + authorization: metadata.get("authorization")[0], + writes: [...writes], + }); + }); + }, + }); + + const formData = new FormData(); + formData.append("model", "salutespeech/general"); + formData.append("language", "ru-RU"); + formData.append("file", buildFile("audio-bytes", "clip.mp3", "audio/mpeg")); + + const response = await handleAudioTranscription({ + formData, + credentials: { + apiKey: "salute-client-secret", + providerSpecificData: { clientId: "salute-client-id" }, + }, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { text: "final transcript" }); + assert.equal(oauthCalls.length, 1); + assert.equal(oauthCalls[0].url, "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"); + assert.equal( + oauthCalls[0].options.headers.Authorization, + `Basic ${Buffer.from("salute-client-id:salute-client-secret").toString("base64")}` + ); + assert.equal(oauthCalls[0].options.headers["Content-Type"], "application/x-www-form-urlencoded"); + assert.equal(String(oauthCalls[0].options.body), "scope=SALUTE_SPEECH_PERS"); + assert.equal(grpcWrites.at(-1).authorization, "Bearer salute-access-token"); + assert.deepEqual(grpcWrites[0].writes[0], { + options: { + audio_encoding: "MP3", + sample_rate: undefined, + channels_count: 1, + language: "ru-RU", + model: "general", + enable_partial_results: { enable: false }, + normalization_options: { enable: { enable: true } }, + }, + }); + assert.deepEqual(grpcWrites.at(-1).writes.at(-1), { + audio_chunk: Buffer.from("audio-bytes"), + }); +}); + +test("handleAudioTranscription rejects unsupported SaluteSpeech audio formats before authorization", async () => { + let called = false; + setSaluteSpeechTranscriptionDependenciesForTests({ + fetch: async () => { + called = true; + return new Response(); + }, + }); + const formData = new FormData(); + formData.append("model", "salutespeech/general"); + formData.append("file", buildFile("audio-bytes", "clip.wav", "audio/wav")); + + const response = await handleAudioTranscription({ + formData, + credentials: { + apiKey: "salute-client-secret", + providerSpecificData: { clientId: "salute-client-id" }, + }, + }); + const payload = await readErrorPayload(response); + + assert.equal(response.status, 400); + assert.equal( + payload.error.message, + "SaluteSpeech supports MP3, FLAC, Opus, and raw PCM S16LE audio" + ); + assert.equal(called, false); +}); + test("handleAudioTranscription rejects unsupported providers", async () => { const formData = new FormData(); formData.append("model", "unknown/provider"); @@ -408,7 +548,7 @@ test("handleAudioTranscription rejects unsupported providers", async () => { formData, credentials: { apiKey: "x" }, }); - const payload = (await response.json()) as any; + const payload = await readErrorPayload(response); assert.equal(response.status, 400); assert.match( @@ -435,7 +575,7 @@ test("handleAudioTranscription surfaces parsed upstream errors for OpenAI-compat formData, credentials: { apiKey: "openai-key" }, }); - const payload = (await response.json()) as any; + const payload = await readErrorPayload(response); assert.equal(response.status, 429); assert.equal(payload.error.message, "too many requests"); @@ -460,7 +600,7 @@ test("handleAudioTranscription returns a 500 when upstream fetch throws", async formData, credentials: { apiKey: "openai-key" }, }); - const payload = (await response.json()) as any; + const payload = await readErrorPayload(response); assert.equal(response.status, 500); assert.equal(payload.error.message, "Transcription request failed: network timeout"); diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index 34703f270ecf..1f447cde19bb 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -600,3 +600,28 @@ test("runAuthzPipeline clears stale dashboard JWTs without error-stack noise", a console.warn = originalWarn; } }); + +test("pipeline passes authenticated client identity to socket-less login handlers", async () => { + process.env.OMNIROUTE_PEER_STAMP_TOKEN = "pipeline-peer-token"; + const { getClientIpFromRequest } = await import("../../../src/lib/ipUtils.ts"); + const { CLIENT_IP_HEADER } = await import("../../../src/server/authz/clientIpStamp.ts"); + const response = await pipeline.runAuthzPipeline( + request("http://localhost/api/auth/login", { + headers: { + "x-omniroute-peer-ip": "pipeline-peer-token|127.0.0.1", + "x-forwarded-for": "198.51.100.10", + "cf-connecting-ip": "203.0.113.1", + [CLIENT_IP_HEADER]: "forged", + }, + }), + { enforce: true } + ); + const clientStamp = response.headers.get(`x-middleware-request-${CLIENT_IP_HEADER}`); + assert.ok(clientStamp); + assert.equal(clientStamp.includes("pipeline-peer-token"), false); + const routeRequest = new Request("http://localhost/api/auth/login", { + headers: { [CLIENT_IP_HEADER]: clientStamp, "cf-connecting-ip": "203.0.113.2" }, + }); + assert.equal(getClientIpFromRequest(routeRequest), "198.51.100.10"); + assert.equal(response.headers.get("x-middleware-request-x-omniroute-peer-ip"), null); +}); diff --git a/tests/unit/bottleneck-doexpire-patch.test.ts b/tests/unit/bottleneck-doexpire-patch.test.ts new file mode 100644 index 000000000000..2f850c951b11 --- /dev/null +++ b/tests/unit/bottleneck-doexpire-patch.test.ts @@ -0,0 +1,136 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +// Import the patch module to test it in isolation +import { applyBottleneckDoExpirePatch } from "../../open-sse/services/bottleneckPatch.ts"; + +// Bottleneck's internal Job / States classes are not part of the public +// exports, so load them directly to build a deterministic RUNNING-state job. +// A real schedule() parks the job in QUEUED then advances it through RUNNING +// on _run; reproducing that exactly lets us hit the RUNNING branch that the +// expiry timer reaches — the branch the (now-fixed) patch is responsible for. +const require = createRequire(import.meta.url); +const BottleneckJob = require("bottleneck/lib/Job.js"); +const BottleneckStates = require("bottleneck/lib/States.js"); + +const fakeEvents = { trigger: async () => false }; + +/** Build a Job that Bottleneck `_run` accepts (state QUEUED under options.id). */ +function buildParkedJob() { + const states = new BottleneckStates(["RECEIVED", "QUEUED", "RUNNING", "EXECUTING", "DONE"]); + const job = new BottleneckJob( + () => {}, + [], + { id: "patch-under-test", expiration: 5000 }, + { id: "patch-under-test" }, + true, + fakeEvents, + states, + Promise + ); + states.start(job.options.id); // -> RECEIVED (idx 0) + states.next(job.options.id); // -> QUEUED (idx 1) — what _run expects + return { job, states }; +} + +test("applyBottleneckDoExpirePatch is idempotent", () => { + // Should not throw on multiple calls + applyBottleneckDoExpirePatch(); + applyBottleneckDoExpirePatch(); + assert.ok(true, "patch applied twice without error"); +}); + +test("patched _run still dispatches jobs correctly", async () => { + applyBottleneckDoExpirePatch(); + + const { default: Bottleneck } = await import("bottleneck"); + const limiter = new Bottleneck({ + id: "test-doexpire-patch", + maxConcurrent: 2, + minTime: 0, + }); + + // Job should execute normally (no expiration triggered) + const result = await limiter.schedule({ expiration: 5000 }, async () => { + return "patched-ok"; + }); + + assert.equal(result, "patched-ok"); + await limiter.disconnect(); +}); + +test("patched doExpire advances a RUNNING job to EXECUTING instead of crashing", async () => { + applyBottleneckDoExpirePatch(); + + const { default: Bottleneck } = await import("bottleneck"); + const limiter = new Bottleneck({ id: "test-doexpire-parked", maxConcurrent: 1, minTime: 0 }); + try { + const { job, states } = buildParkedJob(); + // _run calls doRun (QUEUED -> RUNNING), then parks the job in RUNNING for + // `wait` ms before dispatching to EXECUTING. A large wait holds it in the + // exact state the expiry timer can reach — the branch the patch guards. + limiter._run("parked", job, 10000); + assert.equal(states.jobStatus(job.options.id), "RUNNING", "job must be RUNNING before expiry"); + + // Fire doExpire while the job is RUNNING. Unpatched Bottleneck compares + // `options.id === "RUNNING"` (always false), so _assertStatus("EXECUTING") + // throws and the job is stuck forever. The patched doExpire must advance + // RUNNING -> EXECUTING first, then run the original doExpire cleanly. + job.doExpire( + () => true, + () => {}, + () => {} + ); + assert.equal( + states.jobStatus(job.options.id), + "EXECUTING", + "patched doExpire must advance a RUNNING job to EXECUTING before the original runs" + ); + } finally { + await limiter.disconnect(); + } +}); + +test("unpatched Bottleneck doExpire throws on a RUNNING job (the leak the patch guards)", async () => { + // Lock the motivating bug: with Job.js:162 comparing options.id === "RUNNING" + // (always false) the job is never advanced, so _assertStatus("EXECUTING") + // throws and the job is stuck in RUNNING with a running-slot never freed. + // Proving this FAILS without the patch is what makes the patched test above + // a real signal rather than a vacuous pass-on-empty. + const { job, states } = buildParkedJob(); + states.next(job.options.id); // -> RUNNING + assert.equal(states.jobStatus(job.options.id), "RUNNING"); + assert.throws( + () => + BottleneckJob.prototype.doExpire.call( + job, + () => true, + () => {}, + () => {} + ), + /expected EXECUTING/, + "the unpatched doExpire must throw when the job is RUNNING, or the bug is already fixed upstream and the patch is dead" + ); + // Stays stuck in RUNNING (no advance leaked into a broken slot count). + assert.equal(states.jobStatus(job.options.id), "RUNNING"); +}); + +test("patch does not affect jobs without expiration", async () => { + applyBottleneckDoExpirePatch(); + + const { default: Bottleneck } = await import("bottleneck"); + const limiter = new Bottleneck({ + id: "test-no-expiration", + maxConcurrent: 2, + minTime: 0, + }); + + // Job without expiration should work exactly as before + const result = await limiter.schedule(async () => { + return "no-expire"; + }); + + assert.equal(result, "no-expire"); + await limiter.disconnect(); +}); diff --git a/tests/unit/breaker-network-error-guard.test.ts b/tests/unit/breaker-network-error-guard.test.ts new file mode 100644 index 000000000000..7cb899bb1082 --- /dev/null +++ b/tests/unit/breaker-network-error-guard.test.ts @@ -0,0 +1,105 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { shouldTripProviderBreakerForResult } from "../../src/sse/handlers/chatPredicates.ts"; +import { + recordProviderFailure, + clearProviderFailure, + isProviderInCooldown, +} from "../../open-sse/services/accountFallback.ts"; +import { PROVIDER_PROFILES } from "../../open-sse/config/constants.ts"; + +// Network-layer errors and OmniRoute's own queue timeouts must NOT trip the +// provider circuit breaker. These are not provider failures — the provider never +// saw the request, so it may be perfectly healthy while only the network path is +// broken (single-model path; the combo same-provider dead-proxy case is #8376's +// contract and stays untouched). +test("single-model predicate keeps network and queue-capacity codes out of the provider breaker", () => { + for (const code of ["proxy_unreachable", "RATE_LIMIT_QUEUE_TIMEOUT", "RATE_LIMIT_QUEUE_WEDGED"]) { + assert.equal( + shouldTripProviderBreakerForResult( + { status: 502, errorCode: code, error: "local failure" }, + false, + false + ), + false, + `${code} must be excluded from the provider-breaker trip` + ); + } + assert.equal( + shouldTripProviderBreakerForResult( + { status: 502, errorCode: null, error: "upstream bad gateway" }, + false, + false + ), + true, + "genuine upstream 502s must still trip the provider breaker" + ); +}); + +test("queue-timeout recordProviderFailure never opens the provider breaker", () => { + // Control first: that many real failures WOULD open the breaker — proving the + // isQueueTimeout flag, not an inert provider, is what keeps it closed. + const control = "test-qt-control-provider"; + clearProviderFailure(control); + const threshold = PROVIDER_PROFILES.apikey.circuitBreakerThreshold; + for (let i = 0; i < threshold; i++) { + recordProviderFailure(control, undefined, undefined, null, {}); + } + assert.equal(isProviderInCooldown(control), true, "sanity: real failures open the breaker"); + + // The queue-timeout path must never reach the breaker, no matter how many fire. + const provider = "test-qt-provider"; + clearProviderFailure(provider); + for (let i = 0; i < threshold; i++) { + recordProviderFailure(provider, undefined, undefined, null, { isQueueTimeout: true }); + } + assert.equal(isProviderInCooldown(provider), false, "queue timeouts must not open the breaker"); +}); + +test("same-provider network errors in one window dedup to a single failure", () => { + // Several combo targets on the same provider failing one network event (a VPN blip) + // must count once, not per target — otherwise one blip opens the provider breaker. + const originalNow = Date.now; + let now = 1_700_000_000_000; + Date.now = () => now; + try { + const provider = "test-net-dedup-provider"; + clearProviderFailure(provider); + const threshold = PROVIDER_PROFILES.apikey.circuitBreakerThreshold; + for (let i = 0; i < threshold; i++) { + recordProviderFailure(provider, undefined, undefined, null, { isNetworkError: true }); + now += 500; // every call inside the same 10s window + } + assert.equal( + isProviderInCooldown(provider), + false, + "one transient network event must not open the breaker" + ); + } finally { + Date.now = originalNow; + } +}); + +test("persistent dead proxy across windows still opens the breaker", () => { + // A genuinely dead proxy keeps failing across requests (past the dedup window), so it + // must still accumulate to the breaker threshold — the dedup must not shield real pain. + const originalNow = Date.now; + let now = 1_700_000_000_000; + Date.now = () => now; + try { + const provider = "test-net-deadproxy-provider"; + clearProviderFailure(provider); + const threshold = PROVIDER_PROFILES.apikey.circuitBreakerThreshold; + for (let i = 0; i < threshold; i++) { + recordProviderFailure(provider, undefined, undefined, null, { isNetworkError: true }); + now += 11_000; // past each 10s window + } + assert.equal( + isProviderInCooldown(provider), + true, + "a persistent network failure must still accumulate to the threshold" + ); + } finally { + Date.now = originalNow; + } +}); diff --git a/tests/unit/bug-8020-codex-peek-body-timeout.test.ts b/tests/unit/bug-8020-codex-peek-body-timeout.test.ts new file mode 100644 index 000000000000..b74398cd2fe5 --- /dev/null +++ b/tests/unit/bug-8020-codex-peek-body-timeout.test.ts @@ -0,0 +1,71 @@ +// #8020: `peekCodexSseTransientError()`'s first-chunk read (open-sse/executors/codex.ts) had +// NO timeout wrapper — a 200 text/event-stream response whose body never emits a byte hung on a +// bare `reader.read()` for ~15 minutes (901399ms observed) before the platform killed the +// connection and surfaced a generic 502. This runs BEFORE chatCore's normal readiness/idle-timeout +// pipeline takes over, so FETCH_BODY_TIMEOUT_MS / STREAM_IDLE_TIMEOUT_MS never applied to it. +// +// Fix: every read in the peek loop and the re-assembled passthrough body is now bounded by a +// PER-READ timeout (open-sse/executors/codex/bodyTimeout.ts), so a stalled body settles fast +// instead of hanging, while a long-but-alive stream that keeps emitting chunks never trips it. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { peekCodexSseTransientError } from "../../open-sse/executors/codex.ts"; + +// Small, explicit override — never depends on the ~120s/600s production default, so this test +// settles fast and deterministically regardless of env configuration. +const TEST_TIMEOUT_MS = 200; + +function stuckSseResponse(): Response { + const stuck = new ReadableStream({ + pull() { + // Never enqueue and never close — simulates an upstream body that goes silent. + }, + }); + return new Response(stuck, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +test( + "peekCodexSseTransientError does not hang forever on a silently stuck SSE body (#8020)", + { timeout: 5000 }, + async () => { + const start = Date.now(); + const peek = await peekCodexSseTransientError(stuckSseResponse(), TEST_TIMEOUT_MS); + const elapsed = Date.now() - start; + + assert.ok( + elapsed < 2000, + `expected peek to settle within 2000ms via a body timeout, took ${elapsed}ms` + ); + assert.equal(peek.timedOut, true, "expected the peek to report timedOut on a stalled body"); + assert.equal(peek.matched, null); + assert.equal(peek.replacementBody, null); + } +); + +test( + "peekCodexSseTransientError still detects a transient error when the body responds promptly", + async () => { + const encoder = new TextEncoder(); + const response = new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue( + encoder.encode( + 'event: error\ndata: {"error":{"message":"Selected model is at capacity."}}\n\n' + ) + ); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } } + ); + + const peek = await peekCodexSseTransientError(response, TEST_TIMEOUT_MS); + assert.equal(peek.timedOut ?? false, false); + assert.match(peek.matched ?? "", /capacity/); + } +); diff --git a/tests/unit/build/assemble-standalone.test.ts b/tests/unit/build/assemble-standalone.test.ts index 9fefa5890ea3..e5624efe8954 100644 --- a/tests/unit/build/assemble-standalone.test.ts +++ b/tests/unit/build/assemble-standalone.test.ts @@ -50,6 +50,7 @@ function seedSidecarSources(root: string) { "scripts/build/runtime-env.mjs", "scripts/build/bootstrap-env.mjs", "scripts/dev/healthcheck.mjs", + "open-sse/proto/salutespeech/recognitionv2.proto", "public/logo.svg", ]; for (const rel of files) { @@ -70,6 +71,11 @@ test("assembleStandalone copies standalone + static + public + sidecars into out fs.writeFileSync(path.join(distDir, "static", "x.js"), "x"); fs.mkdirSync(path.join(tmp, "public"), { recursive: true }); fs.writeFileSync(path.join(tmp, "public", "logo.svg"), ""); + fs.mkdirSync(path.join(tmp, "open-sse", "proto", "salutespeech"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, "open-sse", "proto", "salutespeech", "recognitionv2.proto"), + 'syntax = "proto3";' + ); assembleStandalone({ distDir, @@ -141,6 +147,10 @@ test("async and sync sidecar copy paths produce identical bundle trees", async ( asyncTree.includes("src/mitm/tproxy/native/build/Release/transparent.node"), "TPROXY transparent.node copied into the standalone bundle" ); + assert.ok( + asyncTree.includes("open-sse/proto/salutespeech/recognitionv2.proto"), + "SaluteSpeech protocol copied into the standalone bundle" + ); fs.rmSync(tmp, { recursive: true, force: true }); }); diff --git a/tests/unit/call-logs-requested-model.test.ts b/tests/unit/call-logs-requested-model.test.ts index 745854ad325b..e01d0fd1eb42 100644 --- a/tests/unit/call-logs-requested-model.test.ts +++ b/tests/unit/call-logs-requested-model.test.ts @@ -154,3 +154,49 @@ test("requestedModel for anthropic-compatible provider with prefix", async () => assert.equal(all.length, 1); assert.equal(all[0].requestedModel, "mac/claude-opus-4"); }); + +test("call-log summaries expose stable empty-output codes without loading response artifacts", async () => { + const cases = [ + { + error: "[codex/gpt-5.5] returned an empty response (no usable choices/output)", + status: 502, + code: "empty_response", + }, + { error: "Provider returned empty content", status: 502, code: "empty_response" }, + { + error: + "[codex/gpt-5.5] returned empty output on 3 attempts; stopping retries for this provider/model", + status: 502, + code: "empty_response_retry_exhausted", + }, + { + error: { code: "empty_response_retry_exhausted", message: "Attempt limit reached" }, + status: 502, + code: "empty_response_retry_exhausted", + }, + { + error: { code: "empty_response", message: "No output" }, + status: 502, + code: "empty_response", + }, + { error: "Bad gateway: upstream connection reset", status: 502, code: null }, + { error: "Provider returned empty content", status: 500, code: null }, + { error: null, status: 200, code: null }, + ]; + for (let i = 0; i < cases.length; i++) { + await callLogs.saveCallLog({ + method: "POST", + path: "/v1/responses", + model: `model-${i}`, + provider: "codex", + status: cases[i].status, + error: cases[i].error, + duration: 100, + }); + } + const rows = await callLogs.getCallLogs({ limit: 20 }); + assert.equal(rows.length, cases.length); + for (let i = 0; i < cases.length; i++) { + assert.equal(rows.find((row) => row.model === `model-${i}`)?.errorCode, cases[i].code); + } +}); diff --git a/tests/unit/chat-messages-entry-objects-12643.test.ts b/tests/unit/chat-messages-entry-objects-12643.test.ts new file mode 100644 index 000000000000..5890c1e62e18 --- /dev/null +++ b/tests/unit/chat-messages-entry-objects-12643.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; + +const harness = await createChatPipelineHarness("chat-messages-entry-objects-12643"); +const { handleChat, buildRequest, resetStorage, seedConnection } = harness; + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await harness.cleanup(); +}); + +async function postMessages(messages: unknown) { + await seedConnection("anthropic", { apiKey: "sk-ant" }); + + let upstreamCalled = false; + globalThis.fetch = async () => { + upstreamCalled = true; + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + }; + + const response = await handleChat( + buildRequest({ + body: { + model: "anthropic/claude-haiku-4-5", + messages, + }, + }) + ); + const body = (await response.json()) as { error?: { message?: string } }; + return { response, body, upstreamCalled }; +} + +test("#12643: non-object message entries return 400 without an upstream call", async () => { + for (const messages of [[null], [{ role: "user", content: "hi" }, "oops"], [42], [[]]]) { + const { response, body, upstreamCalled } = await postMessages(messages); + + assert.equal(response.status, 400, `must be a 400: ${JSON.stringify(messages)}`); + assert.match(body.error?.message ?? "", /Expected array of objects/i); + assert.equal(upstreamCalled, false, "must not forward invalid messages upstream"); + } +}); + +test("#12643: a well-formed message passes the entry guard", async () => { + const { response, body } = await postMessages([{ role: "user", content: "hi" }]); + + assert.ok( + !(response.status === 400 && /Expected array of objects/i.test(body.error?.message ?? "")) + ); +}); diff --git a/tests/unit/chatcore-semantic-cache.test.ts b/tests/unit/chatcore-semantic-cache.test.ts index 1ca36cc072f4..5d8ee4ee3ea6 100644 --- a/tests/unit/chatcore-semantic-cache.test.ts +++ b/tests/unit/chatcore-semantic-cache.test.ts @@ -14,9 +14,8 @@ const core = await import("../../src/lib/db/core.ts"); // Seeding the real cache (no mock.module under the Stryker tap-runner) lets us drive the // HIT branch deterministically: setCachedResponse populates the in-memory cache that // getCachedResponse checks first, so the signature checkSemanticCache rebuilds resolves. -const { generateSignature, setCachedResponse, clearCache } = await import( - "../../src/lib/semanticCache.ts" -); +const { generateSignature, requestVariantOf, setCachedResponse, clearCache } = + await import("../../src/lib/semanticCache.ts"); const { OMNIROUTE_RESPONSE_HEADERS } = await import("../../src/shared/constants/headers.ts"); const { calculateCost } = await import("../../src/lib/usage/costCalculator.ts"); const { formatOmniRouteCost } = await import("../../src/domain/omnirouteResponseMeta.ts"); @@ -148,7 +147,11 @@ function makeHitArgs(overrides: Record = {}) { const debugCalls: unknown[][] = []; const args = { semanticCacheEnabled: true, - body: { model: "gpt-4o", messages: [{ role: "user", content: "cached query" }], temperature: 0 }, + body: { + model: "gpt-4o", + messages: [{ role: "user", content: "cached query" }], + temperature: 0, + }, clientRawRequest: { headers: {} }, model: "gpt-4o", provider: "openai", @@ -182,7 +185,8 @@ function seedHit(args: ReturnType["args"], response: unknown args.body.messages ?? (args.body as Record).input, args.body.temperature, (args.body as Record).top_p, - args.apiKeyId ?? undefined + args.apiKeyId ?? undefined, + requestVariantOf(args.body) ); setCachedResponse(signature, args.model, response); return signature; @@ -198,7 +202,11 @@ test("checkSemanticCache returns a non-streaming JSON HIT with cache headers + l usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }; const { args, persistCalls, convertedCalls, debugCalls } = makeHitArgs({ - body: { model: "gpt-4o", messages: [{ role: "user", content: "hit query one" }], temperature: 0 }, + body: { + model: "gpt-4o", + messages: [{ role: "user", content: "hit query one" }], + temperature: 0, + }, stream: false, }); seedHit(args, cached); @@ -251,7 +259,11 @@ test("checkSemanticCache returns a streaming SSE HIT (text/event-stream) when st usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 }, }; const { args, persistCalls } = makeHitArgs({ - body: { model: "gpt-4o", messages: [{ role: "user", content: "hit query stream" }], temperature: 0 }, + body: { + model: "gpt-4o", + messages: [{ role: "user", content: "hit query stream" }], + temperature: 0, + }, stream: true, }); seedHit(args, cached); @@ -279,7 +291,11 @@ test("checkSemanticCache HITs even when the cached body has no usage (cost falls const cached = { id: "chatcmpl-cached-no-usage", choices: [ - { index: 0, message: { role: "assistant", content: "no-usage answer" }, finish_reason: "stop" }, + { + index: 0, + message: { role: "assistant", content: "no-usage answer" }, + finish_reason: "stop", + }, ], }; const { args, persistCalls } = makeHitArgs({ diff --git a/tests/unit/chatcore-target-format.test.ts b/tests/unit/chatcore-target-format.test.ts index affa94a113d4..57855f444e35 100644 --- a/tests/unit/chatcore-target-format.test.ts +++ b/tests/unit/chatcore-target-format.test.ts @@ -8,7 +8,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { resolveChatCoreTargetFormat } from "../../open-sse/handlers/chatCore/targetFormat.ts"; -import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../open-sse/config/providerModels.ts"; +import { + PROVIDER_ID_TO_ALIAS, + getModelTargetFormat, +} from "../../open-sse/config/providerModels.ts"; import { getTargetFormat } from "../../open-sse/services/provider.ts"; import { FORMATS } from "../../open-sse/translator/formats.ts"; @@ -24,7 +27,9 @@ function expected( const targetFormat = apiFormat === "responses" ? FORMATS.OPENAI_RESPONSES - : modelTargetFormat || customModelTargetFormat || getTargetFormat(provider, providerSpecificData); + : modelTargetFormat || + customModelTargetFormat || + getTargetFormat(provider, providerSpecificData); return { alias, targetFormat }; } @@ -51,6 +56,18 @@ test("delegates byte-identically for a normal model (no apiFormat / no custom ov assert.deepEqual(r, expected("openai", "gpt-4o", undefined, undefined, undefined)); }); +test("OpenCode Go Luna selects Responses translation before executor dispatch", () => { + const r = resolveChatCoreTargetFormat({ + provider: "opencode-go", + resolvedModel: "gpt-5.6-luna", + apiFormat: undefined, + customModelTargetFormat: undefined, + providerSpecificData: undefined, + }); + assert.equal(r.alias, "opencode-go"); + assert.equal(r.targetFormat, FORMATS.OPENAI_RESPONSES); +}); + test("customModelTargetFormat is used when the model has no registry target format", () => { const customModel = "totally-unknown-custom-model-xyz"; // precondition: the registry has no target format for this unknown model diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index d59df9815c10..98b1d90be970 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -1,3 +1,8 @@ +import { + buildOpenAIResponse, + buildClaudeResponse, + buildResponsesResponse, +} from "../fixtures/chatcore-translation-responses.ts"; // @ts-nocheck import test from "node:test"; import assert from "node:assert/strict"; @@ -83,142 +88,6 @@ function toPlainHeaders(headers) { ); } -function buildOpenAIResponse(stream, text = "ok") { - if (stream) { - return new Response( - `data: ${JSON.stringify({ - id: "chatcmpl-stream", - object: "chat.completion.chunk", - choices: [{ index: 0, delta: { role: "assistant", content: text } }], - })}\n\ndata: [DONE]\n\n`, - { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - } - ); - } - - return new Response( - JSON.stringify({ - id: "chatcmpl-json", - object: "chat.completion", - model: "gpt-4o-mini", - choices: [ - { - index: 0, - message: { role: "assistant", content: text }, - finish_reason: "stop", - }, - ], - usage: { - prompt_tokens: 4, - completion_tokens: 2, - total_tokens: 6, - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); -} - -function buildClaudeResponse(stream, text = "ok") { - if (stream) { - return new Response( - [ - "event: message_start", - `data: ${JSON.stringify({ - type: "message_start", - message: { - id: "msg_stream", - type: "message", - role: "assistant", - model: "claude-sonnet-4-6", - usage: { input_tokens: 12, output_tokens: 0 }, - }, - })}`, - "", - "event: content_block_start", - `data: ${JSON.stringify({ - type: "content_block_start", - index: 0, - content_block: { type: "text", text: "" }, - })}`, - "", - "event: content_block_delta", - `data: ${JSON.stringify({ - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text }, - })}`, - "", - "event: message_delta", - `data: ${JSON.stringify({ - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { output_tokens: 3 }, - })}`, - "", - "event: message_stop", - `data: ${JSON.stringify({ type: "message_stop" })}`, - "", - ].join("\n"), - { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - } - ); - } - - return new Response( - JSON.stringify({ - id: "msg_json", - type: "message", - role: "assistant", - model: "claude-sonnet-4-6", - content: [{ type: "text", text }], - stop_reason: "end_turn", - usage: { - input_tokens: 12, - output_tokens: 3, - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); -} - -function buildResponsesResponse(text = "ok") { - return new Response( - JSON.stringify({ - id: "resp_123", - object: "response", - status: "completed", - model: "gpt-5.1-codex", - output: [ - { - id: "msg_123", - type: "message", - role: "assistant", - content: [{ type: "output_text", text, annotations: [] }], - }, - ], - usage: { - input_tokens: 4, - output_tokens: 2, - total_tokens: 6, - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); -} - function capabilityEntry(limitContext) { return { tool_call: true, @@ -1363,6 +1232,48 @@ test("chatCore refreshes GitHub credentials after 401 and retries with the refre assert.equal(payload.choices[0].message.content, "retry succeeded after refresh"); }); +test("chatCore expires an explicitly invalidated Codex OAuth connection without refresh retries", async () => { + const refreshUpdates = []; + const { calls, result } = await invokeChatCore({ + provider: "codex", + model: "gpt-5.6-sol", + endpoint: "/v1/responses", + credentials: { + accessToken: "expired-access-token", + refreshToken: "must-not-be-used", + connectionId: "codex-invalidated-connection", + providerSpecificData: { workspaceId: "workspace-test" }, + }, + connectionId: "codex-invalidated-connection", + body: { + model: "gpt-5.6-sol", + stream: false, + input: "fail over immediately", + }, + onCredentialsRefreshed(updated) { + refreshUpdates.push(updated); + }, + responseFactory() { + return new Response( + JSON.stringify({ + error: { + message: "Encountered invalidated oauth token for user, failing request", + }, + }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + } + ); + }, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(calls.length, 1, "must not call the OAuth token endpoint or retry upstream"); + assert.deepEqual(refreshUpdates, [{ testStatus: "expired", isActive: false }]); +}); + test("chatCore uses the native executor when no upstream proxy mode is enabled", async () => { const { call } = await invokeChatCore({ provider: "openai", diff --git a/tests/unit/circuit-breaker-client-abort.test.ts b/tests/unit/circuit-breaker-client-abort.test.ts new file mode 100644 index 000000000000..0f1c6abe9782 --- /dev/null +++ b/tests/unit/circuit-breaker-client-abort.test.ts @@ -0,0 +1,129 @@ +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-client-abort-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const { CircuitBreaker, isLocalStreamLifecycleError, resetAllCircuitBreakers } = + await import("../../src/shared/utils/circuitBreaker.ts"); +const { shouldRecordProviderBreakerFailure, shouldSkipConnDisable } = + await import("../../open-sse/services/combo/comboPredicates.ts"); +const { classifyLocalAbortFailure, shouldTripProviderBreakerForResult } = + await import("../../src/sse/handlers/chatPredicates.ts"); +const dbCore = await import("../../src/lib/db/core.ts"); + +const uniqueName = (suffix: string) => + `cb-client-abort-${suffix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +test.after(() => { + resetAllCircuitBreakers(); + dbCore.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; +}); + +test("known client-abort shapes are local lifecycle errors", () => { + const abortError = new Error("nonstandard abort message"); + abortError.name = "AbortError"; + assert.equal(isLocalStreamLifecycleError(abortError), true); + + assert.equal(isLocalStreamLifecycleError("request_signal_aborted"), false); + assert.equal(isLocalStreamLifecycleError(new Error("Client disconnected: model switch")), false); + assert.equal(isLocalStreamLifecycleError({ message: "This operation was aborted" }), false); + assert.equal(isLocalStreamLifecycleError(new Error("502 Bad Gateway")), false); + assert.equal(isLocalStreamLifecycleError(new Error("429 rate limited")), false); + assert.deepEqual(classifyLocalAbortFailure("request_signal_aborted", true), { + status: 499, + message: "Request aborted", + }); + assert.deepEqual(classifyLocalAbortFailure(new Error("combo-per-model-timeout"), true), { + status: 499, + message: "Model timeout: combo-per-model-timeout", + }); + assert.deepEqual(classifyLocalAbortFailure(new Error("hedge-cancelled"), true), { + status: 499, + message: "Request cancelled: hedge-cancelled", + }); + assert.equal(classifyLocalAbortFailure(new Error("combo-per-model-timeout"), false), null); + assert.equal(classifyLocalAbortFailure("request_signal_aborted", false), null); + assert.equal(classifyLocalAbortFailure(new Error("502 Bad Gateway")), null); +}); + +test("repeated client aborts do not advance a circuit breaker", async () => { + const breaker = new CircuitBreaker(uniqueName("repeated"), { + failureThreshold: 2, + isFailure: (error) => !isLocalStreamLifecycleError(error), + }); + + for (let attempt = 0; attempt < 3; attempt++) { + await assert.rejects( + breaker.execute(async () => { + const error = new Error("provider request cancelled"); + error.name = "AbortError"; + throw error; + }), + /provider request cancelled/ + ); + } + + assert.equal(breaker.state, "CLOSED"); + assert.equal(breaker.failureCount, 0); + breaker.reset(); +}); + +test("manual single-model and combo breaker paths exclude normalized 499 but count upstream text", () => { + const abort = { status: 499, error: "request_signal_aborted" } as const; + assert.equal(shouldSkipConnDisable(abort, false, false, "codex"), true); + assert.equal(shouldTripProviderBreakerForResult(abort, false, false), false); + assert.equal( + shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: abort.status, + sameProviderNext: false, + }), + false + ); + + const upstream = { status: 502, error: "upstream bad gateway" } as const; + assert.equal(shouldSkipConnDisable(upstream, false, false, "codex"), false); + assert.equal(shouldTripProviderBreakerForResult(upstream, false, false), true); + assert.equal( + shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: upstream.status, + sameProviderNext: false, + }), + true + ); + + const spoofedAbortText = { status: 502, error: "The operation was aborted" } as const; + assert.equal(shouldSkipConnDisable(spoofedAbortText, false, false, "codex"), false); + assert.equal(shouldTripProviderBreakerForResult(spoofedAbortText, false, false), true); + assert.equal( + shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: spoofedAbortText.status, + sameProviderNext: false, + }), + true + ); +}); + +test("generic AbortError retains the trusted local timeout reason", () => { + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + assert.deepEqual(classifyLocalAbortFailure(error, true, new Error("combo-per-model-timeout")), { + status: 499, + message: "Model timeout: combo-per-model-timeout", + }); + assert.deepEqual(classifyLocalAbortFailure(error, true, "request_signal_aborted"), { + status: 499, + message: "Request aborted", + }); + assert.equal(classifyLocalAbortFailure(new Error("502"), false, "combo-per-model-timeout"), null); +}); diff --git a/tests/unit/claude-codex-identity-version-sync.test.ts b/tests/unit/claude-codex-identity-version-sync.test.ts index cd64c725068a..2ac3bcd97b17 100644 --- a/tests/unit/claude-codex-identity-version-sync.test.ts +++ b/tests/unit/claude-codex-identity-version-sync.test.ts @@ -40,8 +40,8 @@ test("Claude CLI is pinned to the captured 2.1.207 release", () => { assert.equal(id.CLAUDE_CODE_VERSION, "2.1.207"); }); -test("Codex client is pinned to the captured 0.144.1 release", () => { - assert.equal(codexCfg.getCodexClientVersion(), "0.144.1"); - assert.equal(codexCfg.getCodexUserAgent(), "codex-cli/0.144.1 (Windows 10.0.26200; x64)"); - assert.equal(codexCfg.getCodexDefaultHeaders().Version, "0.144.1"); +test("Codex client is pinned to the captured 0.153.4 release", () => { + assert.equal(codexCfg.getCodexClientVersion(), "0.153.4"); + assert.equal(codexCfg.getCodexUserAgent(), "codex-cli/0.153.4 (Windows 10.0.26200; x64)"); + assert.equal(codexCfg.getCodexDefaultHeaders().Version, "0.153.4"); }); diff --git a/tests/unit/cli-tools.test.ts b/tests/unit/cli-tools.test.ts index 5156d3d97b9e..1d0c2c6a456e 100644 --- a/tests/unit/cli-tools.test.ts +++ b/tests/unit/cli-tools.test.ts @@ -86,12 +86,12 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo "codex", { Authorization: "Bearer token", - "User-Agent": "codex-cli/0.144.1 (Windows 10.0.26200; x64)", + "User-Agent": "codex-cli/0.153.4 (Windows 10.0.26200; x64)", }, { model: "gpt-5.5", messages: [], stream: true } ); - assert.equal(codex.headers["User-Agent"], "codex-cli/0.144.1 (Windows 10.0.26200; x64)"); + assert.equal(codex.headers["User-Agent"], "codex-cli/0.153.4 (Windows 10.0.26200; x64)"); assert.deepEqual(Object.keys(JSON.parse(codex.bodyString)), ["model", "stream", "messages"]); const copilot = applyFingerprint( diff --git a/tests/unit/client-identity-profiles.test.ts b/tests/unit/client-identity-profiles.test.ts index d42e250d9594..05d8df1d8b8a 100644 --- a/tests/unit/client-identity-profiles.test.ts +++ b/tests/unit/client-identity-profiles.test.ts @@ -43,7 +43,7 @@ test("getClientIdentityProfileHeaders: known CLI profiles expose their preset he assert.equal(claudeCli["X-App"], "cli"); const codexCli = getClientIdentityProfileHeaders("codex-cli"); - assert.equal(codexCli["User-Agent"], "codex_cli_rs/0.144.1"); + assert.equal(codexCli["User-Agent"], "codex_cli_rs/0.153.4"); assert.equal(codexCli.originator, "codex_cli_rs"); const geminiCli = getClientIdentityProfileHeaders("gemini-cli"); @@ -80,7 +80,7 @@ test("a selected profile's headers land in providerSpecificData.customHeaders", customHeaders: { ...profileHeaders, "X-Operator-Set": "keep-me" }, }; - assert.equal(providerSpecificData.customHeaders["User-Agent"], "codex_cli_rs/0.144.1"); + assert.equal(providerSpecificData.customHeaders["User-Agent"], "codex_cli_rs/0.153.4"); assert.equal(providerSpecificData.customHeaders.originator, "codex_cli_rs"); assert.equal(providerSpecificData.customHeaders["X-Operator-Set"], "keep-me"); }); diff --git a/tests/unit/codex-fast-tier.test.ts b/tests/unit/codex-fast-tier.test.ts index 645d4ce43d97..c6711e48f693 100644 --- a/tests/unit/codex-fast-tier.test.ts +++ b/tests/unit/codex-fast-tier.test.ts @@ -41,7 +41,7 @@ test("Codex global service mode distinguishes no setting from explicit tiers", ( { enabled: true, tier: "default", - supportedModels: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"], + supportedModels: ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"], } ); }); diff --git a/tests/unit/codex-gpt56-catalog.test.ts b/tests/unit/codex-gpt56-catalog.test.ts index 4c5d3c6d302d..c7d8075ffb8f 100644 --- a/tests/unit/codex-gpt56-catalog.test.ts +++ b/tests/unit/codex-gpt56-catalog.test.ts @@ -36,8 +36,8 @@ test("Codex catalog exposes the GPT-5.6 lineup in configured priority order", () for (const modelId of expectedIds) { const model = models.find((entry) => entry.id === modelId); assert.ok(model, `codex must expose ${modelId}`); - assert.equal(model.contextLength, 500000); - assert.equal(model.maxInputTokens, 372000); + assert.equal(model.contextLength, 872000); + assert.equal(model.maxInputTokens, 872000); assert.equal(model.maxOutputTokens, 128000); assert.equal(model.targetFormat, "openai-responses"); assert.equal(model.toolCalling, true); diff --git a/tests/unit/codex-gpt6-astra.test.ts b/tests/unit/codex-gpt6-astra.test.ts new file mode 100644 index 000000000000..56c457d8c660 --- /dev/null +++ b/tests/unit/codex-gpt6-astra.test.ts @@ -0,0 +1,155 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getModelsByProviderId } from "../../open-sse/config/providerModels.ts"; +import { CodexExecutor } from "../../open-sse/executors/codex.ts"; +import { getModelSpec } from "../../src/shared/constants/modelSpecs.ts"; +import { getPricingForModel } from "../../src/shared/constants/pricing.ts"; +import { resolveCodexGlobalFastServiceTier } from "../../src/lib/providers/codexFastTier.ts"; +import { getCodexFastCostMultiplier } from "../../src/lib/usage/costCalculator.ts"; +import { + getCodexClientVersion, + getCodexDefaultHeaders, +} from "../../open-sse/config/codexClient.ts"; +import { getReasoningVariantBaseModelId } from "../../src/lib/vscode/reasoningMetadata.ts"; + +const ASTRA_IDS = [ + "gpt-6-astra", + "gpt-6-astra-ultra", + "gpt-6-astra-max", + "gpt-6-astra-xhigh", + "gpt-6-astra-high", + "gpt-6-astra-medium", + "gpt-6-astra-low", +]; + +test("Codex catalog exposes the GPT-6 Astra lineup at the usable 872K window", () => { + const models = getModelsByProviderId("codex"); + + for (const modelId of ASTRA_IDS) { + const model = models.find((entry) => entry.id === modelId); + assert.ok(model, `codex must expose ${modelId}`); + assert.equal(model.contextLength, 872000); + assert.equal(model.maxInputTokens, 872000); + assert.equal(model.maxOutputTokens, 128000); + assert.equal(model.targetFormat, "openai-responses"); + assert.equal(model.toolCalling, true); + assert.equal(model.supportsReasoning, true); + assert.equal(model.supportsVision, true); + assert.equal(model.supportsXHighEffort, true); + } +}); + +test("Codex catalog exposes no none variant for GPT-6 Astra", () => { + const models = getModelsByProviderId("codex"); + + assert.equal( + models.some((model) => model.id === "gpt-6-astra-none"), + false, + "none is not a supported Astra effort" + ); +}); + +test("OpenAI API catalog exposes GPT-6 Astra at the public 1.05M window", () => { + const model = getModelsByProviderId("openai").find((entry) => entry.id === "gpt-6-astra"); + + assert.ok(model, "openai must expose gpt-6-astra"); + assert.ok(model.unsupportedParams?.includes("temperature")); + assert.equal(model.contextLength, 1050000); + assert.equal(model.maxInputTokens, 922000); + assert.equal(model.maxOutputTokens, 128000); + assert.equal(model.supportsVision, true); + + const spec = getModelSpec("gpt-6-astra"); + assert.equal(spec?.contextWindow, 1050000); + assert.equal(spec?.maxOutputTokens, 128000); +}); + +test("CodexExecutor splits the GPT-6 Astra max alias off the model id", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-6-astra-max", + { model: "gpt-6-astra-max", input: [] }, + false, + { requestEndpointPath: "/responses" } + ); + + assert.equal(result.model, "gpt-6-astra"); + assert.equal(result.reasoning.effort, "max"); +}); + +test("CodexExecutor preserves an explicit max effort for GPT-6 Astra", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-6-astra", + { model: "gpt-6-astra", input: [], reasoning_effort: "max" }, + false, + { requestEndpointPath: "/responses" } + ); + + assert.equal(result.model, "gpt-6-astra"); + assert.equal(result.reasoning.effort, "max"); + assert.equal(result.reasoning_effort, undefined); +}); + +test("CodexExecutor maps the GPT-6 Astra ultra alias to max wire effort", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-6-astra-ultra", + { model: "gpt-6-astra-ultra", input: [] }, + false, + { requestEndpointPath: "/responses" } + ); + + assert.equal(result.model, "gpt-6-astra"); + assert.equal(result.reasoning.effort, "max"); +}); + +test("GPT-6 Astra pricing is wired for both the Codex and OpenAI catalogs", () => { + const expected = { input: 10, cached: 1, cache_creation: 12.5, output: 50 }; + + for (const modelId of ASTRA_IDS) { + const pricing = getPricingForModel("cx", modelId); + assert.ok(pricing, `missing codex pricing for ${modelId}`); + assert.equal(pricing.input, expected.input, `${modelId} input`); + assert.equal(pricing.cached, expected.cached, `${modelId} cached`); + assert.equal(pricing.cache_creation, expected.cache_creation, `${modelId} cache creation`); + assert.equal(pricing.output, expected.output, `${modelId} output`); + } + + const apiPricing = getPricingForModel("openai", "gpt-6-astra"); + assert.ok(apiPricing, "missing openai pricing for gpt-6-astra"); + assert.equal(apiPricing.input, expected.input); + assert.equal(apiPricing.output, expected.output); +}); + +test("VS Code discovery splits Astra's extended effort aliases off the base id", () => { + // Without this the alias survives as the base id and discovery expands it again + // into invalid variants such as `gpt-6-astra-max-high`. + assert.equal(getReasoningVariantBaseModelId("gpt-6-astra-max"), "gpt-6-astra"); + assert.equal(getReasoningVariantBaseModelId("gpt-6-astra-ultra"), "gpt-6-astra"); + assert.equal(getReasoningVariantBaseModelId("cx/gpt-6-astra-max"), "cx/gpt-6-astra"); + assert.equal(getReasoningVariantBaseModelId("gpt-6-astra-high"), "gpt-6-astra"); + assert.equal(getReasoningVariantBaseModelId("gpt-6-astra"), "gpt-6-astra"); + // The GPT-5.6 family keeps its existing behaviour. + assert.equal(getReasoningVariantBaseModelId("gpt-5.6-sol-ultra"), "gpt-5.6-sol"); +}); + +test("Codex identifies as a client version Astra accepts", () => { + // Astra rejects older clients with HTTP 400 "requires a newer version of Codex" + // (upstream issue #12761), so the advertised identity gates the whole model. + assert.equal(getCodexClientVersion(), "0.153.4"); + assert.equal(getCodexDefaultHeaders().Version, "0.153.4"); +}); + +test("Codex Fast bills GPT-6 Astra at the 2.5x multiplier", () => { + assert.equal(getCodexFastCostMultiplier("cx", "gpt-6-astra", "priority"), 2.5); + assert.equal(getCodexFastCostMultiplier("cx", "gpt-6-astra-max", "fast"), 2.5); + assert.equal(getCodexFastCostMultiplier("cx", "gpt-6-astra", "default"), 1); +}); + +test("GPT-6 Astra is Fast-tier eligible by default", () => { + const resolved = resolveCodexGlobalFastServiceTier({ codexServiceTier: { enabled: true } }); + + assert.ok(resolved.supportedModels.includes("gpt-6-astra")); +}); diff --git a/tests/unit/codex-incomplete-finish-reason.test.ts b/tests/unit/codex-incomplete-finish-reason.test.ts new file mode 100644 index 000000000000..843629fef3aa --- /dev/null +++ b/tests/unit/codex-incomplete-finish-reason.test.ts @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts"; +import { parseSSEToResponsesOutput } from "../../open-sse/handlers/sseParser.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +function translateBufferedResponsesSSE(rawSSE: string) { + const parsed = parseSSEToResponsesOutput(rawSSE, "gpt-5.6-sol"); + assert.ok(parsed); + return { + parsed, + translated: translateNonStreamingResponse(parsed, FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI) as { + choices: Array<{ + finish_reason: string; + message: { content: string }; + }>; + }, + }; +} + +test("buffered Codex response.incomplete maps max_output_tokens to finish_reason length", () => { + const partialProfile = JSON.stringify({ requirements: [], domain: null }); + const rawSSE = [ + "event: response.incomplete", + `data: ${JSON.stringify({ + type: "response.incomplete", + response: { + id: "resp_incomplete", + object: "response", + model: "gpt-5.6-sol", + status: "incomplete", + incomplete_details: { reason: "max_output_tokens" }, + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: partialProfile }], + }, + ], + }, + })}`, + "", + ].join("\n"); + + const { parsed, translated } = translateBufferedResponsesSSE(rawSSE); + + assert.equal(parsed.status, "incomplete"); + assert.deepEqual(parsed.incomplete_details, { reason: "max_output_tokens" }); + assert.equal(translated.choices[0].message.content, partialProfile); + assert.equal(translated.choices[0].finish_reason, "length"); +}); + +test("buffered Codex response.completed remains finish_reason stop", () => { + const rawSSE = [ + "event: response.completed", + `data: ${JSON.stringify({ + type: "response.completed", + response: { + id: "resp_completed", + object: "response", + model: "gpt-5.6-sol", + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: '{"requirements":["TypeScript"]}' }], + }, + ], + }, + })}`, + "", + ].join("\n"); + + const { translated } = translateBufferedResponsesSSE(rawSSE); + + assert.equal(translated.choices[0].finish_reason, "stop"); +}); diff --git a/tests/unit/codex-invalid-prompt-status.test.ts b/tests/unit/codex-invalid-prompt-status.test.ts new file mode 100644 index 000000000000..bf4c5be833e9 --- /dev/null +++ b/tests/unit/codex-invalid-prompt-status.test.ts @@ -0,0 +1,66 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { normalizeUpstreamFailure } = + await import("../../open-sse/translator/response/openai-responses/pureHelpers.ts"); +const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts"); + +const POLICY_MESSAGE = + "Invalid prompt: your prompt was flagged as potentially violating our usage policy."; + +test("normalizes a structured invalid_prompt rejection as a non-retryable 400", () => { + const result = normalizeUpstreamFailure({ + response: { + error: { + code: "invalid_prompt", + message: POLICY_MESSAGE, + }, + }, + }); + + assert.deepEqual(result, { + status: 400, + type: "invalid_request_error", + code: "invalid_prompt", + message: POLICY_MESSAGE, + }); + + const fallback = checkFallbackError(result.status, result.message, 0, null, "codex", null, null, { + code: result.code, + type: result.type, + }); + assert.equal(fallback.shouldFallback, false); + assert.equal(fallback.cooldownMs, 0); +}); + +test("recognizes a usage-policy rejection when upstream omits the error code", () => { + const result = normalizeUpstreamFailure({ message: POLICY_MESSAGE }); + + assert.deepEqual(result, { + status: 400, + type: "invalid_request_error", + code: "invalid_prompt", + message: POLICY_MESSAGE, + }); +}); + +test("preserves a structured content_policy_violation code while mapping it to 400", () => { + const result = normalizeUpstreamFailure({ + error: { + code: "content_policy_violation", + message: "Request rejected by the content policy.", + }, + }); + + assert.equal(result.status, 400); + assert.equal(result.type, "invalid_request_error"); + assert.equal(result.code, "content_policy_violation"); +}); + +test("keeps unrelated upstream failures as 502 bad_gateway", () => { + const result = normalizeUpstreamFailure({ message: "Upstream connection closed unexpectedly" }); + + assert.equal(result.status, 502); + assert.equal(result.type, "server_error"); + assert.equal(result.code, "bad_gateway"); +}); diff --git a/tests/unit/codex-invalidated-oauth-failover.test.ts b/tests/unit/codex-invalidated-oauth-failover.test.ts new file mode 100644 index 000000000000..ffe55c4b9fb5 --- /dev/null +++ b/tests/unit/codex-invalidated-oauth-failover.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isTerminalCodexOAuthFailure } from "../../open-sse/handlers/chatCore/codexAuthFailure.ts"; + +test("detects Codex invalidated OAuth 401 messages", () => { + assert.equal( + isTerminalCodexOAuthFailure({ + provider: "codex", + status: 401, + message: "[401]: Encountered invalidated oauth token for user, failing request", + }), + true + ); + assert.equal( + isTerminalCodexOAuthFailure({ + provider: "codex", + status: 401, + responseBody: { + error: { message: "Authentication token has been invalidated" }, + }, + }), + true + ); +}); + +test("does not skip refresh for recoverable or unrelated authentication failures", () => { + assert.equal( + isTerminalCodexOAuthFailure({ + provider: "codex", + status: 401, + message: "Provided authentication token is expired", + }), + false + ); + assert.equal( + isTerminalCodexOAuthFailure({ + provider: "codex", + status: 403, + message: "Encountered invalidated oauth token", + }), + false + ); + assert.equal( + isTerminalCodexOAuthFailure({ + provider: "claude", + status: 401, + message: "Encountered invalidated oauth token", + }), + false + ); +}); diff --git a/tests/unit/codex-peek-nonsse-body-untouched-7536.test.ts b/tests/unit/codex-peek-nonsse-body-untouched-7536.test.ts new file mode 100644 index 000000000000..5fe5bf518622 --- /dev/null +++ b/tests/unit/codex-peek-nonsse-body-untouched-7536.test.ts @@ -0,0 +1,73 @@ +// #7536: Codex non-stream chat 502'd with "Response body is already used". +// +// Root cause (confirmed live on the VPS via fs-instrumentation): the Codex HTTP +// transport uses the wreq-js TLS-fingerprint client, whose Response is backed by +// a native body handle. On that response, merely *accessing* `response.body` +// disturbs the handle so a later `.text()` throws +// `TypeError: Response body is already used`. The Codex non-stream upstream +// response arrives with an EMPTY content-type, so `peekCodexSseTransientError` +// early-returns — but its guard evaluated `!response.body` (touching `.body`) +// BEFORE the content-type check. That single `.body` access consumed the body, +// and chatCore's `readNonStreamingResponseBody` → `.text()` then 502'd. Streaming +// was unaffected because the peek genuinely wants the body for SSE responses. +// +// The fix reorders the guard so the content-type is checked before `.body` is +// touched. This test locks that in: peek must NOT access `.body` for a non-SSE +// response, and the body must remain readable downstream. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { peekCodexSseTransientError } from "../../open-sse/executors/codex.ts"; + +/** + * Mimic a wreq-js native-handle Response: reading `.body` is destructive — once + * accessed, `.text()` throws exactly like the live 502. This is what the real + * bug looked like end-to-end. + */ +function makeDestructiveBodyResponse(contentType: string) { + let bodyAccessCount = 0; + let disturbed = false; + const response = { + ok: true, + status: 200, + statusText: "OK", + headers: new Headers(contentType ? { "content-type": contentType } : {}), + get body() { + bodyAccessCount += 1; + disturbed = true; // native handle is now consumed + return new ReadableStream(); + }, + async text() { + if (disturbed) throw new TypeError("Response body is already used"); + return "downstream still works"; + }, + } as unknown as Response; + return { response, bodyAccessCount: () => bodyAccessCount }; +} + +test("peekCodexSseTransientError does not touch response.body for an empty-content-type response (#7536)", async () => { + const { response, bodyAccessCount } = makeDestructiveBodyResponse(""); + + const result = await peekCodexSseTransientError(response); + + assert.equal(result.matched, null); + assert.equal(result.replacementBody, null); + assert.equal( + bodyAccessCount(), + 0, + "peek must not access .body when content-type is not text/event-stream" + ); + // The real regression: the body had to stay readable for the non-stream path. + assert.equal(await response.text(), "downstream still works"); +}); + +test("peekCodexSseTransientError does not touch response.body for a non-SSE (application/json) response (#7536)", async () => { + const { response, bodyAccessCount } = makeDestructiveBodyResponse("application/json"); + + const result = await peekCodexSseTransientError(response); + + assert.equal(result.matched, null); + assert.equal(result.replacementBody, null); + assert.equal(bodyAccessCount(), 0, "non-SSE content-type must short-circuit before .body"); + assert.equal(await response.text(), "downstream still works"); +}); diff --git a/tests/unit/codex-plan-refresh.test.ts b/tests/unit/codex-plan-refresh.test.ts new file mode 100644 index 000000000000..989fb0c72a09 --- /dev/null +++ b/tests/unit/codex-plan-refresh.test.ts @@ -0,0 +1,144 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +Object.assign(process.env, { + CODEX_OAUTH_CLIENT_ID: "app_EMoamEEZ73f0CkXaXp7hrann", +}); + +const { codex, deriveCodexWorkspaceInfo } = await import("../../src/lib/oauth/providers/codex.ts"); +const { refreshCodexToken } = await import("../../open-sse/services/tokenRefresh.ts"); + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function base64Url(value: string): string { + return Buffer.from(value, "utf-8").toString("base64url"); +} + +/** Build an unsigned id_token carrying the OpenAI auth claim. */ +function makeIdToken(auth: Record, email = "someone@example.com"): string { + const header = base64Url(JSON.stringify({ alg: "none", typ: "JWT" })); + const payload = base64Url(JSON.stringify({ email, "https://api.openai.com/auth": auth })); + return `${header}.${payload}.`; +} + +const PERSONAL_PLUS = { + chatgpt_account_id: "workspace-personal", + chatgpt_plan_type: "plus", + chatgpt_user_id: "user-1", + organizations: [{ id: "org-1", is_default: true, role: "owner", title: "Personal" }], +}; + +test("deriveCodexWorkspaceInfo reads the plan tier out of an id_token", () => { + const info = deriveCodexWorkspaceInfo(makeIdToken(PERSONAL_PLUS)); + + assert.equal(info?.workspacePlanType, "plus"); + assert.equal(info?.workspaceId, "workspace-personal"); + assert.equal(info?.chatgptUserId, "user-1"); +}); + +test("deriveCodexWorkspaceInfo returns null when the token carries no auth claim", () => { + const header = base64Url(JSON.stringify({ alg: "none", typ: "JWT" })); + const payload = base64Url(JSON.stringify({ email: "someone@example.com" })); + + assert.equal(deriveCodexWorkspaceInfo(`${header}.${payload}.`), null); + assert.equal(deriveCodexWorkspaceInfo("not-a-jwt"), null); + assert.equal(deriveCodexWorkspaceInfo(null), null); +}); + +test("deriveCodexWorkspaceInfo keeps the team heuristic used at OAuth time", () => { + const info = deriveCodexWorkspaceInfo( + makeIdToken({ + chatgpt_account_id: "workspace-personal", + chatgpt_plan_type: "free", + chatgpt_user_id: "user-2", + organizations: [ + { id: "org-personal", is_default: true, role: "owner", title: "Personal" }, + { id: "org-team", is_default: false, role: "member", title: "Acme Workspace" }, + ], + }) + ); + + assert.equal(info?.workspacePlanType, "team"); + assert.equal(info?.workspaceId, "org-team"); +}); + +test("mapTokens still derives the persisted workspace record", () => { + const mapped = codex.mapTokens({ + access_token: "at", + refresh_token: "rt", + id_token: makeIdToken(PERSONAL_PLUS, "owner@example.com"), + expires_in: 864000, + }); + + assert.equal(mapped.email, "owner@example.com"); + assert.deepEqual(mapped.providerSpecificData, { + workspaceId: "workspace-personal", + workspacePlanType: "plus", + chatgptUserId: "user-1", + organizations: PERSONAL_PLUS.organizations, + }); +}); + +test("refreshCodexToken re-derives the plan tier from the refreshed id_token", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + access_token: "new-at", + refresh_token: "new-rt", + expires_in: 864000, + // The subscription lapsed since the account was connected as "plus". + id_token: makeIdToken({ ...PERSONAL_PLUS, chatgpt_plan_type: "free" }), + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + + const result = await refreshCodexToken("old-rt", null); + + assert.equal(result.accessToken, "new-at"); + assert.deepEqual(result.providerSpecificDataPatch, { workspacePlanType: "free" }); +}); + +test("refreshCodexToken patches only the plan, never the workspace binding", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + access_token: "new-at", + refresh_token: "new-rt", + expires_in: 864000, + id_token: makeIdToken(PERSONAL_PLUS), + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + + const result = await refreshCodexToken("old-rt", null); + + assert.deepEqual(Object.keys(result.providerSpecificDataPatch), ["workspacePlanType"]); + assert.equal(result.providerSpecificData, undefined); +}); + +test("refreshCodexToken omits the patch when the response carries no usable plan", async () => { + for (const idToken of [undefined, makeIdToken({ ...PERSONAL_PLUS, chatgpt_plan_type: "" })]) { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + access_token: "new-at", + refresh_token: "new-rt", + expires_in: 864000, + ...(idToken ? { id_token: idToken } : {}), + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + + const result = await refreshCodexToken("old-rt", null); + + assert.equal(result.accessToken, "new-at"); + assert.ok( + !("providerSpecificDataPatch" in result), + "an unreadable plan must leave the persisted tier untouched" + ); + } +}); diff --git a/tests/unit/codex-reset-credits.test.ts b/tests/unit/codex-reset-credits.test.ts index a9fb0e282add..56beebb5152a 100644 --- a/tests/unit/codex-reset-credits.test.ts +++ b/tests/unit/codex-reset-credits.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { makeManagementSessionRequest } from "../helpers/managementSession.ts"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-reset-credits-")); process.env.DATA_DIR = TEST_DATA_DIR; @@ -11,8 +13,16 @@ process.env.API_KEY_SECRET = "test-codex-reset-credits-secret"; const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const resetCredits = await import("../../src/lib/usage/codexResetCredits.ts"); +const resetCreditsRoute = + await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.ts"); +const consumeResetCreditRoute = await import("../../src/app/api/usage/codex-reset-credit/route.ts"); + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const originalFetch = globalThis.fetch; +const originalInitialPassword = process.env.INITIAL_PASSWORD; +const originalJwtSecret = process.env.JWT_SECRET; +const originalRequireApiKey = process.env.REQUIRE_API_KEY; type QuotaUsageRecord = Record; async function resetStorage() { @@ -36,6 +46,8 @@ async function createCodexConnection(overrides: Record = {}) { test.beforeEach(async () => { globalThis.fetch = originalFetch; + delete process.env.INITIAL_PASSWORD; + delete process.env.REQUIRE_API_KEY; await resetStorage(); }); @@ -43,9 +55,16 @@ test.after(async () => { globalThis.fetch = originalFetch; core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + + if (originalInitialPassword === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = originalInitialPassword; + if (originalJwtSecret === undefined) delete process.env.JWT_SECRET; + else process.env.JWT_SECRET = originalJwtSecret; + if (originalRequireApiKey === undefined) delete process.env.REQUIRE_API_KEY; + else process.env.REQUIRE_API_KEY = originalRequireApiKey; }); -test("consumeCodexResetCredit fetches a credit id, posts it, then refreshes usage", async () => { +test("consumeCodexResetCredit selects the earliest-expiring credit, then refreshes usage", async () => { const connection = (await createCodexConnection()) as { id: string }; const calls: Array<{ url: string; init: RequestInit }> = []; @@ -56,7 +75,21 @@ test("consumeCodexResetCredit fetches a credit id, posts it, then refreshes usag assert.equal(init.method, "GET"); assert.equal((init.headers as Record)["chatgpt-account-id"], "workspace-123"); return new Response( - JSON.stringify({ credits: [{ id: "credit-123", status: "available" }] }), + JSON.stringify({ + credits: [ + { id: "credit-unknown", status: "available" }, + { + id: "credit-later", + status: "available", + expires_at: "2099-10-04T04:18:25Z", + }, + { + id: "credit-earliest", + status: "available", + expires_at: "2099-09-21T02:16:00Z", + }, + ], + }), { status: 200, headers: { "content-type": "application/json" }, @@ -68,7 +101,7 @@ test("consumeCodexResetCredit fetches a credit id, posts it, then refreshes usag assert.equal((init.headers as Record)["chatgpt-account-id"], "workspace-123"); assert.deepEqual(JSON.parse(String(init.body)), { redeem_request_id: "redeem-1", - credit_id: "credit-123", + credit_id: "credit-earliest", }); return new Response(JSON.stringify({ code: "reset" }), { status: 200, @@ -136,6 +169,227 @@ test("consumeCodexResetCredit accepts alreadyRedeemed as success", async () => { assert.equal(result.outcome, "alreadyRedeemed"); }); +test("consumeCodexResetCredit refuses to spend a replacement credit", async () => { + const connection = (await createCodexConnection()) as { id: string }; + const calls: Array<{ url: string; init: RequestInit }> = []; + + globalThis.fetch = (async (input, init = {}) => { + calls.push({ url: String(input), init }); + return new Response( + JSON.stringify({ + credits: [ + { + id: "replacement-credit", + status: "available", + expires_at: "2099-10-04T04:18:25Z", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) as typeof fetch; + + await assert.rejects( + () => + resetCredits.consumeCodexResetCredit( + connection.id, + "redeem-stale-selection", + "2099-09-21T02:16:00.000Z" + ), + (error: unknown) => + error instanceof resetCredits.CodexResetCreditError && + error.status === 409 && + error.code === "credit_changed" + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.init.method, "GET"); +}); + +test("listCodexResetCredits returns only redeemable credits with normalized expiries", async () => { + const connection = (await createCodexConnection()) as { id: string }; + + globalThis.fetch = async (url) => { + if (String(url).endsWith("/rate-limit-reset-credits")) { + return new Response( + JSON.stringify({ + available_count: 99, + credits: [ + { + id: "credit-snake-case", + status: "available", + expires_at: "2099-10-04T04:18:25+03:00", + title: "must not leak", + profile: { email: "secret@example.test" }, + }, + { + creditId: "credit-camel-case", + available: true, + expiresAt: "2099-09-21T02:16:00.000Z", + token: "must not leak", + }, + { + id: "credit-redeemed", + status: "redeemed", + expires_at: "2099-11-01T00:00:00Z", + }, + { + id: "credit-expired", + status: "available", + expires_at: "2020-01-01T00:00:00Z", + }, + { + id: "credit-invalid-expiry", + status: "available", + expires_at: "not-a-date", + }, + { status: "available", expires_at: "2099-12-01T00:00:00Z" }, + ], + }), + { status: 200 } + ); + } + return new Response("unexpected", { status: 500 }); + }; + + const result = await resetCredits.listCodexResetCredits(connection.id); + + assert.deepEqual(result, { + availableCount: 3, + credits: [ + { expiresAt: "2099-09-21T02:16:00.000Z" }, + { expiresAt: "2099-10-04T01:18:25.000Z" }, + { expiresAt: null }, + ], + }); + assert.equal(JSON.stringify(result).includes("credit-"), false); + assert.equal(JSON.stringify(result).includes("secret@example.test"), false); + assert.equal(JSON.stringify(result).includes("must not leak"), false); +}); + +test("listCodexResetCredits rejects non-Codex connections and sanitizes upstream failures", async () => { + const wrongProvider = (await createCodexConnection({ + provider: "claude", + providerSpecificData: {}, + })) as { id: string }; + + await assert.rejects( + () => resetCredits.listCodexResetCredits(wrongProvider.id), + (error: unknown) => + error instanceof resetCredits.CodexResetCreditError && + error.status === 400 && + error.code === "codex_provider_required" + ); + + const codex = (await createCodexConnection()) as { id: string }; + globalThis.fetch = async () => + new Response(JSON.stringify({ token: "upstream-secret", error: "sensitive detail" }), { + status: 429, + }); + + await assert.rejects( + () => resetCredits.listCodexResetCredits(codex.id), + (error: unknown) => + error instanceof resetCredits.CodexResetCreditError && + error.status === 502 && + error.code === "codex_reset_credit_upstream_error" && + error.message === "Codex reset-credit API request failed." + ); +}); + +test("Codex reset-credit expiry route returns only the public response contract", async () => { + const connection = (await createCodexConnection()) as { id: string }; + + globalThis.fetch = async () => + Response.json({ + credits: [ + { + id: "private-credit-id", + status: "available", + expires_at: "2099-10-04T04:18:25Z", + title: "private-title", + }, + ], + }); + + const response = await resetCreditsRoute.GET( + new Request(`http://localhost/api/usage/${connection.id}/codex-reset-credits`), + { params: Promise.resolve({ connectionId: connection.id }) } + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + availableCount: 1, + credits: [{ expiresAt: "2099-10-04T04:18:25.000Z" }], + }); +}); + +test("Codex reset-credit expiry route authenticates before reading connection params", () => { + const routePath = path.join( + repoRoot, + "src/app/api/usage/[connectionId]/codex-reset-credits/route.ts" + ); + const source = fs.readFileSync(routePath, "utf8"); + const authIndex = source.indexOf("requireManagementAuth(request)"); + const paramsIndex = source.indexOf("await params"); + + assert.match(source, /from "@\/lib\/api\/requireManagementAuth"/); + assert.ok(authIndex >= 0, "GET route must enforce management auth"); + assert.ok(paramsIndex >= 0, "GET route must validate the dynamic connection id"); + assert.ok( + authIndex < paramsIndex, + "GET route must authenticate before reading connection params" + ); +}); + +test("Codex reset-credit POST rejects unauthenticated malformed requests before reading JSON", async () => { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + + const response = await consumeResetCreditRoute.POST( + new Request("http://localhost/api/usage/codex-reset-credit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{malformed-json", + }) + ); + const body = (await response.json()) as { error?: { message?: string } }; + + assert.equal(response.status, 401); + assert.equal(body.error?.message, "Authentication required"); +}); + +test("Codex reset-credit POST authenticates before body parsing and rejects unknown fields", async () => { + const routePath = path.join(repoRoot, "src/app/api/usage/codex-reset-credit/route.ts"); + const source = fs.readFileSync(routePath, "utf8"); + const authIndex = source.indexOf("requireManagementAuth(request)"); + const bodyIndex = source.indexOf("request.json()"); + + assert.ok(authIndex >= 0, "POST route must enforce management auth"); + assert.ok(bodyIndex >= 0, "POST route must parse its JSON body"); + assert.ok(authIndex < bodyIndex, "POST route must authenticate before reading its JSON body"); + + process.env.INITIAL_PASSWORD = "bootstrap-password"; + const request = await makeManagementSessionRequest( + "http://localhost/api/usage/codex-reset-credit", + { + method: "POST", + body: { + connectionId: "connection-123", + idempotencyKey: "redeem-123", + unexpected: true, + }, + } + ); + const response = await consumeResetCreditRoute.POST(request); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { + ok: false, + code: "invalid_request_body", + error: "Invalid request body.", + }); +}); + for (const code of ["noCredit", "nothingToReset"]) { test(`consumeCodexResetCredit maps ${code} to 409`, async () => { const connection = (await createCodexConnection()) as { id: string }; diff --git a/tests/unit/codex-responses-passthrough-strip-3317.test.ts b/tests/unit/codex-responses-passthrough-strip-3317.test.ts index 6c64ef770360..48cc5e654940 100644 --- a/tests/unit/codex-responses-passthrough-strip-3317.test.ts +++ b/tests/unit/codex-responses-passthrough-strip-3317.test.ts @@ -25,6 +25,8 @@ test("codex native responses passthrough strips client-only params (#3317)", asy safety_identifier: "droid-user-123", user: "user-abc", max_output_tokens: 16, + temperature: 0.7, + top_p: 0.9, }; const result = (await executor.transformRequest("gpt-5.5", body, false, {} as never)) as Record< @@ -35,6 +37,12 @@ test("codex native responses passthrough strips client-only params (#3317)", asy assert.equal(result.prompt_cache_retention, undefined, "prompt_cache_retention must be stripped"); assert.equal(result.safety_identifier, undefined, "safety_identifier must be stripped"); assert.equal(result.user, undefined, "user must be stripped"); + assert.equal( + result.temperature, + undefined, + "temperature must be stripped before native passthrough" + ); + assert.equal(result.top_p, undefined, "top_p must be stripped before native passthrough"); // The real request payload must survive the strip. assert.ok(Array.isArray(result.input), "input array preserved"); }); diff --git a/tests/unit/codex-same-account-transport-retry-9708.test.ts b/tests/unit/codex-same-account-transport-retry-9708.test.ts new file mode 100644 index 000000000000..e436df008062 --- /dev/null +++ b/tests/unit/codex-same-account-transport-retry-9708.test.ts @@ -0,0 +1,243 @@ +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 { + isRetryablePreOutputTransportError, + shouldRetrySameAccountTransport, + sameAccountTransportRetryDelayMs, + isTransportCooldownErrorCode, + buildMixedAvailabilityError, + SAME_ACCOUNT_TRANSPORT_RETRY_MAX, +} = await import("../../src/sse/services/sameAccountTransportRetry.ts"); + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9708-codex-retry-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET ||= "codex-9708-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const quotaCache = await import("../../src/domain/quotaCache.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function futureIso(ms = 60_000) { + return new Date(Date.now() + ms).toISOString(); +} + +async function seedConnection(provider: string, overrides: Record = {}) { + return providersDb.createProviderConnection({ + provider, + authType: overrides.authType || "oauth", + name: overrides.name || `${provider}-${Math.random().toString(16).slice(2, 8)}`, + accessToken: overrides.accessToken || `tok-${Math.random().toString(16).slice(2, 10)}`, + isActive: overrides.isActive ?? true, + testStatus: overrides.testStatus || "active", + priority: overrides.priority, + rateLimitedUntil: overrides.rateLimitedUntil, + lastError: overrides.lastError, + lastErrorType: overrides.lastErrorType, + errorCode: overrides.errorCode, + providerSpecificData: overrides.providerSpecificData || {}, + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#9708: 503 connection-reset and 507 buffer errors are retryable pre-output transport", () => { + assert.equal( + isRetryablePreOutputTransportError( + 503, + "upstream connect error or disconnect/reset before headers reset reason: remote connection failure" + ), + true + ); + assert.equal( + isRetryablePreOutputTransportError( + 507, + "exceeded request buffer limit while retrying upstream" + ), + true + ); + assert.equal(isRetryablePreOutputTransportError(504, "gateway timeout"), true); + assert.equal(isRetryablePreOutputTransportError(502, "Bad Gateway"), true); +}); + +test("#9708: quota, auth, and deterministic 400s never enter the same-account retry path", () => { + assert.equal( + isRetryablePreOutputTransportError( + 429, + "All codex accounts reached configured quota threshold" + ), + false + ); + assert.equal(isRetryablePreOutputTransportError(401, "unauthorized"), false); + assert.equal(isRetryablePreOutputTransportError(400, "prompt is too long"), false); + assert.equal( + shouldRetrySameAccountTransport({ + status: 503, + errorText: "remote connection failure", + attempt: 0, + hasForcedConnection: true, + }), + false + ); + assert.equal( + shouldRetrySameAccountTransport({ + status: 503, + errorText: "remote connection failure", + attempt: 0, + hasEmittedOutput: true, + }), + false + ); +}); + +test("#9708: same-account retry is bounded to exactly one attempt", () => { + assert.equal( + shouldRetrySameAccountTransport({ + status: 503, + errorText: "remote connection failure", + attempt: 0, + }), + true + ); + assert.equal( + shouldRetrySameAccountTransport({ + status: 503, + errorText: "remote connection failure", + attempt: SAME_ACCOUNT_TRANSPORT_RETRY_MAX, + }), + false + ); +}); + +test("#9708: retry delay stays in the 2-3s jitter window", () => { + assert.equal( + sameAccountTransportRetryDelayMs(() => 0), + 2000 + ); + assert.equal( + sameAccountTransportRetryDelayMs(() => 1), + 3000 + ); + assert.equal( + sameAccountTransportRetryDelayMs(() => 0.5), + 2500 + ); +}); + +test("#9708: mixed-cause pool error is 503, not all-accounts-quota 429", () => { + const mixed = buildMixedAvailabilityError({ + provider: "codex", + quotaFilteredCount: 2, + transportUnavailableCount: 1, + transportStatus: 507, + }); + assert.equal(mixed.status, 503); + assert.equal(mixed.lastErrorCode, 503); + assert.match(mixed.lastError, /2 quota-filtered/); + assert.match(mixed.lastError, /1 temporarily unavailable after upstream 507/); + assert.equal(mixed.lastError.includes("quota threshold"), false); +}); + +test("#9708: simulate first 503 then success on the same account; second failure rotates", () => { + function simulate(results: Array<{ status: number; error?: string; success?: boolean }>) { + let attempt = 0; + let markUnavailable = 0; + let i = 0; + let connectionId = "acct-a"; + while (true) { + const result = results[Math.min(i, results.length - 1)]; + if (result.success) { + return { outcome: "success", attempt, markUnavailable, connectionId }; + } + if ( + shouldRetrySameAccountTransport({ + status: result.status, + errorText: result.error, + attempt, + }) + ) { + attempt += 1; + i += 1; + continue; + } + markUnavailable += 1; + connectionId = "acct-b"; + return { outcome: "fallback", attempt, markUnavailable, connectionId }; + } + } + + const recovered = simulate([ + { status: 503, error: "remote connection failure" }, + { success: true, status: 200 }, + ]); + assert.equal(recovered.outcome, "success"); + assert.equal(recovered.attempt, 1); + assert.equal(recovered.markUnavailable, 0); + assert.equal(recovered.connectionId, "acct-a"); + + const rotated = simulate([ + { status: 507, error: "exceeded request buffer limit while retrying upstream" }, + { status: 507, error: "exceeded request buffer limit while retrying upstream" }, + ]); + assert.equal(rotated.outcome, "fallback"); + assert.equal(rotated.attempt, 1); + assert.equal(rotated.markUnavailable, 1); + assert.equal(rotated.connectionId, "acct-b"); +}); + +test("#9708: getProviderCredentials does not report all-quota 429 when a sibling is only transport-cooled", async () => { + const resetAt = futureIso(120_000); + const quotaA = await seedConnection("codex", { + name: "codex-quota-a", + priority: 1, + providerSpecificData: { + limitPolicy: { enabled: true, thresholdPercent: 75, windows: ["session"] }, + }, + }); + const quotaB = await seedConnection("codex", { + name: "codex-quota-b", + priority: 2, + providerSpecificData: { + limitPolicy: { enabled: true, thresholdPercent: 75, windows: ["session"] }, + }, + }); + await seedConnection("codex", { + name: "codex-transport-blip", + priority: 3, + rateLimitedUntil: futureIso(8_000), + errorCode: 507, + lastError: "exceeded request buffer limit while retrying upstream", + lastErrorType: "server_error", + }); + + quotaCache.setQuotaCache(quotaA.id, "codex", { + session: { remainingPercentage: 0, resetAt }, + }); + quotaCache.setQuotaCache(quotaB.id, "codex", { + session: { remainingPercentage: 0, resetAt }, + }); + + const result = await auth.getProviderCredentials("codex"); + assert.equal(result.allRateLimited, true); + assert.notEqual(result.lastErrorCode, 429); + assert.equal(result.lastErrorCode, 503); + assert.match(String(result.lastError), /temporarily unavailable after upstream 507/i); + assert.equal(isTransportCooldownErrorCode(507), true); +}); diff --git a/tests/unit/codex-sse-peek-body-double-read.test.ts b/tests/unit/codex-sse-peek-body-double-read.test.ts new file mode 100644 index 000000000000..5ed231ea26a3 --- /dev/null +++ b/tests/unit/codex-sse-peek-body-double-read.test.ts @@ -0,0 +1,136 @@ +// Live-VPS bug (2026-07-16, release/v3.8.49): a Codex (ChatGPT account) NON-STREAM +// chat request fails 100% of the time with `[502]: Response body is already used +// (reset after 1m)`, returned almost instantly (not a real network/timeout error). +// The streaming (playground) path is unaffected. +// +// Root cause: `peekCodexSseTransientError` (open-sse/executors/codex.ts) peeks the +// first bytes of the upstream SSE response by calling `response.body.getReader()`, +// then — when no transient error is found — calls `reader.releaseLock()` followed +// by a SECOND `response.body.getReader()` on the very same underlying body to +// "continue draining" it into a replacement stream. Re-acquiring a reader on a +// response body that has already been disturbed is exactly the pattern undici's +// fetch/Response implementation guards against ("Body is unusable: Body has +// already been read" / surfaced upstream as "Response body is already used"). +// Any runtime/build where the second `getReader()` call on the SAME response.body +// throws turns every single non-streaming Codex request into an uncaught +// TypeError, which chatCore's generic upstream-error handling then classifies as +// a transient failure and stamps with a default 60s cooldown ("reset after 1m") — +// masking a pure code defect as a rate limit. +// +// This test proves the defect directly against `peekCodexSseTransientError`: it +// installs a `getReader` spy on the *original* response body that throws on any +// call after the first (reproducing the "double-acquire" hazard precisely), then +// asserts the function must complete without ever needing a second reader on the +// original body — i.e. it must not throw, and the replacement body it hands back +// must be byte-identical to the original upstream SSE payload. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { peekCodexSseTransientError } from "../../open-sse/executors/codex.ts"; + +function sseStreamFromChunks(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i >= chunks.length) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(chunks[i])); + i++; + }, + }); +} + +/** + * Wrap a ReadableStream so that `getReader()` throws on every call after the + * first — reproducing, at the unit level, a runtime that refuses to re-acquire + * a reader on a body it already considers disturbed (the exact "Response body + * is already used" failure mode observed live). + */ +function withSingleUseGetReader(stream: ReadableStream): { + stream: ReadableStream; + getReaderCallCount: () => number; +} { + let calls = 0; + const originalGetReader = stream.getReader.bind(stream); + Object.defineProperty(stream, "getReader", { + value: (...args: unknown[]) => { + calls++; + if (calls > 1) { + throw new TypeError("Body is unusable: Body has already been read"); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (originalGetReader as any)(...args); + }, + writable: true, + configurable: true, + }); + return { stream, getReaderCallCount: () => calls }; +} + +async function drainStream(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let out = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + out += decoder.decode(value, { stream: true }); + } + out += decoder.decode(); + return out; +} + +test("peekCodexSseTransientError does not re-acquire a reader on the original body for a normal 200-OK SSE response", async () => { + const normalSse = + 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Hello"}\n\n' + + 'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n'; + + const { stream, getReaderCallCount } = withSingleUseGetReader(sseStreamFromChunks([normalSse])); + const response = new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + + const peek = await peekCodexSseTransientError(response); + + assert.equal(peek.matched, null, "must not classify a normal reply as a transient error"); + assert.ok(peek.replacementBody, "must hand back a replacement body to continue draining"); + + const drained = await drainStream(peek.replacementBody!); + assert.equal(drained, normalSse, "replacement body must be byte-identical to the upstream SSE payload"); + + // The regression: the OLD implementation calls response.body.getReader() a + // SECOND time (after releaseLock()) to "continue" reading the same body. A + // runtime that refuses that second acquisition throws — which is exactly + // what withSingleUseGetReader reproduces. The fix must never need more than + // one reader on the ORIGINAL body. + assert.ok( + getReaderCallCount() <= 1, + `expected at most 1 getReader() call on the original response body, got ${getReaderCallCount()}` + ); +}); + +test("peekCodexSseTransientError still detects a 200-OK transient-error SSE payload without touching the original body twice", async () => { + const { stream, getReaderCallCount } = withSingleUseGetReader( + sseStreamFromChunks([ + 'event: error\ndata: {"error":{"message":"Selected model is at capacity. Please try a different model."}}\n\n', + ]) + ); + const response = new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + + const peek = await peekCodexSseTransientError(response); + + assert.equal(peek.matched, "selected model is at capacity"); + assert.match(peek.message ?? "", /at capacity/i); + assert.equal(peek.replacementBody, null); + assert.ok( + getReaderCallCount() <= 1, + `expected at most 1 getReader() call on the original response body, got ${getReaderCallCount()}` + ); +}); diff --git a/tests/unit/combo-context-window-filter.test.ts b/tests/unit/combo-context-window-filter.test.ts index 27f139fd5585..62ec32adf72e 100644 --- a/tests/unit/combo-context-window-filter.test.ts +++ b/tests/unit/combo-context-window-filter.test.ts @@ -4,10 +4,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -// Regression tests for the context-aware combo compatibility filter. -// Unknown context metadata is only safe as a fallback. Once the context filter -// has rejected known-too-small targets and a known-capacity target remains, -// unknown-context targets must not survive over it. +// Context estimates order eligible targets without removing runtime fallbacks. const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-context-filter-")); const ORIGINAL_DATA_DIR = process.env.DATA_DIR; @@ -97,11 +94,16 @@ test("known compatible context target wins over unknown-context targets", () => assert.deepEqual( out.map((entry) => entry.modelStr), - ["unit-known-context/million"] + [ + "unit-known-context/million", + "unit-unknown-context/mystery-a", + "unit-known-context/tiny", + "unit-unknown-context/mystery-b", + ] ); }); -test("unknown-context targets keep strategy order when no known limit was rejected", () => { +test("strategy order is preserved when no known context window is too small", () => { saveModelsDevCapabilities({ "unit-known-context": { million: capabilityEntry(1_000_000), @@ -120,7 +122,7 @@ test("unknown-context targets keep strategy order when no known limit was reject ); }); -test("unknown-context targets do not become the only survivors when no known-compatible context target exists", () => { +test("unknown and known-small targets keep strategy order when none are known to fit", () => { saveModelsDevCapabilities({ "unit-known-context": { tiny: capabilityEntry(8_000), @@ -143,7 +145,7 @@ test("unknown-context targets do not become the only survivors when no known-com ); }); -test("all known-too-small context targets still fall back to strategy order", () => { +test("all known-too-small context targets remain eligible for upstream evaluation", () => { saveModelsDevCapabilities({ "unit-known-context": { tiny: capabilityEntry(8_000), @@ -162,3 +164,149 @@ test("all known-too-small context targets still fall back to strategy order", () ["unit-known-context/tiny", "unit-known-context/small"] ); }); + +test("unknown tools and vision metadata survive while explicit false capabilities are rejected", () => { + saveModelsDevCapabilities({ + "unit-known-context": { + blind: capabilityEntry(1_000_000), + noTools: { ...capabilityEntry(1_000_000), tool_call: false, attachment: true }, + capable: { ...capabilityEntry(1_000_000), attachment: true }, + }, + }); + const out = filterTargetsByRequestCompatibility( + [ + target("unit-known-context/blind"), + target("unit-known-context/noTools"), + target("unit-unknown-context/mystery"), + target("unit-known-context/capable"), + ], + { + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "https://example.com/image.png" } }], + }, + ], + tools: [{ type: "function", function: { name: "describe" } }], + }, + noopLog + ); + assert.deepEqual( + out.map((entry) => entry.modelStr), + ["unit-unknown-context/mystery", "unit-known-context/capable"] + ); +}); + +for (const scenario of [ + { + name: "input cap excludes the output reserve", + input: 256_000, + output: 32_000, + inputCap: 272_000, + window: 400_000, + fits: true, + }, + { + name: "input cap still constrains input inside a larger total window", + input: 280_000, + output: 32_000, + inputCap: 272_000, + window: 400_000, + fits: false, + }, + { + name: "total window constrains input plus output even when input fits", + input: 256_000, + output: 64_000, + inputCap: 272_000, + window: 300_000, + fits: false, + }, +]) { + test(scenario.name, () => { + saveModelsDevCapabilities({ + "unit-input-cap": { + model: { + ...capabilityEntry(scenario.window), + limit_input: scenario.inputCap, + limit_output: 128_000, + }, + tiny: { ...capabilityEntry(1), limit_output: 128_000 }, + }, + }); + const unknown = target("unit-unknown-context/mystery"); + const known = target("unit-input-cap/model"); + const tiny = target("unit-input-cap/tiny"); + const out = filterTargetsByRequestCompatibility( + [unknown, known, tiny], + { + messages: [{ role: "user", content: "x".repeat(scenario.input * 4) }], + max_output_tokens: scenario.output, + }, + noopLog + ); + assert.deepEqual(out, scenario.fits ? [known, unknown, tiny] : [unknown, known, tiny]); + }); +} + +test("context preference never restores hard-rejected targets around a sole small survivor", () => { + saveModelsDevCapabilities({ + "unit-hard-context": { + small: capabilityEntry(100), + noTools: { ...capabilityEntry(1_000_000), tool_call: false }, + noOutput: { ...capabilityEntry(1_000_000), limit_output: 1 }, + noStructured: { ...capabilityEntry(1_000_000), structured_output: false }, + }, + }); + const out = filterTargetsByRequestCompatibility( + ["noTools", "small", "noOutput", "noStructured"].map((name) => + target(`unit-hard-context/${name}`) + ), + { + ...largeContextBody(), + tools: [{ type: "function", function: { name: "lookup" } }], + max_output_tokens: 1000, + response_format: { type: "json_object" }, + }, + noopLog + ); + assert.deepEqual( + out.map((entry) => entry.modelStr), + ["unit-hard-context/small"] + ); +}); + +test("context overrides replace stale input and total metadata, with exact effort precedence", async () => { + const { setModelContextOverride, removeModelContextOverride } = + await import("../../src/lib/db/modelContextOverrides.ts"); + saveModelsDevCapabilities({ + "unit-override": { + "model-high": capabilityEntry(100), + model: capabilityEntry(100), + tiny: capabilityEntry(1), + }, + }); + const unknown = target("unit-unknown-context/mystery"); + const known = target("unit-override/model-high"); + const tiny = target("unit-override/tiny"); + const filter = () => + filterTargetsByRequestCompatibility([unknown, known, tiny], largeContextBody(), noopLog); + try { + assert.deepEqual(filter(), [unknown, known, tiny]); + setModelContextOverride("unit-override", "model", 1_000_000); + assert.deepEqual( + filter(), + [known, unknown, tiny], + "base override must supersede stale input and total caps" + ); + setModelContextOverride("unit-override", "model-high", 100); + assert.deepEqual( + filter(), + [unknown, known, tiny], + "explicit effort override wins but does not reject the target" + ); + } finally { + removeModelContextOverride("unit-override", "model"); + removeModelContextOverride("unit-override", "model-high"); + } +}); diff --git a/tests/unit/combo-roundrobin-compat-fallback-6238.test.ts b/tests/unit/combo-roundrobin-compat-fallback-6238.test.ts index 8ab28e108574..c10ed6ee9fda 100644 --- a/tests/unit/combo-roundrobin-compat-fallback-6238.test.ts +++ b/tests/unit/combo-roundrobin-compat-fallback-6238.test.ts @@ -1,12 +1,4 @@ -// Regression guard for #6238: a round-robin combo returned -// `503 all upstream accounts are unavailable` immediately when every -// compatibility-KEPT target was runtime-unavailable, without ever -// reconsidering a compatibility-REJECTED-but-healthy target. The compat -// pre-filter (filterTargetsByRequestCompatibility) drops request-incompatible -// targets BEFORE availability is known, and its `compatible.length === 0` -// safety net only fires when ALL targets are filtered — not when the kept -// targets later all turn out unavailable. The fix makes the rejected targets a -// genuine last-resort fallback tier. +// Known-incompatible targets must not re-enter the fallback pool on an availability failure. import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; @@ -20,6 +12,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const { handleComboChat } = await import("../../open-sse/services/combo.ts"); const core = await import("../../src/lib/db/core.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); const { saveModelsDevCapabilities, clearModelsDevCapabilities } = await import("../../src/lib/modelsDevSync.ts"); const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts"); @@ -88,64 +81,355 @@ test.after(() => { } }); -test( - "round-robin falls back to a compat-rejected healthy target instead of 503 " + - "when every compat-kept target is unavailable (#6238)", - async () => { - // rr-a is tool-INCAPABLE → the tools-requiring request makes the compat - // pre-filter reject it. rr-b/rr-c are tool-capable → kept, but both are - // runtime-unavailable. Only the rejected rr-a is actually healthy. +test("round-robin returns 503 without dispatching incompatible targets when compatible targets are unavailable", async () => { + // rr-a is tool-INCAPABLE → the tools-requiring request makes the compat + // pre-filter reject it. rr-b/rr-c are tool-capable → kept, but both are + // runtime-unavailable. Only the rejected rr-a is actually healthy. + saveModelsDevCapabilities({ + openai: { + "rr-a": capabilityEntry(128000, { tool_call: false }), + "rr-b": capabilityEntry(128000), + "rr-c": capabilityEntry(128000), + }, + }); + + const attempted: string[] = []; + const availabilityChecks: string[] = []; + + const result = await handleComboChat({ + body: { + messages: [{ role: "user", content: "Use a tool to look something up" }], + tools: [{ type: "function", function: { name: "lookup_weather" } }], + }, + combo: { + name: "rr-compat-fallback-6238", + strategy: "round-robin", + models: ["openai/rr-a", "openai/rr-b", "openai/rr-c"], + config: { maxRetries: 0, concurrencyPerModel: 1, queueTimeoutMs: 1000 }, + }, + handleSingleModel: async (_body, modelStr) => { + attempted.push(modelStr); + return okResponse({ choices: [{ message: { content: `served by ${modelStr}` } }] }); + }, + // Only the compat-rejected rr-a is healthy; the compat-kept rr-b/rr-c + // are all runtime-unavailable. + isModelAvailable: async (modelStr) => { + availabilityChecks.push(modelStr); + return modelStr === "openai/rr-a"; + }, + log: createLog(), + settings: null, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.status, 503); + assert.deepEqual(attempted, [], "known-incompatible targets must never be dispatched"); + assert.equal(availabilityChecks.includes("openai/rr-a"), false); + // Sanity: the compat-kept rr-b/rr-c were probed for availability first. + assert.ok( + availabilityChecks.includes("openai/rr-b") || availabilityChecks.includes("openai/rr-c"), + "compat-kept targets should be probed before the last-resort fallback" + ); +}); + +for (const strategy of ["priority", "round-robin"]) { + for (const requirement of ["output", "tools", "vision"]) { + test(`${strategy} rejects all ${requirement}-incompatible targets with 400 before dispatch`, async () => { + saveModelsDevCapabilities({ + "unit-compat": { + small: capabilityEntry(8000, { tool_call: false, attachment: false }), + }, + }); + const body: Record = { messages: [{ role: "user", content: "hello" }] }; + if (requirement === "output") body.max_output_tokens = 6000; + if (requirement === "tools") + body.tools = [{ type: "function", function: { name: "lookup" } }]; + if (requirement === "vision") + body.input = [ + { + role: "user", + content: [{ type: "input_image", image_url: "https://example.com/image.png" }], + }, + ]; + let attempts = 0; + const result = await handleComboChat({ + body, + combo: { + name: `all-incompatible-${strategy}-${requirement}`, + strategy, + models: ["unit-compat/small"], + config: { maxRetries: 0 }, + }, + handleSingleModel: async () => { + attempts++; + return okResponse(); + }, + log: createLog(), + settings: {}, + allCombos: [], + }); + assert.equal(result.status, 400); + assert.equal((await result.json()).error.code, "no_compatible_target"); + assert.equal(attempts, 0); + }); + } + + test(`${strategy} keeps tools and native image input intact across same-provider fallback`, async () => { saveModelsDevCapabilities({ - openai: { - "rr-a": capabilityEntry(128000, { tool_call: false }), - "rr-b": capabilityEntry(128000), - "rr-c": capabilityEntry(128000), + "unit-compat": { + primary: capabilityEntry(128000, { attachment: true }), + backup: capabilityEntry(128000, { attachment: true }), + blind: capabilityEntry(128000, { attachment: false }), + }, + }); + const body = { + model: "payload-fallback", + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "Describe the image using the tool" }, + { type: "input_image", image_url: "https://example.com/image.png", detail: "high" }, + ], + }, + ], + tools: [ + { + type: "function", + name: "describe", + parameters: { type: "object", properties: { description: { type: "string" } } }, + }, + ], + max_output_tokens: 2000, + }; + const snapshot = structuredClone(body); + const attempts: string[] = []; + const result = await handleComboChat({ + body, + combo: { + name: `payload-fallback-${strategy}`, + strategy, + models: ["unit-compat/blind", "unit-compat/primary", "unit-compat/backup"], + config: { maxRetries: 0, retryDelayMs: 0 }, + }, + handleSingleModel: async (received, model) => { + attempts.push(model); + assert.deepEqual(received.input, snapshot.input); + assert.deepEqual(received.tools, snapshot.tools); + assert.equal(received.max_output_tokens, snapshot.max_output_tokens); + return model.endsWith("primary") + ? Response.json( + { error: { code: "empty_response", message: "Empty output" } }, + { status: 502 } + ) + : okResponse(); }, + log: createLog(), + settings: {}, + allCombos: [], }); + assert.equal(result.status, 200); + assert.deepEqual(attempts, ["unit-compat/primary", "unit-compat/backup"]); + assert.deepEqual(body, snapshot); + }); +} - const attempted: string[] = []; - const availabilityChecks: string[] = []; +test("nested execute mode rejects incompatible direct and child targets before provider dispatch", async () => { + saveModelsDevCapabilities({ + "unit-compat": { noTools: capabilityEntry(128000, { tool_call: false }) }, + }); + const child = { + name: "incompatible-child", + strategy: "priority", + models: ["unit-compat/noTools"], + }; + const outer = { + name: "incompatible-parent", + strategy: "priority", + models: ["unit-compat/noTools", { kind: "combo-ref", comboName: child.name }], + config: { nestedComboMode: "execute", maxRetries: 0 }, + }; + let attempts = 0; + const result = await handleComboChat({ + body: { tools: [{ type: "function", function: { name: "lookup" } }] }, + combo: outer, + allCombos: [outer, child], + settings: {}, + log: createLog(), + handleSingleModel: async () => { + attempts++; + return okResponse(); + }, + }); + assert.equal(result.status, 400); + assert.equal(attempts, 0); +}); +test("pipeline dispatches a large intermediate result despite the next model catalog window", async () => { + saveModelsDevCapabilities({ + "unit-compat": { + primary: capabilityEntry(128000, { tool_call: false }), + small: capabilityEntry(8000), + }, + }); + const attempts: string[] = []; + const result = await handleComboChat({ + body: { + messages: [{ role: "user", content: "hello" }], + tools: [{ type: "function", function: { name: "lookup" } }], + }, + combo: { + name: "pipeline-compat", + strategy: "pipeline", + models: ["unit-compat/primary", "unit-compat/small"], + }, + allCombos: [], + settings: {}, + log: createLog(), + handleSingleModel: async (body, model) => { + attempts.push(model); + if (model === "unit-compat/primary") { + assert.equal( + body.tools, + undefined, + "intermediate pipeline step intentionally strips tools" + ); + return okResponse({ choices: [{ message: { content: "x".repeat(80000) } }] }); + } + assert.ok(JSON.stringify(body.messages).includes("x".repeat(80000))); + assert.ok(body.tools, "final step retains tool requirements"); + return okResponse(); + }, + }); + assert.equal(result.status, 200); + assert.deepEqual(attempts, ["unit-compat/primary", "unit-compat/small"]); +}); + +test("a context-cache pin cannot bypass compatibility after the request gains tools", async () => { + const { recordSessionModelUsage, clearSessionModelHistoryForCombo } = + await import("../../src/lib/db/contextHandoffs.ts"); + saveModelsDevCapabilities({ + "unit-compat": { + noTools: capabilityEntry(128000, { tool_call: false }), + capable: capabilityEntry(128000), + }, + }); + const combo = { + name: "incompatible-pin", + strategy: "priority", + context_cache_protection: true, + models: ["unit-compat/noTools", "unit-compat/capable"], + config: { maxRetries: 0 }, + }; + recordSessionModelUsage("compat-session", combo.name, "unit-compat/noTools", "unit-compat"); + const connection = await providersDb.createProviderConnection({ + provider: "unit-compat", + name: "pin regression", + authType: "apikey", + apiKey: "test-only-key", + isActive: true, + testStatus: "active", + }); + const attempts: string[] = []; + try { const result = await handleComboChat({ - body: { - messages: [{ role: "user", content: "Use a tool to look something up" }], - tools: [{ type: "function", function: { name: "lookup_weather" } }], + body: { tools: [{ type: "function", function: { name: "lookup" } }] }, + combo, + allCombos: [combo], + relayOptions: { sessionId: "compat-session" }, + settings: {}, + log: createLog(), + handleSingleModel: async (_body, model) => { + attempts.push(model); + return okResponse(); }, + }); + assert.equal(result.status, 200); + assert.deepEqual(attempts, ["unit-compat/capable"]); + } finally { + clearSessionModelHistoryForCombo(combo.name); + await providersDb.deleteProviderConnection(connection.id); + } +}); + +for (const strategy of ["priority", "round-robin"]) { + test(`${strategy} dispatches despite stale context metadata and falls back without changing input`, async () => { + saveModelsDevCapabilities({ + "unit-advisory": { primary: capabilityEntry(100), backup: capabilityEntry(100) }, + }); + const body = { messages: [{ role: "user", content: "x".repeat(80_000) }] }; + const attempts: string[] = []; + const result = await handleComboChat({ + body, combo: { - name: "rr-compat-fallback-6238", - strategy: "round-robin", - models: ["openai/rr-a", "openai/rr-b", "openai/rr-c"], - config: { maxRetries: 0, concurrencyPerModel: 1, queueTimeoutMs: 1000 }, - }, - handleSingleModel: async (_body, modelStr) => { - attempted.push(modelStr); - return okResponse({ choices: [{ message: { content: `served by ${modelStr}` } }] }); - }, - // Only the compat-rejected rr-a is healthy; the compat-kept rr-b/rr-c - // are all runtime-unavailable. - isModelAvailable: async (modelStr) => { - availabilityChecks.push(modelStr); - return modelStr === "openai/rr-a"; + name: `advisory-${strategy}`, + strategy, + models: ["unit-advisory/primary", "unit-advisory/backup"], + config: { maxRetries: 0 }, }, + settings: {}, + allCombos: [], log: createLog(), - settings: null, - relayOptions: null, - allCombos: null, + handleSingleModel: async (attemptBody, model) => { + assert.deepEqual(attemptBody.messages, body.messages); + attempts.push(model); + if (attempts.length === 1) + return new Response( + JSON.stringify({ + error: { code: "context_length_exceeded", message: "context length exceeded" }, + }), + { status: 400, headers: { "content-type": "application/json" } } + ); + return okResponse(); + }, }); + assert.equal(result.status, 200); + assert.equal(attempts.length, 2); + assert.equal(new Set(attempts).size, 2); + }); +} - // Before the fix this returned 503 ALL_ACCOUNTS_INACTIVE without ever - // attempting rr-a. After the fix the compat-rejected-but-healthy rr-a - // serves the request. - assert.equal(result.status, 200, "expected a 200 from the compat-rejected fallback, not 503"); - assert.equal(result.ok, true); - assert.deepEqual(attempted, ["openai/rr-a"], "only the healthy compat-rejected target is used"); - - const payload = await result.json(); - assert.equal(payload.choices[0].message.content, "served by openai/rr-a"); - // Sanity: the compat-kept rr-b/rr-c were probed for availability first. - assert.ok( - availabilityChecks.includes("openai/rr-b") || availabilityChecks.includes("openai/rr-c"), - "compat-kept targets should be probed before the last-resort fallback" - ); +test("a context-cache pin survives an approximate context mismatch", async () => { + const { recordSessionModelUsage, clearSessionModelHistoryForCombo } = + await import("../../src/lib/db/contextHandoffs.ts"); + saveModelsDevCapabilities({ + "unit-advisory": { small: capabilityEntry(100), large: capabilityEntry(1_000_000) }, + }); + const combo = { + name: "advisory-pin", + strategy: "priority", + context_cache_protection: true, + models: ["unit-advisory/large", "unit-advisory/small"], + config: { maxRetries: 0 }, + }; + recordSessionModelUsage("advisory-session", combo.name, "unit-advisory/small", "unit-advisory"); + const connection = await providersDb.createProviderConnection({ + provider: "unit-advisory", + name: "pin regression", + authType: "apikey", + apiKey: "test-only-key", + isActive: true, + testStatus: "active", + }); + const attempts: string[] = []; + try { + const result = await handleComboChat({ + body: { messages: [{ role: "user", content: "x".repeat(80_000) }] }, + combo, + allCombos: [combo], + relayOptions: { sessionId: "advisory-session" }, + settings: {}, + log: createLog(), + handleSingleModel: async (_body, model) => { + attempts.push(model); + return okResponse(); + }, + }); + assert.equal(result.status, 200); + assert.deepEqual(attempts, ["unit-advisory/small"]); + } finally { + clearSessionModelHistoryForCombo(combo.name); + await providersDb.deleteProviderConnection(connection.id); } -); +}); diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index 7ea5271345fa..a2a608c82c7b 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -1,3 +1,12 @@ +import { + createLog, + okResponse, + errorResponse, + waitForBackgroundWork, + providerBreakerOpenResponse, + streamResponse, + capabilityEntry, +} from "../fixtures/combo-routing.ts"; import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; @@ -35,83 +44,6 @@ const { acquire: acquireSemaphore, resetAll: resetAllSemaphores } = const { _resetAllDecks } = await import("../../src/shared/utils/shuffleDeck.ts"); const { _setSecureRandomFloatSource } = await import("../../src/shared/utils/secureRandom.ts"); -function createLog() { - const entries: any[] = []; - return { - info: (tag: any, msg: any) => entries.push({ level: "info", tag, msg }), - warn: (tag: any, msg: any) => entries.push({ level: "warn", tag, msg }), - error: (tag: any, msg: any) => entries.push({ level: "error", tag, msg }), - debug: (tag: any, msg: any) => entries.push({ level: "debug", tag, msg }), - entries, - }; -} - -function okResponse(body: any = { choices: [{ message: { content: "ok" } }] }) { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); -} - -function errorResponse(status: number, message: string = `Error ${status}`) { - return new Response(JSON.stringify({ error: { message } }), { - status, - headers: { "content-type": "application/json" }, - }); -} - -function waitForBackgroundWork() { - return new Promise((resolve) => setTimeout(resolve, 25)); -} - -function providerBreakerOpenResponse() { - return new Response( - JSON.stringify({ - error: { - message: "Provider circuit breaker is open", - code: "provider_circuit_open", - }, - }), - { - status: 503, - headers: { - "content-type": "application/json", - "x-omniroute-provider-breaker": "open", - }, - } - ); -} - -function streamResponse(chunks: any[]) { - return new Response(chunks.join(""), { - status: 200, - headers: { "content-type": "text/event-stream" }, - }); -} - -function capabilityEntry(limitContext: unknown, overrides: Record = {}) { - return { - tool_call: true, - reasoning: false, - attachment: false, - structured_output: true, - temperature: true, - modalities_input: JSON.stringify(["text"]), - modalities_output: JSON.stringify(["text"]), - knowledge_cutoff: null, - release_date: null, - last_updated: null, - status: null, - family: null, - open_weights: false, - limit_context: limitContext, - limit_input: limitContext, - limit_output: 4096, - interleaved_field: null, - ...overrides, - }; -} - function getComboTargetExecutionKey(comboName: string, index: number, stepInput: any) { const step = normalizeComboStep(stepInput, { comboName, index }); if (!step) throw new Error(`Failed to normalize combo step for ${comboName}#${index}`); @@ -1841,7 +1773,7 @@ test("handleComboChat skips tool, vision, and structured-output incompatible fal assert.deepEqual(calls, ["openai/compatible"]); }); -test("handleComboChat preserves strategy order when context-aware filtering rejects all targets", async () => { +test("handleComboChat rejects a pool whose models explicitly lack required tools", async () => { saveModelsDevCapabilities({ openai: { "no-tools-a": capabilityEntry(128000, { tool_call: false }), @@ -1871,8 +1803,9 @@ test("handleComboChat preserves strategy order when context-aware filtering reje allCombos: null, }); - assert.equal(result.ok, true); - assert.deepEqual(calls, ["openai/no-tools-a"]); + assert.equal(result.status, 400); + assert.equal((await result.json()).error.code, "no_compatible_target"); + assert.deepEqual(calls, []); }); test("handleComboChat eval-driven routing prioritizes higher scoring evaluated targets", async () => { @@ -2292,10 +2225,59 @@ test("handleComboChat standalone lkgp strategy updates LKGP after a successful c } assert.equal(result.ok, true); - // getLKGP now returns LKGPRecord | null — source: src/lib/db/settings.ts getLKGP() assert.equal(persistedProvider?.provider, "openai"); }); +test("handleComboChat standalone lkgp strategy clears LKGP after the last-known-good target fails", async () => { + // A prior successful request pinned "openai" as the last known good provider — + // exactly the state left behind by the previous (success) test's own scenario. + await settingsDb.setLKGP("standalone-lkgp-clear", "standalone-lkgp-clear", "openai"); + + const calls: string[] = []; + const result = await handleComboChat({ + body: {}, + combo: { + id: "standalone-lkgp-clear", + name: "standalone-lkgp-clear", + strategy: "lkgp", + // maxRetries: 0 below means this single target is tried exactly once, + // then the combo loop gives up on it (and on the whole combo, since it's + // the only model) — the exact "Done retrying this model" failure path. + models: ["openai/gpt-4o-mini"], + config: { maxRetries: 0 }, + }, + handleSingleModel: async (_body: Record, modelStr: string) => { + calls.push(modelStr); + return errorResponse(504, "Stream produced no non-ping SSE event within 95000ms"); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + relayOptions: null, + allCombos: null, + }); + + // Give the async fire-and-forget LKGP clear a chance to execute + let persistedProvider: Awaited> = null; + for (let i = 0; i < 20; i++) { + persistedProvider = await settingsDb.getLKGP("standalone-lkgp-clear", "standalone-lkgp-clear"); + if (persistedProvider === null) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + assert.equal(result.ok, false, "the only target failed, so the whole combo call fails"); + assert.deepEqual(calls, ["openai/gpt-4o-mini"]); + // The bug this guards: without clearing, a *separate* subsequent request would + // keep re-selecting "openai" via LKGP reordering even though it just failed. + assert.equal( + persistedProvider, + null, + "LKGP must be cleared after its target fails, not left pointing at a just-failed provider" + ); +}); + test("handleComboChat auto strategy falls back to the full pool when tool filtering empties candidates", async () => { await settingsDb.updatePricing({ openai: { @@ -2659,6 +2641,94 @@ test("handleComboChat round-robin retries a transient failure on the same model assert.deepEqual(calls, ["model-a", "model-a"]); }); +test("handleComboChat retries a synthetic target timeout when it is the only model", async () => { + const calls: string[] = []; + + const result = await handleComboChat({ + body: {}, + combo: { + name: "priority-timeout-retry", + models: ["codex/model-a"], + config: { maxRetries: 1, retryDelayMs: 1 }, + }, + handleSingleModel: async (_body, modelStr) => { + calls.push(modelStr); + if (calls.length === 1) return errorResponse(524, `Model ${modelStr} timed out`); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["codex/model-a", "codex/model-a"]); +}); + +test("handleComboChat round-robin: failoverBeforeRetry skips the same-model retry and goes straight to the sibling", async () => { + // #2417's whole point: failoverBeforeRetry should prefer a sibling model + // over hammering a rate-limited one again. Same shape as the test above + // (maxRetries: 1, a transient 429 on the first call) but with a second + // model available and failoverBeforeRetry set — calls must show a single + // model-a attempt followed directly by model-b, never a same-model retry. + const calls = []; + + const result = await handleComboChat({ + body: {}, + combo: { + name: "rr-failover-before-retry", + strategy: "round-robin", + models: ["model-a", "model-b"], + config: { + maxRetries: 1, + retryDelayMs: 1, + failoverBeforeRetry: true, + concurrencyPerModel: 1, + queueTimeoutMs: 5, + }, + }, + handleSingleModel: async (_body, modelStr) => { + calls.push(modelStr); + if (modelStr === "model-a") return errorResponse(429, "rate limited"); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["model-a", "model-b"]); +}); + +test("handleComboChat priority strategy: failoverBeforeRetry skips the same-model retry and goes straight to the sibling", async () => { + const calls = []; + + const result = await handleComboChat({ + body: {}, + combo: { + name: "priority-failover-before-retry", + models: ["model-a", "model-b"], + config: { maxRetries: 1, retryDelayMs: 1, failoverBeforeRetry: true }, + }, + handleSingleModel: async (_body, modelStr) => { + calls.push(modelStr); + if (modelStr === "model-a") return errorResponse(429, "rate limited"); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["model-a", "model-b"]); +}); + test("handleComboChat round-robin recovers from 400s when a later model succeeds", async () => { const calls: any[] = []; diff --git a/tests/unit/combo-rr-session-stickiness-3825.test.ts b/tests/unit/combo-rr-session-stickiness-3825.test.ts index 9ef5ed2d2c59..1a46f99b63f0 100644 --- a/tests/unit/combo-rr-session-stickiness-3825.test.ts +++ b/tests/unit/combo-rr-session-stickiness-3825.test.ts @@ -39,17 +39,53 @@ function rrCombo(name: string) { strategy: "round-robin", config: { maxRetries: 0 }, models: [ - { kind: "model", provider: "codex", providerId: "codex", model: "m-a", connectionId: "conn-A", id: `${name}-0` }, - { kind: "model", provider: "codex", providerId: "codex", model: "m-b", connectionId: "conn-B", id: `${name}-1` }, - { kind: "model", provider: "glm-cn", providerId: "glm-cn", model: "m-c", connectionId: "conn-C", id: `${name}-2` }, + { + kind: "model", + provider: "codex", + providerId: "codex", + model: "m-a", + connectionId: "conn-A", + id: `${name}-0`, + }, + { + kind: "model", + provider: "codex", + providerId: "codex", + model: "m-b", + connectionId: "conn-B", + id: `${name}-1`, + }, + { + kind: "model", + provider: "glm-cn", + providerId: "glm-cn", + model: "m-c", + connectionId: "conn-C", + id: `${name}-2`, + }, ], }; } -async function dispatchConnection(combo: Record, firstMessage: string): Promise { +async function dispatchConnection( + combo: Record, + firstMessage: string, + api: "chat" | "responses" = "chat" +): Promise { let conn = "?"; await handleComboChat({ - body: { model: combo.name, messages: [{ role: "user", content: firstMessage }], stream: false }, + body: + api === "responses" + ? { + model: combo.name, + input: [{ role: "user", content: [{ type: "input_text", text: firstMessage }] }], + stream: false, + } + : { + model: combo.name, + messages: [{ role: "user", content: firstMessage }], + stream: false, + }, combo, allCombos: [combo], isModelAvailable: async () => true, @@ -100,11 +136,38 @@ test("round-robin: a sessionless conversation re-pins to its turn-1 connection a assert.equal(conns[1], conns[0], "turn 2 must reuse turn 1's connection"); }); +test("round-robin: a Responses conversation re-pins within its combo namespace (#7277)", async () => { + const combo = rrCombo("rr-responses-stick-a"); + await dispatchConnection(combo, "Warm up the first combo counter", "responses"); + + const conns: string[] = []; + for (let turn = 0; turn < 4; turn++) { + conns.push(await dispatchConnection(combo, "Same Responses conversation", "responses")); + } + + assert.equal(new Set(conns).size, 1, `Responses turns must stay pinned: ${conns.join(", ")}`); + + const otherCombo = rrCombo("rr-responses-stick-b"); + const otherComboFirstConnection = await dispatchConnection( + otherCombo, + "Same Responses conversation", + "responses" + ); + assert.notEqual( + otherComboFirstConnection, + conns[0], + "a Responses pin learned by one combo must not reorder a different combo" + ); +}); + test("round-robin: DISTINCT conversations still spread across connections on turn 1 (#3825 spreading guard)", async () => { const combo = rrCombo("rr-spread"); const hist: Record = {}; for (let i = 0; i < 6; i++) { - const conn = await dispatchConnection(combo, `conversation number ${i} — distinct first message`); + const conn = await dispatchConnection( + combo, + `conversation number ${i} — distinct first message` + ); hist[conn] = (hist[conn] || 0) + 1; } // Round-robin distribution must be preserved across conversations: more than one diff --git a/tests/unit/combo-session-stickiness.test.ts b/tests/unit/combo-session-stickiness.test.ts index 39f486836f69..4a9d7b6a1f5f 100644 --- a/tests/unit/combo-session-stickiness.test.ts +++ b/tests/unit/combo-session-stickiness.test.ts @@ -24,6 +24,7 @@ import type { StickyConnectionHealth } from "../../open-sse/services/combo/sessi const mod = await import("../../open-sse/services/combo/sessionStickiness.ts"); const { + normalizeStickinessMessages, deriveMessageHash, applySessionStickiness, recordStickyBinding, @@ -37,7 +38,9 @@ const { // ─── helpers ───────────────────────────────────────────────────────────────── -function makeTarget(connectionId: string): import("../../open-sse/services/combo/types.ts").ResolvedComboTarget { +function makeTarget( + connectionId: string +): import("../../open-sse/services/combo/types.ts").ResolvedComboTarget { return { kind: "model", stepId: `step-${connectionId}`, @@ -79,6 +82,34 @@ test.after(() => { // ─── deriveMessageHash ─────────────────────────────────────────────────────── +test("normalizeStickinessMessages: supports Chat Completions and Responses inputs", () => { + assert.deepEqual(normalizeStickinessMessages({ messages: [{ role: "user", content: "chat" }] }), [ + { role: "user", content: "chat" }, + ]); + assert.deepEqual(normalizeStickinessMessages({ input: "responses" }), [ + { role: "user", content: "responses" }, + ]); + assert.deepEqual(normalizeStickinessMessages({ input: ["first", "second"] }), [ + { role: "user", content: "first" }, + { role: "user", content: "second" }, + ]); + assert.deepEqual( + normalizeStickinessMessages({ + input: [{ role: "user", content: [{ type: "input_text", text: "structured" }] }], + }), + [{ role: "user", content: [{ type: "input_text", text: "structured" }] }] + ); + assert.equal(normalizeStickinessMessages({}), null); +}); + +test("normalizeStickinessMessages: messages takes precedence over input", () => { + const normalized = normalizeStickinessMessages({ + messages: [{ role: "user", content: "chat" }], + input: "responses", + }); + assert.deepEqual(normalized, [{ role: "user", content: "chat" }]); +}); + 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"); @@ -214,6 +245,36 @@ test("different message hashes can map to different connections", async () => { assert.equal(r2.targets[0].connectionId, "conn-Y"); }); +test("identical first messages do not leak sticky targets across combos", async () => { + injectSat({ util5h: 0.0, util7d: 0.0 }); + const messages = [{ role: "user", content: "Shared control prompt" }]; + const targets = [ + makeTarget("conn-primary"), + makeTarget("conn-intermediate"), + makeTarget("conn-last"), + ]; + + const firstCombo = await applySessionStickiness(targets, messages, "combo-with-last-success"); + assert.ok(firstCombo.messageHash); + recordStickyBinding(firstCombo.messageHash, "conn-last"); + + const repeatedFirstCombo = await applySessionStickiness( + targets, + messages, + "combo-with-last-success" + ); + assert.equal(repeatedFirstCombo.stuck, true); + assert.equal(repeatedFirstCombo.targets[0].connectionId, "conn-last"); + + const secondCombo = await applySessionStickiness(targets, messages, "fresh-priority-combo"); + assert.equal(secondCombo.stuck, false); + assert.deepEqual( + secondCombo.targets.map((target) => target.connectionId), + ["conn-primary", "conn-intermediate", "conn-last"], + "a binding learned by another combo must not reorder this combo's configured priority" + ); +}); + test("saturation fetch error → fail-open (original order, no crash)", async () => { __setStickinessHeadroomFetcherForTests(async (_id: string) => { throw new Error("network failure"); diff --git a/tests/unit/combo-target-timeout-runner.test.ts b/tests/unit/combo-target-timeout-runner.test.ts index 9b89461d4234..cd77455e5944 100644 --- a/tests/unit/combo-target-timeout-runner.test.ts +++ b/tests/unit/combo-target-timeout-runner.test.ts @@ -1,8 +1,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { buildTargetTimeoutRunner } from "../../open-sse/services/combo/targetTimeoutRunner.ts"; +import type { SingleModelTarget } from "../../open-sse/services/combo/types.ts"; -const noopLog = { warn() {}, info() {}, error() {}, debug() {} } as any; +const noopLog = { warn() {}, info() {}, error() {}, debug() {} }; + +function targetAbortSignal(target?: SingleModelTarget): AbortSignal | undefined { + return target?.modelAbortSignal ?? undefined; +} test("timeout<=0: passthrough direto (sem timer)", async () => { let called = false; @@ -35,9 +40,8 @@ test("excede o limite: aborta e retorna 524 timed out", async () => { const runner = buildTargetTimeoutRunner({ handleSingleModel: (_b, _m, target) => new Promise((resolve) => { - // resolve só se abortado (simula um upstream que respeita o signal) - const sig = (target as any)?.modelAbortSignal as AbortSignal | undefined; - sig?.addEventListener("abort", () => resolve(new Response(null, { status: 599 }))); + const signal = targetAbortSignal(target); + signal?.addEventListener("abort", () => resolve(new Response(null, { status: 599 }))); }), comboTargetTimeoutMs: 20, log: noopLog, @@ -59,20 +63,19 @@ test("sucesso rápido vence a corrida do timeout", async () => { assert.equal(await res.text(), "fast"); }); -test("hedge do parent já abortado propaga o abort ao filho", async () => { +test("external parent abort preserves its reason on the child", async () => { const parent = new AbortController(); - parent.abort(new Error("hedge-cancelled")); - let sawAbort = false; + const reason = new Error("request_signal_aborted"); + parent.abort(reason); + let childReason: unknown = null; const runner = buildTargetTimeoutRunner({ - handleSingleModel: (_b, _m, target) => - new Promise((resolve) => { - const sig = (target as any)?.modelAbortSignal as AbortSignal | undefined; - if (sig?.aborted) sawAbort = true; - resolve(new Response("ok")); - }), + handleSingleModel: (_b, _m, target) => { + childReason = targetAbortSignal(target)?.reason; + return Promise.resolve(new Response("ok")); + }, comboTargetTimeoutMs: 1000, log: noopLog, }); - await runner({}, "m", { modelAbortSignal: parent.signal } as any); - assert.equal(sawAbort, true); + await runner({}, "m", { modelAbortSignal: parent.signal }); + assert.equal(childReason, reason); }); diff --git a/tests/unit/combo-vision-aware-routing.test.ts b/tests/unit/combo-vision-aware-routing.test.ts index f617537743e9..97bd1524048f 100644 --- a/tests/unit/combo-vision-aware-routing.test.ts +++ b/tests/unit/combo-vision-aware-routing.test.ts @@ -1,23 +1,4 @@ -/** - * Regression: combo routing must not send an image request to a model that is - * not confirmed vision-capable. - * - * Root cause: `getResolvedModelCapabilities` returned `supportsVision: null` for - * every Mistral model — including Pixtral, which IS multimodal — because Mistral - * ships no models.dev `attachment` flag and the provider registry sets no - * `supportsVision`. The combo compatibility filter only dropped a target when - * `supportsVision === false`, so a `null` (unknown) text model like - * `ministral-14b` slipped through and received the image, replying - * "IMAGEM_INDISPONIVEL" / "image not provided". - * - * Two-part fix, both asserted here: - * A) resolveVisionCapability falls back to a conservative model-id heuristic so - * known-multimodal families (pixtral, llava, qwen-vl, gpt-4o, …) resolve to - * `true` when there is no synced/registry/spec data. - * B) the combo filter treats anything that is not confirmed `=== true` as - * vision-incompatible for image requests, while the existing - * "keep all when none qualify" fallback prevents any regression. - */ +/** Known vision incompatibility is rejected; absent metadata remains unknown. */ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; @@ -84,7 +65,7 @@ const imageBody = { ], }; -test("image request: combo drops the non-vision target, keeps the vision target", () => { +test("image request retains confirmed and unknown vision targets", () => { const out = filterTargetsByRequestCompatibility( [target("mistral/pixtral-12b-latest"), target("mistral/ministral-14b-latest")], imageBody, @@ -92,16 +73,22 @@ test("image request: combo drops the non-vision target, keeps the vision target" ); const ids = out.map((t) => t.modelStr); assert.ok(ids.includes("mistral/pixtral-12b-latest"), "vision target must be kept"); - assert.ok(!ids.includes("mistral/ministral-14b-latest"), "non-vision target must be dropped"); + const capability = getResolvedModelCapabilities("mistral/ministral-14b-latest").supportsVision; + assert.equal(ids.includes("mistral/ministral-14b-latest"), capability !== false); }); -test("image request with NO confirmed-vision target: keep all (fallback, no regression)", () => { +test("image request rejects known false even without a confirmed-vision target", () => { const out = filterTargetsByRequestCompatibility( [target("mistral/ministral-14b-latest"), target("groq/llama-3.1-8b-instant")], imageBody, noopLog ); - assert.equal(out.length, 2, "must not strip every target when none is confirmed vision"); + assert.deepEqual( + out.map((t) => t.modelStr), + ["mistral/ministral-14b-latest", "groq/llama-3.1-8b-instant"].filter( + (model) => getResolvedModelCapabilities(model).supportsVision !== false + ) + ); }); test("text-only request: targets are untouched by the vision filter", () => { diff --git a/tests/unit/combo/combo-target-exhaustion.test.ts b/tests/unit/combo/combo-target-exhaustion.test.ts index c6b03691878b..5695d2645a9e 100644 --- a/tests/unit/combo/combo-target-exhaustion.test.ts +++ b/tests/unit/combo/combo-target-exhaustion.test.ts @@ -363,3 +363,47 @@ test("gemini 524 DOES exhaust connection (cloudflare timeout)", () => { }); assert.equal(s.exhaustedConnections.has("gemini:gemini-key-abc"), true); }); + +test("synthetic combo target 524 does not exhaust the whole provider without a connection id", () => { + const s = sets(); + applyComboTargetExhaustion(target({ provider: "codex" }), { + ...baseOpts, + result: { status: 524 }, + fallbackResult: {}, + errorText: "Model codex/gpt-5.6-terra-medium timed out", + rawModel: "gpt-5.6-terra-medium", + sets: s, + }); + assert.equal(s.exhaustedProviders.has("codex"), false); + assert.equal(s.exhaustedConnections.size, 0); +}); + +for (const connectionId of [null, "conn-1"]) { + for (const code of ["empty_response", "empty_response_retry_exhausted"]) { + test(`${code} preserves healthy provider and connection ${connectionId}`, () => { + const s = sets(); + const exhausted = applyComboTargetExhaustion(target({ connectionId }), { + ...baseOpts, + result: { status: 502 }, + fallbackResult: {}, + structuredError: { code }, + sets: s, + }); + assert.equal(exhausted, false); + assert.equal(s.exhaustedProviders.size, 0); + assert.equal(s.exhaustedConnections.size, 0); + }); + } + test(`legacy empty response preserves healthy provider and connection ${connectionId}`, () => { + const s = sets(); + applyComboTargetExhaustion(target({ connectionId }), { + ...baseOpts, + result: { status: 502 }, + fallbackResult: {}, + sets: s, + errorText: "[codex/gpt-5.5] returned an empty response (no usable choices/output)", + }); + assert.equal(s.exhaustedProviders.size, 0); + assert.equal(s.exhaustedConnections.size, 0); + }); +} diff --git a/tests/unit/dashboard-ingress-auth.test.ts b/tests/unit/dashboard-ingress-auth.test.ts new file mode 100644 index 000000000000..53bfc1e4484f --- /dev/null +++ b/tests/unit/dashboard-ingress-auth.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { SignJWT } from "jose"; + +process.env.JWT_SECRET = "dashboard-ingress-synthetic-signing-secret"; +const { GET, HEAD } = await import("../../src/app/api/auth/dashboard-access/route.ts"); + +function request(headers: HeadersInit = {}) { + return new Request("https://router.example/api/auth/dashboard-access", { headers }); +} + +async function cookie(secret = process.env.JWT_SECRET, expired = false) { + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime(expired ? "-1h" : "1h") + .sign(new TextEncoder().encode(secret)); + return `auth_token=${token}`; +} + +test("dashboard ingress accepts a verified session for GET and HEAD without caching", async () => { + for (const handler of [GET, HEAD]) { + const response = await handler(request({ cookie: await cookie() })); + assert.equal(response.status, 204); + assert.equal(await response.text(), ""); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.equal(response.headers.get("vary"), "Cookie"); + } +}); + +test("dashboard ingress rejects absent, forged and expired sessions", async () => { + for (const headers of [ + {}, + { cookie: "auth_token=forged" }, + { cookie: await cookie("a-different-synthetic-signing-secret") }, + { cookie: await cookie(process.env.JWT_SECRET, true) }, + { authorization: "Bearer synthetic-client-key", "x-omniroute-auth-kind": "dashboard_session" }, + ]) { + assert.equal((await GET(request(headers))).status, 401); + } +}); + +test("even a configured master API key cannot replace the dashboard session", async () => { + const previous = process.env.OMNIROUTE_API_KEY; + process.env.OMNIROUTE_API_KEY = "synthetic-dashboard-ingress-master-key"; + try { + const response = await GET( + request({ authorization: `Bearer ${process.env.OMNIROUTE_API_KEY}` }) + ); + assert.equal(response.status, 401); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_API_KEY; + else process.env.OMNIROUTE_API_KEY = previous; + } +}); diff --git a/tests/unit/db-settings-crud.test.ts b/tests/unit/db-settings-crud.test.ts index 1cb64445105f..bc5d06aa4e90 100644 --- a/tests/unit/db-settings-crud.test.ts +++ b/tests/unit/db-settings-crud.test.ts @@ -234,6 +234,22 @@ test("LKGP overwrites connectionId when updated without one", async () => { assert.deepEqual(record, { provider: "openai" }); }); +test("clearLKGP deletes only the targeted combo/model key", async () => { + await settingsDb.setLKGP("combo-f", "model-f", "openai"); + await settingsDb.setLKGP("combo-f", "model-g", "anthropic"); + + await settingsDb.clearLKGP("combo-f", "model-f"); + + assert.equal(await settingsDb.getLKGP("combo-f", "model-f"), null); + // A sibling key under the same combo must survive. + assert.deepEqual(await settingsDb.getLKGP("combo-f", "model-g"), { provider: "anthropic" }); +}); + +test("clearLKGP on a key with no existing pin does not throw", async () => { + await assert.doesNotReject(() => settingsDb.clearLKGP("combo-never-set", "model-never-set")); + assert.equal(await settingsDb.getLKGP("combo-never-set", "model-never-set"), null); +}); + test("pricing helpers ignore malformed synced data and LKGP falls back to raw values", async () => { const db = core.getDbInstance(); diff --git a/tests/unit/db-settings-split.test.ts b/tests/unit/db-settings-split.test.ts index 88ca1cc2030e..2b71ccf7f24c 100644 --- a/tests/unit/db-settings-split.test.ts +++ b/tests/unit/db-settings-split.test.ts @@ -71,6 +71,7 @@ describe("settings.ts public API surface", () => { "getLKGP", "setLKGP", "clearAllLKGP", + "clearLKGP", // Cache metrics (re-exported from ./settings/cacheMetrics) "getCacheMetrics", "updateCacheMetrics", diff --git a/tests/unit/earlyStreamKeepalive.test.ts b/tests/unit/earlyStreamKeepalive.test.ts index c7a1594ca97c..e6c18fb9de06 100644 --- a/tests/unit/earlyStreamKeepalive.test.ts +++ b/tests/unit/earlyStreamKeepalive.test.ts @@ -69,60 +69,71 @@ test("slow path forwards SSE stream content", async () => { // ── Upstream error with 0 bytes → error frame emitted ───────────────────── -test("upstream error with 0 bytes forwarded emits error frame", { skip: true, todo: "ReadableStream error simulation hangs in Node.js test runner" }, async () => { - // Use a TransformStream where we error the writable side - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - writer.releaseLock(); - writable.abort(new Error("upstream died")).catch(() => {}); - - const response = new Response(readable, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }); - - const delayed = new Promise((resolve) => setTimeout(() => resolve(response), 10)); - - const result = await withEarlyStreamKeepalive(delayed, { - thresholdMs: 10, - intervalMs: 50, - }); - - assert.equal(result.status, 200); - const text = await drainStream(result.body!); - assert.ok(text.includes("Upstream stream failed before completion"), "should contain error frame"); -}); +test( + "upstream error with 0 bytes forwarded emits error frame", + { skip: true, todo: "ReadableStream error simulation hangs in Node.js test runner" }, + async () => { + // Use a TransformStream where we error the writable side + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + writer.releaseLock(); + writable.abort(new Error("upstream died")).catch(() => {}); + + const response = new Response(readable, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + + const delayed = new Promise((resolve) => setTimeout(() => resolve(response), 10)); + + const result = await withEarlyStreamKeepalive(delayed, { + thresholdMs: 10, + intervalMs: 50, + }); + + assert.equal(result.status, 200); + const text = await drainStream(result.body!); + assert.ok( + text.includes("Upstream stream failed before completion"), + "should contain error frame" + ); + } +); // ── Upstream error after partial content → NO error frame ────────────────── -test("upstream error after partial content does NOT emit error frame", { skip: true, todo: "ReadableStream error simulation hangs in Node.js test runner" }, async () => { - // Use a TransformStream where we send one chunk then error - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - await writer.write(ENCODER.encode('data: {"choices":[{"delta":{"content":"partial"}}]}\n\n')); - writer.releaseLock(); - writable.abort(new Error("upstream died mid-stream")).catch(() => {}); - - const response = new Response(readable, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }); - - const delayed = new Promise((resolve) => setTimeout(() => resolve(response), 10)); - - const result = await withEarlyStreamKeepalive(delayed, { - thresholdMs: 10, - intervalMs: 50, - }); - - assert.equal(result.status, 200); - const text = await drainStream(result.body!); - assert.ok(text.includes("partial"), "should contain forwarded content"); - assert.ok( - !text.includes("Upstream stream failed"), - "should NOT contain error frame after partial content" - ); -}); +test( + "upstream error after partial content does NOT emit error frame", + { skip: true, todo: "ReadableStream error simulation hangs in Node.js test runner" }, + async () => { + // Use a TransformStream where we send one chunk then error + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + await writer.write(ENCODER.encode('data: {"choices":[{"delta":{"content":"partial"}}]}\n\n')); + writer.releaseLock(); + writable.abort(new Error("upstream died mid-stream")).catch(() => {}); + + const response = new Response(readable, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + + const delayed = new Promise((resolve) => setTimeout(() => resolve(response), 10)); + + const result = await withEarlyStreamKeepalive(delayed, { + thresholdMs: 10, + intervalMs: 50, + }); + + assert.equal(result.status, 200); + const text = await drainStream(result.body!); + assert.ok(text.includes("partial"), "should contain forwarded content"); + assert.ok( + !text.includes("Upstream stream failed"), + "should NOT contain error frame after partial content" + ); + } +); // ── Handler rejection → error frame ─────────────────────────────────────── @@ -162,3 +173,221 @@ test("client abort stops keepalive and closes stream", async () => { // Should have received some keepalive frames then closed assert.ok(text.length >= 0, "stream should close on abort"); }); + +// ── Slow path: upstream status survives inside the committed 200 ─────────── + +test("slow path in-band error frame carries the handler's HTTP status", async () => { + // OmniRoute relays provider overload as a JSON 502; once the keepalive stream has + // committed to 200 the status line is gone, so `error.status` is the only signal + // a client has left to tell a retryable failure from a terminal one. + const handler = new Promise((resolve) => { + setTimeout( + () => + resolve( + new Response( + JSON.stringify({ + error: { + message: "Our servers are currently overloaded. Please try again later.", + type: "server_error", + code: "bad_gateway", + }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ) + ), + 30 + ); + }); + + const result = await withEarlyStreamKeepalive(handler, { thresholdMs: 5, intervalMs: 250 }); + assert.equal(result.status, 200); + + const text = await drainStream(result.body!); + const frame = text.split("\n").find((line) => line.startsWith("data: ")); + assert.ok(frame, "expected an in-band error frame"); + const payload = JSON.parse(frame!.slice("data: ".length)); + assert.equal(payload.error.status, 502); + assert.equal(payload.error.code, "bad_gateway"); + assert.match(payload.error.message, /overloaded/); +}); + +test("annotateErrorFrameStatus leaves unparseable and pre-tagged payloads alone", async () => { + const { annotateErrorFrameStatus } = await import("../../open-sse/utils/earlyStreamKeepalive.ts"); + + // Not JSON — must reach the client verbatim. + assert.equal(annotateErrorFrameStatus("upstream exploded", 502), "upstream exploded"); + // Already carries a status — never overwritten. + const tagged = JSON.stringify({ error: { message: "x", status: 429 } }); + assert.equal(annotateErrorFrameStatus(tagged, 502), tagged); + // Success-shaped or statusless payloads are untouched. + const noError = JSON.stringify({ choices: [] }); + assert.equal(annotateErrorFrameStatus(noError, 502), noError); + // Out-of-range statuses are not annotations worth making. + const body = JSON.stringify({ error: { message: "x" } }); + assert.equal(annotateErrorFrameStatus(body, 200), body); +}); + +test("anthropic-format error frame is shaped for Claude clients", async () => { + const { buildErrorFrameData } = await import("../../open-sse/utils/earlyStreamKeepalive.ts"); + + const overload = JSON.stringify({ + error: { + message: "Our servers are currently overloaded. Please try again later.", + type: "server_error", + code: "bad_gateway", + }, + }); + + // A 502 capacity failure must arrive as `overloaded_error` — one of the two types + // Claude clients retry — with the top-level Anthropic error envelope. + const framed = JSON.parse(buildErrorFrameData(overload, 502, "anthropic")); + assert.equal(framed.type, "error"); + assert.equal(framed.error.type, "overloaded_error"); + assert.equal(framed.error.status, 502); + assert.match(framed.error.message, /overloaded/); + + // Status-based mapping for the other cases clients branch on. + const pick = (status: number) => + JSON.parse( + buildErrorFrameData(JSON.stringify({ error: { message: "x" } }), status, "anthropic") + ).error.type; + assert.equal(pick(429), "rate_limit_error"); + assert.equal(pick(401), "authentication_error"); + assert.equal(pick(400), "invalid_request_error"); + assert.equal(pick(500), "api_error"); + + // A capacity message still maps to overloaded_error when the status is a bare 500. + const byMessage = JSON.parse( + buildErrorFrameData( + JSON.stringify({ error: { message: "Model is at capacity" } }), + 500, + "anthropic" + ) + ); + assert.equal(byMessage.error.type, "overloaded_error"); + + // OpenAI format stays the default and keeps the original envelope. + const openai = JSON.parse(buildErrorFrameData(overload, 502)); + assert.equal(openai.error.code, "bad_gateway"); + assert.equal(openai.error.status, 502); + assert.equal(openai.type, undefined); +}); + +test("slow path emits the anthropic envelope when the route asks for it", async () => { + const handler = new Promise((resolve) => { + setTimeout( + () => + resolve( + new Response(JSON.stringify({ error: { message: "Overloaded", type: "server_error" } }), { + status: 502, + headers: { "Content-Type": "application/json" }, + }) + ), + 30 + ); + }); + + const result = await withEarlyStreamKeepalive(handler, { + thresholdMs: 5, + intervalMs: 250, + errorFrameFormat: "anthropic", + }); + + const text = await drainStream(result.body!); + const frame = text + .split("\n") + .find((line) => line.startsWith("data: ") && line.includes("error")); + assert.ok(frame, "expected an anthropic error frame"); + const payload = JSON.parse(frame!.slice("data: ".length)); + assert.equal(payload.type, "error"); + assert.equal(payload.error.type, "overloaded_error"); +}); + +test("responses format carries the capacity failure in a response.failed envelope", async () => { + const { buildErrorFrameData } = await import("../../open-sse/utils/earlyStreamKeepalive.ts"); + const overload = JSON.stringify({ + error: { message: "Our servers are currently overloaded. Please try again later." }, + }); + + const payload = JSON.parse(buildErrorFrameData(overload, 502, "responses")); + assert.equal(payload.type, "response.failed"); + assert.equal(payload.response.status, "failed"); + assert.equal(payload.response.error.code, "server_overloaded"); + assert.equal(payload.response.error.status_code, 502); + assert.match(payload.response.error.message, /overloaded/i); + + // A 429 must stay distinguishable from a 5xx so the client picks the right backoff. + const rateLimited = JSON.parse( + buildErrorFrameData(JSON.stringify({ error: { message: "Rate limited" } }), 429, "responses") + ); + assert.equal(rateLimited.response.error.code, "rate_limit_exceeded"); + assert.equal(rateLimited.response.error.status_code, 429); + + // A terminal 4xx must NOT read as retryable capacity pressure. + const badRequest = JSON.parse( + buildErrorFrameData(JSON.stringify({ error: { message: "Bad model" } }), 400, "responses") + ); + assert.equal(badRequest.response.error.code, "invalid_request_error"); + assert.equal(badRequest.response.error.type, "invalid_request_error"); +}); + +test("event name follows the frame format so Responses clients see response.failed", async () => { + const { errorFrameEventName } = await import("../../open-sse/utils/earlyStreamKeepalive.ts"); + assert.equal(errorFrameEventName("openai"), "error"); + assert.equal(errorFrameEventName("anthropic"), "error"); + assert.equal(errorFrameEventName("responses"), "response.failed"); + assert.equal(errorFrameEventName(), "error"); +}); + +test("slow path emits a response.failed event when the route asks for it", async () => { + const handler = new Promise((resolve) => { + setTimeout( + () => + resolve( + new Response( + JSON.stringify({ + error: { message: "Our servers are currently overloaded. Please try again later." }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ) + ), + 30 + ); + }); + + const result = await withEarlyStreamKeepalive(handler, { + thresholdMs: 5, + intervalMs: 250, + errorFrameFormat: "responses", + }); + + const text = await drainStream(result.body!); + assert.ok( + text.includes("event: response.failed"), + "expected a response.failed event, got: " + text + ); + const frame = text.split("\n").find((line) => line.startsWith("data: {")); + assert.ok(frame, "expected a data frame"); + const payload = JSON.parse(frame!.slice("data: ".length)); + assert.equal(payload.type, "response.failed"); + assert.equal(payload.response.error.status_code, 502); +}); + +test("a rejected handler also fails a Responses stream in-band, not silently", async () => { + const handler = new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("boom at /secret/path.ts:12")), 30); + }); + + const result = await withEarlyStreamKeepalive(handler, { + thresholdMs: 5, + intervalMs: 250, + errorFrameFormat: "responses", + }); + + const text = await drainStream(result.body!); + assert.ok(text.includes("event: response.failed"), "expected a response.failed event"); + assert.ok(!text.includes("secret/path.ts"), "must never leak the raw error/stack"); + const frame = text.split("\n").find((line) => line.startsWith("data: {")); + const payload = JSON.parse(frame!.slice("data: ".length)); + assert.equal(payload.response.error.status_code, 502); +}); diff --git a/tests/unit/empty-response-retry-budget.test.ts b/tests/unit/empty-response-retry-budget.test.ts new file mode 100644 index 000000000000..e940e40a5639 --- /dev/null +++ b/tests/unit/empty-response-retry-budget.test.ts @@ -0,0 +1,211 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + EmptyResponseRetryBudget, + EMPTY_RESPONSE_RETRY_EXHAUSTED, +} from "../../open-sse/services/combo/emptyResponseRetryBudget.ts"; +import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; + +const h = await createChatPipelineHarness("empty-response-budget"); +const { getCallLogs } = await import("../../src/lib/usage/callLogs.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { createErrorResult } = await import("../../open-sse/utils/error.ts"); +const log = { info() {}, warn() {}, debug() {}, error() {} }; + +test.beforeEach(async () => { + await h.resetStorage(); +}); +test.after(async () => { + await h.cleanup(); +}); + +test("empty failure budget is isolated by request, provider and model; overload does not consume it", () => { + const budget = new EmptyResponseRetryBudget(); + for (let i = 0; i < 8; i++) + assert.equal( + budget.recordFailure("codex", "astra", { status: 502, error: "overloaded" }), + false + ); + for (let i = 0; i < 3; i++) { + assert.equal( + budget.recordFailure("codex", "astra", { status: 502, errorCode: "empty_response" }), + i === 2 + ); + } + assert.equal(budget.isExhausted("codex", "astra"), true); + assert.equal(budget.isExhausted("sale", "astra"), false); + assert.equal(budget.isExhausted("codex", "terra"), false); + assert.equal(new EmptyResponseRetryBudget().isExhausted("codex", "astra"), false); +}); + +function emptyResponse() { + return Response.json({ + object: "response", + status: "completed", + model: "gpt-4.1", + output: [ + { + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "" }], + }, + ], + usage: { input_tokens: 10, output_tokens: 4 }, + }); +} + +async function seedPrimary() { + for (let i = 0; i < 5; i++) + await h.seedConnection("openai", { + apiKey: `sk-primary-${i}`, + name: `primary-${i}`, + priority: i, + }); +} + +function request(model: string) { + return h.buildRequest({ + body: { model, stream: false, messages: [{ role: "user", content: "Reply OK" }] }, + }); +} + +test("real chat pipeline caps empty output at three attempts across accounts, then serves the combo fallback and persists reasons", async () => { + await seedPrimary(); + await h.seedConnection("claude", { apiKey: "sk-backup" }); + await h.combosDb.createCombo({ + name: "empty-fallback", + strategy: "priority", + config: { maxRetries: 5, retryDelayMs: 0, failoverBeforeRetry: false }, + models: ["openai/gpt-4.1", "claude/claude-3-5-sonnet-20241022"], + }); + const primaryAccounts: string[] = []; + let fallbackCalls = 0; + globalThis.fetch = async (_url, init = {}) => { + const headers = h.toPlainHeaders(init.headers) as Record; + if (headers["x-api-key"] === "sk-backup") { + fallbackCalls++; + return h.buildClaudeResponse("Fallback OK"); + } + primaryAccounts.push(headers.authorization); + return emptyResponse(); + }; + const response = await h.handleChat(request("empty-fallback")); + assert.equal(response.status, 200); + assert.equal((await response.json()).choices[0].message.content, "Fallback OK"); + assert.equal(primaryAccounts.length, 3, "account retry and combo retry must share a limit"); + assert.equal( + new Set(primaryAccounts).size, + 2, + "retry first account, then try one alternate account" + ); + assert.equal(fallbackCalls, 1); + await h.waitFor(async () => (await getCallLogs({ status: 502 })).length >= 3); + const rows = await getCallLogs({ status: 502 }); + assert.equal(rows.length, 3); + for (const row of rows) assert.match(String(row.error), /returned an empty response/); +}); + +test("standalone model stops after three empty attempts and returns an explicit exhausted code", async () => { + await seedPrimary(); + let calls = 0; + globalThis.fetch = async () => { + calls++; + return emptyResponse(); + }; + const response = await h.handleChat(request("openai/gpt-4.1")); + assert.equal(response.status, 502); + assert.equal((await response.json()).error.code, EMPTY_RESPONSE_RETRY_EXHAUSTED); + assert.equal(calls, 3); +}); + +test("transient empty output can still recover on its first retry", async () => { + await seedPrimary(); + let calls = 0; + globalThis.fetch = async () => + ++calls === 1 ? emptyResponse() : h.buildOpenAIResponse("Recovered"); + const response = await h.handleChat(request("openai/gpt-4.1")); + assert.equal(response.status, 200); + assert.equal((await response.json()).choices[0].message.content, "Recovered"); + assert.equal(calls, 2); +}); + +for (const strategy of ["priority", "round-robin"]) { + test(`${strategy} combo advances immediately after an exhausted empty-response budget`, async () => { + const calls: string[] = []; + const response = await handleComboChat({ + body: { model: "budget-combo", messages: [{ role: "user", content: "hi" }] }, + combo: { + name: `budget-${strategy}`, + strategy, + config: { maxRetries: 5, retryDelayMs: 0, failoverBeforeRetry: false }, + models: ["openai/gpt-4.1", "openai/gpt-4o-mini"], + }, + handleSingleModel: async (_body, model) => { + calls.push(model); + return model === "openai/gpt-4.1" + ? createErrorResult( + 502, + "Empty output attempt limit reached", + null, + EMPTY_RESPONSE_RETRY_EXHAUSTED + ).response + : h.buildOpenAIResponse("OK"); + }, + log, + settings: {}, + allCombos: [], + }); + assert.equal(response.status, 200); + assert.deepEqual(calls, ["openai/gpt-4.1", "openai/gpt-4o-mini"]); + }); +} + +test("separate combo steps for the same provider/model share the empty-output budget", async () => { + const accounts = []; + for (let i = 0; i < 5; i++) { + accounts.push( + await h.seedConnection("openai", { name: `pinned-${i}`, apiKey: `sk-pinned-${i}` }) + ); + } + await h.seedConnection("claude", { apiKey: "sk-backup" }); + await h.combosDb.createCombo({ + name: "pinned-empty-fallback", + strategy: "priority", + config: { maxRetries: 0, retryDelayMs: 0 }, + models: [ + ...accounts.map((account, index) => ({ + id: `pinned-step-${index}`, + kind: "model", + model: "openai/gpt-4.1", + connectionId: account.id, + })), + { kind: "model", model: "claude/claude-3-5-sonnet-20241022" }, + ], + }); + let primaryCalls = 0; + globalThis.fetch = async (_url, init = {}) => { + const headers = h.toPlainHeaders(init.headers) as Record; + if (headers["x-api-key"] === "sk-backup") return h.buildClaudeResponse("Backup"); + primaryCalls++; + return emptyResponse(); + }; + const response = await h.handleChat(request("pinned-empty-fallback")); + assert.equal(response.status, 200); + assert.equal(primaryCalls, 3, "the fourth and fifth combo steps must not reset the budget"); +}); + +for (const failure of [ + { errorCode: "empty_response_retry_exhausted" }, + { error: "[codex/gpt-5.5] returned an empty response (no usable choices/output)" }, + { error: "Provider returned empty content" }, +]) { + test(`retry budget shares empty-output classification: ${JSON.stringify(failure)}`, () => { + const budget = new EmptyResponseRetryBudget(); + for (let i = 0; i < 3; i++) { + assert.equal(budget.recordFailure("codex", "primary", { status: 502, ...failure }), i === 2); + assert.equal(budget.recordFailure("codex", "backup", { status: 500, ...failure }), false); + } + assert.equal(budget.isExhausted("codex", "backup"), false); + }); +} diff --git a/tests/unit/empty-stream-no-content-8649.test.ts b/tests/unit/empty-stream-no-content-8649.test.ts new file mode 100644 index 000000000000..dc1023648d26 --- /dev/null +++ b/tests/unit/empty-stream-no-content-8649.test.ts @@ -0,0 +1,271 @@ +/** + * #8649 / #8732 — a successful terminal event must not reach the client when + * the provider produced no model content. The error has to be the first + * terminal event because clients commonly stop reading at the success marker. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createDisconnectAwareStream, createStreamController } = + await import("../../open-sse/utils/streamHandler.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +const encoder = new TextEncoder(); + +function noopAbortWritable(): { getWriter: () => { abort: () => Promise } } { + return { getWriter: () => ({ abort: () => Promise.resolve() }) }; +} + +async function drainStream(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const parts: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + const size = parts.reduce((total, part) => total + part.length, 0); + const merged = new Uint8Array(size); + let offset = 0; + for (const part of parts) { + merged.set(part, offset); + offset += part.length; + } + return new TextDecoder().decode(merged); +} + +async function runClientStream(frames: string[], format: string | null): Promise { + const upstream = new ReadableStream({ + start(controller) { + for (const frame of frames) controller.enqueue(encoder.encode(frame)); + controller.close(); + }, + }); + const streamController = createStreamController({ + provider: "test", + model: "test-model", + clientResponseFormat: format, + }); + return drainStream( + createDisconnectAwareStream( + { readable: upstream, writable: noopAbortWritable() }, + streamController + ) + ); +} + +const chatChunk = (delta: string, finishReason: string) => + `data: {"id":"chatcmpl-x","object":"chat.completion.chunk","choices":[{"index":0,"delta":${delta},"finish_reason":${finishReason}}]}\n\n`; +const DONE = "data: [DONE]\n\n"; + +test("#8732 OpenAI empty stop is replaced before the success terminal", async () => { + const text = await runClientStream( + [chatChunk('{"role":"assistant"}', "null"), chatChunk("{}", '"stop"'), DONE], + null + ); + + assert.doesNotMatch(text, /"finish_reason":"stop"/); + assert.equal(text.match(/"finish_reason":"error"/g)?.length, 1); + assert.equal(text.match(/data: \[DONE\]/g)?.length, 1); + assert.match(text, /Provider returned empty content/); +}); + +test("#8732 Responses empty completion is replaced by response.failed", async () => { + const text = await runClientStream( + [ + 'event: response.created\ndata: {"type":"response.created","response":{"output":[]}}\n\n', + 'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed","output":[]}}\n\n', + ], + FORMATS.OPENAI_RESPONSES + ); + + assert.doesNotMatch(text, /event: response\.completed/); + assert.match(text, /event: response\.failed/); + assert.equal(text.match(/event: response\.failed/g)?.length, 1); +}); + +test("#8732 encrypted-only Responses reasoning counts as model output", async () => { + const text = await runClientStream( + [ + 'event: response.output_item.done\ndata: {"type":"response.output_item.done","item":{"type":"reasoning","encrypted_content":"opaque","summary":[]}}\n\n', + 'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed","output":[]}}\n\n', + ], + FORMATS.OPENAI_RESPONSES + ); + + assert.match(text, /"encrypted_content":"opaque"/); + assert.match(text, /event: response\.completed/); + assert.doesNotMatch(text, /response\.failed|Provider returned empty content/); +}); + +test("#8732 legitimate Responses incomplete reason remains the first terminal", async () => { + const text = await runClientStream( + [ + 'event: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","output":[],"incomplete_details":{"reason":"max_output_tokens"}}}\n\n', + ], + FORMATS.OPENAI_RESPONSES + ); + + assert.match(text, /event: response\.incomplete/); + assert.doesNotMatch(text, /response\.failed|Provider returned empty content/); +}); + +test("#8732 unexplained empty Responses incomplete is replaced before its terminal", async () => { + const text = await runClientStream( + [ + 'event: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","output":[]}}\n\n', + ], + FORMATS.OPENAI_RESPONSES + ); + + assert.doesNotMatch(text, /event: response\.incomplete/); + assert.match(text, /event: response\.failed/); +}); + +test("#8732 Claude empty stop is replaced before stop_reason and message_stop", async () => { + const text = await runClientStream( + [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"m","content":[]}}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ], + FORMATS.CLAUDE + ); + + assert.doesNotMatch(text, /stop_reason|message_stop/); + assert.equal(text.match(/event: error/g)?.length, 1); + assert.match(text, /Provider returned empty content/); +}); + +test("#8732 healthy text and reasoning-only streams remain successful", async () => { + const text = await runClientStream( + [ + chatChunk('{"content":"hello"}', "null"), + chatChunk('{"reasoning_content":"thinking"}', "null"), + chatChunk("{}", '"stop"'), + DONE, + ], + null + ); + + assert.match(text, /"content":"hello"/); + assert.match(text, /"reasoning_content":"thinking"/); + assert.match(text, /"finish_reason":"stop"/); + assert.doesNotMatch(text, /Provider returned empty content/); +}); + +test("#8732 tool-only and length-limited streams remain legitimate", async () => { + const toolText = await runClientStream( + [ + chatChunk( + '{"tool_calls":[{"index":0,"id":"c1","function":{"name":"f","arguments":"{}"}}]}', + "null" + ), + chatChunk("{}", '"tool_calls"'), + DONE, + ], + null + ); + const lengthText = await runClientStream( + [chatChunk('{"role":"assistant"}', "null"), chatChunk("{}", '"length"'), DONE], + null + ); + + assert.match(toolText, /tool_calls/); + assert.doesNotMatch(toolText, /Provider returned empty content/); + assert.match(lengthText, /"finish_reason":"length"/); + assert.doesNotMatch(lengthText, /Provider returned empty content/); +}); + +test("#8732 split Responses terminal marker is fully withheld and replaced", async () => { + const text = await runClientStream( + [ + 'event: response.created\ndata: {"type":"response.created"}\n\n', + "event: response.comp", + 'leted\ndata: {"type":"response.comp', + 'leted","response":{"output":[]}}\n\n', + ], + FORMATS.OPENAI_RESPONSES + ); + + assert.doesNotMatch(text, /event: response\.completed/); + assert.match(text, /event: response\.failed/); +}); + +test("#8732 oversized Responses terminal with output_text remains successful", async () => { + const outputText = "x".repeat(512 * 1024); + const terminal = `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { output_text: outputText }, + })}\n\n`; + const text = await runClientStream([terminal], FORMATS.OPENAI_RESPONSES); + + assert.match(text, /event: response\.completed/); + assert.equal(text.includes(outputText), true); + assert.doesNotMatch(text, /response\.failed/); +}); + +test("#8732 oversized split Responses terminal remains successful", async () => { + const outputText = "y".repeat(512 * 1024); + const payload = JSON.stringify({ + type: "response.completed", + response: { output_text: outputText }, + }); + const text = await runClientStream( + [ + "event: response.comp", + `leted\ndata: ${payload.slice(0, 200_000)}`, + `${payload.slice(200_000)}\n\n`, + ], + FORMATS.OPENAI_RESPONSES + ); + + assert.match(text, /event: response\.completed/); + assert.equal(text.includes(outputText), true); + assert.doesNotMatch(text, /response\.failed/); +}); + +test("#8732 complete keepalive is delivered before a later empty-terminal error", async () => { + let upstreamController: ReadableStreamDefaultController | null = null; + const upstream = new ReadableStream({ + start(controller) { + upstreamController = controller; + controller.enqueue(encoder.encode(": keepalive\n\n")); + }, + }); + const streamController = createStreamController({ clientResponseFormat: null }); + const stream = createDisconnectAwareStream( + { readable: upstream, writable: noopAbortWritable() }, + streamController + ); + const reader = stream.getReader(); + + const first = await reader.read(); + assert.equal(new TextDecoder().decode(first.value), ": keepalive\n\n"); + upstreamController?.enqueue(encoder.encode(chatChunk("{}", '"stop"'))); + upstreamController?.close(); + const rest: Uint8Array[] = []; + for (;;) { + const result = await reader.read(); + if (result.done) break; + rest.push(result.value); + } + const tail = new TextDecoder().decode(Uint8Array.from(rest.flatMap((part) => Array.from(part)))); + assert.doesNotMatch(tail, /"finish_reason":"stop"/); + assert.match(tail, /"finish_reason":"error"/); +}); + +test("#8732 non-SSE bodies and structured upstream errors are not rewritten", async () => { + const plain = await runClientStream(["plain forwarded bytes"], null); + const upstreamError = await runClientStream( + [ + 'event: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"message":"upstream"}}}\n\n', + ], + FORMATS.OPENAI_RESPONSES + ); + + assert.equal(plain, "plain forwarded bytes"); + assert.equal(upstreamError.match(/response\.failed/g)?.length, 2); + assert.match(upstreamError, /upstream/); + assert.doesNotMatch(upstreamError, /Provider returned empty content/); +}); diff --git a/tests/unit/executor-codex-gpt56-lite-ultra.test.ts b/tests/unit/executor-codex-gpt56-lite-ultra.test.ts new file mode 100644 index 000000000000..ed0995e17b14 --- /dev/null +++ b/tests/unit/executor-codex-gpt56-lite-ultra.test.ts @@ -0,0 +1,89 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { CodexExecutor } from "../../open-sse/executors/codex.ts"; + +// Issue #7821: enforceCodexResponsesLiteParallelToolCalls() used to have zero model +// awareness — it force-set parallel_tool_calls:false for EVERY model when Responses +// Lite was detected, including gpt-5.6-sol/-terra at "ultra" effort (and gpt-5.6-luna +// at "max"), whose delegation-to-sub-agents capability depends on parallel_tool_calls +// staying enabled. This collided with the effort-clamp comment near clampEffort() +// ("Ultra coordinates delegation in Codex clients") and is why GPT-5.6 was reported +// unusable through the stock Codex CLI/App (which enables Responses Lite by default) +// while GPT-5.5 (no delegation tier) was unaffected. +// +// Covers the interaction (lite marker + delegation-dependent model/effort together), +// not just each behavior in isolation — that interaction was the actual blind spot. + +async function runLiteRequest(model: string): Promise[]> { + const executor = new CodexExecutor(); + const originalFetch = globalThis.fetch; + const capturedBodies: Record[] = []; + + globalThis.fetch = async (_url, init) => { + capturedBodies.push(JSON.parse(String(init?.body || "{}"))); + return new Response(JSON.stringify({ id: "resp_lite", object: "response" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const body = { + _nativeCodexPassthrough: true, + model, + input: [], + parallel_tool_calls: true, + }; + + try { + await executor.execute({ + model, + body, + stream: true, + credentials: { accessToken: "codex-token" }, + clientHeaders: { "X-OpenAI-Internal-Codex-Responses-Lite": "true" }, + }); + } finally { + globalThis.fetch = originalFetch; + } + + return capturedBodies; +} + +test("Responses Lite must not strip parallel_tool_calls for GPT-5.6 sol ultra-tier delegation", async () => { + const capturedBodies = await runLiteRequest("gpt-5.6-sol-ultra"); + assert.equal( + capturedBodies[0].parallel_tool_calls, + true, + "Responses Lite stripped parallel_tool_calls for an ultra-tier GPT-5.6 delegation " + + "request — this breaks sub-agent delegation and is the root cause of #7821" + ); +}); + +test("Responses Lite must not strip parallel_tool_calls for GPT-5.6 terra ultra-tier delegation", async () => { + const capturedBodies = await runLiteRequest("gpt-5.6-terra-ultra"); + assert.equal(capturedBodies[0].parallel_tool_calls, true); +}); + +test("Responses Lite must not strip parallel_tool_calls for GPT-5.6 luna max-tier delegation", async () => { + const capturedBodies = await runLiteRequest("gpt-5.6-luna-max"); + assert.equal(capturedBodies[0].parallel_tool_calls, true); +}); + +test("Responses Lite still forces parallel_tool_calls:false for non-delegation GPT-5.5", async () => { + const capturedBodies = await runLiteRequest("gpt-5.5"); + assert.equal( + capturedBodies[0].parallel_tool_calls, + false, + "GPT-5.5 has no delegation tier — Responses Lite behavior for it must be unchanged" + ); +}); + +test("Responses Lite still forces parallel_tool_calls:false for GPT-5.6 sol at non-ultra effort", async () => { + const capturedBodies = await runLiteRequest("gpt-5.6-sol-high"); + assert.equal( + capturedBodies[0].parallel_tool_calls, + false, + "Non-ultra GPT-5.6 effort tiers have no delegation dependency — must stay forced off" + ); +}); diff --git a/tests/unit/executor-codex-gpt56.test.ts b/tests/unit/executor-codex-gpt56.test.ts index 3e5d2b950683..1205bdcd695a 100644 --- a/tests/unit/executor-codex-gpt56.test.ts +++ b/tests/unit/executor-codex-gpt56.test.ts @@ -50,3 +50,67 @@ test("CodexExecutor.transformRequest clamps Luna ultra requests to its max effor assert.equal(result.model, "gpt-5.6-luna"); assert.equal(result.reasoning.effort, "max"); }); + +test("CodexExecutor.execute disables parallel tool calls for Responses Lite markers", async () => { + const executor = new CodexExecutor(); + const originalFetch = globalThis.fetch; + const capturedBodies: Record[] = []; + + globalThis.fetch = async (_url, init) => { + capturedBodies.push(JSON.parse(String(init?.body || "{}"))); + return new Response(JSON.stringify({ id: "resp_lite", object: "response" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const headerBody = { + _nativeCodexPassthrough: true, + model: "gpt-5.6-sol", + input: [], + parallel_tool_calls: true, + }; + const metadataBody = { + _nativeCodexPassthrough: true, + model: "gpt-5.6-sol", + input: [], + client_metadata: { + ws_request_header_x_openai_internal_codex_responses_lite: "true", + }, + }; + const standardBody = { + _nativeCodexPassthrough: true, + model: "gpt-5.6-sol", + input: [], + parallel_tool_calls: true, + }; + + try { + for (const request of [ + { + body: headerBody, + clientHeaders: { "X-OpenAI-Internal-Codex-Responses-Lite": "true" }, + }, + { body: metadataBody }, + { body: standardBody }, + ]) { + const result = await executor.execute({ + model: "gpt-5.6-sol", + body: request.body, + stream: true, + credentials: { accessToken: "codex-token" }, + clientHeaders: request.clientHeaders, + }); + assert.equal(result.response.status, 200); + } + + assert.equal(capturedBodies[0].parallel_tool_calls, false); + assert.equal(headerBody.parallel_tool_calls, true); + assert.equal(capturedBodies[1].parallel_tool_calls, false); + assert.equal(metadataBody.parallel_tool_calls, undefined); + assert.equal(capturedBodies[2].parallel_tool_calls, true); + assert.equal(standardBody.parallel_tool_calls, true); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 771930e1e50b..e9df2c26b55f 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -184,10 +184,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.144.1"); + assert.equal(standardHeaders.Version, "0.153.4"); assert.equal(standardHeaders["Openai-Beta"], "responses=experimental"); assert.equal(standardHeaders["X-Codex-Beta-Features"], "responses_websockets"); - assert.equal(standardHeaders["User-Agent"], "codex-cli/0.144.1 (Windows 10.0.26200; x64)"); + assert.equal(standardHeaders["User-Agent"], "codex-cli/0.153.4 (Windows 10.0.26200; x64)"); assert.equal(compactHeaders.Accept, "application/json"); }); @@ -213,7 +213,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.144.1"); + assert.equal(headers.Version, "0.153.4"); assert.equal(headers["User-Agent"], "custom-codex/9.9.9"); } ); @@ -1260,11 +1260,7 @@ test("CodexExecutor.refreshCredentials refreshes OAuth tokens and returns null w } }); -test("CodexExecutor.refreshCredentials returns null for unrecoverable errors to preserve original credentials", async () => { - // Source intentionally returns null (not an error object) so that base.ts does - // not spread stale error fields onto activeCredentials. The upstream 401/403 - // drives the proper re-auth / mark-expired path instead. - // Source: open-sse/executors/codex.ts — refreshCredentials(), lines ~1205-1216. +test("CodexExecutor.refreshCredentials preserves unrecoverable errors for immediate retry bail-out", async () => { const executor = new CodexExecutor(); const originalFetch = globalThis.fetch; globalThis.fetch = async () => @@ -1275,7 +1271,10 @@ test("CodexExecutor.refreshCredentials returns null for unrecoverable errors to try { const result = await executor.refreshCredentials({ refreshToken: "dead-token" }, null); - assert.equal(result, null, "should return null to leave original credentials untouched"); + assert.deepEqual(result, { + error: "unrecoverable_refresh_error", + code: "invalid_grant", + }); } finally { globalThis.fetch = originalFetch; } diff --git a/tests/unit/executor-xai.test.ts b/tests/unit/executor-xai.test.ts index f8a6031e5ef2..85342f1a8945 100644 --- a/tests/unit/executor-xai.test.ts +++ b/tests/unit/executor-xai.test.ts @@ -20,6 +20,12 @@ test("XaiExecutor is registered under the 'xai' key and set as the registry exec assert.equal(xaiProvider.executor, "xai"); }); +test("XaiExecutor can target the separate xAI OAuth provider config", () => { + const executor = new XaiExecutor("xai-oauth"); + assert.equal(executor.getProvider(), "xai-oauth"); + assert.equal(executor.buildUrl("grok-4.5", false), "https://api.x.ai/v1/chat/completions"); +}); + test("strips a -{level} suffix from an allow-listed model and sets reasoning_effort", () => { const executor = new XaiExecutor(); diff --git a/tests/unit/fork-version-check.test.ts b/tests/unit/fork-version-check.test.ts new file mode 100644 index 000000000000..fcbdace77047 --- /dev/null +++ b/tests/unit/fork-version-check.test.ts @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { resolveForkVersionInfo } from "../../src/lib/system/forkVersionCheck.ts"; + +const currentSha = "1111111111111111111111111111111111111111"; +const latestSha = "2222222222222222222222222222222222222222"; +const published = (sha = latestSha) => ({ + workflow_runs: [ + { id: 123, head_sha: sha, head_branch: "stable", status: "completed", conclusion: "success" }, + ], +}); +function responses(...payloads: unknown[]) { + const urls: string[] = []; + const fetchImpl = (async (input, init) => { + urls.push(String(input)); + assert.equal(init?.cache, "no-store"); + assert.ok(init?.signal); + assert.ok(payloads.length, "unexpected external request"); + const payload = payloads.shift(); + return payload instanceof Response ? payload : Response.json(payload); + }) as typeof fetch; + return { urls, fetchImpl }; +} + +test("current published SHA has no update and needs no comparison", async () => { + const fake = responses(published(currentSha)); + const result = await resolveForkVersionInfo("fork · sha-1111111", fake.fetchImpl); + assert.equal(result.updateAvailable, false); + assert.equal(result.checkStatus, "ok"); + assert.equal(result.latest, "sha-1111111"); + assert.equal(fake.urls.length, 1); + assert.match( + fake.urls[0], + /fenix007\/OmniRoute\/actions\/workflows\/fork-image-fenix007.yml\/runs\?branch=stable&status=success/ + ); +}); + +test("newer successfully published fork advertises SHA and its build page", async () => { + const fake = responses(published(), { status: "ahead" }); + const result = await resolveForkVersionInfo("fork · sha-1111111", fake.fetchImpl); + assert.equal(result.updateAvailable, true); + assert.equal(result.channel, "fork"); + assert.equal(result.autoUpdateSupported, false); + assert.equal(result.latest, "sha-2222222"); + assert.equal("latestLabel" in result && result.latestLabel, "fork · sha-2222222"); + assert.equal( + "releaseUrl" in result && result.releaseUrl, + "https://github.com/fenix007/OmniRoute/actions/runs/123" + ); + assert.match(fake.urls[1], /compare\/1111111\.\.\.2222222222222222222222222222222222222222/); +}); + +for (const status of ["behind", "identical", "diverged"]) { + test(`comparison ${status} never advertises a downgrade`, async () => { + const fake = responses(published(), { status }); + const result = await resolveForkVersionInfo("fork · sha-1111111", fake.fetchImpl); + assert.equal(result.updateAvailable, false); + assert.equal(result.checkStatus, "ok"); + }); +} + +test("tagged fork release is compared against the published commit", async () => { + const fake = responses(published(), { status: "ahead" }); + const result = await resolveForkVersionInfo("3.8.48-fork.19", fake.fetchImpl); + assert.equal(result.updateAvailable, true); + assert.match(fake.urls[1], /compare\/3\.8\.48-fork\.19\.\.\./); +}); + +for (const payload of [ + { workflow_runs: [] }, + { workflow_runs: [{ ...published().workflow_runs[0], conclusion: "failure" }] }, + { workflow_runs: [{ ...published().workflow_runs[0], status: "in_progress" }] }, + { workflow_runs: [{ ...published().workflow_runs[0], head_branch: "main" }] }, + { workflow_runs: [{ ...published().workflow_runs[0], head_sha: "unsafe/ref" }] }, + { unexpected: true }, +]) { + test(`unpublished or malformed run is unavailable: ${JSON.stringify(payload)}`, async () => { + const fake = responses(payload); + const result = await resolveForkVersionInfo("fork · sha-1111111", fake.fetchImpl); + assert.equal(result.checkStatus, "unavailable"); + assert.equal(result.updateAvailable, false); + assert.equal(fake.urls.length, 1); + }); +} + +test("rate limits and failed comparisons never fall back to upstream", async () => { + for (const payloads of [ + [new Response(null, { status: 403 })], + [published(), new Response(null, { status: 404 })], + ]) { + const fake = responses(...payloads); + const result = await resolveForkVersionInfo("fork · sha-1111111", fake.fetchImpl); + assert.equal(result.checkStatus, "unavailable"); + assert.equal(result.latest, "unavailable"); + assert.ok( + fake.urls.every((url) => url.startsWith("https://api.github.com/repos/fenix007/OmniRoute/")) + ); + } +}); + +test("network failure and unrecognized current identity fail closed", async () => { + let calls = 0; + const fetchImpl = (async () => { + calls++; + throw new Error("network unavailable"); + }) as typeof fetch; + assert.equal( + (await resolveForkVersionInfo("fork · sha-1111111", fetchImpl)).checkStatus, + "unavailable" + ); + assert.equal( + (await resolveForkVersionInfo("unexpected/ref", fetchImpl)).checkStatus, + "unavailable" + ); + assert.equal(calls, 1); +}); + +test("production lookups share one in-flight check and cache outages", async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response(null, { status: 403 }); + }) as typeof fetch; + try { + const [first, second] = await Promise.all([ + resolveForkVersionInfo("fork · sha-abcdef1"), + resolveForkVersionInfo("fork · sha-abcdef1"), + ]); + assert.equal(first.checkStatus, "unavailable"); + assert.deepEqual(first, second); + await resolveForkVersionInfo("fork · sha-abcdef1"); + assert.equal(calls, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/gemini-claude-stream-usage.test.ts b/tests/unit/gemini-claude-stream-usage.test.ts new file mode 100644 index 000000000000..d520e9f9cfb6 --- /dev/null +++ b/tests/unit/gemini-claude-stream-usage.test.ts @@ -0,0 +1,109 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import "../_setup/isolateDataDir.ts"; + +// Exercise the real SSE pipeline and local accounting without provider requests. +test.mock.method(globalThis, "fetch", async () => { + throw new Error("Unexpected network request in Gemini usage regression test"); +}); +const { createSSEStream } = await import("../../open-sse/utils/stream.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { getLoggedInputTokens } = await import("../../src/lib/usage/tokenAccounting.ts"); +const { computeCostFromPricing } = await import("../../src/lib/usage/costCalculator.ts"); + +type Completed = Parameters[0]["onComplete"]>>[0]; + +for (const scenario of [ + { name: "uncached", cached: undefined, output: 7, thoughts: 3 }, + { name: "zero cache", cached: 0, output: 7, thoughts: 3 }, + { name: "partial cache", cached: 90, output: 7, thoughts: 3 }, + { name: "full cache and zero output", cached: 100, output: 0, thoughts: 0 }, + { name: "full cache and missing output", cached: 100, output: undefined, thoughts: 0 }, + { name: "wrapped partial cache", cached: 90, output: 7, thoughts: 3, wrapped: true }, + { + name: "wrapped full cache and zero output", + cached: 100, + output: 0, + thoughts: 0, + wrapped: true, + }, +]) { + test(`Gemini -> Claude SSE accounting: ${scenario.name}`, async () => { + const completed: Completed[] = []; + const chunks = [ + { + candidates: [{ content: { parts: [{ text: "hello" }] } }], + usageMetadata: { + promptTokenCount: 100, + cachedContentTokenCount: scenario.cached, + candidatesTokenCount: scenario.output, + thoughtsTokenCount: scenario.thoughts, + }, + }, + // Usage must survive a later terminal event with no usage metadata. + { candidates: [{ content: { parts: [] }, finishReason: "STOP" }] }, + ]; + const upstream = chunks + .map((chunk) => `data: ${JSON.stringify(scenario.wrapped ? { response: chunk } : chunk)}\n\n`) + .join(""); + const bytes = new TextEncoder().encode(upstream); + const input = new ReadableStream({ + start(controller) { + // Split within JSON fields as a real transport can do. + for (let offset = 0; offset < bytes.length; offset += 17) { + controller.enqueue(bytes.slice(offset, offset + 17)); + } + controller.close(); + }, + }); + const wire = await new Response( + input.pipeThrough( + createSSEStream({ + mode: "translate", + targetFormat: scenario.wrapped ? FORMATS.ANTIGRAVITY : FORMATS.GEMINI, + sourceFormat: FORMATS.CLAUDE, + provider: "gemini", + model: "gemini-2.5-pro", + body: { messages: [{ role: "user", content: "hi" }] }, + onComplete: (payload) => completed.push(payload), + }) + ) + ).text(); + + const events = wire + .split("\n") + .filter((line) => line.startsWith("data: ") && line !== "data: [DONE]") + .map((line) => JSON.parse(line.slice(6))); + const cached = scenario.cached ?? 0; + const output = (scenario.output ?? 0) + scenario.thoughts; + const deltas = events.filter((event) => event.type === "message_delta"); + assert.equal(deltas.length, 1); + assert.deepEqual(deltas[0].usage, { + input_tokens: 100 - cached, + output_tokens: output, + ...(cached > 0 ? { cache_read_input_tokens: cached } : {}), + }); + assert.equal(deltas[0].delta.stop_reason, "end_turn"); + assert.equal(events.filter((event) => event.type === "message_stop").length, 1); + assert.equal(events.at(-1).type, "message_stop"); + assert.ok(events.some((event) => event.delta?.text === "hello")); + + assert.equal(completed.length, 1); + assert.equal(completed[0].status, 200); + const usage = completed[0].usage as Record; + assert.equal(getLoggedInputTokens(usage), 100, "must retain metered usage, not an estimate"); + const responseBody = completed[0].responseBody as { usage: unknown }; + assert.deepEqual(responseBody.usage, { + prompt_tokens: 100, + completion_tokens: output, + total_tokens: 100 + output, + }); + assert.match(wire, /^: x-omniroute-tokens-in=100$/m); + assert.match(wire, new RegExp(`^: x-omniroute-tokens-out=${output}$`, "m")); + + // The pricing consumer must subtract cache exactly once from the full prompt. + const cost = computeCostFromPricing({ input: 2, cached: 0.5, output: 3 }, usage); + const expectedCost = ((100 - cached) * 2 + cached * 0.5 + output * 3) / 1_000_000; + assert.ok(Math.abs(cost - expectedCost) < 1e-12, `${cost} != ${expectedCost}`); + }); +} diff --git a/tests/unit/gemini-schema-keyword-properties.test.ts b/tests/unit/gemini-schema-keyword-properties.test.ts new file mode 100644 index 000000000000..808ff16705b0 --- /dev/null +++ b/tests/unit/gemini-schema-keyword-properties.test.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { cleanJSONSchemaForAntigravity } from "../../open-sse/translator/helpers/geminiHelper.ts"; +import { buildGeminiTools } from "../../open-sse/translator/helpers/geminiToolsSanitizer.ts"; +import { claudeToGeminiRequest } from "../../open-sse/translator/request/claude-to-gemini.ts"; +import { openaiToGeminiRequest } from "../../open-sse/translator/request/openai-to-gemini.ts"; + +const keywordNames = [ + "const", + "additionalProperties", + "properties", + "type", + "enum", + "required", + "items", + "allOf", + "anyOf", + "oneOf", + "pattern", + "minLength", +]; + +function keywordSchema(names = keywordNames) { + return { + type: "object", + properties: Object.fromEntries(names.map((name) => [name, { type: "string" }])), + required: [...names], + }; +} + +// #13059: v3.8.48 lacks the newer type-injection phase, but the existing const +// and additionalProperties passes already corrupt keyword-named tool arguments. +for (const name of ["const", "additionalProperties"]) { + test(`preserves a required tool argument named ${name}`, () => { + const schema = keywordSchema([name]); + assert.deepEqual(cleanJSONSchemaForAntigravity(schema), schema); + }); +} + +test("preserves all keyword-named arguments and does not mutate the caller's schema", () => { + const schema = keywordSchema(); + const original = structuredClone(schema); + assert.deepEqual(cleanJSONSchemaForAntigravity(schema), original); + assert.deepEqual(schema, original); +}); + +test("preserves keyword-named arguments inside object and array schemas", () => { + const schema = { + type: "object", + properties: { + nested: keywordSchema(), + rows: { type: "array", items: keywordSchema() }, + }, + required: ["nested", "rows"], + }; + assert.deepEqual(cleanJSONSchemaForAntigravity(schema), schema); +}); + +test("still normalizes actual constraints within keyword-named arguments", () => { + const schema = { + type: "object", + additionalProperties: false, + properties: { + const: { const: "fixed" }, + enum: { type: "integer", enum: [1, 2] }, + additionalProperties: { + type: "object", + properties: { const: { type: ["string", "null"], minLength: 2 } }, + additionalProperties: true, + required: ["const", "absent"], + }, + }, + required: ["const", "enum", "additionalProperties", "absent"], + }; + assert.deepEqual(cleanJSONSchemaForAntigravity(schema), { + type: "object", + properties: { + const: { type: "string", enum: ["fixed"] }, + // The frozen cleaner drops numeric enum constraints, retaining the numeric type. + enum: { type: "integer" }, + additionalProperties: { + type: "object", + properties: { const: { type: "string" } }, + required: ["const"], + }, + }, + required: ["const", "enum", "additionalProperties"], + }); +}); + +test("retains keyword arguments after composition flattening and local ref expansion", () => { + const schema = { + type: "object", + $defs: { entry: keywordSchema() }, + properties: { + composed: { + type: "object", + allOf: [keywordSchema(["const"]), keywordSchema(["additionalProperties"])], + }, + choice: { anyOf: [{ type: "null" }, keywordSchema()] }, + exclusive: { oneOf: [keywordSchema()] }, + referenced: { $ref: "#/$defs/entry" }, + }, + }; + assert.deepEqual(cleanJSONSchemaForAntigravity(schema), { + type: "object", + properties: { + composed: keywordSchema(["const", "additionalProperties"]), + choice: keywordSchema(), + exclusive: keywordSchema(), + referenced: keywordSchema(), + }, + }); +}); + +test("still inserts required placeholders into truly empty object schemas", () => { + const result = cleanJSONSchemaForAntigravity({ + type: "object", + properties: { additionalProperties: { type: "object", properties: {} } }, + }); + assert.deepEqual(result, { + type: "object", + properties: { + additionalProperties: { + type: "object", + properties: { + reason: { + type: "string", + description: "Brief explanation of why you are calling this tool", + }, + }, + required: ["reason"], + }, + }, + }); +}); + +test("buildGeminiTools preserves required argument names in functionDeclarations", () => { + const schema = keywordSchema(); + const tools = [{ type: "function", function: { name: "inspect", parameters: schema } }]; + assert.deepEqual(buildGeminiTools(tools)?.[0]?.functionDeclarations?.[0]?.parameters, schema); +}); + +for (const stream of [false, true]) { + test(`OpenAI request translation preserves tool and response schemas (stream=${stream})`, () => { + const schema = keywordSchema(); + const result = openaiToGeminiRequest( + "gemini-2.5-pro", + { + messages: [{ role: "user", content: "Inspect this object" }], + tools: [{ type: "function", function: { name: "inspect", parameters: schema } }], + response_format: { type: "json_schema", json_schema: { name: "result", schema } }, + }, + stream + ); + assert.deepEqual(result.tools?.[0]?.functionDeclarations?.[0]?.parameters, schema); + assert.deepEqual(result.generationConfig?.responseSchema, schema); + }); + + test(`Anthropic request translation preserves tool input schemas (stream=${stream})`, () => { + const schema = keywordSchema(); + const result = claudeToGeminiRequest( + "gemini-2.5-pro", + { + messages: [{ role: "user", content: "Inspect this object" }], + tools: [{ name: "inspect", description: "Inspect fields", input_schema: schema }], + }, + stream + ); + assert.deepEqual(result.tools?.[0]?.functionDeclarations?.[0]?.parameters, schema); + }); +} diff --git a/tests/unit/image-edits-multipart-3273.test.ts b/tests/unit/image-edits-multipart-3273.test.ts index d7e3e390ddb8..dda3884a40b4 100644 --- a/tests/unit/image-edits-multipart-3273.test.ts +++ b/tests/unit/image-edits-multipart-3273.test.ts @@ -40,7 +40,10 @@ test("#3273 /v1/images/edits forwards model as real multipart (undici-patched fe await handleOpenAIImageEdit({ model: "gpt-image-2", provider: "customopenai", - credentials: { apiKey: "sk-test", providerSpecificData: { baseUrl: `http://127.0.0.1:${port}` } }, + credentials: { + apiKey: "sk-test", + providerSpecificData: { baseUrl: `http://127.0.0.1:${port}` }, + }, prompt: "make it blue", imageBytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]), imageMime: "image/png", @@ -61,6 +64,38 @@ test("#3273 /v1/images/edits forwards model as real multipart (undici-patched fe assert.ok(captured.body.includes('name="image"'), "image part must be present"); }); +test("built-in OpenAI image edits use the official endpoint and preserve GPT Image 2.5 model IDs", async () => { + const originalFetch = globalThis.fetch; + let capturedUrl = ""; + let capturedBody = ""; + globalThis.fetch = async (url, options = {}) => { + capturedUrl = String(url); + capturedBody = Buffer.from(options.body as Uint8Array).toString("utf8"); + return new Response(JSON.stringify({ data: [{ b64_json: "ZmFrZQ==" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleOpenAIImageEdit({ + model: "gpt-image-2.5-sunburst", + provider: "openai", + credentials: { apiKey: "sk-test" }, + prompt: "make it blue", + imageBytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]), + imageMime: "image/png", + }); + + assert.equal(result.success, true); + assert.equal(capturedUrl, "https://api.openai.com/v1/images/edits"); + assert.ok(capturedBody.includes('name="model"')); + assert.ok(capturedBody.includes("gpt-image-2.5-sunburst")); + } finally { + globalThis.fetch = originalFetch; + } +}); + test.after(() => { try { resetDbInstance(); diff --git a/tests/unit/image-generation-codex-account-fallback.test.ts b/tests/unit/image-generation-codex-account-fallback.test.ts new file mode 100644 index 000000000000..723091221a88 --- /dev/null +++ b/tests/unit/image-generation-codex-account-fallback.test.ts @@ -0,0 +1,278 @@ +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-codex-image-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "codex-image-route-test-api-key-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const accountFallback = await import("../../open-sse/services/accountFallback.ts"); +const imageRoute = await import("../../src/app/api/v1/images/generations/route.ts"); + +const originalFetch = globalThis.fetch; + +interface ImageResponseBody { + data: Array<{ b64_json?: string; url?: string }>; +} + +interface ErrorResponseBody { + error: { message: string; code?: string }; +} + +interface ImageCallLogRow { + account: string | null; + connection_id: string | null; + model: string; + status: number; +} + +async function resetStorage() { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + accountFallback.clearAllModelLockouts(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedCodexConnection(overrides: { + apiKey: string; + name?: string; + priority?: number; + providerSpecificData?: Record; +}) { + return providersDb.createProviderConnection({ + provider: "codex", + authType: "apikey", + name: overrides.name ?? `codex-${Math.random().toString(16).slice(2, 8)}`, + apiKey: overrides.apiKey, + isActive: true, + priority: overrides.priority, + testStatus: "active", + providerSpecificData: overrides.providerSpecificData ?? {}, + }); +} + +function codexImageSuccessResponse(result = "Y29kZXgtaW1hZ2U="): Response { + const event = { + type: "response.output_item.done", + item: { + type: "image_generation_call", + id: "ig_route_test", + status: "completed", + result, + }, + }; + return new Response(`data: ${JSON.stringify(event)}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function postCodexImageGeneration(): Promise { + return imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "codex/gpt-5.6-sol", + prompt: "draw a route regression guard", + response_format: "b64_json", + }), + }) + ); +} + +async function waitForImageCallLogs( + expectedCount: number, + connectionIds: string[] = [] +): Promise { + const db = core.getDbInstance(); + const expectedConnectionIds = new Set(connectionIds); + const deadline = Date.now() + 2_000; + let rows: ImageCallLogRow[] = []; + while (rows.length < expectedCount && Date.now() < deadline) { + const persistedRows = db + .prepare( + "SELECT account, connection_id, model, status FROM call_logs WHERE path = ? ORDER BY timestamp ASC, id ASC" + ) + .all("/v1/images/generations") as ImageCallLogRow[]; + rows = + expectedConnectionIds.size > 0 + ? persistedRows.filter( + (row) => row.connection_id && expectedConnectionIds.has(row.connection_id) + ) + : persistedRows; + if (rows.length < expectedCount) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + return rows; +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + accountFallback.clearAllModelLockouts(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("skips Codex accounts that exclude the requested image model", async () => { + await seedCodexConnection({ + apiKey: "excluded-token", + priority: 1, + providerSpecificData: { excludedModels: ["gpt-5.6-sol"] }, + }); + await seedCodexConnection({ apiKey: "eligible-token", priority: 2 }); + + const authorizations: string[] = []; + globalThis.fetch = async (_url, options: RequestInit = {}) => { + const headers = options.headers as Record; + authorizations.push(headers.Authorization); + return codexImageSuccessResponse(); + }; + + const response = await postCodexImageGeneration(); + + assert.equal(response.status, 200); + assert.deepEqual(authorizations, ["Bearer eligible-token"]); +}); + +test("retries one Codex sibling for the ChatGPT account model-access error", async () => { + const unsupportedConnection = await seedCodexConnection({ + apiKey: "unsupported-token", + name: "codex-unsupported-account", + priority: 1, + }); + const supportedConnection = await seedCodexConnection({ + apiKey: "supported-token", + name: "codex-supported-account", + priority: 2, + }); + + const authorizations: string[] = []; + globalThis.fetch = async (_url, options: RequestInit = {}) => { + const headers = options.headers as Record; + authorizations.push(headers.Authorization); + if (headers.Authorization === "Bearer unsupported-token") { + return new Response( + JSON.stringify({ + detail: + "The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account.", + }), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + return codexImageSuccessResponse("ZmFsbGJhY2staW1hZ2U="); + }; + + const response = await postCodexImageGeneration(); + const body = (await response.json()) as ImageResponseBody; + + assert.equal(response.status, 200); + assert.equal(body.data[0].b64_json, "ZmFsbGJhY2staW1hZ2U="); + assert.deepEqual(authorizations, ["Bearer unsupported-token", "Bearer supported-token"]); + + const callLogs = await waitForImageCallLogs(2, [ + unsupportedConnection.id, + supportedConnection.id, + ]); + assert.deepEqual( + callLogs.map((row) => ({ + account: row.account, + connectionId: row.connection_id, + status: row.status, + })), + [ + { + account: "codex-unsupported-account", + connectionId: unsupportedConnection.id, + status: 400, + }, + { + account: "codex-supported-account", + connectionId: supportedConnection.id, + status: 200, + }, + ] + ); + + const nextResponse = await postCodexImageGeneration(); + assert.equal(nextResponse.status, 200); + assert.deepEqual(authorizations, [ + "Bearer unsupported-token", + "Bearer supported-token", + "Bearer supported-token", + ]); +}); + +test("does not retry ordinary Codex 400 responses", async () => { + await seedCodexConnection({ apiKey: "invalid-request-token", priority: 1 }); + await seedCodexConnection({ apiKey: "unused-token", priority: 2 }); + + let fetchCount = 0; + globalThis.fetch = async () => { + fetchCount += 1; + return new Response(JSON.stringify({ detail: "invalid image size" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + }; + + const response = await postCodexImageGeneration(); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 400); + assert.match(body.error.message, /invalid image size/i); + assert.equal(fetchCount, 1); +}); + +test("stops after one Codex account fallback", async () => { + await seedCodexConnection({ apiKey: "unsupported-first-token", priority: 1 }); + await seedCodexConnection({ apiKey: "unsupported-second-token", priority: 2 }); + + let fetchCount = 0; + globalThis.fetch = async () => { + fetchCount += 1; + return new Response( + JSON.stringify({ + detail: "The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account.", + }), + { status: 400, headers: { "content-type": "application/json" } } + ); + }; + + const response = await postCodexImageGeneration(); + + assert.equal(response.status, 400); + assert.equal(fetchCount, 2); +}); + +test("attributes Codex image call logs to the selected connection", async () => { + const connection = await seedCodexConnection({ + apiKey: "attributed-token", + name: "codex-attributed-account", + }); + globalThis.fetch = async () => codexImageSuccessResponse(); + + const response = await postCodexImageGeneration(); + assert.equal(response.status, 200); + + const [row] = await waitForImageCallLogs(1, [connection.id]); + + assert.ok(row); + assert.equal(row.connection_id, connection.id); + assert.equal(row.account, "codex-attributed-account"); + assert.equal(row.model, "codex/gpt-5.6-sol"); + assert.equal(row.status, 200); +}); diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index 348cd838171b..fc51cf1ebdfe 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -362,6 +362,59 @@ test("image registry resolves flux aliases and exposes planned catalog aliases", assert.deepEqual(fluxKontext?.inputModalities, ["text", "image"]); }); +test("image registry exposes GPT Image 2.5 models and forwards Sunburst generation requests to OpenAI", async () => { + const originalFetch = globalThis.fetch; + let captured; + + globalThis.fetch = async (url, options = {}) => { + captured = { + url: String(url), + body: JSON.parse(String(options.body || "{}")), + }; + return new Response(JSON.stringify({ data: [{ b64_json: "ZmFrZQ==" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const catalog = new Map(getAllImageModels().map((model) => [model.id, model])); + assert.deepEqual(parseImageModel("openai/gpt-image-2.5-sunburst"), { + provider: "openai", + model: "gpt-image-2.5-sunburst", + }); + assert.deepEqual(parseImageModel("gpt-image-2.5-flare"), { + provider: "openai", + model: "gpt-image-2.5-flare", + }); + assert.deepEqual(catalog.get("openai/gpt-image-2.5-sunburst")?.inputModalities, [ + "text", + "image", + ]); + assert.deepEqual(catalog.get("openai/gpt-image-2.5-flare")?.inputModalities, ["text", "image"]); + + const result = await handleImageGeneration({ + body: { + model: "openai/gpt-image-2.5-sunburst", + prompt: "A watercolor landscape with a lake at sunrise.", + size: "1024x1024", + }, + credentials: { apiKey: "image-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(captured.url, "https://api.openai.com/v1/images/generations"); + assert.deepEqual(captured.body, { + model: "gpt-image-2.5-sunburst", + prompt: "A watercolor landscape with a lake at sunrise.", + size: "1024x1024", + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("handleImageGeneration calls Fal AI with Key auth and normalizes URL results to base64", async () => { const originalFetch = globalThis.fetch; let requestCapture; diff --git a/tests/unit/image-generation-route-auth.test.ts b/tests/unit/image-generation-route-auth.test.ts new file mode 100644 index 000000000000..e6f25631a71a --- /dev/null +++ b/tests/unit/image-generation-route-auth.test.ts @@ -0,0 +1,345 @@ +/** + * Auth + call-log attribution contract for the image generation routes. + * + * Split out of image-generation-route.test.ts to stay under the 800-line + * new-test-file cap enforced by `npm run check:file-size`. + */ + +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-route-auth-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "image-route-test-api-key-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const { getCallLogs } = await import("../../src/lib/usage/callLogs.ts"); +const imageRoute = await import("../../src/app/api/v1/images/generations/route.ts"); +const providerImageRoute = + await import("../../src/app/api/v1/providers/[provider]/images/generations/route.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +const originalFetch = globalThis.fetch; + +async function resetStorage() { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +async function seedConnection(provider: string, apiKey: string) { + return providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: `${provider}-${Math.random().toString(16).slice(2, 8)}`, + apiKey, + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); +} + +async function waitForCallLog(apiKeyId: string, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const logs = await getCallLogs({ apiKey: apiKeyId, limit: 5 }); + const match = logs.find((log: { apiKeyId?: string | null }) => log.apiKeyId === apiKeyId); + if (match) return match; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return null; +} + +async function readErrorMessage(response: Response): Promise { + const body = (await response.json()) as { error?: { message?: unknown } }; + return typeof body.error?.message === "string" ? body.error.message : ""; +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("v1 image generation POST requires an API key when REQUIRE_API_KEY is enabled", async () => { + const originalRequireApiKey = process.env.REQUIRE_API_KEY; + process.env.REQUIRE_API_KEY = "true"; + + try { + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "openai/gpt-image-2", + prompt: "authentication test", + }), + }) + ); + + assert.equal(response.status, 401); + assert.match(await readErrorMessage(response), /Authentication required/); + } finally { + if (originalRequireApiKey === undefined) { + delete process.env.REQUIRE_API_KEY; + } else { + process.env.REQUIRE_API_KEY = originalRequireApiKey; + } + } +}); + +test("v1 image generation POST rejects an invalid presented API key", async () => { + const originalOmniRouteApiKey = process.env.OMNIROUTE_API_KEY; + const originalRequireApiKey = process.env.REQUIRE_API_KEY; + process.env.OMNIROUTE_API_KEY = "valid-image-route-key"; + process.env.REQUIRE_API_KEY = "true"; + + try { + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { + Authorization: "Bearer invalid-image-route-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "openai/gpt-image-2", + prompt: "invalid authentication test", + }), + }) + ); + + assert.equal(response.status, 401); + assert.match(await readErrorMessage(response), /Invalid API key/); + } finally { + if (originalOmniRouteApiKey === undefined) { + delete process.env.OMNIROUTE_API_KEY; + } else { + process.env.OMNIROUTE_API_KEY = originalOmniRouteApiKey; + } + if (originalRequireApiKey === undefined) { + delete process.env.REQUIRE_API_KEY; + } else { + process.env.REQUIRE_API_KEY = originalRequireApiKey; + } + } +}); + +// Issue #2257: with enforcement off, a stale key in a CLI config must degrade to +// anonymous exactly like clientApiPolicy does — the route guard must not be +// stricter than the middleware that already fronts it. +test("v1 image generation POST ignores an invalid presented key while REQUIRE_API_KEY is off", async () => { + const originalOmniRouteApiKey = process.env.OMNIROUTE_API_KEY; + const originalRequireApiKey = process.env.REQUIRE_API_KEY; + process.env.OMNIROUTE_API_KEY = "valid-image-route-key"; + process.env.REQUIRE_API_KEY = "false"; + + globalThis.fetch = async (url) => { + assert.equal(String(url), "http://localhost:7860/sdapi/v1/txt2img"); + return new Response(JSON.stringify({ images: ["YW5vbnltb3Vz"] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { + Authorization: "Bearer stale-cli-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "sdwebui/stable-diffusion-v1-5", + prompt: "stale key degrades to anonymous", + }), + }) + ); + + assert.equal(response.status, 200); + } finally { + if (originalOmniRouteApiKey === undefined) { + delete process.env.OMNIROUTE_API_KEY; + } else { + process.env.OMNIROUTE_API_KEY = originalOmniRouteApiKey; + } + if (originalRequireApiKey === undefined) { + delete process.env.REQUIRE_API_KEY; + } else { + process.env.REQUIRE_API_KEY = originalRequireApiKey; + } + } +}); + +// The dashboard Media page and Playground call these routes with a session +// cookie and no Bearer. clientApiPolicy admits them; the route guard must too. +test("v1 image generation POST accepts a dashboard session when REQUIRE_API_KEY is enabled", async () => { + const originalRequireApiKey = process.env.REQUIRE_API_KEY; + const originalJwtSecret = process.env.JWT_SECRET; + process.env.REQUIRE_API_KEY = "true"; + process.env.JWT_SECRET = "image-route-dashboard-session-secret"; + + globalThis.fetch = async (url) => { + assert.equal(String(url), "http://localhost:7860/sdapi/v1/txt2img"); + return new Response(JSON.stringify({ images: ["ZGFzaGJvYXJk"] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const { SignJWT } = await import("jose"); + const token = await new SignJWT({ sub: "dashboard" }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(new TextEncoder().encode(process.env.JWT_SECRET)); + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { + Cookie: `auth_token=${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "sdwebui/stable-diffusion-v1-5", + prompt: "dashboard session test", + }), + }) + ); + + assert.equal(response.status, 200); + } finally { + if (originalRequireApiKey === undefined) { + delete process.env.REQUIRE_API_KEY; + } else { + process.env.REQUIRE_API_KEY = originalRequireApiKey; + } + if (originalJwtSecret === undefined) { + delete process.env.JWT_SECRET; + } else { + process.env.JWT_SECRET = originalJwtSecret; + } + } +}); + +test("v1 image generation POST attributes its call log to the validated API key", async () => { + const createdKey = await apiKeysDb.createApiKey( + "Image generation caller", + "machine-image-generation" + ); + await seedConnection("openai", "image-provider-key"); + + globalThis.fetch = async (url) => { + assert.equal(String(url), "https://api.openai.com/v1/images/generations"); + return new Response( + JSON.stringify({ + created: 123, + data: [{ url: "https://cdn.example.com/attributed-image.png" }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { + Authorization: `Bearer ${createdKey.key}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "openai/gpt-image-2", + prompt: "call log attribution test", + }), + }) + ); + + assert.equal(response.status, 200); + const logged = await waitForCallLog(createdKey.id); + assert.ok(logged, "expected an attributed image-generation call log"); + assert.equal(logged.apiKeyId, createdKey.id); + assert.equal(logged.apiKeyName, "Image generation caller"); +}); + +test("provider-scoped image generation requires an API key when configured", async () => { + const originalRequireApiKey = process.env.REQUIRE_API_KEY; + process.env.REQUIRE_API_KEY = "true"; + + try { + const response = await providerImageRoute.POST( + new Request("http://localhost/api/v1/providers/openai/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "gpt-image-2", + prompt: "provider-scoped authentication test", + }), + }), + { params: Promise.resolve({ provider: "openai" }) } + ); + + assert.equal(response.status, 401); + assert.match(await readErrorMessage(response), /Authentication required/); + } finally { + if (originalRequireApiKey === undefined) { + delete process.env.REQUIRE_API_KEY; + } else { + process.env.REQUIRE_API_KEY = originalRequireApiKey; + } + } +}); + +test("provider-scoped image generation attributes its call log to the validated API key", async () => { + const createdKey = await apiKeysDb.createApiKey( + "Provider-scoped image caller", + "machine-provider-image" + ); + await seedConnection("openai", "provider-scoped-upstream-key"); + + globalThis.fetch = async (url) => { + assert.equal(String(url), "https://api.openai.com/v1/images/generations"); + return new Response( + JSON.stringify({ + created: 456, + data: [{ url: "https://cdn.example.com/provider-scoped-image.png" }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + const response = await providerImageRoute.POST( + new Request("http://localhost/api/v1/providers/openai/images/generations", { + method: "POST", + headers: { + Authorization: `Bearer ${createdKey.key}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-image-2", + prompt: "provider-scoped attribution test", + }), + }), + { params: Promise.resolve({ provider: "openai" }) } + ); + + assert.equal(response.status, 200); + const logged = await waitForCallLog(createdKey.id); + assert.ok(logged, "expected an attributed provider-scoped image-generation call log"); + assert.equal(logged.apiKeyId, createdKey.id); + assert.equal(logged.apiKeyName, "Provider-scoped image caller"); +}); diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts index 4cd99aa0e5ea..806cc9f98d0f 100644 --- a/tests/unit/image-generation-route.test.ts +++ b/tests/unit/image-generation-route.test.ts @@ -13,6 +13,8 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); const imageRoute = await import("../../src/app/api/v1/images/generations/route.ts"); +const providerImageRoute = + await import("../../src/app/api/v1/providers/[provider]/images/generations/route.ts"); const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); @@ -32,15 +34,33 @@ async function resetStorage() { v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); } -async function seedConnection(provider: string, overrides: { apiKey?: string | null } = {}) { +async function seedConnection( + provider: string, + overrides: { + authType?: string; + apiKey?: string | null; + accessToken?: string; + refreshToken?: string; + expiresAt?: string; + projectId?: string; + priority?: number; + providerSpecificData?: Record; + } = {} +) { + const authType = overrides.authType ?? "apikey"; return providersDb.createProviderConnection({ provider, - authType: "apikey", + authType, name: `${provider}-${Math.random().toString(16).slice(2, 8)}`, - apiKey: overrides.apiKey ?? "test-key", + ...(authType === "apikey" ? { apiKey: overrides.apiKey ?? "test-key" } : {}), + ...(overrides.accessToken ? { accessToken: overrides.accessToken } : {}), + ...(overrides.refreshToken ? { refreshToken: overrides.refreshToken } : {}), + ...(overrides.expiresAt ? { expiresAt: overrides.expiresAt } : {}), + ...(overrides.projectId ? { projectId: overrides.projectId } : {}), + ...(overrides.priority ? { priority: overrides.priority } : {}), isActive: true, testStatus: "active", - providerSpecificData: {}, + providerSpecificData: overrides.providerSpecificData ?? {}, }); } @@ -55,6 +75,20 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); +test("image routes expose CORS preflight handlers", async () => { + const responses = await Promise.all([ + imageRoute.OPTIONS(), + providerImageRoute.OPTIONS(), + imageEditRoute.OPTIONS(), + ]); + + for (const response of responses) { + assert.equal(response.status, 200); + assert.match(response.headers.get("Access-Control-Allow-Methods") ?? "", /POST/); + assert.equal(response.headers.get("Access-Control-Allow-Headers"), "*"); + } +}); + test("v1 image models GET exposes image-only modalities for credential-backed image-only models", async () => { await seedConnection("topaz", { apiKey: "topaz-key" }); await seedConnection("stability-ai", { apiKey: "stability-key" }); @@ -167,6 +201,38 @@ test("v1 image edit POST enforces disabled API key policy", async () => { assert.match(body.error.message, /disabled/); }); +test("v1 image edit POST routes GPT Image 2.5 Sunburst to OpenAI", async () => { + await seedConnection("openai", { apiKey: "sunburst-image-key" }); + let capturedUrl = ""; + let capturedBody = ""; + + globalThis.fetch = async (url, options: RequestInit = {}) => { + capturedUrl = String(url); + capturedBody = Buffer.from(options.body as Uint8Array).toString("utf8"); + return new Response(JSON.stringify({ data: [{ b64_json: "ZmFrZQ==" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const formData = new FormData(); + formData.set("prompt", "make the background lighter"); + formData.set("model", "openai/gpt-image-2.5-sunburst"); + formData.set("image", new File([new Uint8Array([1, 2, 3])], "source.png", { type: "image/png" })); + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + body: formData, + }) + ); + + assert.equal(response.status, 200); + assert.equal(capturedUrl, "https://api.openai.com/v1/images/edits"); + assert.ok(capturedBody.includes("gpt-image-2.5-sunburst")); + assert.deepEqual((await response.json()).data, [{ b64_json: "ZmFrZQ==" }]); +}); + test("v1 image generation POST resolves proxy and executes with proxy context when credentials.connectionId exists", async () => { // Create a connection — it gets an auto-generated id used as credentials.connectionId const connection = await seedConnection("openai", { apiKey: "image-proxy-key" }); @@ -260,3 +326,136 @@ test("v1 image generation POST executes directly when credentials.connectionId i assert.equal(response.status, 200); assert.ok(body.data, "should have image data"); }); + +test("v1 image generation POST rotates to the next account after an upstream 401", async () => { + await seedConnection("openai", { apiKey: "expired-image-key", priority: 1 }); + await seedConnection("openai", { apiKey: "healthy-image-key", priority: 2 }); + const authorizationHeaders: string[] = []; + + globalThis.fetch = async (url, options: RequestInit = {}) => { + assert.equal(String(url), "https://api.openai.com/v1/images/generations"); + const authorization = new Headers(options.headers).get("authorization") ?? ""; + authorizationHeaders.push(authorization); + if (authorization === "Bearer expired-image-key") { + return new Response(JSON.stringify({ error: { message: "expired access token" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + assert.equal(authorization, "Bearer healthy-image-key"); + return new Response( + JSON.stringify({ created: 123, data: [{ url: "https://cdn.example.com/rotated.png" }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "openai/gpt-image-2", prompt: "rotate image account" }), + }) + ); + const body = (await response.json()) as ImageResponseBody; + + assert.equal(response.status, 200); + assert.equal(body.data[0].url, "https://cdn.example.com/rotated.png"); + assert.deepEqual(authorizationHeaders, ["Bearer expired-image-key", "Bearer healthy-image-key"]); +}); + +test("provider-scoped image generation POST uses the shared 401 account fallback", async () => { + await seedConnection("openai", { apiKey: "provider-expired-key", priority: 1 }); + await seedConnection("openai", { apiKey: "provider-healthy-key", priority: 2 }); + const authorizationHeaders: string[] = []; + + globalThis.fetch = async (_url, options: RequestInit = {}) => { + const authorization = new Headers(options.headers).get("authorization") ?? ""; + authorizationHeaders.push(authorization); + if (authorization === "Bearer provider-expired-key") { + return new Response(JSON.stringify({ error: { message: "expired access token" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + return new Response( + JSON.stringify({ created: 123, data: [{ url: "https://cdn.example.com/provider.png" }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + const response = await providerImageRoute.POST( + new Request("http://localhost/api/v1/providers/openai/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "gpt-image-2", prompt: "provider route rotation" }), + }), + { params: Promise.resolve({ provider: "openai" }) } + ); + + assert.equal(response.status, 200); + assert.deepEqual(authorizationHeaders, [ + "Bearer provider-expired-key", + "Bearer provider-healthy-key", + ]); +}); + +test("v1 image generation POST refreshes an expired Antigravity token before dispatch", async () => { + await seedConnection("antigravity", { + authType: "oauth", + accessToken: "expired-antigravity-token", + refreshToken: "valid-antigravity-refresh-token", + expiresAt: new Date(Date.now() - 60_000).toISOString(), + projectId: "test-cloud-code-project", + }); + const calls: Array<{ url: string; authorization: string }> = []; + + globalThis.fetch = async (url, options: RequestInit = {}) => { + const stringUrl = String(url); + const authorization = new Headers(options.headers).get("authorization") ?? ""; + calls.push({ url: stringUrl, authorization }); + + if (stringUrl.includes("oauth2.googleapis.com/token")) { + return new Response( + JSON.stringify({ + access_token: "fresh-antigravity-token", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + assert.equal(stringUrl, "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent"); + assert.equal(authorization, "Bearer fresh-antigravity-token"); + return new Response( + JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: "image/jpeg", data: "ZnJlc2gtaW1hZ2U=" } }], + }, + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "antigravity/gemini-3.1-flash-image", + prompt: "refresh before image generation", + }), + }) + ); + const body = (await response.json()) as ImageResponseBody; + + assert.equal(response.status, 200); + assert.equal(body.data[0].b64_json, "ZnJlc2gtaW1hZ2U="); + assert.equal(calls.filter((call) => call.url.includes("oauth2.googleapis.com/token")).length, 1); +}); diff --git a/tests/unit/ipUtils.test.ts b/tests/unit/ipUtils.test.ts index cb1c83f63848..bf46bf502b31 100644 --- a/tests/unit/ipUtils.test.ts +++ b/tests/unit/ipUtils.test.ts @@ -36,9 +36,9 @@ describe("ipUtils — loopback-gated forwarding headers", () => { assert.equal(getClientIpFromRequest(req), "203.0.113.12"); }); - it("trusts CF-Connecting-IP when TCP peer is loopback", () => { + it("ignores CF-Connecting-IP even when TCP peer is loopback", () => { const req = makeReq({ "cf-connecting-ip": "203.0.113.13" }, "127.0.0.1"); - assert.equal(getClientIpFromRequest(req), "203.0.113.13"); + assert.equal(getClientIpFromRequest(req), "127.0.0.1"); }); it("ignores spoofed X-Forwarded-For when TCP peer is a public address", () => { @@ -53,11 +53,10 @@ describe("ipUtils — loopback-gated forwarding headers", () => { assert.equal(getClientIpFromRequest(req), "198.51.100.7"); }); - it("falls back to forwarding headers when no socket peer is known", () => { - // Edge runtime / fetch path where req.socket is absent — preserve prior - // behavior, otherwise we'd lose all IPs in that path. + it("uses the unknown bucket when no socket peer is known", () => { + // Socket-less requests need an authenticated stamp, not caller headers. const req = makeReq({ "x-forwarded-for": "203.0.113.20" }); - assert.equal(getClientIpFromRequest(req), "203.0.113.20"); + assert.equal(getClientIpFromRequest(req), "unknown"); }); it("returns loopback peer when no forwarding headers are present", () => { diff --git a/tests/unit/keepalive-threshold.test.ts b/tests/unit/keepalive-threshold.test.ts index a8973d2de929..d6cdbc912853 100644 --- a/tests/unit/keepalive-threshold.test.ts +++ b/tests/unit/keepalive-threshold.test.ts @@ -12,26 +12,26 @@ import { } from "../../open-sse/utils/keepaliveThreshold.ts"; describe("resolveKeepaliveThreshold", () => { - it("returns 2000ms default for undefined model", () => { - assert.equal(resolveKeepaliveThreshold(undefined), 2000); + it("returns the 4000ms default for undefined model", () => { + assert.equal(resolveKeepaliveThreshold(undefined), 4000); }); - it("returns 2000ms default for null model", () => { - assert.equal(resolveKeepaliveThreshold(null), 2000); + it("returns the 4000ms default for null model", () => { + assert.equal(resolveKeepaliveThreshold(null), 4000); }); - it("returns 2000ms default for empty string", () => { - assert.equal(resolveKeepaliveThreshold(""), 2000); + it("returns the 4000ms default for empty string", () => { + assert.equal(resolveKeepaliveThreshold(""), 4000); }); - it("returns 2000ms default for model without prefix", () => { - assert.equal(resolveKeepaliveThreshold("gpt-4"), 2000); + it("returns the 4000ms default for model without prefix", () => { + assert.equal(resolveKeepaliveThreshold("gpt-4"), 4000); }); - it("returns 2000ms default for normal API-key provider", () => { - assert.equal(resolveKeepaliveThreshold("openai/gpt-4"), 2000); - assert.equal(resolveKeepaliveThreshold("anthropic/claude-sonnet-4"), 2000); - assert.equal(resolveKeepaliveThreshold("deepseek/deepseek-chat"), 2000); + it("returns the 4000ms default for normal API-key provider", () => { + assert.equal(resolveKeepaliveThreshold("openai/gpt-4"), 4000); + assert.equal(resolveKeepaliveThreshold("anthropic/claude-sonnet-4"), 4000); + assert.equal(resolveKeepaliveThreshold("deepseek/deepseek-chat"), 4000); }); it("returns 15000ms for anonymous fallback provider (pollinations)", () => { @@ -73,3 +73,51 @@ describe("resolveKeepaliveThreshold", () => { assert.ok(!SLOW_KEEPALIVE_PROVIDERS.has("deepseek")); }); }); + +describe("resolveKeepaliveThreshold default threshold", () => { + const ENV_VAR = "OMNIROUTE_KEEPALIVE_THRESHOLD_MS"; + + function withEnv(value: string | undefined, fn: () => void) { + const previous = process.env[ENV_VAR]; + if (value === undefined) delete process.env[ENV_VAR]; + else process.env[ENV_VAR] = value; + try { + fn(); + } finally { + if (previous === undefined) delete process.env[ENV_VAR]; + else process.env[ENV_VAR] = previous; + } + } + + it("defaults to 4000ms so a failing handler can still set a real HTTP status", () => { + withEnv(undefined, () => { + assert.equal(resolveKeepaliveThreshold("codex/gpt-5.6-sol"), 4000); + assert.equal(resolveKeepaliveThreshold(undefined), 4000); + }); + }); + + it("honors the env override, including a revert to the historical 2000ms", () => { + withEnv("2000", () => { + assert.equal(resolveKeepaliveThreshold("codex/gpt-5.6-sol"), 2000); + }); + withEnv("0", () => { + assert.equal(resolveKeepaliveThreshold("openai/gpt-4"), 0); + }); + }); + + it("ignores unusable or unsafe overrides", () => { + // Above the reqwest idle-read timeout the keepalive exists to beat, negative, + // and non-numeric values all fall back to the built-in default. + for (const raw of ["9000", "-1", "abc", ""]) { + withEnv(raw, () => { + assert.equal(resolveKeepaliveThreshold("codex/gpt-5.6-sol"), 4000); + }); + } + }); + + it("keeps the slow-provider threshold independent of the override", () => { + withEnv("2000", () => { + assert.equal(resolveKeepaliveThreshold("chatgpt-web/gpt-5"), 15000); + }); + }); +}); diff --git a/tests/unit/kiro-interleaved-tool-results-8903.test.ts b/tests/unit/kiro-interleaved-tool-results-8903.test.ts new file mode 100644 index 000000000000..04963ffc196b --- /dev/null +++ b/tests/unit/kiro-interleaved-tool-results-8903.test.ts @@ -0,0 +1,406 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildKiroPayload } = await import("../../open-sse/translator/request/openai-to-kiro.ts"); + +const CREDENTIALS = { + accessToken: "test-token", + profileArn: "arn:aws:codewhisperer:us-east-1:000000000000:profile/TEST", + region: "us-east-1", +}; + +const PARALLEL_TOOL_CALLS = { + role: "assistant", + content: null, + tool_calls: [ + { id: "call_A", type: "function", function: { name: "list_files", arguments: "{}" } }, + { id: "call_B", type: "function", function: { name: "read_file", arguments: "{}" } }, + ], +}; + +function build(messages) { + return buildKiroPayload( + "claude-sonnet-4.5", + { model: "claude-sonnet-4.5", messages }, + false, + CREDENTIALS + ); +} + +/** + * Collect every toolUseId advertised by assistant turns and every toolUseId + * answered by a toolResult, across history plus currentMessage. + * + * Bedrock rejects a transcript where an assistant turn advertises toolUses + * that are never answered ("Expected toolResult blocks"), so these two sets + * must match. + */ +function collectToolIds(payload) { + const history = payload?.conversationState?.history ?? []; + const advertised = []; + const answered = []; + + for (const entry of history) { + const toolUses = entry?.assistantResponseMessage?.toolUses; + if (Array.isArray(toolUses)) { + for (const use of toolUses) advertised.push(use.toolUseId ?? use.id); + } + const toolResults = entry?.userInputMessage?.userInputMessageContext?.toolResults; + if (Array.isArray(toolResults)) { + for (const result of toolResults) answered.push(result.toolUseId); + } + } + + const currentResults = + payload?.conversationState?.currentMessage?.userInputMessage?.userInputMessageContext + ?.toolResults; + if (Array.isArray(currentResults)) { + for (const result of currentResults) answered.push(result.toolUseId); + } + + return { advertised, answered }; +} + +/** + * Flatten history + currentMessage into an ordered, easy-to-assert turn list. + * + * Tool-id assertions alone are not enough: a translator can keep every + * toolUseId paired and still silently delete assistant prose or reorder turns. + * These tests assert content and order too. + */ +function collectTurns(payload) { + const history = payload?.conversationState?.history ?? []; + const turns = history.map((entry) => { + if (entry?.userInputMessage) { + return { + role: "user", + content: String(entry.userInputMessage.content ?? ""), + toolResults: (entry.userInputMessage.userInputMessageContext?.toolResults ?? []).map( + (r) => r.toolUseId + ), + }; + } + return { + role: "assistant", + content: String(entry?.assistantResponseMessage?.content ?? ""), + toolUses: (entry?.assistantResponseMessage?.toolUses ?? []).map((u) => u.toolUseId ?? u.id), + }; + }); + + const current = payload?.conversationState?.currentMessage?.userInputMessage; + if (current) { + turns.push({ + role: "user", + current: true, + content: String(current.content ?? ""), + toolResults: (current.userInputMessageContext?.toolResults ?? []).map((r) => r.toolUseId), + }); + } + + return turns; +} + +function assistantContents(turns) { + return turns.filter((t) => t.role === "assistant").map((t) => t.content); +} + +// --- Characterization: shapes that already work must keep working ---------- + +test("kiro #8903: consecutive tool messages answer every parallel tool call", () => { + const payload = build([ + { role: "user", content: "list files then read one" }, + PARALLEL_TOOL_CALLS, + { role: "tool", tool_call_id: "call_A", content: "a.txt\nb.txt" }, + { role: "tool", tool_call_id: "call_B", content: "hello" }, + { role: "user", content: "thanks" }, + ]); + + const { advertised, answered } = collectToolIds(payload); + assert.deepEqual(advertised, ["call_A", "call_B"]); + assert.deepEqual(answered.sort(), ["call_A", "call_B"]); +}); + +test("kiro #8903: three parallel tool calls are all answered", () => { + const payload = build([ + { role: "user", content: "go" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: "c1", type: "function", function: { name: "f1", arguments: "{}" } }, + { id: "c2", type: "function", function: { name: "f2", arguments: "{}" } }, + { id: "c3", type: "function", function: { name: "f3", arguments: "{}" } }, + ], + }, + { role: "tool", tool_call_id: "c1", content: "r1" }, + { role: "tool", tool_call_id: "c2", content: "r2" }, + { role: "tool", tool_call_id: "c3", content: "r3" }, + { role: "user", content: "next" }, + ]); + + const { advertised, answered } = collectToolIds(payload); + assert.deepEqual(advertised.sort(), ["c1", "c2", "c3"]); + assert.deepEqual(answered.sort(), ["c1", "c2", "c3"]); +}); + +test("kiro #8903: two sequential rounds of parallel tool calls are all answered", () => { + const payload = build([ + { role: "user", content: "go" }, + PARALLEL_TOOL_CALLS, + { role: "tool", tool_call_id: "call_A", content: "r1" }, + { role: "tool", tool_call_id: "call_B", content: "r2" }, + { + role: "assistant", + content: null, + tool_calls: [{ id: "call_C", type: "function", function: { name: "grep", arguments: "{}" } }], + }, + { role: "tool", tool_call_id: "call_C", content: "r3" }, + { role: "user", content: "done" }, + ]); + + const { advertised, answered } = collectToolIds(payload); + assert.deepEqual(advertised.sort(), ["call_A", "call_B", "call_C"]); + assert.deepEqual(answered.sort(), ["call_A", "call_B", "call_C"]); +}); + +test("kiro #8903: transcript ending on tool results still answers every tool call", () => { + const payload = build([ + { role: "user", content: "go" }, + PARALLEL_TOOL_CALLS, + { role: "tool", tool_call_id: "call_A", content: "r1" }, + { role: "tool", tool_call_id: "call_B", content: "r2" }, + ]); + + const { advertised, answered } = collectToolIds(payload); + assert.deepEqual(advertised, ["call_A", "call_B"]); + assert.deepEqual(answered.sort(), ["call_A", "call_B"]); +}); + +test("kiro #8903: structured array tool content is answered for every tool call", () => { + const payload = build([ + { role: "user", content: "go" }, + PARALLEL_TOOL_CALLS, + { role: "tool", tool_call_id: "call_A", content: [{ type: "text", text: "r1" }] }, + { role: "tool", tool_call_id: "call_B", content: [{ type: "text", text: "r2" }] }, + { role: "user", content: "done" }, + ]); + + const { advertised, answered } = collectToolIds(payload); + assert.deepEqual(advertised, ["call_A", "call_B"]); + assert.deepEqual(answered.sort(), ["call_A", "call_B"]); +}); + +// --- RED: interleaved assistant text drops the trailing tool result -------- + +test("kiro #8903: assistant text between tool results does not drop a tool result", () => { + const payload = build([ + { role: "user", content: "list files then read one" }, + PARALLEL_TOOL_CALLS, + { role: "tool", tool_call_id: "call_A", content: "a.txt\nb.txt" }, + { role: "assistant", content: "Let me check that file." }, + { role: "tool", tool_call_id: "call_B", content: "hello" }, + { role: "user", content: "thanks" }, + ]); + + const { advertised, answered } = collectToolIds(payload); + assert.deepEqual(advertised, ["call_A", "call_B"]); + assert.deepEqual( + answered.sort(), + ["call_A", "call_B"], + "every advertised toolUse must have a matching toolResult; Bedrock rejects the transcript otherwise" + ); +}); + +// --- Content + order: grouping must not be paid for with lost assistant text - + +test("kiro #8903 probe A: a final text-only assistant reply survives a tool result", () => { + const payload = build([ + { role: "user", content: "what is the weather" }, + { + role: "assistant", + content: null, + tool_calls: [{ id: "call_A", type: "function", function: { name: "wx", arguments: "{}" } }], + }, + { role: "tool", tool_call_id: "call_A", content: "sunny" }, + { role: "assistant", content: "It is sunny. THIS_TEXT_MUST_SURVIVE" }, + ]); + + const turns = collectTurns(payload); + assert.ok( + assistantContents(turns).some((c) => c.includes("THIS_TEXT_MUST_SURVIVE")), + `the final assistant reply must not be dropped; got ${JSON.stringify(turns)}` + ); + + // Order: the reply belongs after the turn carrying call_A's result. + const resultIdx = turns.findIndex((t) => (t.toolResults ?? []).includes("call_A")); + const replyIdx = turns.findIndex((t) => t.content.includes("THIS_TEXT_MUST_SURVIVE")); + assert.ok(resultIdx >= 0, "call_A's toolResult must be present"); + assert.ok(replyIdx > resultIdx, "the assistant reply must come after the tool result turn"); +}); + +test("kiro #8903 probe C: a mid-conversation assistant answer survives a later user turn", () => { + const payload = build([ + { role: "user", content: "q1" }, + { + role: "assistant", + content: null, + tool_calls: [{ id: "call_A", type: "function", function: { name: "a", arguments: "{}" } }], + }, + { role: "tool", tool_call_id: "call_A", content: "res A" }, + { role: "assistant", content: "ANSWER_TURN_1" }, + { role: "user", content: "q2" }, + ]); + + const turns = collectTurns(payload); + assert.ok( + assistantContents(turns).some((c) => c.includes("ANSWER_TURN_1")), + `the previous turn's assistant answer must not be erased; got ${JSON.stringify(turns)}` + ); + + const answerIdx = turns.findIndex((t) => t.content.includes("ANSWER_TURN_1")); + const q2Idx = turns.findIndex((t) => t.current); + assert.ok(q2Idx > answerIdx, "the new user question must come after the previous answer"); + assert.ok( + turns[q2Idx].content.includes("q2"), + "the new user question must be the current message" + ); +}); + +test("kiro #8903 probe B: deferred assistant text survives AND the tool batch stays grouped", () => { + const payload = build([ + { role: "user", content: "check two things" }, + PARALLEL_TOOL_CALLS, + { role: "tool", tool_call_id: "call_A", content: "res A" }, + { role: "assistant", content: "DEFERRED_TEXT_HERE" }, + { role: "tool", tool_call_id: "call_B", content: "res B" }, + { role: "user", content: "thanks" }, + ]); + + const turns = collectTurns(payload); + + // 1. grouping: both results answered from a single turn + const batchTurn = turns.find((t) => (t.toolResults ?? []).length > 0); + assert.ok(batchTurn, "a turn carrying toolResults must exist"); + assert.deepEqual( + [...batchTurn.toolResults].sort(), + ["call_A", "call_B"], + "call_A and call_B must stay in one toolResults batch" + ); + + // 2. no data loss: the interleaved text is still in the transcript + assert.ok( + assistantContents(turns).some((c) => c.includes("DEFERRED_TEXT_HERE")), + `interleaved assistant text must not be dropped; got ${JSON.stringify(turns)}` + ); + + // 3. order: text after the batch, final user question last + const batchIdx = turns.indexOf(batchTurn); + const textIdx = turns.findIndex((t) => t.content.includes("DEFERRED_TEXT_HERE")); + const currentIdx = turns.findIndex((t) => t.current); + assert.ok(textIdx > batchIdx, "the deferred text must be emitted after the tool batch"); + assert.ok(currentIdx > textIdx, "the final user turn must come last"); + assert.ok(turns[currentIdx].content.includes("thanks")); + + // 4. the tool results must not have leaked into user prose + assert.ok( + !turns[currentIdx].content.includes("res B"), + "call_B's result must be a toolResult, not stuffed into user text" + ); +}); + +test("kiro #8903: mixed Anthropic tool_result and text keeps the batch grouped", () => { + const payload = build([ + { role: "user", content: "check two things" }, + PARALLEL_TOOL_CALLS, + { role: "tool", tool_call_id: "call_A", content: "res A" }, + { role: "assistant", content: "DEFERRED_BEFORE_MIXED_RESULT" }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "call_B", content: "res B" }, + { type: "text", text: "USER_TEXT_AFTER_GROUPED_RESULTS" }, + ], + }, + { role: "user", content: "next question" }, + ]); + + const { advertised, answered } = collectToolIds(payload); + assert.deepEqual(advertised, ["call_A", "call_B"]); + assert.deepEqual(answered.sort(), ["call_A", "call_B"]); + + const turns = collectTurns(payload); + const batchIdx = turns.findIndex( + (turn) => JSON.stringify((turn.toolResults ?? []).toSorted()) === '["call_A","call_B"]' + ); + const assistantIdx = turns.findIndex((turn) => + turn.content.includes("DEFERRED_BEFORE_MIXED_RESULT") + ); + const userTextIdx = turns.findIndex((turn) => + turn.content.includes("USER_TEXT_AFTER_GROUPED_RESULTS") + ); + + assert.ok(batchIdx >= 0, "both tool results must remain in one Kiro user turn"); + assert.ok(assistantIdx > batchIdx, "interleaved assistant prose follows the grouped results"); + assert.ok(userTextIdx > assistantIdx, "the mixed message's user text keeps its original order"); + assert.match(turns[userTextIdx].content, /next question/); +}); + +test("kiro #8903: assistant text is preserved on an ordinary non-tool transcript", () => { + const payload = build([ + { role: "user", content: "hi" }, + { role: "assistant", content: "PLAIN_REPLY" }, + { role: "user", content: "again" }, + ]); + + const turns = collectTurns(payload); + assert.deepEqual(assistantContents(turns), ["PLAIN_REPLY"]); +}); + +test("kiro #8903: interleaved tool batch preserves the fork JSON response contract", () => { + const payload = buildKiroPayload( + "claude-sonnet-4.5", + { + model: "claude-sonnet-4.5", + messages: [ + { role: "user", content: "check two things" }, + PARALLEL_TOOL_CALLS, + { role: "tool", tool_call_id: "call_A", content: "res A" }, + { role: "assistant", content: "DEFERRED_JSON_TEXT" }, + { role: "tool", tool_call_id: "call_B", content: "res B" }, + { role: "user", content: "return the verdict" }, + ], + response_format: { + type: "json_schema", + json_schema: { + name: "verdict", + strict: true, + schema: { + type: "object", + additionalProperties: false, + required: ["verdict"], + properties: { verdict: { type: "string" } }, + }, + }, + }, + }, + false, + CREDENTIALS + ); + + const turns = collectTurns(payload); + const batchTurn = turns.find((turn) => (turn.toolResults ?? []).length > 0); + assert.ok(batchTurn, "the grouped tool-result turn must survive"); + assert.deepEqual([...batchTurn.toolResults].sort(), ["call_A", "call_B"]); + + const deferredIdx = turns.findIndex((turn) => turn.content.includes("DEFERRED_JSON_TEXT")); + const currentIdx = turns.findIndex((turn) => turn.current); + assert.ok(deferredIdx > turns.indexOf(batchTurn), "deferred prose follows the grouped results"); + assert.ok(currentIdx > deferredIdx, "the JSON-constrained user turn remains current"); + + const currentContent = turns[currentIdx].content; + assert.match(currentContent, /return the verdict/); + assert.match(currentContent, /json<\/response_format>/); + assert.match(currentContent, /You must respond with valid JSON that strictly follows/); + assert.match(currentContent, /no markdown code fences/); + assert.ok(currentContent.trimEnd().endsWith("
")); +}); diff --git a/tests/unit/kiro-json-fence.test.ts b/tests/unit/kiro-json-fence.test.ts new file mode 100644 index 000000000000..a4f0d1afb214 --- /dev/null +++ b/tests/unit/kiro-json-fence.test.ts @@ -0,0 +1,189 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { stripJsonFenceFromSse, kiroPayloadWantsJsonOnly } = + await import("../../open-sse/executors/kiro/jsonFence.ts"); +const { buildKiroPayload } = await import("../../open-sse/translator/request/openai-to-kiro.ts"); + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function sseChunk(content: string): string { + return `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 1, + model: "kr/claude-haiku-4.5", + choices: [{ index: 0, delta: { content }, finish_reason: null }], + })}\n\n`; +} + +const FINISH_LINE = `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 1, + model: "kr/claude-haiku-4.5", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], +})}\n\n`; + +/** Run raw SSE text through the stripper, in the given byte splits. */ +async function run(parts: string[]): Promise { + const source = new ReadableStream({ + start(controller) { + for (const part of parts) controller.enqueue(encoder.encode(part)); + controller.close(); + }, + }); + + const reader = stripJsonFenceFromSse(source).getReader(); + let out = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + out += decoder.decode(value, { stream: true }); + } + return out; +} + +/** The content a client would assemble from the resulting stream. */ +function assembledContent(sse: string): string { + let content = ""; + for (const line of sse.split("\n")) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6); + if (payload === "[DONE]") continue; + const delta = JSON.parse(payload).choices?.[0]?.delta; + if (typeof delta?.content === "string") content += delta.content; + } + return content; +} + +const OBJECT = '{\n "residual_gap_score": 4,\n "evidence_kind": "explicit"\n}'; + +test("a fenced JSON reply is unwrapped into parseable content", async () => { + const sse = await run([ + sseChunk("```json\n"), + sseChunk(OBJECT), + sseChunk("\n```"), + FINISH_LINE, + "data: [DONE]\n\n", + ]); + + const content = assembledContent(sse); + assert.equal(content, OBJECT); + assert.deepEqual(JSON.parse(content), { residual_gap_score: 4, evidence_kind: "explicit" }); +}); + +test("the fence survives being split across chunk boundaries", async () => { + const sse = await run([ + sseChunk("``"), + sseChunk("`js"), + sseChunk("on\n{"), + sseChunk('"a":1}\n`'), + sseChunk("``"), + FINISH_LINE, + ]); + + assert.equal(assembledContent(sse), '{"a":1}'); +}); + +test("a bare fence with no language tag is unwrapped too", async () => { + const sse = await run([sseChunk('```\n{"a":1}\n```'), FINISH_LINE]); + assert.equal(assembledContent(sse), '{"a":1}'); +}); + +test("an unfenced reply passes through byte for byte", async () => { + const sse = await run([sseChunk('{"a":1,\n'), sseChunk('"b":2}'), FINISH_LINE]); + assert.equal(assembledContent(sse), '{"a":1,\n"b":2}'); +}); + +test("backticks inside a JSON string are not mistaken for a fence", async () => { + const value = '{"note":"use ``` for code","a":1}'; + const sse = await run([sseChunk(value), FINISH_LINE]); + assert.equal(assembledContent(sse), value); +}); + +test("held-back content is released before the finishing chunk", async () => { + const sse = await run([sseChunk('```json\n{"a":1}\n'), sseChunk("```"), FINISH_LINE]); + + const dataLines = sse.split("\n").filter((l) => l.startsWith("data: ")); + const finishIndex = dataLines.findIndex((l) => JSON.parse(l.slice(6)).choices[0].finish_reason); + assert.ok(finishIndex >= 0, "finishing chunk must still be present"); + + const before = dataLines + .slice(0, finishIndex) + .map((l) => JSON.parse(l.slice(6)).choices[0].delta.content ?? "") + .join(""); + assert.equal(before, '{"a":1}'); +}); + +test("a stream that ends without a finishing chunk still releases its tail", async () => { + const sse = await run([sseChunk('```json\n{"a":1}\n```')]); + assert.equal(assembledContent(sse), '{"a":1}'); +}); + +test("tool-call and reasoning deltas are left alone", async () => { + const toolLine = `data: ${JSON.stringify({ + id: "chatcmpl-1", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: 0, function: { arguments: "```" } }] }, + finish_reason: null, + }, + ], + })}\n\n`; + const reasoningLine = `data: ${JSON.stringify({ + id: "chatcmpl-1", + choices: [{ index: 0, delta: { reasoning_content: "```thinking" }, finish_reason: null }], + })}\n\n`; + + const sse = await run([toolLine, reasoningLine, FINISH_LINE]); + assert.ok(sse.includes('"arguments":"```"')); + assert.ok(sse.includes('"reasoning_content":"```thinking"')); +}); + +test("non-data lines and unparseable payloads pass through untouched", async () => { + const sse = await run([": keepalive\n\n", "data: not-json\n\n", FINISH_LINE]); + assert.ok(sse.includes(": keepalive")); + assert.ok(sse.includes("data: not-json")); +}); + +/** + * The executor is handed the already-translated Kiro payload, not the OpenAI + * request, so this decision must be made against real translator output — a + * check written against `{ response_format }` compiles, passes, and never fires + * in production. + */ +function payloadFor(responseFormat?: unknown): unknown { + return buildKiroPayload( + "claude-haiku-4.5", + { + messages: [{ role: "user", content: "Score this candidate." }], + ...(responseFormat === undefined ? {} : { response_format: responseFormat }), + }, + false, + {} + ); +} + +test("a translated payload built from a JSON response_format is detected", () => { + const schema = { type: "object", properties: { a: { type: "integer" } } }; + assert.equal( + kiroPayloadWantsJsonOnly(payloadFor({ type: "json_schema", json_schema: { schema } })), + true + ); + assert.equal(kiroPayloadWantsJsonOnly(payloadFor({ type: "json_object" })), true); +}); + +test("a translated payload without a JSON contract is not detected", () => { + for (const format of [undefined, { type: "text" }, { type: "json_schema" }]) { + assert.equal(kiroPayloadWantsJsonOnly(payloadFor(format)), false, JSON.stringify(format)); + } +}); + +test("kiroPayloadWantsJsonOnly tolerates malformed payloads", () => { + for (const value of [null, undefined, {}, { conversationState: {} }, "payload", 42]) { + assert.equal(kiroPayloadWantsJsonOnly(value), false, JSON.stringify(value)); + } +}); diff --git a/tests/unit/kiro-response-format.test.ts b/tests/unit/kiro-response-format.test.ts new file mode 100644 index 000000000000..294ccc3b91bc --- /dev/null +++ b/tests/unit/kiro-response-format.test.ts @@ -0,0 +1,111 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildKiroPayload } = await import("../../open-sse/translator/request/openai-to-kiro.ts"); +const { buildKiroJsonFormatInstruction } = + await import("../../open-sse/translator/request/openai-to-kiro/responseFormat.ts"); + +/** + * Kiro has no Structured Output request parameter, so `response_format` was + * dropped silently and strict-schema callers got invented property names back. + * The contract now rides in the prompt instead. + */ + +const RESIDUAL_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["rows", "verdict"], + properties: { + rows: { + type: "array", + items: { $ref: "#/$defs/row" }, + }, + verdict: { type: "string", enum: ["pass", "fail"] }, + }, + $defs: { + row: { + type: "object", + additionalProperties: false, + required: ["evidence_kind"], + properties: { evidence_kind: { type: "string", const: "explicit" } }, + }, + }, +}; + +function contentFor(responseFormat?: unknown): string { + const payload = buildKiroPayload( + "claude-haiku-4.5", + { + messages: [ + { role: "system", content: "You are a judge." }, + { role: "user", content: "Score this candidate." }, + ], + ...(responseFormat === undefined ? {} : { response_format: responseFormat }), + }, + false, + {} + ) as { + conversationState: { currentMessage: { userInputMessage: { content: string } } }; + }; + return payload.conversationState.currentMessage.userInputMessage.content; +} + +test("json_schema response_format reaches the model as a prompt contract", () => { + const content = contentFor({ + type: "json_schema", + json_schema: { name: "residual", strict: true, schema: RESIDUAL_SCHEMA }, + }); + + assert.match(content, /You must respond with valid JSON that strictly follows this JSON schema/); + assert.match(content, /[\s\S]*evidence_kind[\s\S]*<\/system-reminder>/); + assert.match(content, /no markdown code fences/); + // The user's own turn survives untouched alongside the contract. + assert.match(content, /Score this candidate\./); +}); + +test("the embedded schema is verbatim, including strict-only keywords", () => { + const content = contentFor({ + type: "json_schema", + json_schema: { name: "residual", strict: true, schema: RESIDUAL_SCHEMA }, + }); + + // kiroSanitizer strips additionalProperties/$ref/$defs from *tool* schemas — + // a prompt-embedded schema must not go through it or the contract loses the + // very keywords that make it strict. + assert.ok(content.includes(JSON.stringify(RESIDUAL_SCHEMA, null, 2))); +}); + +test("the contract is appended last, after the user turn", () => { + const content = contentFor({ + type: "json_schema", + json_schema: { schema: RESIDUAL_SCHEMA }, + }); + + const userTurn = content.indexOf("Score this candidate."); + const contract = content.indexOf("You must respond with valid JSON"); + assert.ok(userTurn >= 0 && contract > userTurn, "contract must follow the user turn"); + assert.ok(content.trimEnd().endsWith("")); +}); + +test("json_object asks for JSON without inventing a schema block", () => { + const content = contentFor({ type: "json_object" }); + + assert.match(content, /You must respond with valid JSON\. Respond with the JSON value only/); + assert.ok(!content.includes("follows this JSON schema")); +}); + +test("requests without a JSON contract are left alone", () => { + for (const format of [undefined, { type: "text" }, { type: "json_schema" }]) { + const content = contentFor(format); + assert.ok( + !content.includes("You must respond with valid JSON"), + `unexpected contract for ${JSON.stringify(format)}` + ); + } +}); + +test("buildKiroJsonFormatInstruction rejects non-format values", () => { + for (const value of [null, undefined, "json_object", 42, [{ type: "json_object" }]]) { + assert.equal(buildKiroJsonFormatInstruction(value), null); + } +}); diff --git a/tests/unit/live-stt-session.test.ts b/tests/unit/live-stt-session.test.ts new file mode 100644 index 000000000000..23f039590dab --- /dev/null +++ b/tests/unit/live-stt-session.test.ts @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +process.env.LIVE_STT_TOKEN_SECRET = "test-live-stt-session-secret-0123456789abcdef"; +process.env.LIVE_STT_SESSION_TTL_SECONDS = "60"; + +const { createLiveSttSession, consumeLiveSttSession, resetLiveSttSessionsForTests } = + await import("../../src/lib/liveStt/session.ts"); + +test.afterEach(() => resetLiveSttSessionsForTests()); + +test("live STT session is origin-bound and one-time", async () => { + const input = { + apiKeyId: "key-1", + connectionId: "connection-1", + origin: "https://router.example.test", + language: "ru-RU", + model: "general" as const, + }; + const session = await createLiveSttSession(input); + + const consumed = await consumeLiveSttSession(session.token, input.origin); + assert.equal(consumed.connectionId, input.connectionId); + assert.equal(consumed.apiKeyId, input.apiKeyId); + assert.equal(consumed.language, input.language); + assert.equal(consumed.model, input.model); + + await assert.rejects(() => consumeLiveSttSession(session.token, input.origin)); +}); + +test("live STT session rejects an origin mismatch without consuming the token", async () => { + const session = await createLiveSttSession({ + apiKeyId: null, + connectionId: "connection-1", + origin: "https://router.example.test", + language: "ru-RU", + model: "callcenter", + }); + + await assert.rejects(() => consumeLiveSttSession(session.token, "https://attacker.example.test")); + const consumed = await consumeLiveSttSession(session.token, "https://router.example.test"); + assert.equal(consumed.model, "callcenter"); +}); + +test("live STT session does not fall back to the dashboard JWT secret", async () => { + const liveSttSecret = process.env.LIVE_STT_TOKEN_SECRET; + process.env.LIVE_STT_TOKEN_SECRET = ""; + process.env.JWT_SECRET = "dashboard-jwt-secret-0123456789abcdef"; + + try { + await assert.rejects(() => + createLiveSttSession({ + apiKeyId: null, + connectionId: "connection-1", + origin: "https://router.example.test", + language: "ru-RU", + model: "general", + }) + ); + } finally { + process.env.LIVE_STT_TOKEN_SECRET = liveSttSecret; + } +}); diff --git a/tests/unit/live-stt-standalone-wiring.test.ts b/tests/unit/live-stt-standalone-wiring.test.ts new file mode 100644 index 000000000000..52edbc6c5216 --- /dev/null +++ b/tests/unit/live-stt-standalone-wiring.test.ts @@ -0,0 +1,21 @@ +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"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +function read(relativePath: string): string { + return fs.readFileSync(path.join(root, relativePath), "utf8"); +} + +test("live STT sidecar is started and proxied by the standalone server", () => { + const instrumentation = read("src/instrumentation-node.ts"); + const standaloneWrapper = read("scripts/dev/standalone-server-ws.mjs"); + + assert.match(instrumentation, /import\("@\/server\/liveStt\/server"\)/); + assert.match(standaloneWrapper, /LIVE_STT_PATH\s*=\s*"\/v1\/audio\/transcriptions\/live"/); + assert.match(standaloneWrapper, /url\.pathname\s*===\s*LIVE_STT_PATH/); + assert.match(standaloneWrapper, /proxyLiveSttWs\(req, socket, head\)/); +}); diff --git a/tests/unit/memory-extraction.test.ts b/tests/unit/memory-extraction.test.ts index 6cd57c5c8663..e356041b894c 100644 --- a/tests/unit/memory-extraction.test.ts +++ b/tests/unit/memory-extraction.test.ts @@ -1,7 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { extractFactsFromText, extractFacts } = await import("../../src/lib/memory/extraction.ts"); +const { extractFactsFromText, extractFacts, MemoryExtractionQueue } = + await import("../../src/lib/memory/extraction.ts"); // ─── extractFactsFromText: Preferences ───────────────────────────────────── @@ -168,6 +169,65 @@ test("extractFacts: does not throw on empty inputs", () => { assert.doesNotThrow(() => extractFacts(null, "key-123", "session-456")); }); +test("MemoryExtractionQueue: limits concurrent extraction jobs", async () => { + const scheduled: Array<() => void> = []; + const queue = new MemoryExtractionQueue(1, 10, (callback) => scheduled.push(callback)); + let active = 0; + let peakActive = 0; + const releases: Array<() => void> = []; + + for (let index = 0; index < 3; index += 1) { + assert.equal( + queue.enqueue( + () => + new Promise((resolve) => { + active += 1; + peakActive = Math.max(peakActive, active); + releases.push(() => { + active -= 1; + resolve(); + }); + }) + ), + true + ); + } + + scheduled.shift()?.(); + assert.deepEqual(queue.stats(), { active: 1, queued: 2, concurrency: 1, maxQueue: 10 }); + assert.equal(peakActive, 1); + + releases.shift()?.(); + await new Promise((resolve) => setImmediate(resolve)); + scheduled.shift()?.(); + assert.deepEqual(queue.stats(), { active: 1, queued: 1, concurrency: 1, maxQueue: 10 }); + assert.equal(peakActive, 1); + + releases.shift()?.(); + await new Promise((resolve) => setImmediate(resolve)); + scheduled.shift()?.(); + assert.deepEqual(queue.stats(), { active: 1, queued: 0, concurrency: 1, maxQueue: 10 }); + assert.equal(peakActive, 1); + + releases.shift()?.(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(queue.stats(), { active: 0, queued: 0, concurrency: 1, maxQueue: 10 }); +}); + +test("MemoryExtractionQueue: rejects jobs above its bounded backlog", () => { + const queue = new MemoryExtractionQueue(1, 1, () => undefined); + + assert.equal( + queue.enqueue(async () => undefined), + true + ); + assert.equal( + queue.enqueue(async () => undefined), + false + ); + assert.deepEqual(queue.stats(), { active: 0, queued: 1, concurrency: 1, maxQueue: 1 }); +}); + test("extractFactsFromText scans only the bounded tail of very large text", () => { const text = "I prefer prefix-only-editor. " + "x".repeat(70 * 1024) + " I prefer tail-only-editor."; diff --git a/tests/unit/middleware-header-strip-5849.test.ts b/tests/unit/middleware-header-strip-5849.test.ts index 3e087c77bbfa..9fe275c6e7c5 100644 --- a/tests/unit/middleware-header-strip-5849.test.ts +++ b/tests/unit/middleware-header-strip-5849.test.ts @@ -3,7 +3,9 @@ import { test } from "node:test"; import { buildStreamingResponseHeaders, + isCodexAccountQuotaHeader, isNextMiddlewareControlHeader, + stripCodexAccountQuotaHeaders, stripNextMiddlewareControlHeaders, } from "@omniroute/open-sse/handlers/chatCore/responseHeaders.ts"; @@ -65,3 +67,37 @@ test("non-streaming JSON path: stripNextMiddlewareControlHeaders removes the fam assert.equal(headers.get("x-request-id"), "req-456"); assert.equal(headers.get("content-type"), "application/json"); }); + +test("Codex account quota headers are not forwarded to multi-account clients", () => { + const quotaHeaders = [ + "x-codex-primary-used-percent", + "x-codex-primary-window-minutes", + "x-codex-primary-reset-at", + "x-codex-secondary-used-percent", + "x-codex-credits-balance", + "x-codex-plan-type", + "x-codex-rate-limit-reached-type", + ]; + const upstream = new Headers({ + "x-codex-turn-state": "protocol-state", + "x-request-id": "req-codex-quota", + }); + for (const name of quotaHeaders) upstream.set(name, "1"); + + const streaming = buildStreamingResponseHeaders(upstream, {}); + for (const name of quotaHeaders) { + assert.equal(isCodexAccountQuotaHeader(name), true, name); + assert.equal( + Object.keys(streaming).some((key) => key.toLowerCase() === name), + false, + `${name} must not describe the selected backend account to the client` + ); + } + assert.equal(streaming["x-codex-turn-state"], "protocol-state"); + assert.equal(streaming["x-request-id"], "req-codex-quota"); + + stripCodexAccountQuotaHeaders(upstream); + for (const name of quotaHeaders) assert.equal(upstream.get(name), null, name); + assert.equal(upstream.get("x-codex-turn-state"), "protocol-state"); + assert.equal(upstream.get("x-request-id"), "req-codex-quota"); +}); diff --git a/tests/unit/minimax-media-servicekinds.test.ts b/tests/unit/minimax-media-servicekinds.test.ts index 6eecb2a1c256..c50e8db3f603 100644 --- a/tests/unit/minimax-media-servicekinds.test.ts +++ b/tests/unit/minimax-media-servicekinds.test.ts @@ -30,6 +30,7 @@ test("representative media providers derive the expected kinds", () => { // Each pair: provider id -> at least these kinds must be derived. const cases: Record = { elevenlabs: ["tts"], + "inference-sh": ["tts"], deepgram: ["stt", "tts"], suno: ["music"], udio: ["music"], @@ -40,7 +41,10 @@ test("representative media providers derive the expected kinds", () => { for (const [id, expected] of Object.entries(cases)) { const derived = getRegistryMediaKinds(id); for (const kind of expected) { - assert.ok(derived.includes(kind as never), `${id} should derive ${kind}; got ${derived.join(",")}`); + assert.ok( + derived.includes(kind as never), + `${id} should derive ${kind}; got ${derived.join(",")}` + ); } } }); @@ -72,13 +76,19 @@ test("media listing filter surfaces minimax where the old declared-only filter m .map((p) => p.id); for (const kind of ["tts", "video", "music"]) { - assert.ok(!oldListFor(kind).includes("minimax"), `precondition (bug): old filter missed minimax under ${kind}`); + assert.ok( + !oldListFor(kind).includes("minimax"), + `precondition (bug): old filter missed minimax under ${kind}` + ); assert.ok(newListFor(kind).includes("minimax"), `fix: minimax now listed under ${kind}`); assert.ok(!newListFor(kind).includes("minimax-cn"), `minimax-cn must not appear under ${kind}`); } // The fix is systemic, not minimax-only: many providers were invisible before. - assert.ok(oldListFor("tts").length < newListFor("tts").length, "fix surfaces additional tts providers"); + assert.ok( + oldListFor("tts").length < newListFor("tts").length, + "fix surfaces additional tts providers" + ); }); test("ocr is a registry-backed media kind and mistral derives it", () => { diff --git a/tests/unit/model-context-override-readpath.test.ts b/tests/unit/model-context-override-readpath.test.ts index e9aef3653885..0de1076d17bd 100644 --- a/tests/unit/model-context-override-readpath.test.ts +++ b/tests/unit/model-context-override-readpath.test.ts @@ -28,8 +28,10 @@ after(() => { describe("getModelContextLimit override precedence (5004)", () => { it("an override wins over the catalog, and removing it falls back to the catalog", () => { // Read the override-free catalog value dynamically (non-brittle for any model). - const catalog = caps.getResolvedModelCapabilities({ provider: "openai", model: "gpt-4o" }) - .contextWindow; + const catalog = caps.getResolvedModelCapabilities({ + provider: "openai", + model: "gpt-4o", + }).contextWindow; const distinct = (catalog ?? 0) + 12345; mco.setModelContextOverride("openai", "gpt-4o", distinct); @@ -51,9 +53,28 @@ describe("getModelContextLimit override precedence (5004)", () => { it("leaves getResolvedModelCapabilities override-free (so the reconciler sees the catalog)", () => { mco.setModelContextOverride("openai", "gpt-4o", 999999, "auto:discovery"); - const catalog = caps.getResolvedModelCapabilities({ provider: "openai", model: "gpt-4o" }) - .contextWindow; + const catalog = caps.getResolvedModelCapabilities({ + provider: "openai", + model: "gpt-4o", + }).contextWindow; assert.notEqual(catalog, 999999, "getResolvedModelCapabilities must not reflect the override"); - assert.equal(caps.getModelContextLimit("openai", "gpt-4o"), 999999, "but getModelContextLimit does"); + assert.equal( + caps.getModelContextLimit("openai", "gpt-4o"), + 999999, + "but getModelContextLimit does" + ); }); }); + +for (const model of ["gpt-5.5-high", "gpt-5.5-xhigh"]) { + it(`inherits base context override for ${model} through provider alias`, () => { + mco.setModelContextOverride("codex", "gpt-5.5", 900_000); + assert.equal(caps.getModelContextLimit("cx", model), 900_000); + mco.setModelContextOverride("codex", model, 800_000); + assert.equal(caps.getModelContextLimit("cx", model), 800_000); + assert.notEqual( + caps.getResolvedModelCapabilities({ provider: "codex", model }).contextWindow, + 800_000 + ); + }); +} diff --git a/tests/unit/oauth-connection-persistence-codex-dedup.test.ts b/tests/unit/oauth-connection-persistence-codex-dedup.test.ts new file mode 100644 index 000000000000..21ffc7c621af --- /dev/null +++ b/tests/unit/oauth-connection-persistence-codex-dedup.test.ts @@ -0,0 +1,172 @@ +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-codex-dedup-")); +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 { findExistingOAuthConnectionMatch, persistOAuthConnection } = + await import("../../src/lib/oauth/connectionPersistence.ts"); + +async function resetStorage(): Promise { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt += 1) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException)?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(resetStorage); +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +const personalConnection = { + id: "personal-a", + email: "shared@example.com", + authType: "oauth", + providerSpecificData: { chatgptUserId: "user-a" }, +}; + +test("Codex OAuth persists personal accounts with the same email separately", async () => { + const accountA = await persistOAuthConnection("codex", { + email: "shared@example.com", + accessToken: "token-account-a", + refreshToken: "refresh-account-a", + expiresIn: 3600, + providerSpecificData: { chatgptUserId: "user-a" }, + }); + const accountB = await persistOAuthConnection("codex", { + email: "shared@example.com", + accessToken: "token-account-b", + refreshToken: "refresh-account-b", + expiresIn: 3600, + providerSpecificData: { chatgptUserId: "user-b" }, + }); + + const rows = await providersDb.getProviderConnections({ provider: "codex" }); + assert.notEqual(accountB.id, accountA.id); + assert.equal(rows.length, 2); + assert.equal( + rows.find((row: { id: string }) => row.id === accountA.id)?.accessToken, + "token-account-a" + ); +}); + +test("Codex OAuth updates the same personal account by chatgptUserId", async () => { + const first = await persistOAuthConnection("codex", { + email: "solo@example.com", + accessToken: "token-first", + refreshToken: "refresh-first", + expiresIn: 3600, + providerSpecificData: { chatgptUserId: "user-solo" }, + }); + const second = await persistOAuthConnection("codex", { + email: "solo@example.com", + accessToken: "token-second", + refreshToken: "refresh-second", + expiresIn: 3600, + providerSpecificData: { chatgptUserId: "user-solo" }, + }); + + const rows = await providersDb.getProviderConnections({ provider: "codex" }); + assert.equal(second.id, first.id); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.accessToken, "token-second"); +}); + +test("public ticket mode never matches claims implicitly but honors a bound connectionId", async () => { + const identity = { + email: "public@example.com", + refreshToken: "refresh-public", + expiresIn: 3600, + providerSpecificData: { chatgptUserId: "user-public" }, + }; + const existing = await persistOAuthConnection("codex", { + ...identity, + accessToken: "token-existing", + }); + const created = await persistOAuthConnection( + "codex", + { ...identity, accessToken: "token-untrusted-claims" }, + undefined, + { allowImplicitMatch: false } + ); + const updated = await persistOAuthConnection( + "codex", + { ...identity, accessToken: "token-explicit-reauth" }, + existing.id, + { allowImplicitMatch: false } + ); + + const rows = await providersDb.getProviderConnections({ provider: "codex" }); + assert.notEqual(created.id, existing.id); + assert.equal(updated.id, existing.id); + assert.equal(rows.length, 2); + assert.equal( + rows.find((row: { id: string }) => row.id === existing.id)?.accessToken, + "token-explicit-reauth" + ); +}); + +test("Codex OAuth requires workspaceId agreement when either side has a workspace", () => { + const teamConnection = { + ...personalConnection, + id: "team-a", + providerSpecificData: { workspaceId: "workspace-a", chatgptUserId: "user-a" }, + }; + + assert.equal( + findExistingOAuthConnectionMatch([teamConnection], "codex", { + email: "shared@example.com", + providerSpecificData: { workspaceId: "workspace-b", chatgptUserId: "user-a" }, + }), + undefined + ); + assert.equal( + findExistingOAuthConnectionMatch([teamConnection], "codex", { + email: "shared@example.com", + providerSpecificData: { workspaceId: "workspace-a", chatgptUserId: "user-b" }, + })?.id, + "team-a" + ); +}); + +test("explicit connectionId remains authoritative for OAuth reauthentication", () => { + const match = findExistingOAuthConnectionMatch( + [personalConnection], + "codex", + { + email: "different@example.com", + providerSpecificData: { chatgptUserId: "different-user" }, + }, + "personal-a" + ); + + assert.equal(match?.id, "personal-a"); +}); + +test("non-Codex OAuth providers retain email-based deduplication", () => { + const match = findExistingOAuthConnectionMatch([personalConnection], "github", { + email: "shared@example.com", + }); + + assert.equal(match?.id, "personal-a"); +}); diff --git a/tests/unit/oauth-management-auth.test.ts b/tests/unit/oauth-management-auth.test.ts new file mode 100644 index 000000000000..e37acf1a88fd --- /dev/null +++ b/tests/unit/oauth-management-auth.test.ts @@ -0,0 +1,141 @@ +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(), "omni-oauth-management-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET ||= "oauth-management-auth-test-secret"; +process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1"; +process.env.JWT_SECRET = "oauth-management-auth-jwt"; +process.env.INITIAL_PASSWORD = "oauth-management-auth-password"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const dynamicOAuth = await import("../../src/app/api/oauth/[provider]/[action]/route.ts"); +const codexImportToken = await import("../../src/app/api/oauth/codex/import-token/route.ts"); +const pasteCredentials = + await import("../../src/app/api/oauth/[provider]/paste-credentials/route.ts"); +const publicRoutes = await import("../../src/shared/constants/publicApiRoutes.ts"); +const authzClassify = await import("../../src/server/authz/classify.ts"); +const routeGuard = await import("../../src/server/authz/routeGuard.ts"); + +test.before(async () => { + await settingsDb.updateSettings({ requireLogin: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function keys() { + const client = await apiKeysDb.createApiKey("client", "machine-client", []); + const manager = await apiKeysDb.createApiKey("manager", "machine-manager", ["manage"]); + return { client: client.key, manager: manager.key }; +} + +function request(url: string, method: "GET" | "POST", key?: string, body?: unknown) { + return new Request(url, { + method, + headers: { + ...(key ? { authorization: `Bearer ${key}` } : {}), + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +test("generic OAuth actions require management authorization", async () => { + const { client, manager } = await keys(); + const params = { params: Promise.resolve({ provider: "codex", action: "authorize" }) }; + + assert.equal( + ( + await dynamicOAuth.GET( + request("http://localhost/api/oauth/codex/authorize", "GET", client), + params + ) + ).status, + 403 + ); + assert.equal( + (await dynamicOAuth.GET(request("http://localhost/api/oauth/codex/authorize", "GET"), params)) + .status, + 401 + ); + + const allowed = await dynamicOAuth.GET( + request("http://localhost/api/oauth/codex/authorize", "GET", manager), + params + ); + assert.equal(allowed.status, 200); +}); + +test("generic OAuth writes reject client keys before request validation", async () => { + const { client, manager } = await keys(); + const params = { params: Promise.resolve({ provider: "windsurf", action: "import-token" }) }; + + assert.equal( + ( + await dynamicOAuth.POST( + request("http://localhost/api/oauth/windsurf/import-token", "POST", client, {}), + params + ) + ).status, + 403 + ); + + const allowed = await dynamicOAuth.POST( + request("http://localhost/api/oauth/windsurf/import-token", "POST", manager, {}), + params + ); + assert.equal(allowed.status, 400); +}); + +test("static credential persistence routes reject client keys", async () => { + const { client, manager } = await keys(); + + assert.equal( + ( + await codexImportToken.POST( + request("http://localhost/api/oauth/codex/import-token", "POST", client, {}) + ) + ).status, + 403 + ); + assert.equal( + ( + await pasteCredentials.POST( + request("http://localhost/api/oauth/codex/paste-credentials", "POST", client, {}), + { params: Promise.resolve({ provider: "codex" }) } + ) + ).status, + 403 + ); + + assert.equal( + ( + await codexImportToken.POST( + request("http://localhost/api/oauth/codex/import-token", "POST", manager, {}) + ) + ).status, + 400 + ); +}); + +test("host credential imports reach the local-only management tier", () => { + const paths = [ + "/api/oauth/cliproxy-import", + "/api/oauth/cursor/auto-import", + "/api/oauth/kiro/auto-import", + ]; + + for (const routePath of paths) { + assert.equal(publicRoutes.isPublicApiRoute(routePath), false); + assert.equal(authzClassify.classifyRoute(routePath, "GET").routeClass, "MANAGEMENT"); + assert.equal(routeGuard.isLocalOnlyPath(routePath), true); + } +}); diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 54a1a4150fc8..04761ac65ee7 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -42,6 +42,7 @@ const { QWEN_CONFIG, TRAE_CONFIG, WINDSURF_CONFIG, + XAI_OAUTH_CONFIG, ZED_HOSTED_CONFIG, } = oauthModule; const { getAntigravityLoadCodeAssistMetadata } = antigravityHeadersModule; @@ -68,6 +69,7 @@ const EXPECTED_PROVIDER_KEYS = [ "windsurf", "devin-cli", "grok-cli", + "xai-oauth", "codebuddy-cn", "zed", "zed-hosted", @@ -100,6 +102,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { "devin-cli": WINDSURF_CONFIG, trae: TRAE_CONFIG, "grok-cli": GROK_CLI_CONFIG, + "xai-oauth": XAI_OAUTH_CONFIG, "codebuddy-cn": CODEBUDDY_CN_CONFIG, zed: ZED_CONFIG, "zed-hosted": ZED_HOSTED_CONFIG, @@ -144,6 +147,9 @@ const REQUIRED_FIELDS_BY_PROVIDER = { windsurf: ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], "devin-cli": ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], trae: ["apiEndpoint", "chatEndpoint", "webUrl"], + // prettier-ignore + "xai-oauth": ["authorizeUrl", "tokenUrl", "scope", "codeChallengeMethod", "clientId", "loopbackPort", "callbackPath", "callbackHost"], + // prettier-ignore "zed-hosted": ["webBaseUrl", "cloudBaseUrl", "llmBaseUrl", "userInfoUrl", "llmTokenUrl", "modelsUrl"], }; @@ -351,7 +357,10 @@ test("zed-hosted buildAuthUrl returns {authUrl, codeVerifier, redirectUri} carry test("generateAuthData honors an object-returning buildAuthUrl (zed-hosted) without breaking string-returning providers", async () => { const oauthHelpers = await import("../../src/lib/oauth/providers.ts"); - const zedAuthData = oauthHelpers.generateAuthData("zed-hosted", "http://localhost:20128/callback"); + const zedAuthData = oauthHelpers.generateAuthData( + "zed-hosted", + "http://localhost:20128/callback" + ); assert.equal(zedAuthData.flowType, "authorization_code"); assert.ok(zedAuthData.authUrl.startsWith("https://zed.dev/native_app_signin?")); assert.ok(zedAuthData.codeVerifier.startsWith("zed-rsa-pkcs1:")); diff --git a/tests/unit/openai-responses-request-split.test.ts b/tests/unit/openai-responses-request-split.test.ts index 50699805a619..cca4106fcd06 100644 --- a/tests/unit/openai-responses-request-split.test.ts +++ b/tests/unit/openai-responses-request-split.test.ts @@ -54,3 +54,74 @@ test("both directions are callable via the host module", async () => { ) as Record; assert.ok(Array.isArray(out.input)); }); + +test("mid-conversation system turns are preserved as developer-role input items (#6954)", async () => { + const mod = await import("../../open-sse/translator/request/openai-responses.ts"); + const out = mod.openaiToOpenAIResponsesRequest( + "gpt-5.5", + { + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "Hello" }, + { role: "system", content: "Available agent types: claude, claude-code-guide" }, + { role: "assistant", content: "Hi there!" }, + { role: "system", content: "Reminder: stay concise." }, + ], + }, + true, + null + ) as Record; + + // The FIRST system turn becomes `instructions`. + assert.equal(out.instructions, "You are a helpful assistant."); + + const input = out.input as Array>; + + // Mid-conversation system turns must survive as developer-role input items — + // not dropped, and never misattributed as assistant (#6954). + const developerItems = input.filter((i) => i.role === "developer"); + assert.equal( + developerItems.length, + 2, + "two mid-conversation system turns should become developer items" + ); + assert.deepEqual( + (developerItems[0].content as Array>).map((c) => c.text), + ["Available agent types: claude, claude-code-guide"] + ); + assert.deepEqual( + (developerItems[1].content as Array>).map((c) => c.text), + ["Reminder: stay concise."] + ); + + // No harness-injected system content should appear as assistant prose. + const assistantText = input + .filter((i) => i.role === "assistant") + .map((i) => JSON.stringify(i.content)) + .join(" "); + assert.ok(!assistantText.includes("Available agent types")); + assert.ok(!assistantText.includes("Reminder: stay concise.")); +}); + +test("mid-conversation system content as an array with a bare string survives (#6954 follow-up)", async () => { + const mod = await import("../../open-sse/translator/request/openai-responses.ts"); + const out = mod.openaiToOpenAIResponsesRequest( + "gpt-5.5", + { + messages: [ + { role: "system", content: "Base instructions." }, + { role: "user", content: "Hi" }, + { role: "system", content: ["Remember to be concise."] }, + ], + }, + true, + null + ) as Record; + const input = out.input as Array>; + const devItems = input.filter((i) => i.role === "developer"); + assert.equal(devItems.length, 1, "array-form system turn should become a developer item"); + assert.deepEqual( + (devItems[0].content as Array>).map((c) => c.text), + ["Remember to be concise."] + ); +}); diff --git a/tests/unit/opencode-executor.test.ts b/tests/unit/opencode-executor.test.ts index 716c0f3d91d7..5e091ad71d85 100644 --- a/tests/unit/opencode-executor.test.ts +++ b/tests/unit/opencode-executor.test.ts @@ -137,6 +137,22 @@ describe("OpencodeExecutor", () => { assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/responses"); }); + it("routes OpenCode Go Responses models to /responses with bearer auth", async () => { + for (const model of [ + "gpt-5.6-luna", + "grok-4.5", + "grok-4.6", + "muse-spark-1.2-contributor", + "muse-spark-1.3-contributor", + ]) { + const result = await goExecutor.execute(createInput(model)); + assert.equal(result.url, "https://opencode.ai/zen/go/v1/responses", model); + assert.equal(result.headers.Authorization, "Bearer test-key", model); + assert.equal(result.headers["x-api-key"], undefined, model); + assert.equal(result.headers["anthropic-version"], undefined, model); + } + }); + it("routes gemini streaming requests to streamGenerateContent", async () => { registerModel("opencode-zen", { id: "gemini-2.5-pro", @@ -260,6 +276,13 @@ describe("OpencodeExecutor", () => { ); assert.equal(qwen35.url, "https://opencode.ai/zen/go/v1/messages"); assert.equal(qwen35.headers["anthropic-version"], "2023-06-01"); + + const qwen38 = await goExecutor.execute( + createInput("qwen3.8-max", true, { apiKey: "claude-key" }) + ); + assert.equal(qwen38.url, "https://opencode.ai/zen/go/v1/messages"); + assert.equal(qwen38.headers["anthropic-version"], "2023-06-01"); + assert.equal(qwen38.headers.Authorization, undefined); }); it("builds bearer auth headers for opencode-go openai models", async () => { diff --git a/tests/unit/opencode-go-catalog-alignment.test.ts b/tests/unit/opencode-go-catalog-alignment.test.ts index 6b9bd3b68bb9..be05a044d25c 100644 --- a/tests/unit/opencode-go-catalog-alignment.test.ts +++ b/tests/unit/opencode-go-catalog-alignment.test.ts @@ -1,53 +1,70 @@ -/** - * 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 { getModelTargetFormat } = await import("../../open-sse/config/providerModels.ts"); 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); -} - -const DEPRECATED_MIMO_V2_MODELS = ["mimo-v2-pro", "mimo-v2-omni"]; +const LIVE_MODEL_PROTOCOLS = { + "deepseek-v4-flash": "openai", + "deepseek-v4-flash-vision-exp": "openai", + "deepseek-v4-pro": "openai", + "glm-5": "openai", + "glm-5.1": "openai", + "glm-5.2": "openai", + "glm-5.3": "openai", + "glm-5.3-flash": "openai", + "gpt-5.6-luna": "openai-responses", + "grok-4.5": "openai-responses", + "grok-4.6": "openai-responses", + hy3: "openai", + "hy3-preview": "openai", + "hy4-preview": "openai", + "kimi-k2.5": "openai", + "kimi-k2.6": "openai", + "kimi-k2.7-code": "openai", + "kimi-k3": "openai", + "longcat-2.0": "openai", + "mimo-v2-omni": "openai", + "mimo-v2-pro": "openai", + "mimo-v2.5": "openai", + "mimo-v2.5-pro": "openai", + "minimax-m2.5": "claude", + "minimax-m2.7": "claude", + "minimax-m3": "claude", + "muse-spark-1.2-contributor": "openai-responses", + "muse-spark-1.3-contributor": "openai-responses", + "qwen3.5-plus": "claude", + "qwen3.6-plus": "claude", + "qwen3.7-max": "claude", + "qwen3.7-plus": "claude", + "qwen3.8-flash": "claude", + "qwen3.8-max": "claude", +} as const; -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 catalog contains every live upstream model exactly once", () => { + const ids = (opencode_goProvider.models ?? []).map((model) => model.id); + assert.equal(new Set(ids).size, ids.length, "catalog contains duplicate model ids"); -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(", ")}` - ); + for (const modelId of Object.keys(LIVE_MODEL_PROTOCOLS)) { + assert.ok(ids.includes(modelId), `missing live OpenCode Go model: ${modelId}`); + } }); -test("opencode-go does not advertise deprecated MiMo V2 models", () => { - const ids = modelIds(); - for (const modelId of DEPRECATED_MIMO_V2_MODELS) { - assert.ok(!ids.includes(modelId), `${modelId} is deprecated`); +test("opencode-go live models use the documented endpoint protocol", () => { + for (const [modelId, expectedFormat] of Object.entries(LIVE_MODEL_PROTOCOLS)) { + assert.equal(getModelTargetFormat("opencode-go", modelId) ?? "openai", expectedFormat, modelId); } }); -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]) - ); - assert.equal(byId.get("minimax-m3")?.targetFormat, "claude"); - assert.equal(byId.get("qwen3.7-max")?.targetFormat, "claude"); +test("opencode-go family fallback protects dynamically discovered successor models", () => { + assert.equal(getModelTargetFormat("opencode-go", "gpt-future"), "openai-responses"); + assert.equal(getModelTargetFormat("opencode-go", "grok-future"), "openai-responses"); + assert.equal(getModelTargetFormat("opencode-go", "muse-spark-future"), "openai-responses"); + assert.equal(getModelTargetFormat("opencode-go", "minimax-future"), "claude"); + assert.equal(getModelTargetFormat("opencode-go", "qwen3.future"), "claude"); + assert.equal(getModelTargetFormat("opencode-go", "glm-future"), null); + + assert.equal(getModelTargetFormat("blackbox", "grok-future"), null); + assert.equal(getModelTargetFormat("blackbox", "qwen3.future"), null); }); diff --git a/tests/unit/opencode-go-usage.test.ts b/tests/unit/opencode-go-usage.test.ts index 64e63578001d..5db84c1a90bb 100644 --- a/tests/unit/opencode-go-usage.test.ts +++ b/tests/unit/opencode-go-usage.test.ts @@ -1,81 +1,72 @@ -import test from "node:test"; import assert from "node:assert/strict"; +import test from "node:test"; -const usage = await import("../../open-sse/services/usage.ts"); -const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +import { getUsageForProvider } from "../../open-sse/services/usage.ts"; +import { invalidateOpencodeQuotaCache } from "../../open-sse/services/opencodeQuotaFetcher.ts"; +import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts"; + +type ProviderQuota = { + used: number; + total: number; + remaining: number; + remainingPercentage: number; + resetAt: string | null; + unlimited: boolean; + displayName?: string; + currency?: string; +}; +type ProviderUsage = { + plan?: string | null; + quotas?: Record; + limitReached?: boolean; + message?: string; +}; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); test("USAGE_SUPPORTED_PROVIDERS includes opencode-go", () => { - assert.ok( - (USAGE_SUPPORTED_PROVIDERS as string[]).includes("opencode-go"), - "opencode-go must be in the usage-supported providers allowlist" - ); + assert.ok((USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("opencode-go")); }); -test("getUsageForProvider returns helpful message when opencode-go has no apiKey", async () => { +test("getUsageForProvider does not fetch OpenCode Go quota without an API key", async () => { let called = false; - const originalFetch = globalThis.fetch; globalThis.fetch = async () => { called = true; return new Response("unexpected", { status: 500 }); }; - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-no-key", - provider: "opencode-go", - apiKey: "", - })) as { message?: string }; + const result = (await getUsageForProvider({ + id: `opencode-go-no-key-${Date.now()}`, + provider: "opencode-go", + apiKey: "", + })) as ProviderUsage; - assert.equal(called, false, "quota fetch must not run without an API key"); - assert.match(result.message ?? "", /OpenCode Go/); - assert.match(result.message ?? "", /OPENCODE_GO_WORKSPACE_ID/); - } finally { - globalThis.fetch = originalFetch; - } + assert.equal(called, false); + assert.match(result.message ?? "", /OpenCode.*API key/i); }); -test("getUsageForProvider exposes OpenCode Go 5h, weekly, and monthly quotas", async () => { - const originalFetch = globalThis.fetch; - const reset5h = Date.now() + 2 * 60 * 60 * 1000; - const resetWeekly = Date.now() + 4 * 24 * 60 * 60 * 1000; - const resetMonthly = Date.now() + 20 * 24 * 60 * 60 * 1000; +test("getUsageForProvider shapes official OpenCode Go usage into Provider Limits quotas", async () => { + const connectionId = `opencode-go-usage-${Date.now()}`; + const rollingReset = "2026-09-01T01:02:03.000Z"; + const weeklyReset = "2026-09-05T04:05:06.000Z"; + const monthlyReset = "2026-09-30T07:08:09.000Z"; let requestUrl = ""; - let requestHeaders: Headers | null = null; + let authorization: string | null = null; globalThis.fetch = async (input, init) => { - requestUrl = String(input); - requestHeaders = new Headers(init?.headers as HeadersInit | undefined); - + const request = new Request(input, init); + requestUrl = request.url; + authorization = request.headers.get("Authorization"); return new Response( JSON.stringify({ - code: 200, - success: true, - data: { - level: "pro", - limits: [ - { - type: "TOKENS_LIMIT", - unit: 3, - number: 5, - percentage: 25, - nextResetTime: reset5h, - }, - { - type: "TOKENS_LIMIT", - unit: 6, - number: 1, - percentage: 50, - nextResetTime: resetWeekly, - }, - { - type: "TIME_LIMIT", - percentage: 10, - currentValue: 6, - usage: 60, - nextResetTime: resetMonthly, - usageDetails: [{ modelCode: "search-prime", usage: 3 }], - }, - ], + usage: { + rolling: { status: "ok", percent: 25, resetsAt: rollingReset }, + weekly: { status: "ok", percent: 50, resetsAt: weeklyReset }, + monthly: { status: "ok", percent: 10, resetsAt: monthlyReset }, }, }), { status: 200, headers: { "content-type": "application/json" } } @@ -83,289 +74,100 @@ test("getUsageForProvider exposes OpenCode Go 5h, weekly, and monthly quotas", a }; try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-usage", + const result = (await getUsageForProvider({ + id: connectionId, provider: "opencode-go", - apiKey: "Bearer opencode-go-key", - })) as { - plan?: string | null; - quotas?: Record< - string, - { - used: number; - total: number; - remaining: number; - remainingPercentage: number; - resetAt: string | null; - displayName?: string; - currency?: string; - details?: Array<{ name: string; used: number }>; - } - >; - }; + apiKey: "opencode-go-key", + })) as ProviderUsage; - assert.equal(requestUrl, "https://api.z.ai/api/monitor/usage/quota/limit"); - assert.equal(requestHeaders?.get("Authorization"), "Bearer opencode-go-key"); - assert.equal(requestHeaders?.get("Content-Type"), "application/json"); - assert.equal(result.plan, "OpenCode Go Pro"); + assert.equal(requestUrl, "https://opencode.ai/zen/go/v1/usage"); + assert.equal(authorization, "Bearer opencode-go-key"); + assert.equal(result.plan, "OpenCode Go"); + assert.equal(result.limitReached, false); assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly", "mcp_monthly"]); - assert.equal(result.quotas!.session.displayName, "5-hour rolling"); - assert.equal(result.quotas!.session.currency, "USD"); - assert.equal(result.quotas!.session.used, 3); - assert.equal(result.quotas!.session.total, 12); - assert.equal(result.quotas!.session.remaining, 9); - assert.equal(result.quotas!.session.remainingPercentage, 75); - assert.equal(result.quotas!.session.resetAt, new Date(reset5h).toISOString()); - - assert.equal(result.quotas!.weekly.displayName, "Weekly"); - assert.equal(result.quotas!.weekly.used, 15); - assert.equal(result.quotas!.weekly.total, 30); - assert.equal(result.quotas!.weekly.remaining, 15); - assert.equal(result.quotas!.weekly.remainingPercentage, 50); - assert.equal(result.quotas!.weekly.resetAt, new Date(resetWeekly).toISOString()); - - assert.equal(result.quotas!.mcp_monthly.displayName, "Monthly"); - assert.equal(result.quotas!.mcp_monthly.used, 6); - assert.equal(result.quotas!.mcp_monthly.total, 60); - assert.equal(result.quotas!.mcp_monthly.remaining, 54); - assert.equal(result.quotas!.mcp_monthly.remainingPercentage, 90); - assert.equal(result.quotas!.mcp_monthly.resetAt, new Date(resetMonthly).toISOString()); - assert.deepEqual(result.quotas!.mcp_monthly.details, [{ name: "search-prime", used: 3 }]); + assert.deepEqual(result.quotas?.session, { + used: 3, + total: 12, + remaining: 9, + remainingPercentage: 75, + resetAt: rollingReset, + unlimited: false, + displayName: "$12 / 5-hour", + currency: "USD", + }); + assert.deepEqual(result.quotas?.weekly, { + used: 15, + total: 30, + remaining: 15, + remainingPercentage: 50, + resetAt: weeklyReset, + unlimited: false, + displayName: "$30 / week", + currency: "USD", + }); + assert.deepEqual(result.quotas?.mcp_monthly, { + used: 6, + total: 60, + remaining: 54, + remainingPercentage: 90, + resetAt: monthlyReset, + unlimited: false, + displayName: "$60 / month", + currency: "USD", + }); } finally { - globalThis.fetch = originalFetch; + invalidateOpencodeQuotaCache(connectionId); } }); -test("getUsageForProvider ignores out-of-range OpenCode Go reset timestamps", async () => { - const originalFetch = globalThis.fetch; +test("getUsageForProvider shows zero remaining for a rate-limited OpenCode Go window", async () => { + const connectionId = `opencode-go-rate-limited-${Date.now()}`; + const rollingReset = "2026-09-01T01:02:03.000Z"; globalThis.fetch = async () => new Response( JSON.stringify({ - code: 200, - success: true, - data: { - level: "pro", - limits: [ - { - type: "TOKENS_LIMIT", - unit: 3, - number: 5, - percentage: 25, - nextResetTime: Number.MAX_VALUE, - }, - ], + usage: { + rolling: { status: "rate-limited", percent: 5, resetsAt: rollingReset }, + weekly: { status: "ok", percent: 90, resetsAt: "2026-09-05T04:05:06.000Z" }, + monthly: { status: "ok", percent: 20, resetsAt: "2026-09-30T07:08:09.000Z" }, }, }), { status: 200, headers: { "content-type": "application/json" } } ); try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-huge-reset", - provider: "opencode-go", - apiKey: "opencode-go-key", - })) as { quotas?: Record }; - - assert.equal(result.quotas!.session.resetAt, null); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("getUsageForProvider scrapes OpenCode Go dashboard quota when workspace cookie is configured", async () => { - const originalFetch = globalThis.fetch; - const originalWorkspace = process.env.OPENCODE_GO_WORKSPACE_ID; - const originalCookie = process.env.OPENCODE_GO_AUTH_COOKIE; - let requestUrl = ""; - let requestHeaders: Headers | null = null; - - process.env.OPENCODE_GO_WORKSPACE_ID = "workspace-123"; - process.env.OPENCODE_GO_AUTH_COOKIE = "auth-cookie-value"; - - globalThis.fetch = async (input, init) => { - requestUrl = String(input); - requestHeaders = new Headers(init?.headers as HeadersInit | undefined); - return new Response( - [ - '
', - 'Rolling Usage', - '25%', - 'Resets in 1 hour 30 minutes', - "
", - '
', - 'Weekly Usage', - '50%', - 'Resets in 2 days', - "
", - '
', - 'Monthly Usage', - '10%', - 'Resets in 10 days', - "
", - ].join(""), - { status: 200, headers: { "content-type": "text/html" } } - ); - }; - - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-dashboard", - provider: "opencode-go", - apiKey: "opencode-go-key", - })) as { - plan?: string | null; - quotas?: Record; - }; - - assert.equal(requestUrl, "https://opencode.ai/workspace/workspace-123/go"); - assert.equal(requestHeaders?.get("Cookie"), "auth=auth-cookie-value"); - assert.equal(result.plan, "OpenCode Go"); - assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly", "mcp_monthly"]); - assert.equal(result.quotas!.session.used, 3); - assert.equal(result.quotas!.session.total, 12); - assert.equal(result.quotas!.session.remainingPercentage, 75); - assert.equal(result.quotas!.weekly.used, 15); - assert.equal(result.quotas!.weekly.remainingPercentage, 50); - assert.equal(result.quotas!.mcp_monthly.used, 6); - assert.equal(result.quotas!.mcp_monthly.remainingPercentage, 90); - } finally { - globalThis.fetch = originalFetch; - if (originalWorkspace === undefined) delete process.env.OPENCODE_GO_WORKSPACE_ID; - else process.env.OPENCODE_GO_WORKSPACE_ID = originalWorkspace; - if (originalCookie === undefined) delete process.env.OPENCODE_GO_AUTH_COOKIE; - else process.env.OPENCODE_GO_AUTH_COOKIE = originalCookie; - } -}); - -// Regression: React SSR wraps the reset-time text in hydration comment markers -// ( … ). The reset string must be fully sanitized (complete -// removal, not just the two literal markers) so the reset time still parses — and so no -// partial "Resets in 1 hour 30 minutes', - "", - ].join(""), - { status: 200, headers: { "content-type": "text/html" } } - ); - - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-dashboard", + const result = (await getUsageForProvider({ + id: connectionId, provider: "opencode-go", apiKey: "opencode-go-key", - })) as { - quotas?: Record; - }; - - // session quota resolved → the comment-wrapped reset time was sanitized and parsed - assert.ok( - result.quotas?.session, - "session quota should resolve from comment-wrapped reset-time" - ); - assert.equal(result.quotas!.session.remainingPercentage, 75); + })) as ProviderUsage; + + assert.equal(result.limitReached, true); + assert.equal(result.quotas?.session.used, 12); + assert.equal(result.quotas?.session.remaining, 0); + assert.equal(result.quotas?.session.remainingPercentage, 0); + assert.equal(result.quotas?.session.resetAt, rollingReset); + assert.ok(Math.abs((result.quotas?.weekly.remainingPercentage ?? Number.NaN) - 10) < 1e-9); } finally { - globalThis.fetch = originalFetch; - if (originalWorkspace === undefined) delete process.env.OPENCODE_GO_WORKSPACE_ID; - else process.env.OPENCODE_GO_WORKSPACE_ID = originalWorkspace; - if (originalCookie === undefined) delete process.env.OPENCODE_GO_AUTH_COOKIE; - else process.env.OPENCODE_GO_AUTH_COOKIE = originalCookie; + invalidateOpencodeQuotaCache(connectionId); } }); -test("getUsageForProvider returns message for invalid OpenCode Go API keys", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => new Response("nope", { status: 401 }); - - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-401", - provider: "opencode-go", - apiKey: "bad-key", - })) as { message: string }; - assert.equal( - result.message, - "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + - "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("getUsageForProvider returns message when OpenCode Go quota fetch fails", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => { - throw new Error("network offline"); - }; +test("getUsageForProvider reports unavailable quota when the official request fails open", async () => { + const connectionId = `opencode-go-fail-open-${Date.now()}`; + globalThis.fetch = async () => new Response("unavailable", { status: 503 }); try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-network-error", + const result = (await getUsageForProvider({ + id: connectionId, provider: "opencode-go", apiKey: "opencode-go-key", - })) as { message: string }; + })) as ProviderUsage; - assert.match(result.message, /OpenCode Go quota API error:/); - assert.match(result.message, /network offline/); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("getUsageForProvider returns message when OpenCode Go quota API returns 200 with auth error in body", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - new Response(JSON.stringify({ code: 401, msg: "token expired or incorrect", success: false }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-body-401", - provider: "opencode-go", - apiKey: "sk-test-key", - })) as { message: string }; - assert.equal( - result.message, - "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + - "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("getUsageForProvider returns message when OpenCode Go quota response is invalid JSON", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - new Response("not json", { - status: 200, - headers: { "content-type": "text/html" }, - }); - - try { - const result = (await usage.getUsageForProvider({ - id: "opencode-go-bad-json", - provider: "opencode-go", - apiKey: "sk-test-key", - })) as { message: string }; - assert.equal(result.message, "OpenCode Go quota response parsing failed."); + assert.equal(result.plan, undefined); + assert.match(result.message ?? "", /Unable to fetch quota data/i); } finally { - globalThis.fetch = originalFetch; + invalidateOpencodeQuotaCache(connectionId); } }); diff --git a/tests/unit/opencode-quota-fetcher.test.ts b/tests/unit/opencode-quota-fetcher.test.ts index 3c9b905e18d3..b015fa2c935b 100644 --- a/tests/unit/opencode-quota-fetcher.test.ts +++ b/tests/unit/opencode-quota-fetcher.test.ts @@ -1,12 +1,12 @@ -import test from "node:test"; import assert from "node:assert/strict"; +import test from "node:test"; import { fetchOpencodeQuota, invalidateOpencodeQuotaCache, registerOpencodeQuotaFetcher, } from "../../open-sse/services/opencodeQuotaFetcher.ts"; -import { preflightQuota } from "../../open-sse/services/quotaPreflight.ts"; +import { getQuotaFetcher, getQuotaWindows } from "../../open-sse/services/quotaPreflight.ts"; import { clearQuotaMonitors, getActiveMonitorCount, @@ -15,7 +15,33 @@ import { } from "../../open-sse/services/quotaMonitor.ts"; import { clearSessions, touchSession } from "../../open-sse/services/sessionManager.ts"; +type UsageStatus = "ok" | "rate-limited"; +type UsageWindow = { status: UsageStatus; percent: number; resetsAt: string }; +type OfficialUsage = { + rolling: UsageWindow; + weekly: UsageWindow; + monthly: UsageWindow; +}; + const originalFetch = globalThis.fetch; +const RESET_ROLLING = "2026-09-01T01:02:03.000Z"; +const RESET_WEEKLY = "2026-09-05T04:05:06.000Z"; +const RESET_MONTHLY = "2026-09-30T07:08:09.000Z"; + +function healthyUsage(): OfficialUsage { + return { + rolling: { status: "ok", percent: 25, resetsAt: RESET_ROLLING }, + weekly: { status: "ok", percent: 50, resetsAt: RESET_WEEKLY }, + monthly: { status: "ok", percent: 10, resetsAt: RESET_MONTHLY }, + }; +} + +function jsonResponse(usage: unknown): Response { + return new Response(JSON.stringify({ usage }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} test.afterEach(() => { globalThis.fetch = originalFetch; @@ -23,377 +49,208 @@ test.afterEach(() => { clearSessions(); }); -// ─── null / missing credentials ────────────────────────────────────────────── - -test("fetchOpencodeQuota returns null when no API key is provided", async () => { - const quota = await fetchOpencodeQuota(`missing-${Date.now()}`); - assert.equal(quota, null); -}); - -test("fetchOpencodeQuota returns null when connection has empty apiKey", async () => { - const quota = await fetchOpencodeQuota(`empty-key-${Date.now()}`, { apiKey: "" }); - assert.equal(quota, null); -}); - -// ─── non-200 responses (fail-open) ─────────────────────────────────────────── - -test("fetchOpencodeQuota returns null on 404 response", async () => { - const connectionId = `oc-404-${Date.now()}`; - - globalThis.fetch = async () => new Response(null, { status: 404 }); - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(quota, null); - - invalidateOpencodeQuotaCache(connectionId); -}); - -test("fetchOpencodeQuota returns null on 401 (invalid token)", async () => { - const connectionId = `oc-401-${Date.now()}`; - - globalThis.fetch = async () => new Response(null, { status: 401 }); - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "bad-key" }); - assert.equal(quota, null); -}); - -test("fetchOpencodeQuota returns null on 403 (forbidden)", async () => { - const connectionId = `oc-403-${Date.now()}`; - - globalThis.fetch = async () => new Response(null, { status: 403 }); - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "bad-key" }); - assert.equal(quota, null); -}); - -test("fetchOpencodeQuota returns null on 500 server error", async () => { - const connectionId = `oc-500-${Date.now()}`; - - globalThis.fetch = async () => new Response(null, { status: 500 }); - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(quota, null); - - invalidateOpencodeQuotaCache(connectionId); -}); - -test("fetchOpencodeQuota returns null on network error (fail-open)", async () => { - const connectionId = `oc-net-${Date.now()}`; - +test("fetchOpencodeQuota returns null without an API key", async () => { + let called = false; globalThis.fetch = async () => { - throw new Error("Network error"); + called = true; + return jsonResponse(healthyUsage()); }; - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(quota, null); + assert.equal(await fetchOpencodeQuota(`missing-${Date.now()}`), null); + assert.equal(await fetchOpencodeQuota(`empty-${Date.now()}`, { apiKey: "" }), null); + assert.equal(called, false); }); -test("fetchOpencodeQuota returns null on timeout (fail-open)", async () => { - const connectionId = `oc-timeout-${Date.now()}`; +test("fetchOpencodeQuota parses official usage windows as fractions and sends Bearer auth", async () => { + const connectionId = `official-${Date.now()}`; + let authorization: string | null = null; + let method = ""; - globalThis.fetch = async () => { - await new Promise((_, reject) => setTimeout(reject, 100)); - throw new Error("Timeout"); - }; - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(quota, null); -}); - -// ─── 3-window parsing ($12/5h, $30/wk, $60/mo) ─────────────────────────────── - -test("fetchOpencodeQuota parses three-window quota response", async () => { - const connectionId = `oc-three-${Date.now()}`; - const calls: { url: string; init: RequestInit }[] = []; - - globalThis.fetch = async (url, init) => { - calls.push({ url: url as string, init: init as RequestInit }); - return new Response( - JSON.stringify({ - quota: { - window_5h: { used: 4.0, limit: 12.0, reset_at: null }, - window_weekly: { used: 15.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 20.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); + globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + authorization = request.headers.get("Authorization"); + method = request.method; + return jsonResponse(healthyUsage()); }; - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - - assert.equal(calls.length, 1); - assert.ok( - (calls[0].init as Record)?.headers && - ((calls[0].init as Record).headers as Record)[ - "Authorization" - ] === "Bearer test-key", - "should send Bearer auth" - ); - - assert.ok(quota !== null, "should return a quota object"); - assert.ok(quota!.windows, "should have windows map"); - - // window_5h: 4/12 = 33.3% - assert.ok( - Math.abs((quota!.windows!["window_5h"].percentUsed as number) - 4 / 12) < 0.001, - "window_5h percentUsed should be ~0.333" - ); - // window_weekly: 15/30 = 50% - assert.ok( - Math.abs((quota!.windows!["window_weekly"].percentUsed as number) - 0.5) < 0.001, - "window_weekly percentUsed should be 0.5" - ); - // window_monthly: 20/60 = 33.3% - assert.ok( - Math.abs((quota!.windows!["window_monthly"].percentUsed as number) - 20 / 60) < 0.001, - "window_monthly percentUsed should be ~0.333" - ); - - // Worst-case: weekly at 50% - assert.ok( - Math.abs(quota!.percentUsed - 0.5) < 0.001, - "overall percentUsed should mirror worst window" - ); - - invalidateOpencodeQuotaCache(connectionId); -}); - -test("fetchOpencodeQuota parses reset_at timestamps in windows", async () => { - const connectionId = `oc-reset-${Date.now()}`; - const futureTs = Math.floor((Date.now() + 3_600_000) / 1000); // +1h unix seconds - - globalThis.fetch = async () => - new Response( - JSON.stringify({ - quota: { - window_5h: { used: 10.0, limit: 12.0, reset_at: futureTs }, - window_weekly: { used: 28.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 55.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - - assert.ok(quota !== null); - // window_5h reset_at should be an ISO string - const resetAt5h = quota!.windows?.["window_5h"]?.resetAt; - assert.ok(typeof resetAt5h === "string", "window_5h resetAt should be an ISO string"); - assert.ok( - new Date(resetAt5h as string).getTime() > Date.now(), - "resetAt should be in the future" - ); - - invalidateOpencodeQuotaCache(connectionId); -}); - -test("fetchOpencodeQuota sets limitReached when any window is exhausted", async () => { - const connectionId = `oc-exhausted-${Date.now()}`; - - globalThis.fetch = async () => - new Response( - JSON.stringify({ - quota: { - window_5h: { used: 12.0, limit: 12.0, reset_at: null }, - window_weekly: { used: 5.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 10.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - - assert.ok(quota !== null); - // window_5h is 100% used → worst-case - assert.ok(Math.abs(quota!.percentUsed - 1.0) < 0.001, "percentUsed should be 1.0 when exhausted"); - assert.equal((quota as any).limitReached, true, "limitReached should be true"); + const quota = await fetchOpencodeQuota(connectionId, { apiKey: "Bearer opencode-key" }); + + assert.ok(quota); + assert.equal(authorization, "Bearer opencode-key"); + assert.equal(method, "GET"); + assert.equal(quota.percentUsed, 0.5); + assert.equal(quota.resetAt, RESET_WEEKLY); + assert.equal(quota.limitReached, false); + assert.deepEqual(quota.windows, { + window_5h: { percentUsed: 0.25, resetAt: RESET_ROLLING }, + window_weekly: { percentUsed: 0.5, resetAt: RESET_WEEKLY }, + window_monthly: { percentUsed: 0.1, resetAt: RESET_MONTHLY }, + }); invalidateOpencodeQuotaCache(connectionId); }); -test("fetchOpencodeQuota returns null when quota object is absent from response", async () => { - const connectionId = `oc-no-quota-${Date.now()}`; +test("fetchOpencodeQuota makes a rate-limited window effectively exhausted", async () => { + const connectionId = `rate-limited-${Date.now()}`; + const usage = healthyUsage(); + usage.rolling = { status: "rate-limited", percent: 37, resetsAt: RESET_ROLLING }; + globalThis.fetch = async () => jsonResponse(usage); - globalThis.fetch = async () => - new Response(JSON.stringify({ message: "ok" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); + const quota = await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }); - const quota = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(quota, null); + assert.ok(quota); + assert.equal(quota.window5h.percentUsed, 1); + assert.equal(quota.percentUsed, 1); + assert.equal(quota.limitReached, true); invalidateOpencodeQuotaCache(connectionId); }); -// ─── caching ───────────────────────────────────────────────────────────────── - -test("fetchOpencodeQuota caches results within TTL (second call is a no-op)", async () => { - const connectionId = `oc-cache-${Date.now()}`; - let calls = 0; - - globalThis.fetch = async () => { - calls++; - return new Response( - JSON.stringify({ - quota: { - window_5h: { used: 2.0, limit: 12.0, reset_at: null }, - window_weekly: { used: 10.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 20.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }; - - const first = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - const second = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - - assert.equal(calls, 1, "should only hit the network once"); - assert.deepEqual(first, second, "cached result should be identical"); +test("fetchOpencodeQuota treats an ok window at 100 percent as exhausted", async () => { + const connectionId = `hundred-${Date.now()}`; + const usage = healthyUsage(); + usage.monthly = { status: "ok", percent: 100, resetsAt: RESET_MONTHLY }; + globalThis.fetch = async () => jsonResponse(usage); - invalidateOpencodeQuotaCache(connectionId); + const quota = await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }); - const third = await fetchOpencodeQuota(connectionId, { apiKey: "test-key" }); - assert.equal(calls, 2, "should re-fetch after cache invalidation"); - assert.ok(third !== null); + assert.ok(quota); + assert.equal(quota.windowMonthly.percentUsed, 1); + assert.equal(quota.percentUsed, 1); + assert.equal(quota.limitReached, true); invalidateOpencodeQuotaCache(connectionId); }); -// ─── registration + preflight integration ──────────────────────────────────── - -test("registerOpencodeQuotaFetcher exposes opencode-go quota to preflight system", async () => { - const connectionId = `oc-preflight-${Date.now()}`; - - registerOpencodeQuotaFetcher(); - - // Fully exhausted 5h window — preflight should block - globalThis.fetch = async () => - new Response( - JSON.stringify({ - quota: { - window_5h: { used: 12.0, limit: 12.0, reset_at: null }, - window_weekly: { used: 5.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 10.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - - const preflight = await preflightQuota("opencode-go", connectionId, { - apiKey: "test-key", - providerSpecificData: { quotaPreflightEnabled: true }, +test("fetchOpencodeQuota fails open on non-200, network, and invalid JSON responses", async (t) => { + await t.test("non-200", async () => { + const connectionId = `non-200-${Date.now()}`; + globalThis.fetch = async () => new Response("unavailable", { status: 503 }); + assert.equal(await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }), null); + invalidateOpencodeQuotaCache(connectionId); }); - assert.equal(preflight.proceed, false, "preflight should block when window is exhausted"); - assert.equal(preflight.reason, "quota_exhausted"); + await t.test("network error", async () => { + const connectionId = `network-${Date.now()}`; + globalThis.fetch = async () => { + throw new Error("network offline"); + }; + assert.equal(await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }), null); + invalidateOpencodeQuotaCache(connectionId); + }); - invalidateOpencodeQuotaCache(connectionId); + await t.test("invalid JSON", async () => { + const connectionId = `invalid-json-${Date.now()}`; + globalThis.fetch = async () => + new Response("not json", { + status: 200, + headers: { "content-type": "text/html" }, + }); + assert.equal(await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }), null); + invalidateOpencodeQuotaCache(connectionId); + }); }); -test("preflight honors an explicit opencode limit_reached flag even before a window reaches 100%", async () => { - const connectionId = `oc-limit-reached-${Date.now()}`; - const resetAt = Math.floor((Date.now() + 30 * 60_000) / 1000); - - registerOpencodeQuotaFetcher(); - - globalThis.fetch = async () => - new Response( - JSON.stringify({ - limit_reached: true, - quota: { - window_5h: { used: 10.0, limit: 12.0, reset_at: resetAt }, - window_weekly: { used: 20.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 30.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - - const preflight = await preflightQuota("opencode-go", connectionId, { - apiKey: "test-key", - providerSpecificData: { quotaPreflightEnabled: true }, - }); +test("fetchOpencodeQuota rejects malformed official usage windows", async (t) => { + const malformedBodies: Array<{ name: string; usage: unknown }> = [ + { + name: "unknown status", + usage: { + ...healthyUsage(), + rolling: { status: "paused", percent: 25, resetsAt: RESET_ROLLING }, + }, + }, + { + name: "out-of-range percent", + usage: { + ...healthyUsage(), + weekly: { status: "ok", percent: 101, resetsAt: RESET_WEEKLY }, + }, + }, + { + name: "invalid reset timestamp", + usage: { + ...healthyUsage(), + monthly: { status: "ok", percent: 10, resetsAt: "not-a-date" }, + }, + }, + ]; + + for (const malformed of malformedBodies) { + await t.test(malformed.name, async () => { + const connectionId = `malformed-${malformed.name}-${Date.now()}`; + globalThis.fetch = async () => jsonResponse(malformed.usage); + assert.equal(await fetchOpencodeQuota(connectionId, { apiKey: "opencode-key" }), null); + invalidateOpencodeQuotaCache(connectionId); + }); + } +}); - assert.equal(preflight.proceed, false, "explicit limit_reached must block the account"); - assert.equal(preflight.reason, "quota_exhausted"); - assert.equal(preflight.quotaPercent, 10 / 12); - assert.equal(preflight.resetAt, new Date(resetAt * 1000).toISOString()); +test("fetchOpencodeQuota caches for 60 seconds but not across API key changes", async () => { + const connectionId = `cache-${Date.now()}`; + const originalNow = Date.now; + let now = originalNow(); + let calls = 0; + const authorizations: Array = []; + Date.now = () => now; + globalThis.fetch = async (input, init) => { + calls += 1; + authorizations.push(new Request(input, init).headers.get("Authorization")); + const usage = healthyUsage(); + usage.weekly.percent = calls === 1 ? 50 : calls === 2 ? 75 : 80; + return jsonResponse(usage); + }; - invalidateOpencodeQuotaCache(connectionId); + try { + const first = await fetchOpencodeQuota(connectionId, { apiKey: "first-key" }); + now += 59_999; + const cached = await fetchOpencodeQuota(connectionId, { apiKey: "first-key" }); + assert.equal(calls, 1); + assert.deepEqual(cached, first); + + const changedKey = await fetchOpencodeQuota(connectionId, { apiKey: "second-key" }); + assert.ok(changedKey); + assert.equal(calls, 2); + assert.equal(changedKey.percentUsed, 0.75); + assert.deepEqual(authorizations, ["Bearer first-key", "Bearer second-key"]); + + now += 59_999; + const secondKeyCached = await fetchOpencodeQuota(connectionId, { apiKey: "second-key" }); + assert.equal(calls, 2); + assert.deepEqual(secondKeyCached, changedKey); + + now += 1; + const refreshed = await fetchOpencodeQuota(connectionId, { apiKey: "second-key" }); + assert.ok(refreshed); + assert.equal(calls, 3); + assert.equal(refreshed.percentUsed, 0.8); + } finally { + Date.now = originalNow; + invalidateOpencodeQuotaCache(connectionId); + } }); -test("registerOpencodeQuotaFetcher also covers opencode and opencode-zen providers", async () => { +test("registerOpencodeQuotaFetcher registers every OpenCode provider and quota window", () => { registerOpencodeQuotaFetcher(); - const { getQuotaFetcher } = await import("../../open-sse/services/quotaPreflight.ts"); - - assert.ok(getQuotaFetcher("opencode-go"), "opencode-go should be registered"); - assert.ok(getQuotaFetcher("opencode"), "opencode should be registered"); - assert.ok(getQuotaFetcher("opencode-zen"), "opencode-zen should be registered"); + for (const provider of ["opencode-go", "opencode", "opencode-zen"]) { + assert.equal(getQuotaFetcher(provider), fetchOpencodeQuota); + assert.deepEqual(getQuotaWindows(provider), ["window_5h", "window_weekly", "window_monthly"]); + } }); -test("registerOpencodeQuotaFetcher registers opencode-go in quotaMonitor system", async () => { - const connectionId = `oc-monitor-${Date.now()}`; - +test("registerOpencodeQuotaFetcher keeps OpenCode Go quota monitoring available", () => { + const sessionId = `session-${Date.now()}`; + const connectionId = `monitor-${Date.now()}`; registerOpencodeQuotaFetcher(); + touchSession(sessionId, connectionId); - globalThis.fetch = async () => - new Response( - JSON.stringify({ - quota: { - window_5h: { used: 11.0, limit: 12.0, reset_at: null }, - window_weekly: { used: 29.0, limit: 30.0, reset_at: null }, - window_monthly: { used: 58.0, limit: 60.0, reset_at: null }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - - touchSession("session-oc", connectionId); - startQuotaMonitor("session-oc", "opencode-go", connectionId, { + startQuotaMonitor(sessionId, "opencode-go", connectionId, { + apiKey: "opencode-key", providerSpecificData: { quotaMonitorEnabled: true }, }); - assert.equal(getActiveMonitorCount(), 1); - stopQuotaMonitor("session-oc"); + stopQuotaMonitor(sessionId); assert.equal(getActiveMonitorCount(), 0); - - invalidateOpencodeQuotaCache(connectionId); -}); - -// ─── 404 warning: log once, cache 5 min ──────────────────────────────────── - -test("404 response is cached for 5 minutes to avoid hammering", async () => { - let callCount = 0; - globalThis.fetch = (async () => { - callCount += 1; - return new Response("Not Found", { status: 404 }); - }) as typeof fetch; - - const connectionId = `conn-cache-${Date.now()}`; - - // First call: 1 fetch, no cache - await fetchOpencodeQuota(connectionId, { apiKey: "sk-test-key" }); - const callsAfterFirst = callCount; - assert.equal(callsAfterFirst, 1); - - // Second call within 5 min: should hit cache, no fetch - await fetchOpencodeQuota(connectionId, { apiKey: "sk-test-key" }); - const callsAfterSecond = callCount; - assert.equal( - callsAfterSecond, - 1, - `expected cache hit on second 404 call, but fetch ran ${callsAfterSecond - callsAfterFirst} extra times` - ); - - // After invalidation: 1 fresh fetch - invalidateOpencodeQuotaCache(connectionId); - await fetchOpencodeQuota(connectionId, { apiKey: "sk-test-key" }); - assert.equal(callCount, callsAfterSecond + 1); }); diff --git a/tests/unit/outbound-url-guard-ipv6.test.ts b/tests/unit/outbound-url-guard-ipv6.test.ts new file mode 100644 index 000000000000..a2dcb0ab08c8 --- /dev/null +++ b/tests/unit/outbound-url-guard-ipv6.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import test from "node:test"; + +import { + isCloudMetadataHost, + isPrivateHost, + parseAndValidatePublicUrl, +} from "@/shared/network/outboundUrlGuard"; +import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; + +const blockedIpv6 = [ + "::", + "0:0:0:0:0:0:0:0", + "::1", + "0:0:0:0:0:0:0:1", + "::ffff:127.0.0.1", + "0:0:0:0:0:ffff:7f00:1", + "::127.0.0.1", + "fc00::1", + "FD12:3456::1", + "fe80::1", + "febf::1", + "fec0::1", + "ff02::1", +]; + +test("public URL guard rejects canonical and expanded non-public IPv6 addresses", () => { + for (const address of blockedIpv6) { + assert.equal(isPrivateHost(address), true, address); + assert.equal(isPrivateHost(`[${address}]`), true, address); + assert.throws(() => parseAndValidatePublicUrl(`http://[${address}]/image.png`), /Blocked/); + } + assert.equal(isPrivateHost("fe80::1%eth0"), true); + for (const address of ["2606:4700:4700::1111", "2001:4860:4860:0:0:0:0:8888"]) { + assert.equal(isPrivateHost(address), false, address); + assert.doesNotThrow(() => parseAndValidatePublicUrl(`https://[${address}]/image.png`)); + } + assert.equal(isCloudMetadataHost("FD00:0EC2:0:0:0:0:0:0254"), true); + for (const address of ["224.0.0.1", "239.255.255.255", "240.0.0.1", "255.255.255.255"]) { + assert.equal(isPrivateHost(address), true, address); + } +}); + +test("real image fetch cannot reach a local listener through the unspecified IPv6 address", async (t) => { + let requests = 0; + const server = http.createServer((_req, res) => { + requests++; + res.end("synthetic local data"); + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "::1", resolve); + }); + } catch (error) { + if (["EAFNOSUPPORT", "EADDRNOTAVAIL"].includes((error as NodeJS.ErrnoException).code || "")) { + t.skip("IPv6 loopback unavailable"); + return; + } + throw error; + } + t.after(() => new Promise((resolve) => server.close(() => resolve()))); + const port = (server.address() as AddressInfo).port; + await assert.rejects( + fetchRemoteImage(`http://[::]:${port}/image.png`, { guard: "public-only" }), + /Blocked/ + ); + assert.equal(requests, 0); +}); + +test("public image fetch rejects non-public IPv6 literals and DNS answers before fetching", async () => { + let fetchCalls = 0; + const fetchImpl = async () => { + fetchCalls++; + return new Response("unexpected"); + }; + for (const address of blockedIpv6) { + await assert.rejects( + fetchRemoteImage(`http://[${address}]/image.png`, { guard: "public-only", fetchImpl }), + /blocked/i + ); + await assert.rejects( + fetchRemoteImage("https://images.example.com/image.png", { + guard: "public-only", + fetchImpl, + lookup: async () => [{ address, family: 6 }], + }), + /blocked/i + ); + } + assert.equal(fetchCalls, 0); +}); + +test("public image fetch rejects redirects to IPv6 unspecified before the second fetch", async () => { + let fetchCalls = 0; + await assert.rejects( + fetchRemoteImage("https://images.example.com/image.png", { + guard: "public-only", + lookup: async () => [{ address: "8.8.8.8", family: 4 }], + fetchImpl: async () => { + fetchCalls++; + return new Response(null, { status: 302, headers: { location: "http://[::]/private" } }); + }, + }), + /Blocked/ + ); + assert.equal(fetchCalls, 1); +}); diff --git a/tests/unit/provider-breaker-halfopen-recovery.test.ts b/tests/unit/provider-breaker-halfopen-recovery.test.ts new file mode 100644 index 000000000000..b3b50aea3e0a --- /dev/null +++ b/tests/unit/provider-breaker-halfopen-recovery.test.ts @@ -0,0 +1,275 @@ +/** + * Tests for circuit breaker HALF_OPEN -> CLOSED recovery via recordProviderSuccess. + * + * Production scenario: provider breaker stuck in HALF_OPEN with 699 requests + * at 98% success rate, never recovering. Root cause: combo.ts success path + * calls recordProviderSuccess (cooldown-only) but never calls breaker._onSuccess(). + * + * This test verifies that recordProviderSuccess in accountFallback.ts + * transitions the breaker from HALF_OPEN to CLOSED and resets failure count. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + getCircuitBreaker, + resetAllCircuitBreakers, +} from "../../src/shared/utils/circuitBreaker.ts"; +import { + recordProviderFailure, + recordProviderSuccess, +} from "../../open-sse/services/accountFallback.ts"; +import { + recordProviderCooldown, + isProviderInCooldown, + clearCooldownState, +} from "../../open-sse/services/providerCooldownTracker.ts"; + +const uniqueProvider = (suffix: string) => + `halfopen-test-${suffix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +test("recordProviderSuccess transitions breaker from HALF_OPEN to CLOSED and resets failureCount", async () => { + const provider = uniqueProvider("recovery"); + + // Step 1: Open the breaker (failureThreshold: 1 -> one failure opens it) + recordProviderFailure(provider, undefined, undefined, { + failureThreshold: 1, + resetTimeoutMs: 150, + }); + + const breaker = getCircuitBreaker(provider); + assert.equal(breaker.state, "OPEN", "breaker should be OPEN after failure"); + assert.equal(breaker.failureCount, 1, "failureCount should be 1"); + + // Step 2: Wait past resetTimeout so _refreshOpenState transitions to HALF_OPEN + await new Promise((r) => setTimeout(r, 200)); + breaker.canExecute(); // triggers _refreshOpenState -> HALF_OPEN + assert.equal(breaker.state, "HALF_OPEN", "breaker should be HALF_OPEN after resetTimeout"); + + // Step 3: recordProviderSuccess should close the breaker + recordProviderSuccess(provider, undefined); + + assert.equal(breaker.state, "CLOSED", "breaker should be CLOSED after success"); + assert.equal(breaker.failureCount, 0, "failureCount should be reset to 0"); +}); + +test("recordProviderSuccess does not prematurely close an OPEN breaker before resetTimeout", () => { + const provider = uniqueProvider("no-premature-close"); + + // Open the breaker + recordProviderFailure(provider, undefined, undefined, { + failureThreshold: 1, + resetTimeoutMs: 60_000, // long timeout -- won't elapse during test + }); + + const breaker = getCircuitBreaker(provider); + assert.equal(breaker.state, "OPEN"); + + // Call success while still in OPEN (timeout hasn't elapsed) + recordProviderSuccess(provider, undefined); + + // Breaker should stay OPEN -- we don't want to bypass the cooldown + assert.equal(breaker.state, "OPEN", "breaker must stay OPEN when resetTimeout has not elapsed"); + assert.equal(breaker.failureCount, 1, "failureCount must not change while OPEN"); +}); + +test("recordProviderSuccess does not reset cooldown when breaker is OPEN", () => { + const provider = uniqueProvider("open-cooldown-guard"); + const connectionId = "conn-open"; + + // Build up cooldown first + for (let i = 0; i < 3; i++) { + recordProviderCooldown(provider, connectionId); + } + assert.equal(isProviderInCooldown(provider, connectionId), true); + + // Open the breaker + recordProviderFailure(provider, undefined, connectionId, { + failureThreshold: 1, + resetTimeoutMs: 60_000, + }); + const breaker = getCircuitBreaker(provider); + assert.equal(breaker.state, "OPEN"); + + // Success while OPEN should NOT reset cooldown (early-return guard) + recordProviderSuccess(provider, connectionId); + + assert.equal(breaker.state, "OPEN", "breaker stays OPEN"); + assert.equal( + isProviderInCooldown(provider, connectionId), + true, + "cooldown must NOT be cleared while breaker is OPEN" + ); +}); + +test("recordProviderSuccess on CLOSED breaker decays failureCount (gradual recovery)", () => { + const provider = uniqueProvider("closed-gradual"); + + // Get a breaker in CLOSED state (no failures) + const breaker = getCircuitBreaker(provider, { + failureThreshold: 3, + resetTimeoutMs: 30_000, + }); + assert.equal(breaker.state, "CLOSED"); + + // Add a failure via public API (but not enough to open: threshold=3) + recordProviderFailure(provider, undefined, undefined, { + failureThreshold: 3, + resetTimeoutMs: 30_000, + }); + assert.equal(breaker.failureCount, 1); + assert.equal(breaker.state, "CLOSED"); // still closed (threshold=3) + + // recordProviderSuccess on CLOSED decays failureCount by 1 (matching execute()) + recordProviderSuccess(provider, undefined); + + assert.equal(breaker.failureCount, 0, "failureCount decremented by 1 (gradual recovery)"); + assert.equal(breaker.state, "CLOSED", "should stay CLOSED"); +}); + +test("recordProviderSuccess with null/undefined provider is a safe no-op", () => { + // Should not throw + recordProviderSuccess(null, undefined); + recordProviderSuccess(undefined, undefined); + recordProviderSuccess("", undefined); +}); + +test("recordProviderSuccess is idempotent: multiple calls on HALF_OPEN are safe", async () => { + const provider = uniqueProvider("idempotent"); + + // Open the breaker + recordProviderFailure(provider, undefined, undefined, { + failureThreshold: 1, + resetTimeoutMs: 100, + }); + + const breaker = getCircuitBreaker(provider); + assert.equal(breaker.state, "OPEN"); + + // Wait for HALF_OPEN + await new Promise((r) => setTimeout(r, 150)); + breaker.canExecute(); + assert.equal(breaker.state, "HALF_OPEN"); + + // First success closes the breaker + recordProviderSuccess(provider, undefined); + assert.equal(breaker.state, "CLOSED"); + assert.equal(breaker.failureCount, 0); + + // Second success is a no-op (state is CLOSED, not HALF_OPEN) + recordProviderSuccess(provider, undefined); + assert.equal(breaker.state, "CLOSED"); + assert.equal(breaker.failureCount, 0); +}); + +test("full lifecycle: CLOSED -> OPEN -> HALF_OPEN -> CLOSED via success", async () => { + const provider = uniqueProvider("lifecycle"); + + // Start CLOSED + const breaker = getCircuitBreaker(provider, { + failureThreshold: 1, + resetTimeoutMs: 100, + }); + assert.equal(breaker.state, "CLOSED"); + + // Failure opens the breaker + recordProviderFailure(provider, undefined, undefined, { + failureThreshold: 1, + resetTimeoutMs: 100, + }); + assert.equal(breaker.state, "OPEN"); + assert.equal(breaker.failureCount, 1); + + // Still OPEN before timeout + recordProviderSuccess(provider, undefined); + assert.equal(breaker.state, "OPEN", "must not close before resetTimeout"); + + // Wait for HALF_OPEN transition + await new Promise((r) => setTimeout(r, 150)); + breaker.canExecute(); + assert.equal(breaker.state, "HALF_OPEN"); + + // Success recovers to CLOSED + recordProviderSuccess(provider, undefined); + assert.equal(breaker.state, "CLOSED"); + assert.equal(breaker.failureCount, 0); + + // Subsequent failure re-opens (proves breaker is functional after recovery) + recordProviderFailure(provider, undefined, undefined, { + failureThreshold: 1, + resetTimeoutMs: 100, + }); + assert.equal(breaker.state, "OPEN"); + assert.equal(breaker.failureCount, 1); +}); + +test("recordProviderSuccess resets cooldown failureCount (exponential backoff)", () => { + const provider = uniqueProvider("cooldown-reset"); + const connectionId = "conn-1"; + + // Build up 4 failures -> cooldown with exponential backoff + for (let i = 0; i < 4; i++) { + recordProviderCooldown(provider, connectionId); + } + + // Cooldown should be active (failureCount > 0) + assert.equal( + isProviderInCooldown(provider, connectionId), + true, + "provider should be in cooldown after 4 failures" + ); + + // Success should reset the cooldown + recordProviderSuccess(provider, connectionId); + + assert.equal( + isProviderInCooldown(provider, connectionId), + false, + "cooldown should be cleared after success (failureCount reset to 0)" + ); + + // Breaker should remain CLOSED (cooldown test does not open the breaker) + const breaker = getCircuitBreaker(provider); + assert.equal(breaker.state, "CLOSED", "breaker stays CLOSED (cooldown-only scenario)"); +}); + +test("recordProviderSuccess with connectionId transitions breaker and resets cooldown", async () => { + const provider = uniqueProvider("connid-breaker"); + const connectionId = "conn-abc"; + + // Open the breaker + recordProviderFailure(provider, undefined, connectionId, { + failureThreshold: 1, + resetTimeoutMs: 100, + }); + + const breaker = getCircuitBreaker(provider); + assert.equal(breaker.state, "OPEN"); + + // Build up cooldown with connectionId + for (let i = 0; i < 3; i++) { + recordProviderCooldown(provider, connectionId); + } + assert.equal(isProviderInCooldown(provider, connectionId), true); + + // Wait for HALF_OPEN + await new Promise((r) => setTimeout(r, 150)); + breaker.canExecute(); + assert.equal(breaker.state, "HALF_OPEN"); + + // Success with connectionId should close breaker AND reset cooldown + recordProviderSuccess(provider, connectionId); + + assert.equal(breaker.state, "CLOSED", "breaker CLOSED after success with connectionId"); + assert.equal(breaker.failureCount, 0, "failureCount reset"); + assert.equal(isProviderInCooldown(provider, connectionId), false, "cooldown cleared"); +}); + +test("resetAllCircuitBreakers cleans up test state", () => { + // Verify cleanup works (prevents state leaking between tests) + resetAllCircuitBreakers(); + clearCooldownState(); + const provider = uniqueProvider("cleanup"); + const breaker = getCircuitBreaker(provider); + assert.equal(breaker.state, "CLOSED"); + assert.equal(breaker.failureCount, 0); +}); diff --git a/tests/unit/provider-limits-recovery.test.ts b/tests/unit/provider-limits-recovery.test.ts index 76e3f2fa608b..f6f275525bdc 100644 --- a/tests/unit/provider-limits-recovery.test.ts +++ b/tests/unit/provider-limits-recovery.test.ts @@ -86,12 +86,9 @@ test("successful GLM quota refresh clears transient rate-limit state", async () const connection = await createGlmConnectionWithTransientCooldown(); const connectionId = (connection as { id: string }).id; - await withMockedFetch( - (() => glmQuotaResponse()) as typeof fetch, - async () => { - await providerLimits.fetchAndPersistProviderLimits(connectionId, "manual"); - } - ); + await withMockedFetch((() => glmQuotaResponse()) as typeof fetch, async () => { + await providerLimits.fetchAndPersistProviderLimits(connectionId, "manual"); + }); const updated = (await providersDb.getProviderConnectionById(connectionId)) as Record< string, @@ -122,12 +119,9 @@ test("successful quota refresh does not clear terminal credits_exhausted status" const connection = await createGlmConnectionWithStatus("credits_exhausted"); const connectionId = (connection as { id: string }).id; - await withMockedFetch( - (() => glmQuotaResponse()) as typeof fetch, - async () => { - await providerLimits.fetchAndPersistProviderLimits(connectionId, "manual"); - } - ); + await withMockedFetch((() => glmQuotaResponse()) as typeof fetch, async () => { + await providerLimits.fetchAndPersistProviderLimits(connectionId, "manual"); + }); const updated = (await providersDb.getProviderConnectionById(connectionId)) as Record< string, @@ -141,12 +135,9 @@ test("successful quota refresh does not clear terminal banned status", async () const connection = await createGlmConnectionWithStatus("banned"); const connectionId = (connection as { id: string }).id; - await withMockedFetch( - (() => glmQuotaResponse()) as typeof fetch, - async () => { - await providerLimits.fetchAndPersistProviderLimits(connectionId, "manual"); - } - ); + await withMockedFetch((() => glmQuotaResponse()) as typeof fetch, async () => { + await providerLimits.fetchAndPersistProviderLimits(connectionId, "manual"); + }); const updated = (await providersDb.getProviderConnectionById(connectionId)) as Record< string, @@ -159,12 +150,9 @@ test("successful quota refresh does not clear terminal expired status", async () const connection = await createGlmConnectionWithStatus("expired"); const connectionId = (connection as { id: string }).id; - await withMockedFetch( - (() => glmQuotaResponse()) as typeof fetch, - async () => { - await providerLimits.fetchAndPersistProviderLimits(connectionId, "manual"); - } - ); + await withMockedFetch((() => glmQuotaResponse()) as typeof fetch, async () => { + await providerLimits.fetchAndPersistProviderLimits(connectionId, "manual"); + }); const updated = (await providersDb.getProviderConnectionById(connectionId)) as Record< string, @@ -237,6 +225,115 @@ test("error-only quota response does not clear transient state", async () => { assert.equal(updated.lastErrorType, "rate_limited"); }); +test("partial quota refresh does not clear a quota cooldown before its reset", async () => { + const resetAt = new Date(Date.now() + 30 * 60 * 1000).toISOString(); + const created = await providersDb.createProviderConnection({ + provider: "kimi-coding", + authType: "oauth", + accessToken: "kimi-access-token", + refreshToken: "kimi-refresh-token", + testStatus: "unavailable", + isActive: true, + lastError: "usage limit reached", + lastErrorType: "quota_exhausted", + errorCode: 403, + rateLimitedUntil: resetAt, + backoffLevel: 1, + }); + const connectionId = (created as { id: string }).id; + const connection = await providersDb.getProviderConnectionById(connectionId); + + await providerLimits.maybeClearRecoveredQuotaState(connection, { + quotas: { + Ratelimit: { remainingPercentage: 0 }, + Weekly: { remainingPercentage: 62 }, + }, + }); + const after = await providersDb.getProviderConnectionById(connectionId); + + assert.equal(after.testStatus, "unavailable"); + assert.equal(after.lastErrorType, "quota_exhausted"); + assert.equal(after.rateLimitedUntil, resetAt); +}); + +test("Claude subscription quota recovery clears synthetic cooldown once the real window resets", async () => { + // Reproduces the reported deadlock: a Claude subscription 429 persists a synthetic + // 1h rateLimitedUntil (SUBSCRIPTION_QUOTA_COOLDOWN_MS, no parseable upstream reset). + // The scheduled poller later fetches the REAL quota windows and finds the session + // window has already reset with quota available — the connection must clear even + // though the synthetic rateLimitedUntil is still in the future. + const syntheticRateLimitedUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const created = await providersDb.createProviderConnection({ + provider: "claude", + authType: "oauth", + accessToken: "claude-access-token", + refreshToken: "claude-refresh-token", + testStatus: "unavailable", + isActive: true, + lastError: "usage limit reached", + lastErrorType: "quota_exhausted", + errorCode: 429, + rateLimitedUntil: syntheticRateLimitedUntil, + backoffLevel: 1, + }); + const connectionId = (created as { id: string }).id; + const connection = await providersDb.getProviderConnectionById(connectionId); + + const realResetInThePast = new Date(Date.now() - 60 * 1000).toISOString(); + const result = await providerLimits.maybeClearRecoveredQuotaState(connection, { + quotas: { + "session (5h)": { remaining: 87, remainingPercentage: 87, resetAt: realResetInThePast }, + "weekly (7d)": { remaining: 62, remainingPercentage: 62, resetAt: realResetInThePast }, + }, + }); + + assert.equal(result.testStatus, "active", "returned snapshot should be cleared"); + assert.equal(result.rateLimitedUntil, null, "returned snapshot should drop rateLimitedUntil"); + assert.equal(result.lastErrorType, null, "returned snapshot should drop lastErrorType"); + + const after = await providersDb.getProviderConnectionById(connectionId); + assert.equal(after.testStatus, "active", "Sonnet/Opus connection should be usable again"); + assert.equal(after.rateLimitedUntil, undefined, "synthetic cooldown must be cleared"); + assert.equal(after.lastErrorType, undefined, "quota_exhausted marker must be cleared"); + assert.equal(after.backoffLevel, 0, "backoff level should reset to 0"); +}); + +test("Claude subscription quota still exhausted keeps the connection locked (no real recovery yet)", async () => { + // Inverse of the above: the real session window is still exhausted with no parseable + // reset (mirrors the existing kimi-coding test's semantics) — must stay locked even + // though other windows (e.g. weekly) show remaining quota. + const syntheticRateLimitedUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const created = await providersDb.createProviderConnection({ + provider: "claude", + authType: "oauth", + accessToken: "claude-access-token", + refreshToken: "claude-refresh-token", + testStatus: "unavailable", + isActive: true, + lastError: "usage limit reached", + lastErrorType: "quota_exhausted", + errorCode: 429, + rateLimitedUntil: syntheticRateLimitedUntil, + backoffLevel: 1, + }); + const connectionId = (created as { id: string }).id; + const connection = await providersDb.getProviderConnectionById(connectionId); + + const result = await providerLimits.maybeClearRecoveredQuotaState(connection, { + quotas: { + "session (5h)": { remaining: 0, remainingPercentage: 0 }, + "weekly (7d)": { remaining: 62, remainingPercentage: 62 }, + }, + }); + + assert.equal(result.testStatus, "unavailable", "still-exhausted session window must stay locked"); + + const after = await providersDb.getProviderConnectionById(connectionId); + assert.equal(after.testStatus, "unavailable"); + assert.equal(after.lastErrorType, "quota_exhausted"); + assert.equal(after.rateLimitedUntil, syntheticRateLimitedUntil); +}); + test("CAS primitive clears when expected state matches", async () => { const created = await createGlmConnectionWithTransientCooldown(); const connectionId = (created as { id: string }).id; @@ -299,9 +396,10 @@ test("CAS primitive aborts when state changed concurrently", async () => { test("quota recovery path does NOT overwrite a concurrent mark (TOCTOU closed)", async () => { const created = await createGlmConnectionWithTransientCooldown(); const connectionId = (created as { id: string }).id; - const snapshotBeforeClear = (await providersDb.getProviderConnectionById( - connectionId - )) as Record; + const snapshotBeforeClear = (await providersDb.getProviderConnectionById(connectionId)) as Record< + string, + unknown + >; const expectedLastErrorAt = (snapshotBeforeClear.lastErrorAt as string) ?? null; // Mock fetch so that DURING the quota fetch (between read and clear), a @@ -337,3 +435,98 @@ test("quota recovery path does NOT overwrite a concurrent mark (TOCTOU closed)", assert.equal(after.backoffLevel, 3, "fresh backoff level must survive"); assert.equal(after.lastError, "fresh concurrent 429"); }); + +function claudeUsageResponseWithQueuedExtraUsage() { + // Session/weekly windows are fully recovered (low utilization, future reset) + // but extra_usage.queued stays true — the two states are orthogonal upstream. + return new Response( + JSON.stringify({ + tier: "pro", + five_hour: { + utilization: 5, + resets_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + seven_day: { + utilization: 10, + resets_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }, + extra_usage: { queued: true }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +function claudeBootstrapResponseForExtraUsageTest() { + return new Response( + JSON.stringify({ + oauth_account: { + account_uuid: "account-uuid-extra-usage-test", + account_email: "claude-extra-usage@example.test", + organization_uuid: "org-uuid-extra-usage-test", + organization_name: "Extra Usage Test Org", + organization_type: "pro", + organization_rate_limit_tier: "pro", + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +test("Claude extra-usage block stays locked through the real sync chain when recovered quota windows coexist with extraUsage.queued=true", async () => { + // Walks the REAL call order inside fetchLiveProviderLimitsWithOptions: + // syncClaudeExtraUsageStateIfNeeded → re-asserts the extra-usage block + // maybeClearRecoveredQuotaState → must NOT undo it just because the + // session/weekly quota windows look + // recovered in the same fetch. + const created = await providersDb.createProviderConnection({ + provider: "claude", + authType: "oauth", + name: `Claude Extra Usage ${Date.now()} ${Math.random()}`, + email: `claude-extra-usage-${Date.now()}@example.test`, + accessToken: "claude-access-token", + refreshToken: "claude-refresh-token", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + testStatus: "unavailable", + isActive: true, + lastError: "Claude extra usage was detected and blocked by this connection policy.", + lastErrorType: "quota_exhausted", + lastErrorSource: "extra_usage", + errorCode: 429, + rateLimitedUntil: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + backoffLevel: 1, + // blockExtraUsage defaults to enabled (policy is opt-out via `=== false`). + providerSpecificData: {}, + }); + const connectionId = (created as { id: string }).id; + + await withMockedFetch( + (async (url) => { + const urlText = String(url); + if (urlText.includes("/api/claude_cli/bootstrap")) { + return claudeBootstrapResponseForExtraUsageTest(); + } + return claudeUsageResponseWithQueuedExtraUsage(); + }) as typeof fetch, + async () => { + const result = await providerLimits.fetchAndPersistProviderLimits(connectionId, "manual"); + assert.equal( + result.connection.testStatus, + "unavailable", + "returned snapshot must stay blocked" + ); + assert.equal(result.connection.lastErrorSource, "extra_usage"); + } + ); + + const after = (await providersDb.getProviderConnectionById(connectionId)) as Record< + string, + unknown + >; + assert.equal(after.testStatus, "unavailable", "connection must remain unavailable"); + assert.equal(after.lastErrorType, "quota_exhausted"); + assert.equal( + after.lastErrorSource, + "extra_usage", + "extra_usage marker must survive the general recovery-clearing logic" + ); +}); diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index 895fdb8ac3dc..9b8d2fcfe127 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -50,6 +50,39 @@ test("Codex workspacePlanType is used when live plan is missing or unknown", () assert.equal(tier.variant, "success"); }); +test("a lapsed Codex subscription beats the plan captured at connect time", () => { + const resolvedPlan = providerLimitUtils.resolvePlanValue( + "free", + { workspacePlanType: "plus" }, + "codex" + ); + + assert.equal(resolvedPlan, "free"); + assert.equal(providerLimitUtils.normalizePlanTier(resolvedPlan).key, "free"); +}); + +test("antigravity keeps the persisted tier when its live plan falls back to Free", () => { + for (const provider of ["antigravity", "agy"]) { + const resolvedPlan = providerLimitUtils.resolvePlanValue( + "Free", + { subscriptionTier: "Pro" }, + provider + ); + + assert.equal(resolvedPlan, "Pro", `${provider} must prefer the persisted tier`); + } +}); + +test("antigravity still prefers a live paid tier over persisted metadata", () => { + const resolvedPlan = providerLimitUtils.resolvePlanValue( + "Ultra", + { subscriptionTier: "Pro" }, + "antigravity" + ); + + assert.equal(resolvedPlan, "Ultra"); +}); + test("Claude providerSpecificData plan is used when live plan is missing", () => { const resolvedPlan = providerLimitUtils.resolvePlanValue(null, { plan: "Pro", diff --git a/tests/unit/provider-models-discovery-split.test.ts b/tests/unit/provider-models-discovery-split.test.ts index 9dd3502575c5..ad527cd5a675 100644 --- a/tests/unit/provider-models-discovery-split.test.ts +++ b/tests/unit/provider-models-discovery-split.test.ts @@ -224,6 +224,32 @@ test("codex.normalizeCodexModelsResponse parses the Codex live catalog shape", ( assert.equal(parsed.find((model) => model.id === "gpt-5.5")?.outputTokenLimit, 64000); }); +test("codex.normalizeCodexModelsResponse prefers max_context_window over the pricing tier", () => { + const parsed = normalizeCodexModelsResponse({ + models: [ + { + slug: "gpt-5.6-luna", + display_name: "GPT 5.6 Luna", + visibility: "list", + supported_in_api: true, + context_window: 272000, + max_context_window: 872000, + }, + { + slug: "gpt-5.4", + display_name: "GPT-5.4", + visibility: "list", + supported_in_api: true, + context_window: 272000, + max_context_window: 1000000, + }, + ], + }); + + assert.equal(parsed.find((model) => model.id === "gpt-5.6-luna")?.inputTokenLimit, 872000); + assert.equal(parsed.find((model) => model.id === "gpt-5.4")?.inputTokenLimit, 1000000); +}); + test("codex.normalizeCodexGithubCatalogResponse parses current client catalog metadata", () => { const parsed = normalizeCodexGithubCatalogResponse({ models: [ diff --git a/tests/unit/provider-models-route-codex.test.ts b/tests/unit/provider-models-route-codex.test.ts index c9b382479304..4b85a400eb46 100644 --- a/tests/unit/provider-models-route-codex.test.ts +++ b/tests/unit/provider-models-route-codex.test.ts @@ -159,11 +159,11 @@ test("provider models route merges live Codex models with the local catalog then assert.equal(body.discoveredCandidateCount, undefined); assert.deepEqual(seenRequests, [ { - url: "https://chatgpt.com/backend-api/codex/models?client_version=0.144.1", + url: "https://chatgpt.com/backend-api/codex/models?client_version=0.153.4", authorization: "Bearer codex-access-token", workspaceId: "account-123", originator: "codex_cli_rs", - userAgent: "codex-cli/0.144.1 (Windows 10.0.26200; x64)", + userAgent: "codex-cli/0.153.4 (Windows 10.0.26200; x64)", }, { url: "https://raw.githubusercontent.com/openai/codex/refs/heads/main/codex-rs/models-manager/models.json", diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index a9317bcb381d..9946a09fef38 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -346,6 +346,30 @@ test("AWS Polly specialty validator requires an access key id", async () => { assert.equal(result.error, "Missing AWS accessKeyId"); }); +test("SaluteSpeech specialty validator exchanges OAuth credentials without exposing the secret", async () => { + globalThis.fetch = async (url, init = {}) => { + assert.equal(String(url), "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"); + const headers = toPlainHeaders(init.headers); + assert.equal( + headers.Authorization, + `Basic ${Buffer.from("salute-client-id:salute-client-secret").toString("base64")}` + ); + assert.equal(headers["Content-Type"], "application/x-www-form-urlencoded"); + assert.match(headers.RqUID, /^[0-9a-f-]{36}$/i); + assert.equal(String(init.body), "scope=SALUTE_SPEECH_PERS"); + return new Response(JSON.stringify({ access_token: "salute-access-token" }), { status: 200 }); + }; + + const result = await validateProviderApiKey({ + provider: "salutespeech", + apiKey: "salute-client-secret", + providerSpecificData: { clientId: "salute-client-id" }, + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + test("embedding and rerank specialty validators surface auth failures for Voyage AI and Jina AI", async () => { globalThis.fetch = async (url) => { const target = String(url); diff --git a/tests/unit/rate-limit-local-capacity-classification.test.ts b/tests/unit/rate-limit-local-capacity-classification.test.ts new file mode 100644 index 000000000000..7f451086e4f6 --- /dev/null +++ b/tests/unit/rate-limit-local-capacity-classification.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { isLocalQueueCapacityErrorBody } = + await import("../../open-sse/services/combo/comboPredicates.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); + +function localQueueResponse() { + return new Response( + JSON.stringify({ + error: { + message: "Local rate-limit queue wait exceeded", + code: "RATE_LIMIT_QUEUE_TIMEOUT", + type: "local_queue_capacity", + }, + }), + { status: 429, headers: { "content-type": "application/json" } } + ); +} + +function createLog() { + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; +} + +// This base has no request-scoped/connection-cooldown predicate machinery +// (isRequestScopedUpstreamFailure, shouldSkipConnDisable arrived with a later +// 3.8.50 refactor), so the ported fix covers the classifier plus the combo and +// provider-breaker legs only. +test("a local queue capacity body is classified as local capacity", () => { + assert.equal( + isLocalQueueCapacityErrorBody({ + error: { code: "RATE_LIMIT_QUEUE_TIMEOUT", type: "local_queue_capacity" }, + }), + true + ); + assert.equal(isLocalQueueCapacityErrorBody({ error: { code: "rate_limit_exceeded" } }), false); +}); + +test("combo returns a local queue capacity response without retrying or rotating targets", async () => { + let calls = 0; + const response = await handleComboChat({ + body: { model: "openai/gpt-4" }, + combo: { + name: `local-queue-capacity-${Math.random().toString(16).slice(2)}`, + strategy: "priority", + models: ["openai/gpt-4", "openai/gpt-4o-mini"], + config: { maxRetries: 2, maxSetRetries: 1, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async () => { + calls += 1; + return localQueueResponse(); + }, + isModelAvailable: async () => true, + log: createLog() as never, + settings: {}, + allCombos: null, + }); + + assert.equal(response.status, 429); + assert.equal(calls, 1, "a local queue limit must not amplify into retries or fallback calls"); + const body = await response.json(); + assert.equal(body.error.code, "RATE_LIMIT_QUEUE_TIMEOUT"); + assert.equal(body.error.type, "local_queue_capacity"); +}); diff --git a/tests/unit/reasoning-cache-isolation.test.ts b/tests/unit/reasoning-cache-isolation.test.ts new file mode 100644 index 000000000000..07b049a4d3e7 --- /dev/null +++ b/tests/unit/reasoning-cache-isolation.test.ts @@ -0,0 +1,191 @@ +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const testDataDir = mkdtempSync(join(tmpdir(), "omniroute-reasoning-isolation-")); +process.env.DATA_DIR = testDataDir; +process.env.API_KEY_SECRET = "reasoning-isolation-test-secret"; +process.env.APP_LOG_TO_FILE = "false"; + +const { + buildReasoningCacheKey, + cacheReasoningFromAssistantMessage, + clearReasoningCacheAll, + getReasoningCacheServiceStats, + lookupReasoning, +} = await import("../../open-sse/services/reasoningCache.ts"); +const { setReasoningCache } = await import("../../src/lib/db/reasoningCache.ts"); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + +after(() => { + resetDbInstance(); + rmSync(testDataDir, { recursive: true, force: true }); +}); +const { translateRequest } = await import("../../open-sse/translator/index.ts"); +const { NON_ANTHROPIC_THINKING_PLACEHOLDER } = + await import("../../open-sse/translator/helpers/claudeHelper.ts"); + +const owner = "authenticated-key-a"; +const other = "authenticated-key-b"; +const callId = "shared-upstream-call-id"; + +function capture(apiKeyId: string | undefined, reasoning: string, provider = "deepseek") { + return cacheReasoningFromAssistantMessage( + { + role: "assistant", + reasoning_content: reasoning, + tool_calls: [{ id: callId }], + }, + provider, + "deepseek-chat", + { apiKeyId } + ); +} + +function replay(apiKeyId?: string, provider = "deepseek", extraBody = {}) { + const translated = translateRequest( + "openai", + "openai", + "deepseek-chat", + { + ...extraBody, + messages: [ + { role: "user", content: "use a tool" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: callId, type: "function", function: { name: "read_file", arguments: "{}" } }, + ], + }, + { role: "tool", tool_call_id: callId, content: "done" }, + ], + }, + false, + null, + provider, + null, + { reasoningCacheApiKeyId: apiKeyId } + ); + return translated.messages[1].reasoning_content; +} + +test("capture and actual translation isolate identical tool IDs between authenticated clients", () => { + clearReasoningCacheAll(); + assert.equal(capture(owner, "client A private reasoning"), 1); + assert.equal(replay(other), NON_ANTHROPIC_THINKING_PLACEHOLDER); + assert.equal(capture(other, "client B private reasoning"), 1); + assert.equal(replay(owner), "client A private reasoning"); + assert.equal(replay(other), "client B private reasoning"); +}); + +test("replay never crosses provider boundaries for the same authenticated key", () => { + clearReasoningCacheAll(); + capture(owner, "deepseek private reasoning"); + assert.equal(replay(owner, "siliconflow"), NON_ANTHROPIC_THINKING_PLACEHOLDER); + assert.equal(replay(owner), "deepseek private reasoning"); +}); + +test("anonymous requests cannot capture or replay, including spoofed body identity", () => { + clearReasoningCacheAll(); + assert.equal(capture(undefined, "anonymous private reasoning"), 0); + assert.equal(getReasoningCacheServiceStats().dbEntries, 0); + capture(owner, "authenticated private reasoning"); + const spoof = { + apiKeyId: owner, + reasoningCacheApiKeyId: owner, + _reasoningCacheApiKeyId: owner, + signatureNamespace: owner, + }; + assert.equal(replay(undefined, "deepseek", spoof), NON_ANTHROPIC_THINKING_PLACEHOLDER); + assert.equal(replay(other, "deepseek", spoof), NON_ANTHROPIC_THINKING_PLACEHOLDER); + assert.equal(lookupReasoning(callId), null); +}); + +test("SQLite fallback is scoped and never loads old unscoped cache entries", () => { + clearReasoningCacheAll(); + setReasoningCache(callId, "deepseek", "deepseek-chat", "legacy secret"); + assert.equal(replay(owner), NON_ANTHROPIC_THINKING_PLACEHOLDER); + const scopedKey = buildReasoningCacheKey(callId, owner, "deepseek")!; + setReasoningCache(scopedKey, "deepseek", "deepseek-chat", "persisted A secret"); + assert.equal(replay(other), NON_ANTHROPIC_THINKING_PLACEHOLDER); + assert.equal(replay(owner), "persisted A secret"); + assert.equal(lookupReasoning(scopedKey, other, "deepseek"), null); +}); + +test("plain assistant request-ID replay is also isolated", () => { + clearReasoningCacheAll(); + cacheReasoningFromAssistantMessage( + { role: "assistant", reasoning_content: "plain private reasoning" }, + "deepseek", + "deepseek-chat", + { apiKeyId: owner, requestId: "shared-request", messageIndex: 0 } + ); + for (const [apiKeyId, expected] of [ + [other, NON_ANTHROPIC_THINKING_PLACEHOLDER], + [owner, "plain private reasoning"], + ]) { + const result = translateRequest( + "openai", + "openai", + "deepseek-chat", + { + request_id: "shared-request", + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "answer" }, + { role: "user", content: "continue" }, + ], + }, + false, + null, + "deepseek", + null, + { reasoningCacheApiKeyId: apiKeyId } + ); + assert.equal(result.messages[1].reasoning_content, expected); + } +}); + +test("Claude-shape replay isolates both missing and empty thinking blocks", () => { + clearReasoningCacheAll(); + capture(owner, "kimi private reasoning", "kimi-coding"); + for (const emptyThinking of [false, true]) { + for (const [apiKeyId, expected] of [ + [other, NON_ANTHROPIC_THINKING_PLACEHOLDER], + [owner, "kimi private reasoning"], + ]) { + const result = translateRequest( + "claude", + "claude", + "kimi-k2.5", + { + thinking: { type: "enabled", budget_tokens: 2000 }, + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + ...(emptyThinking ? [{ type: "thinking", thinking: "" }] : []), + { type: "tool_use", id: callId, name: "read_file", input: {} }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: callId, content: "done" }], + }, + ], + }, + false, + null, + "kimi-coding", + null, + { reasoningCacheApiKeyId: apiKeyId } + ); + const thinking = result.messages[1].content.find((block) => block.type === "thinking"); + assert.equal(thinking?.thinking, expected); + } + } +}); diff --git a/tests/unit/reasoning-cache-translator.test.ts b/tests/unit/reasoning-cache-translator.test.ts new file mode 100644 index 000000000000..9d01fa8c67d6 --- /dev/null +++ b/tests/unit/reasoning-cache-translator.test.ts @@ -0,0 +1,454 @@ +/** Translator replay scenarios split from reasoning-cache.test.ts. */ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import "../_setup/isolateDataDir.ts"; + +const { + cacheReasoningFromAssistantMessage, + cacheReasoning, + getReasoningCacheServiceStats, + clearReasoningCacheAll, +} = await import("../../open-sse/services/reasoningCache.ts"); +const { translateRequest } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { clearModelsDevCapabilities, saveModelsDevCapabilities } = + await import("../../src/lib/modelsDevSync.ts"); + +function buildCapability(overrides = {}) { + return { + tool_call: null, + reasoning: null, + attachment: null, + structured_output: null, + temperature: null, + modalities_input: "[]", + modalities_output: "[]", + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: null, + limit_context: null, + limit_input: null, + limit_output: null, + interleaved_field: null, + ...overrides, + }; +} + +describe("Reasoning Replay Cache — Translator Replay", () => { + before(() => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + }); + + after(() => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + }); + + function translateWithToolHistory(provider: string, model: string, callId: string) { + return translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + model, + { + messages: [ + { role: "user", content: "use a tool" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: callId, type: "function", function: { name: "read_file", arguments: "{}" } }, + ], + }, + { role: "tool", tool_call_id: callId, content: "tool result" }, + ], + }, + false, + null, + provider, + null, + { reasoningCacheApiKeyId: "reasoning-test-key" } + ); + } + + it("should inject cached reasoning for DeepSeek instead of empty fallback", () => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + deepseek: { + "deepseek-reasoner": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + }); + cacheReasoning( + "call_translate_ds", + "deepseek", + "deepseek-reasoner", + "DeepSeek cached plan", + "reasoning-test-key" + ); + + const translated = translateWithToolHistory( + "deepseek", + "deepseek-reasoner", + "call_translate_ds" + ); + + assert.equal(translated.messages[1].reasoning_content, "DeepSeek cached plan"); + assert.equal(getReasoningCacheServiceStats().replays, 1); + }); + + it("should preserve client-provided reasoning content", () => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + deepseek: { + "deepseek-reasoner": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + }); + cacheReasoning( + "call_preserve", + "deepseek", + "deepseek-reasoner", + "Cached reasoning", + "reasoning-test-key" + ); + + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "deepseek-reasoner", + { + messages: [ + { role: "user", content: "use a tool" }, + { + role: "assistant", + content: null, + reasoning_content: "Client reasoning", + tool_calls: [ + { + id: "call_preserve", + type: "function", + function: { name: "tool", arguments: "{}" }, + }, + ], + }, + ], + }, + false, + null, + "deepseek", + null, + { reasoningCacheApiKeyId: "reasoning-test-key" } + ); + + assert.equal(translated.messages[1].reasoning_content, "Client reasoning"); + assert.equal(getReasoningCacheServiceStats().replays, 0); + }); + + it("should inject cached reasoning for Qwen and GLM thinking models", () => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + qwen: { + "qwen3-thinking-235b": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + glm: { + "glm-5-thinking": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + }); + cacheReasoning( + "call_qwen_think", + "qwen", + "qwen3-thinking-235b", + "Qwen cached plan", + "reasoning-test-key" + ); + cacheReasoning( + "call_glm_think", + "glm", + "glm-5-thinking", + "GLM cached plan", + "reasoning-test-key" + ); + + const qwen = translateWithToolHistory("qwen", "qwen3-thinking-235b", "call_qwen_think"); + const glm = translateWithToolHistory("glm", "glm-5-thinking", "call_glm_think"); + + assert.equal(qwen.messages[1].reasoning_content, "Qwen cached plan"); + assert.equal(glm.messages[1].reasoning_content, "GLM cached plan"); + assert.equal(getReasoningCacheServiceStats().replays, 2); + }); + + it("should not inject reasoning_content for generic non-reasoning providers", () => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + cacheReasoning("call_openai", "openai", "gpt-4o", "Should not replay", "reasoning-test-key"); + + const translated = translateWithToolHistory("openai", "gpt-4o", "call_openai"); + + assert.equal(translated.messages[1].reasoning_content, undefined); + assert.equal(getReasoningCacheServiceStats().replays, 0); + }); + + it("should support the full capture then replay flow", () => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + deepseek: { + "deepseek-reasoner": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + }); + + const captured = cacheReasoningFromAssistantMessage( + { + role: "assistant", + reasoning_content: "Full flow cached plan", + tool_calls: [{ id: "call_full_flow", type: "function" }], + }, + "deepseek", + "deepseek-reasoner", + { apiKeyId: "reasoning-test-key" } + ); + + const translated = translateWithToolHistory("deepseek", "deepseek-reasoner", "call_full_flow"); + + assert.equal(captured, 1); + assert.equal(translated.messages[1].reasoning_content, "Full flow cached plan"); + assert.equal(getReasoningCacheServiceStats().replays, 1); + }); + + it("should strip reasoning_content when model has no interleaved replay signal", () => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "deepseek-reasoner", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: "ok", + reasoning_content: "should be stripped", + }, + ], + }, + false, + null, + "deepseek", + null, + { reasoningCacheApiKeyId: "reasoning-test-key" } + ); + + assert.equal(translated.messages[1].reasoning_content, undefined); + }); + + it("should not inject reasoning_content when interleaved field is reasoning_details", () => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + testprovider: { + "test-reasoning-details": buildCapability({ + interleaved_field: "reasoning_details", + reasoning: true, + tool_call: true, + }), + }, + }); + cacheReasoning( + "call_details", + "testprovider", + "test-reasoning-details", + "cached", + "reasoning-test-key" + ); + + const translated = translateWithToolHistory( + "testprovider", + "test-reasoning-details", + "call_details" + ); + + assert.equal(translated.messages[1].reasoning_content, undefined); + }); + + it("should replace empty-string reasoning_content with NON_ANTHROPIC_THINKING_PLACEHOLDER on cache miss", async () => { + // Regression: injectEmptyReasoningContentForToolCalls (schemaCoercion.ts) pre-sets + // reasoning_content="" before the cache lookup. The old condition + // `msg.reasoning_content === undefined` never fired on cache miss, leaving the + // empty string in place. DeepSeek V4+ rejects "" with a 400. + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + deepseek: { + "deepseek-v4-flash": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + }); + + const { NON_ANTHROPIC_THINKING_PLACEHOLDER } = + await import("../../open-sse/translator/helpers/claudeHelper.ts"); + + // No cache entry → cache miss + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "deepseek-v4-flash", + { + messages: [ + { role: "user", content: "use a tool" }, + { + role: "assistant", + content: null, + reasoning_content: "", + tool_calls: [ + { + id: "call_empty_rc", + type: "function", + function: { name: "read_file", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_empty_rc", content: "file contents" }, + ], + }, + false, + null, + "deepseek", + null, + { reasoningCacheApiKeyId: "reasoning-test-key" } + ); + + assert.equal( + translated.messages[1].reasoning_content, + NON_ANTHROPIC_THINKING_PLACEHOLDER, + "empty reasoning_content should be replaced with placeholder on cache miss" + ); + }); + + it("should inject placeholder for a plain (non-tool-call) DeepSeek turn missing reasoning_content (#1682)", async () => { + // Regression (#1682): a multi-turn text conversation where the prior assistant + // turn has NO tool calls and the client (e.g. Cursor) stripped reasoning_content + // from history. DeepSeek V4+ still requires reasoning_content on every assistant + // message in thinking mode, so without a placeholder the upstream returns 400. + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + deepseek: { + "deepseek-v4-pro": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + }); + + const { NON_ANTHROPIC_THINKING_PLACEHOLDER } = + await import("../../open-sse/translator/helpers/claudeHelper.ts"); + + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "deepseek-v4-pro", + { + messages: [ + { role: "user", content: "hi" }, + // Plain assistant turn, no tool_calls, reasoning_content stripped by client. + { role: "assistant", content: "Hello! How can I help?" }, + { role: "user", content: "tell me more" }, + ], + }, + false, + null, + "deepseek", + null, + { reasoningCacheApiKeyId: "reasoning-test-key" } + ); + + assert.equal( + translated.messages[1].reasoning_content, + NON_ANTHROPIC_THINKING_PLACEHOLDER, + "plain DeepSeek assistant turn missing reasoning_content should get the placeholder" + ); + }); + + it("should replay cached reasoning for a plain (non-tool-call) DeepSeek turn when available (#1682)", () => { + // When a request_id-keyed cache entry exists for the plain turn, the real + // reasoning is replayed instead of the placeholder. + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + deepseek: { + "deepseek-v4-pro": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + }); + // NOTE: the non-tool-call cache key is built as `getAssistantMessageCacheKey(result, 0)` + // — the message index is hardcoded to 0 in the translator, so the key is always + // `request::message:0` regardless of the assistant message's actual position. + cacheReasoning( + "request:req-plain-1:message:0", + "deepseek", + "deepseek-v4-pro", + "Real cached plain-turn reasoning", + "reasoning-test-key" + ); + + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "deepseek-v4-pro", + { + request_id: "req-plain-1", + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "Hello! How can I help?" }, + { role: "user", content: "tell me more" }, + ], + }, + false, + null, + "deepseek", + null, + { reasoningCacheApiKeyId: "reasoning-test-key" } + ); + + assert.equal( + translated.messages[1].reasoning_content, + "Real cached plain-turn reasoning", + "plain DeepSeek assistant turn should replay the real cached reasoning when present" + ); + assert.equal(getReasoningCacheServiceStats().replays, 1); + }); +}); diff --git a/tests/unit/reasoning-cache-truncation.test.ts b/tests/unit/reasoning-cache-truncation.test.ts index dd42b262b2b7..a04ee48f541e 100644 --- a/tests/unit/reasoning-cache-truncation.test.ts +++ b/tests/unit/reasoning-cache-truncation.test.ts @@ -37,8 +37,8 @@ test("reasoning string > 10000 chars is truncated to 10000", async () => { reset(); const key = randomUUID(); const long = "A".repeat(15000); - cacheReasoningByKey(key, "deepseek", "deepseek-r1", long); - const result = lookupReasoning(key); + cacheReasoningByKey(key, "deepseek", "deepseek-r1", long, "reasoning-test-key"); + const result = lookupReasoning(key, "reasoning-test-key", "deepseek"); assert.ok(result, "should return cached reasoning"); assert.equal(result.length, 10000, "should be truncated to MAX_ENTRY_BYTES"); }); @@ -47,8 +47,8 @@ test("short reasoning string is cached unchanged", async () => { reset(); const key = randomUUID(); const short = "short reasoning content"; - cacheReasoningByKey(key, "deepseek", "deepseek-r1", short); - const result = lookupReasoning(key); + cacheReasoningByKey(key, "deepseek", "deepseek-r1", short, "reasoning-test-key"); + const result = lookupReasoning(key, "reasoning-test-key", "deepseek"); assert.ok(result, "should return cached reasoning"); assert.equal(result, short); }); @@ -58,8 +58,8 @@ test("truncation preserves the beginning of the string", async () => { const key = randomUUID(); const prefix = "BEGINNING_MARKER_"; const long = prefix + "X".repeat(20000); - cacheReasoningByKey(key, "deepseek", "deepseek-r1", long); - const result = lookupReasoning(key); + cacheReasoningByKey(key, "deepseek", "deepseek-r1", long, "reasoning-test-key"); + const result = lookupReasoning(key, "reasoning-test-key", "deepseek"); assert.ok(result, "should return cached reasoning"); assert.ok(result.startsWith(prefix), "truncated result should preserve the beginning"); assert.equal(result.length, 10000); @@ -74,7 +74,7 @@ test("memory cache respects MAX_MEMORY_ENTRIES limit (200)", async () => { for (let i = 0; i < 201; i++) { const k = `entry-${i}-${randomUUID()}`; keys.push(k); - cacheReasoningByKey(k, "deepseek", "deepseek-r1", `reasoning-${i}`); + cacheReasoningByKey(k, "deepseek", "deepseek-r1", `reasoning-${i}`, "reasoning-test-key"); } // The first entry should have been evicted from memory. @@ -84,7 +84,7 @@ test("memory cache respects MAX_MEMORY_ENTRIES limit (200)", async () => { // // Simpler approach: verify that we don't blow up and that the 201st entry // is retrievable (it was the last inserted, so definitely in memory). - const last = lookupReasoning(keys[200]); + const last = lookupReasoning(keys[200], "reasoning-test-key", "deepseek"); assert.ok(last, "most recent entry should be in memory cache"); assert.ok(last.includes("reasoning-200"), "should contain expected content"); }); diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts index acc32671d109..473dc01a69e4 100644 --- a/tests/unit/reasoning-cache.test.ts +++ b/tests/unit/reasoning-cache.test.ts @@ -1,3 +1,4 @@ +import { buildReasoningCacheKey } from "../../open-sse/services/reasoningCache.ts"; /** * Unit tests for the Reasoning Replay Cache (Issue #1628). * @@ -32,41 +33,12 @@ import { requiresReasoningReplay, cleanupReasoningCache, } from "../../open-sse/services/reasoningCache.ts"; -import { translateRequest } from "../../open-sse/translator/index.ts"; -import { FORMATS } from "../../open-sse/translator/formats.ts"; import { ensureToolCallIds } from "../../open-sse/translator/helpers/toolCallHelper.ts"; import { getDbInstance } from "../../src/lib/db/core.ts"; import { getReasoningCache, setReasoningCache } from "../../src/lib/db/reasoningCache.ts"; import { DELETE, GET } from "../../src/app/api/cache/reasoning/route.ts"; import { createApiKey } from "../../src/lib/db/apiKeys.ts"; import { updateSettings } from "../../src/lib/db/settings"; -import { - clearModelsDevCapabilities, - saveModelsDevCapabilities, -} from "../../src/lib/modelsDevSync.ts"; - -function buildCapability(overrides = {}) { - return { - tool_call: null, - reasoning: null, - attachment: null, - structured_output: null, - temperature: null, - modalities_input: "[]", - modalities_output: "[]", - knowledge_cutoff: null, - release_date: null, - last_updated: null, - status: null, - family: null, - open_weights: null, - limit_context: null, - limit_input: null, - limit_output: null, - interleaved_field: null, - ...overrides, - }; -} before(async () => { await updateSettings({ requireLogin: false }); @@ -91,18 +63,31 @@ describe("Reasoning Replay Cache — Service Layer", () => { "call_test_1", "deepseek", "deepseek-reasoner", - "The user wants to read the file..." + "The user wants to read the file...", + "reasoning-test-key" ); - const result = lookupReasoning("call_test_1"); + const result = lookupReasoning("call_test_1", "reasoning-test-key", "deepseek"); assert.equal(result, "The user wants to read the file..."); - assert.equal(getReasoningCache("call_test_1")?.reasoning, "The user wants to read the file..."); + assert.equal( + getReasoningCache(buildReasoningCacheKey("call_test_1", "reasoning-test-key", "deepseek")!) + ?.reasoning, + "The user wants to read the file..." + ); }); it("should fall back to SQLite when memory misses", () => { clearReasoningCacheAll(); - setReasoningCache("call_db_only", "deepseek", "deepseek-reasoner", "DB-only reasoning"); + setReasoningCache( + buildReasoningCacheKey("call_db_only", "reasoning-test-key", "deepseek")!, + "deepseek", + "deepseek-reasoner", + "DB-only reasoning" + ); - assert.equal(lookupReasoning("call_db_only"), "DB-only reasoning"); + assert.equal( + lookupReasoning("call_db_only", "reasoning-test-key", "deepseek"), + "DB-only reasoning" + ); const stats = getReasoningCacheServiceStats(); assert.equal(stats.hits, 1); @@ -111,18 +96,18 @@ describe("Reasoning Replay Cache — Service Layer", () => { }); it("should return null for unknown tool_call_id", () => { - const result = lookupReasoning("call_nonexistent"); + const result = lookupReasoning("call_nonexistent", "reasoning-test-key", "deepseek"); assert.equal(result, null); }); it("should return null for empty tool_call_id", () => { - const result = lookupReasoning(""); + const result = lookupReasoning("", "reasoning-test-key", "deepseek"); assert.equal(result, null); }); it("should skip caching when reasoning is empty", () => { - cacheReasoning("call_empty", "deepseek", "deepseek-chat", ""); - const result = lookupReasoning("call_empty"); + cacheReasoning("call_empty", "deepseek", "deepseek-chat", "", "reasoning-test-key"); + const result = lookupReasoning("call_empty", "reasoning-test-key", "deepseek"); assert.equal(result, null); }); @@ -131,11 +116,21 @@ describe("Reasoning Replay Cache — Service Layer", () => { ["call_batch_1", "call_batch_2", "call_batch_3"], "deepseek", "deepseek-reasoner", + "Batch reasoning content", + "reasoning-test-key" + ); + assert.equal( + lookupReasoning("call_batch_1", "reasoning-test-key", "deepseek"), + "Batch reasoning content" + ); + assert.equal( + lookupReasoning("call_batch_2", "reasoning-test-key", "deepseek"), + "Batch reasoning content" + ); + assert.equal( + lookupReasoning("call_batch_3", "reasoning-test-key", "deepseek"), "Batch reasoning content" ); - assert.equal(lookupReasoning("call_batch_1"), "Batch reasoning content"); - assert.equal(lookupReasoning("call_batch_2"), "Batch reasoning content"); - assert.equal(lookupReasoning("call_batch_3"), "Batch reasoning content"); }); it("should capture assistant reasoning for all tool_call IDs", () => { @@ -148,12 +143,19 @@ describe("Reasoning Replay Cache — Service Layer", () => { tool_calls: [{ id: "call_capture_1" }, { id: "call_capture_2" }], }, "deepseek", - "deepseek-reasoner" + "deepseek-reasoner", + { apiKeyId: "reasoning-test-key" } ); assert.equal(cached, 2); - assert.equal(lookupReasoning("call_capture_1"), "Captured assistant reasoning"); - assert.equal(lookupReasoning("call_capture_2"), "Captured assistant reasoning"); + assert.equal( + lookupReasoning("call_capture_1", "reasoning-test-key", "deepseek"), + "Captured assistant reasoning" + ); + assert.equal( + lookupReasoning("call_capture_2", "reasoning-test-key", "deepseek"), + "Captured assistant reasoning" + ); }); it("should keep request message cache keys stable when tool call IDs change", () => { @@ -178,13 +180,22 @@ describe("Reasoning Replay Cache — Service Layer", () => { ], }; - cacheReasoning(cacheKey, "deepseek", "deepseek-reasoner", "Stable cached reasoning"); + cacheReasoning( + cacheKey, + "deepseek", + "deepseek-reasoner", + "Stable cached reasoning", + "reasoning-test-key" + ); const originalToolCallId = body.messages[0].tool_calls[0].id; ensureToolCallIds(body, { use9CharId: true }); assert.notEqual(body.messages[0].tool_calls[0].id, originalToolCallId); - assert.equal(lookupReasoning(cacheKey), "Stable cached reasoning"); + assert.equal( + lookupReasoning(cacheKey, "reasoning-test-key", "deepseek"), + "Stable cached reasoning" + ); }); it("should capture provider reasoning alias when reasoning_content is absent", () => { @@ -197,11 +208,15 @@ describe("Reasoning Replay Cache — Service Layer", () => { tool_calls: [{ id: "call_capture_alias" }], }, "kimi", - "kimi-k2.5" + "kimi-k2.5", + { apiKeyId: "reasoning-test-key" } ); assert.equal(cached, 1); - assert.equal(lookupReasoning("call_capture_alias"), "Alias reasoning"); + assert.equal( + lookupReasoning("call_capture_alias", "reasoning-test-key", "kimi"), + "Alias reasoning" + ); }); it("should cache assistant reasoning without tool calls by request and message index", () => { @@ -214,11 +229,14 @@ describe("Reasoning Replay Cache — Service Layer", () => { }, "deepseek", "deepseek-reasoner", - { requestId: "req_no_tools", messageIndex: 3 } + { ...{ requestId: "req_no_tools", messageIndex: 3 }, apiKeyId: "reasoning-test-key" } ); assert.equal(cached, 1); - assert.equal(lookupReasoning("request:req_no_tools:message:3"), "No tool call reasoning"); + assert.equal( + lookupReasoning("request:req_no_tools:message:3", "reasoning-test-key", "deepseek"), + "No tool call reasoning" + ); }); it("should skip assistant reasoning without tool calls when stable key context is absent", () => { @@ -230,11 +248,15 @@ describe("Reasoning Replay Cache — Service Layer", () => { reasoning_content: "Missing key context", }, "deepseek", - "deepseek-reasoner" + "deepseek-reasoner", + { apiKeyId: "reasoning-test-key" } ); assert.equal(cached, 0); - assert.equal(lookupReasoning("request:req_missing:message:0"), null); + assert.equal( + lookupReasoning("request:req_missing:message:0", "reasoning-test-key", "deepseek"), + null + ); }); it("should store arbitrary reasoning cache keys", () => { @@ -244,29 +266,56 @@ describe("Reasoning Replay Cache — Service Layer", () => { "request:req_direct:message:1", "deepseek", "deepseek-reasoner", - "Keyed plan" + "Keyed plan", + "reasoning-test-key" ); - assert.equal(lookupReasoning("request:req_direct:message:1"), "Keyed plan"); - assert.equal(getReasoningCache("request:req_direct:message:1")?.reasoning, "Keyed plan"); + assert.equal( + lookupReasoning("request:req_direct:message:1", "reasoning-test-key", "deepseek"), + "Keyed plan" + ); + assert.equal( + getReasoningCache( + buildReasoningCacheKey("request:req_direct:message:1", "reasoning-test-key", "deepseek")! + )?.reasoning, + "Keyed plan" + ); }); it("should not overwrite if same tool_call_id is cached again", () => { - cacheReasoning("call_overwrite", "deepseek", "deepseek-chat", "First reasoning"); - cacheReasoning("call_overwrite", "deepseek", "deepseek-chat", "Updated reasoning"); + cacheReasoning( + "call_overwrite", + "deepseek", + "deepseek-chat", + "First reasoning", + "reasoning-test-key" + ); + cacheReasoning( + "call_overwrite", + "deepseek", + "deepseek-chat", + "Updated reasoning", + "reasoning-test-key" + ); // Second write wins (INSERT OR REPLACE) - const result = lookupReasoning("call_overwrite"); + const result = lookupReasoning("call_overwrite", "reasoning-test-key", "deepseek"); assert.equal(result, "Updated reasoning"); }); it("should track hits and misses correctly", () => { clearReasoningCacheAll(); - cacheReasoning("call_hit_test", "deepseek", "deepseek-chat", "test reasoning"); + cacheReasoning( + "call_hit_test", + "deepseek", + "deepseek-chat", + "test reasoning", + "reasoning-test-key" + ); - lookupReasoning("call_hit_test"); // hit - lookupReasoning("call_hit_test"); // hit - lookupReasoning("call_miss_test"); // miss + lookupReasoning("call_hit_test", "reasoning-test-key", "deepseek"); // hit + lookupReasoning("call_hit_test", "reasoning-test-key", "deepseek"); // hit + lookupReasoning("call_miss_test", "reasoning-test-key", "deepseek"); // miss const stats = getReasoningCacheServiceStats(); assert.ok(stats.hits >= 2, `Expected at least 2 hits, got ${stats.hits}`); @@ -287,8 +336,20 @@ describe("Reasoning Replay Cache — Service Layer", () => { it("should report correct stats structure", () => { clearReasoningCacheAll(); - cacheReasoning("call_stat_1", "deepseek", "deepseek-reasoner", "Reasoning A"); - cacheReasoning("call_stat_2", "kimi", "kimi-k2.5", "Reasoning B from Kimi"); + cacheReasoning( + "call_stat_1", + "deepseek", + "deepseek-reasoner", + "Reasoning A", + "reasoning-test-key" + ); + cacheReasoning( + "call_stat_2", + "kimi", + "kimi-k2.5", + "Reasoning B from Kimi", + "reasoning-test-key" + ); const stats = getReasoningCacheServiceStats(); @@ -311,8 +372,14 @@ describe("Reasoning Replay Cache — Service Layer", () => { it("should list persisted entries for the dashboard API", () => { clearReasoningCacheAll(); - cacheReasoning("call_entry_1", "deepseek", "deepseek-reasoner", "Entry reasoning A"); - cacheReasoning("call_entry_2", "kimi", "kimi-k2.5", "Entry reasoning B"); + cacheReasoning( + "call_entry_1", + "deepseek", + "deepseek-reasoner", + "Entry reasoning A", + "reasoning-test-key" + ); + cacheReasoning("call_entry_2", "kimi", "kimi-k2.5", "Entry reasoning B", "reasoning-test-key"); const deepseekEntries = getReasoningCacheServiceEntries({ provider: "deepseek" }) as Array<{ toolCallId: string; @@ -320,56 +387,106 @@ describe("Reasoning Replay Cache — Service Layer", () => { }>; assert.equal(deepseekEntries.length, 1); - assert.equal(deepseekEntries[0].toolCallId, "call_entry_1"); + assert.equal( + deepseekEntries[0].toolCallId, + buildReasoningCacheKey("call_entry_1", "reasoning-test-key", "deepseek") + ); assert.doesNotThrow(() => new Date(deepseekEntries[0].expiresAt).toISOString()); }); it("should clear all entries", () => { - cacheReasoning("call_clear_1", "deepseek", "deepseek-chat", "Will be cleared"); - cacheReasoning("call_clear_2", "deepseek", "deepseek-chat", "Also cleared"); + cacheReasoning( + "call_clear_1", + "deepseek", + "deepseek-chat", + "Will be cleared", + "reasoning-test-key" + ); + cacheReasoning( + "call_clear_2", + "deepseek", + "deepseek-chat", + "Also cleared", + "reasoning-test-key" + ); const count = clearReasoningCacheAll(); assert.ok(count >= 0); - assert.equal(lookupReasoning("call_clear_1"), null); - assert.equal(lookupReasoning("call_clear_2"), null); + assert.equal(lookupReasoning("call_clear_1", "reasoning-test-key", "deepseek"), null); + assert.equal(lookupReasoning("call_clear_2", "reasoning-test-key", "deepseek"), null); }); it("should delete one entry by tool_call_id", () => { clearReasoningCacheAll(); - cacheReasoning("call_delete_1", "deepseek", "deepseek-chat", "Delete me"); - cacheReasoning("call_delete_2", "deepseek", "deepseek-chat", "Keep me"); + cacheReasoning("call_delete_1", "deepseek", "deepseek-chat", "Delete me", "reasoning-test-key"); + cacheReasoning("call_delete_2", "deepseek", "deepseek-chat", "Keep me", "reasoning-test-key"); - assert.equal(deleteReasoningCacheEntry("call_delete_1"), 1); - assert.equal(lookupReasoning("call_delete_1"), null); - assert.equal(lookupReasoning("call_delete_2"), "Keep me"); + assert.equal( + deleteReasoningCacheEntry( + buildReasoningCacheKey("call_delete_1", "reasoning-test-key", "deepseek")! + ), + 1 + ); + assert.equal(lookupReasoning("call_delete_1", "reasoning-test-key", "deepseek"), null); + assert.equal(lookupReasoning("call_delete_2", "reasoning-test-key", "deepseek"), "Keep me"); }); it("should clear entries by provider only", () => { clearReasoningCacheAll(); - cacheReasoning("call_provider_ds", "deepseek", "deepseek-chat", "DeepSeek reasoning"); - cacheReasoning("call_provider_kimi", "kimi", "kimi-k2.5", "Kimi reasoning"); + cacheReasoning( + "call_provider_ds", + "deepseek", + "deepseek-chat", + "DeepSeek reasoning", + "reasoning-test-key" + ); + cacheReasoning( + "call_provider_kimi", + "kimi", + "kimi-k2.5", + "Kimi reasoning", + "reasoning-test-key" + ); assert.equal(clearReasoningCacheAll("deepseek"), 1); - assert.equal(lookupReasoning("call_provider_ds"), null); - assert.equal(lookupReasoning("call_provider_kimi"), "Kimi reasoning"); + assert.equal(lookupReasoning("call_provider_ds", "reasoning-test-key", "deepseek"), null); + assert.equal( + lookupReasoning("call_provider_kimi", "reasoning-test-key", "kimi"), + "Kimi reasoning" + ); }); it("should cleanup expired reasoning (no-op when nothing expired)", () => { - cacheReasoning("call_cleanup_test", "deepseek", "deepseek-chat", "Not expired yet"); + cacheReasoning( + "call_cleanup_test", + "deepseek", + "deepseek-chat", + "Not expired yet", + "reasoning-test-key" + ); const cleaned = cleanupReasoningCache(); assert.equal(typeof cleaned, "number"); // Entry should still be available since TTL is 2 hours - assert.equal(lookupReasoning("call_cleanup_test"), "Not expired yet"); + assert.equal( + lookupReasoning("call_cleanup_test", "reasoning-test-key", "deepseek"), + "Not expired yet" + ); }); it("should not return expired SQLite entries and cleanup should prune them", () => { clearReasoningCacheAll(); - setReasoningCache("call_expired", "deepseek", "deepseek-chat", "Expired reasoning", -1_000); + setReasoningCache( + buildReasoningCacheKey("call_expired", "reasoning-test-key", "deepseek")!, + "deepseek", + "deepseek-chat", + "Expired reasoning", + -1_000 + ); - assert.equal(lookupReasoning("call_expired"), null); + assert.equal(lookupReasoning("call_expired", "reasoning-test-key", "deepseek"), null); assert.equal(cleanupReasoningCache(), 1); assert.equal(getReasoningCacheServiceStats().dbEntries, 0); }); @@ -385,7 +502,7 @@ describe("Reasoning Replay Cache — Service Layer", () => { (tool_call_id, provider, model, reasoning, char_count, created_at, expires_at) VALUES (?, ?, ?, ?, ?, datetime('now'), ?)` ).run( - "call_legacy_iso_active", + buildReasoningCacheKey("call_legacy_iso_active", "reasoning-test-key", "deepseek"), "deepseek", "deepseek-chat", "Legacy ISO reasoning", @@ -397,7 +514,7 @@ describe("Reasoning Replay Cache — Service Layer", () => { (tool_call_id, provider, model, reasoning, char_count, created_at, expires_at) VALUES (?, ?, ?, ?, ?, datetime('now'), ?)` ).run( - "call_legacy_iso_expired", + buildReasoningCacheKey("call_legacy_iso_expired", "reasoning-test-key", "deepseek"), "deepseek", "deepseek-chat", "Expired legacy ISO reasoning", @@ -405,8 +522,14 @@ describe("Reasoning Replay Cache — Service Layer", () => { expiredIso ); - assert.equal(lookupReasoning("call_legacy_iso_active"), "Legacy ISO reasoning"); - assert.equal(lookupReasoning("call_legacy_iso_expired"), null); + assert.equal( + lookupReasoning("call_legacy_iso_active", "reasoning-test-key", "deepseek"), + "Legacy ISO reasoning" + ); + assert.equal( + lookupReasoning("call_legacy_iso_expired", "reasoning-test-key", "deepseek"), + null + ); const entries = getReasoningCacheServiceEntries({ provider: "deepseek" }) as Array<{ toolCallId: string; expiresAt: string; @@ -532,378 +655,6 @@ describe("Reasoning Replay Cache — Provider Detection", () => { }); }); -describe("Reasoning Replay Cache — Translator Replay", () => { - before(() => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - }); - - after(() => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - }); - - function translateWithToolHistory(provider: string, model: string, callId: string) { - return translateRequest( - FORMATS.OPENAI, - FORMATS.OPENAI, - model, - { - messages: [ - { role: "user", content: "use a tool" }, - { - role: "assistant", - content: null, - tool_calls: [ - { id: callId, type: "function", function: { name: "read_file", arguments: "{}" } }, - ], - }, - { role: "tool", tool_call_id: callId, content: "tool result" }, - ], - }, - false, - null, - provider - ); - } - - it("should inject cached reasoning for DeepSeek instead of empty fallback", () => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - saveModelsDevCapabilities({ - deepseek: { - "deepseek-reasoner": buildCapability({ - interleaved_field: "reasoning_content", - reasoning: true, - tool_call: true, - }), - }, - }); - cacheReasoning("call_translate_ds", "deepseek", "deepseek-reasoner", "DeepSeek cached plan"); - - const translated = translateWithToolHistory( - "deepseek", - "deepseek-reasoner", - "call_translate_ds" - ); - - assert.equal(translated.messages[1].reasoning_content, "DeepSeek cached plan"); - assert.equal(getReasoningCacheServiceStats().replays, 1); - }); - - it("should preserve client-provided reasoning content", () => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - saveModelsDevCapabilities({ - deepseek: { - "deepseek-reasoner": buildCapability({ - interleaved_field: "reasoning_content", - reasoning: true, - tool_call: true, - }), - }, - }); - cacheReasoning("call_preserve", "deepseek", "deepseek-reasoner", "Cached reasoning"); - - const translated = translateRequest( - FORMATS.OPENAI, - FORMATS.OPENAI, - "deepseek-reasoner", - { - messages: [ - { role: "user", content: "use a tool" }, - { - role: "assistant", - content: null, - reasoning_content: "Client reasoning", - tool_calls: [ - { - id: "call_preserve", - type: "function", - function: { name: "tool", arguments: "{}" }, - }, - ], - }, - ], - }, - false, - null, - "deepseek" - ); - - assert.equal(translated.messages[1].reasoning_content, "Client reasoning"); - assert.equal(getReasoningCacheServiceStats().replays, 0); - }); - - it("should inject cached reasoning for Qwen and GLM thinking models", () => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - saveModelsDevCapabilities({ - qwen: { - "qwen3-thinking-235b": buildCapability({ - interleaved_field: "reasoning_content", - reasoning: true, - tool_call: true, - }), - }, - glm: { - "glm-5-thinking": buildCapability({ - interleaved_field: "reasoning_content", - reasoning: true, - tool_call: true, - }), - }, - }); - cacheReasoning("call_qwen_think", "qwen", "qwen3-thinking-235b", "Qwen cached plan"); - cacheReasoning("call_glm_think", "glm", "glm-5-thinking", "GLM cached plan"); - - const qwen = translateWithToolHistory("qwen", "qwen3-thinking-235b", "call_qwen_think"); - const glm = translateWithToolHistory("glm", "glm-5-thinking", "call_glm_think"); - - assert.equal(qwen.messages[1].reasoning_content, "Qwen cached plan"); - assert.equal(glm.messages[1].reasoning_content, "GLM cached plan"); - assert.equal(getReasoningCacheServiceStats().replays, 2); - }); - - it("should not inject reasoning_content for generic non-reasoning providers", () => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - cacheReasoning("call_openai", "openai", "gpt-4o", "Should not replay"); - - const translated = translateWithToolHistory("openai", "gpt-4o", "call_openai"); - - assert.equal(translated.messages[1].reasoning_content, undefined); - assert.equal(getReasoningCacheServiceStats().replays, 0); - }); - - it("should support the full capture then replay flow", () => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - saveModelsDevCapabilities({ - deepseek: { - "deepseek-reasoner": buildCapability({ - interleaved_field: "reasoning_content", - reasoning: true, - tool_call: true, - }), - }, - }); - - const captured = cacheReasoningFromAssistantMessage( - { - role: "assistant", - reasoning_content: "Full flow cached plan", - tool_calls: [{ id: "call_full_flow", type: "function" }], - }, - "deepseek", - "deepseek-reasoner" - ); - - const translated = translateWithToolHistory("deepseek", "deepseek-reasoner", "call_full_flow"); - - assert.equal(captured, 1); - assert.equal(translated.messages[1].reasoning_content, "Full flow cached plan"); - assert.equal(getReasoningCacheServiceStats().replays, 1); - }); - - it("should strip reasoning_content when model has no interleaved replay signal", () => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - - const translated = translateRequest( - FORMATS.OPENAI, - FORMATS.OPENAI, - "deepseek-reasoner", - { - messages: [ - { role: "user", content: "hello" }, - { - role: "assistant", - content: "ok", - reasoning_content: "should be stripped", - }, - ], - }, - false, - null, - "deepseek" - ); - - assert.equal(translated.messages[1].reasoning_content, undefined); - }); - - it("should not inject reasoning_content when interleaved field is reasoning_details", () => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - saveModelsDevCapabilities({ - testprovider: { - "test-reasoning-details": buildCapability({ - interleaved_field: "reasoning_details", - reasoning: true, - tool_call: true, - }), - }, - }); - cacheReasoning("call_details", "testprovider", "test-reasoning-details", "cached"); - - const translated = translateWithToolHistory( - "testprovider", - "test-reasoning-details", - "call_details" - ); - - assert.equal(translated.messages[1].reasoning_content, undefined); - }); - - it("should replace empty-string reasoning_content with NON_ANTHROPIC_THINKING_PLACEHOLDER on cache miss", async () => { - // Regression: injectEmptyReasoningContentForToolCalls (schemaCoercion.ts) pre-sets - // reasoning_content="" before the cache lookup. The old condition - // `msg.reasoning_content === undefined` never fired on cache miss, leaving the - // empty string in place. DeepSeek V4+ rejects "" with a 400. - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - saveModelsDevCapabilities({ - deepseek: { - "deepseek-v4-flash": buildCapability({ - interleaved_field: "reasoning_content", - reasoning: true, - tool_call: true, - }), - }, - }); - - const { NON_ANTHROPIC_THINKING_PLACEHOLDER } = - await import("../../open-sse/translator/helpers/claudeHelper.ts"); - - // No cache entry → cache miss - const translated = translateRequest( - FORMATS.OPENAI, - FORMATS.OPENAI, - "deepseek-v4-flash", - { - messages: [ - { role: "user", content: "use a tool" }, - { - role: "assistant", - content: null, - reasoning_content: "", - tool_calls: [ - { - id: "call_empty_rc", - type: "function", - function: { name: "read_file", arguments: "{}" }, - }, - ], - }, - { role: "tool", tool_call_id: "call_empty_rc", content: "file contents" }, - ], - }, - false, - null, - "deepseek" - ); - - assert.equal( - translated.messages[1].reasoning_content, - NON_ANTHROPIC_THINKING_PLACEHOLDER, - "empty reasoning_content should be replaced with placeholder on cache miss" - ); - }); - - it("should inject placeholder for a plain (non-tool-call) DeepSeek turn missing reasoning_content (#1682)", async () => { - // Regression (#1682): a multi-turn text conversation where the prior assistant - // turn has NO tool calls and the client (e.g. Cursor) stripped reasoning_content - // from history. DeepSeek V4+ still requires reasoning_content on every assistant - // message in thinking mode, so without a placeholder the upstream returns 400. - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - saveModelsDevCapabilities({ - deepseek: { - "deepseek-v4-pro": buildCapability({ - interleaved_field: "reasoning_content", - reasoning: true, - tool_call: true, - }), - }, - }); - - const { NON_ANTHROPIC_THINKING_PLACEHOLDER } = - await import("../../open-sse/translator/helpers/claudeHelper.ts"); - - const translated = translateRequest( - FORMATS.OPENAI, - FORMATS.OPENAI, - "deepseek-v4-pro", - { - messages: [ - { role: "user", content: "hi" }, - // Plain assistant turn, no tool_calls, reasoning_content stripped by client. - { role: "assistant", content: "Hello! How can I help?" }, - { role: "user", content: "tell me more" }, - ], - }, - false, - null, - "deepseek" - ); - - assert.equal( - translated.messages[1].reasoning_content, - NON_ANTHROPIC_THINKING_PLACEHOLDER, - "plain DeepSeek assistant turn missing reasoning_content should get the placeholder" - ); - }); - - it("should replay cached reasoning for a plain (non-tool-call) DeepSeek turn when available (#1682)", () => { - // When a request_id-keyed cache entry exists for the plain turn, the real - // reasoning is replayed instead of the placeholder. - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - saveModelsDevCapabilities({ - deepseek: { - "deepseek-v4-pro": buildCapability({ - interleaved_field: "reasoning_content", - reasoning: true, - tool_call: true, - }), - }, - }); - // NOTE: the non-tool-call cache key is built as `getAssistantMessageCacheKey(result, 0)` - // — the message index is hardcoded to 0 in the translator, so the key is always - // `request::message:0` regardless of the assistant message's actual position. - cacheReasoning( - "request:req-plain-1:message:0", - "deepseek", - "deepseek-v4-pro", - "Real cached plain-turn reasoning" - ); - - const translated = translateRequest( - FORMATS.OPENAI, - FORMATS.OPENAI, - "deepseek-v4-pro", - { - request_id: "req-plain-1", - messages: [ - { role: "user", content: "hi" }, - { role: "assistant", content: "Hello! How can I help?" }, - { role: "user", content: "tell me more" }, - ], - }, - false, - null, - "deepseek" - ); - - assert.equal( - translated.messages[1].reasoning_content, - "Real cached plain-turn reasoning", - "plain DeepSeek assistant turn should replay the real cached reasoning when present" - ); - assert.equal(getReasoningCacheServiceStats().replays, 1); - }); -}); - describe("Reasoning Replay Cache — API Route", () => { let managementApiKey: string; @@ -930,7 +681,13 @@ describe("Reasoning Replay Cache — API Route", () => { it("should return stats and entries from GET", async () => { clearReasoningCacheAll(); - cacheReasoning("call_api_get", "deepseek", "deepseek-reasoner", "API visible reasoning"); + cacheReasoning( + "call_api_get", + "deepseek", + "deepseek-reasoner", + "API visible reasoning", + "reasoning-test-key" + ); const response = await GET( authedRequest("http://localhost/api/cache/reasoning?provider=deepseek") as never @@ -940,30 +697,63 @@ describe("Reasoning Replay Cache — API Route", () => { assert.equal(response.status, 200); assert.equal(body.stats.dbEntries, 1); assert.equal(body.entries.length, 1); - assert.equal(body.entries[0].toolCallId, "call_api_get"); + assert.equal( + body.entries[0].toolCallId, + buildReasoningCacheKey("call_api_get", "reasoning-test-key", "deepseek") + ); }); it("should delete a single entry by toolCallId", async () => { clearReasoningCacheAll(); - cacheReasoning("call_api_delete_1", "deepseek", "deepseek-reasoner", "Delete API"); - cacheReasoning("call_api_delete_2", "deepseek", "deepseek-reasoner", "Keep API"); + cacheReasoning( + "call_api_delete_1", + "deepseek", + "deepseek-reasoner", + "Delete API", + "reasoning-test-key" + ); + cacheReasoning( + "call_api_delete_2", + "deepseek", + "deepseek-reasoner", + "Keep API", + "reasoning-test-key" + ); const response = await DELETE( - authedRequest("http://localhost/api/cache/reasoning?toolCallId=call_api_delete_1") as never + authedRequest( + "http://localhost/api/cache/reasoning?toolCallId=" + + buildReasoningCacheKey("call_api_delete_1", "reasoning-test-key", "deepseek") + ) as never ); const body = await response.json(); assert.equal(response.status, 200); assert.equal(body.scope, "toolCallId"); assert.equal(body.cleared, 1); - assert.equal(lookupReasoning("call_api_delete_1"), null); - assert.equal(lookupReasoning("call_api_delete_2"), "Keep API"); + assert.equal(lookupReasoning("call_api_delete_1", "reasoning-test-key", "deepseek"), null); + assert.equal( + lookupReasoning("call_api_delete_2", "reasoning-test-key", "deepseek"), + "Keep API" + ); }); it("should delete entries by provider", async () => { clearReasoningCacheAll(); - cacheReasoning("call_api_provider_ds", "deepseek", "deepseek-reasoner", "Delete provider"); - cacheReasoning("call_api_provider_kimi", "kimi", "kimi-k2.5", "Keep provider"); + cacheReasoning( + "call_api_provider_ds", + "deepseek", + "deepseek-reasoner", + "Delete provider", + "reasoning-test-key" + ); + cacheReasoning( + "call_api_provider_kimi", + "kimi", + "kimi-k2.5", + "Keep provider", + "reasoning-test-key" + ); const response = await DELETE( authedRequest("http://localhost/api/cache/reasoning?provider=deepseek") as never @@ -973,7 +763,10 @@ describe("Reasoning Replay Cache — API Route", () => { assert.equal(response.status, 200); assert.equal(body.scope, "provider"); assert.equal(body.cleared, 1); - assert.equal(lookupReasoning("call_api_provider_ds"), null); - assert.equal(lookupReasoning("call_api_provider_kimi"), "Keep provider"); + assert.equal(lookupReasoning("call_api_provider_ds", "reasoning-test-key", "deepseek"), null); + assert.equal( + lookupReasoning("call_api_provider_kimi", "reasoning-test-key", "kimi"), + "Keep provider" + ); }); }); diff --git a/tests/unit/rejected-request-usage.test.ts b/tests/unit/rejected-request-usage.test.ts index bec7fc223efa..0e52c39bb011 100644 --- a/tests/unit/rejected-request-usage.test.ts +++ b/tests/unit/rejected-request-usage.test.ts @@ -24,7 +24,10 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); const callLogs = await import("../../src/lib/usage/callLogs.ts"); -const { recordRejectedRequestUsage } = await import("../../src/sse/handlers/rejectedRequestUsage.ts"); +const { + describeRejectedComboFailure, + recordRejectedRequestUsage, +} = await import("../../src/sse/handlers/rejectedRequestUsage.ts"); test.beforeEach(() => { core.resetDbInstance(); @@ -82,3 +85,14 @@ test("combo-exhausted rejection is also counted per api key", async () => { assert.equal(keyRows.length, 1); assert.equal(keyRows[0].success, false); }); + +test("interrupted 499 combo preserves its terminal reason", () => { + assert.equal( + describeRejectedComboFailure({ + status: 499, + comboName: "coding", + reason: "Model timeout: combo-per-model-timeout", + }), + '[499] Combo "coding" request interrupted: Model timeout: combo-per-model-timeout' + ); +}); diff --git a/tests/unit/request-dedup-rejection.test.ts b/tests/unit/request-dedup-rejection.test.ts new file mode 100644 index 000000000000..ade911d91874 --- /dev/null +++ b/tests/unit/request-dedup-rejection.test.ts @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + clearInflight, + computeRequestHash, + deduplicate, +} from "../../open-sse/services/requestDedup.ts"; + +test.afterEach(() => clearInflight()); + +test("waiters execute independently when the shared request rejects", async () => { + let calls = 0; + const execute = async () => { + const callNumber = ++calls; + await new Promise((resolve) => setTimeout(resolve, 10)); + if (callNumber === 1) throw new Error("upstream transport failed"); + return `response-${callNumber}`; + }; + + const results = await Promise.allSettled( + Array.from({ length: 3 }, () => deduplicate("shared-rejection", execute)) + ); + + assert.equal(calls, 3); + assert.equal(results.filter((result) => result.status === "rejected").length, 1); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 2); +}); + +test("Codex Responses prompts and strict schemas are part of the dedup hash", () => { + const base = { + model: "codex/gpt-5.6-sol", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "first prompt" }] }], + text: { + format: { + type: "json_schema", + name: "profile", + strict: true, + schema: { type: "object", properties: { first: { type: "string" } } }, + }, + }, + }; + + const differentPrompt = { + ...base, + input: [{ role: "user", content: [{ type: "input_text", text: "second prompt" }] }], + }; + const differentSchema = { + ...base, + text: { + format: { + ...base.text.format, + schema: { type: "object", properties: { second: { type: "number" } } }, + }, + }, + }; + + assert.notEqual(computeRequestHash(base), computeRequestHash(differentPrompt)); + assert.notEqual(computeRequestHash(base), computeRequestHash(differentSchema)); +}); + +test("dedup hashes are stable across key order and isolated by API key", () => { + const first = { + model: "openai/gpt-4o-mini", + messages: [{ role: "user", content: "same prompt" }], + temperature: 0, + stream: false, + }; + const reordered = { + temperature: 0, + messages: [{ content: "same prompt", role: "user" }], + stream: true, + model: "openai/gpt-4o-mini", + }; + + assert.equal(computeRequestHash(first, "key-a"), computeRequestHash(reordered, "key-a")); + assert.notEqual(computeRequestHash(first, "key-a"), computeRequestHash(first, "key-b")); +}); diff --git a/tests/unit/request-dedup-response-reuse.test.ts b/tests/unit/request-dedup-response-reuse.test.ts new file mode 100644 index 000000000000..92269c3b27b9 --- /dev/null +++ b/tests/unit/request-dedup-response-reuse.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { isReusableDeduplicatedExecutionResult } from "../../open-sse/handlers/chatCore/responseHeaders.ts"; + +function executionResult(status: number, payload: unknown) { + return { + _dedupSnapshot: { + status, + statusText: "", + headers: [["content-type", "application/json"]] as [string, string][], + payload: typeof payload === "string" ? payload : JSON.stringify(payload), + }, + }; +} + +test("only a successful completion envelope is reusable", () => { + const valid = executionResult(200, { + choices: [{ message: { role: "assistant", content: "usable" }, finish_reason: "stop" }], + }); + const empty = executionResult(200, { + choices: [{ message: { role: "assistant", content: "" }, finish_reason: "stop" }], + }); + const error = executionResult(422, { error: { message: "invalid" } }); + + assert.equal(isReusableDeduplicatedExecutionResult(valid), true); + assert.equal(isReusableDeduplicatedExecutionResult(empty), false); + assert.equal(isReusableDeduplicatedExecutionResult(error), false); +}); + +test("unknown provider envelopes and incomplete Responses output are not reusable", () => { + const unknownEmptyEnvelope = executionResult(200, { candidates: [] }); + const incomplete = executionResult(200, { + object: "response", + status: "incomplete", + output: [ + { + type: "message", + content: [{ type: "output_text", text: '{"requirements":[],"domain":null}' }], + }, + ], + }); + + assert.equal(isReusableDeduplicatedExecutionResult(unknownEmptyEnvelope), false); + assert.equal(isReusableDeduplicatedExecutionResult(incomplete), false); +}); + +test("an empty Claude text block is not reusable", () => { + const emptyClaudeMessage = executionResult(200, { + type: "message", + role: "assistant", + content: [{ type: "text", text: "" }], + stop_reason: "end_turn", + }); + + assert.equal(isReusableDeduplicatedExecutionResult(emptyClaudeMessage), false); +}); diff --git a/tests/unit/request-logger-api-key-redaction.test.ts b/tests/unit/request-logger-api-key-redaction.test.ts new file mode 100644 index 000000000000..ff0ebf5d8d4a --- /dev/null +++ b/tests/unit/request-logger-api-key-redaction.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createRequestLogger } from "../../open-sse/utils/requestLogger.ts"; +import { protectPayloadForLog } from "../../src/lib/logPayloads.ts"; + +const GEMINI_KEY = "test-gemini-api-key-value-00000"; +const AZURE_KEY = "test-azure-api-key-value-00000"; +const ELEVENLABS_KEY = "test-elevenlabs-api-key-value-00000"; + +test("request logger masks every api-key header variant", async () => { + const logger = await createRequestLogger("openai", "gemini", "gemini-2.5-flash"); + logger.logTargetRequest( + "https://generativelanguage.googleapis.com/v1beta/models/test:generateContent", + { + "x-goog-api-key": GEMINI_KEY, + "api-key": AZURE_KEY, + "xi-api-key": ELEVENLABS_KEY, + "x-ratelimit-remaining": "99", + }, + { contents: [] } + ); + + const headers = logger.getPipelinePayloads()?.providerRequest?.headers ?? {}; + const dumped = JSON.stringify(headers); + assert.equal(dumped.includes(GEMINI_KEY), false); + assert.equal(dumped.includes(AZURE_KEY), false); + assert.equal(dumped.includes(ELEVENLABS_KEY), false); + assert.equal(headers["x-ratelimit-remaining"], "99"); +}); + +test("persisted payload redaction masks api-key header variants", () => { + const payload = protectPayloadForLog({ + headers: { + "x-goog-api-key": GEMINI_KEY, + "api-key": AZURE_KEY, + "xi-api-key": ELEVENLABS_KEY, + "Content-Type": "application/json", + }, + }) as { headers: Record }; + + assert.equal(payload.headers["x-goog-api-key"], "[REDACTED]"); + assert.equal(payload.headers["api-key"], "[REDACTED]"); + assert.equal(payload.headers["xi-api-key"], "[REDACTED]"); + assert.equal(payload.headers["Content-Type"], "application/json"); +}); diff --git a/tests/unit/require-management-auth-access-token.test.ts b/tests/unit/require-management-auth-access-token.test.ts index 985e0d39c148..6b3e2c2ac1c0 100644 --- a/tests/unit/require-management-auth-access-token.test.ts +++ b/tests/unit/require-management-auth-access-token.test.ts @@ -47,7 +47,7 @@ test("read token: allowed on GET, rejected (403) on a write route", async () => test("write token: allowed on write route, rejected (403) on admin route", async () => { const { secret } = at.createAccessToken({ name: "write-tok", scope: "write" }); - assert.equal(await requireManagementAuth(req("POST", "/api/keys", secret)), null); + assert.equal(await requireManagementAuth(req("POST", "/api/combos", secret)), null); assert.equal(await requireManagementAuth(req("GET", "/api/v1/models", secret)), null); const denied = await requireManagementAuth(req("POST", "/api/cli/tokens", secret)); @@ -61,7 +61,9 @@ test("admin token: allowed on admin route", async () => { }); test("invalid/expired access token is rejected with 401", async () => { - const bad = await requireManagementAuth(req("GET", "/api/v1/models", "oma_live_not_a_real_token")); + const bad = await requireManagementAuth( + req("GET", "/api/v1/models", "oma_live_not_a_real_token") + ); assert.equal(bad?.status, 401); const past = new Date(Date.now() - 60_000).toISOString(); diff --git a/tests/unit/resolve-stream-flag.test.ts b/tests/unit/resolve-stream-flag.test.ts index caf544b960d5..078344024e23 100644 --- a/tests/unit/resolve-stream-flag.test.ts +++ b/tests/unit/resolve-stream-flag.test.ts @@ -15,26 +15,24 @@ describe("resolveStreamFlag — forceStream / providerRequiresStreaming guard (# undefined, // sourceFormat { providerRequiresStreaming: true } ); - assert.equal(result, true, "stream-only provider must stay streaming even when client prefers JSON"); + assert.equal( + result, + true, + "stream-only provider must stay streaming even when client prefers JSON" + ); }); it("non-forceStream provider: client prefers JSON + stream:false → non-streaming (unchanged behavior)", () => { - const result = resolveStreamFlag( - false, - "application/json", - undefined, - { providerRequiresStreaming: false } - ); + const result = resolveStreamFlag(false, "application/json", undefined, { + providerRequiresStreaming: false, + }); assert.equal(result, false, "normal provider should respect client JSON preference"); }); it("forceStream provider: no explicit stream flag → streams by default", () => { - const result = resolveStreamFlag( - undefined, - undefined, - undefined, - { providerRequiresStreaming: true } - ); + const result = resolveStreamFlag(undefined, undefined, undefined, { + providerRequiresStreaming: true, + }); assert.equal(result, true); }); @@ -44,24 +42,18 @@ describe("resolveStreamFlag — forceStream / providerRequiresStreaming guard (# }); it("forceStream provider: client explicitly sends stream:true → stays true", () => { - const result = resolveStreamFlag( - true, - "application/json", - undefined, - { providerRequiresStreaming: true } - ); + const result = resolveStreamFlag(true, "application/json", undefined, { + providerRequiresStreaming: true, + }); assert.equal(result, true); }); it("forceStream provider: client sends Accept: text/event-stream + stream:false → stays true", () => { // SSE Accept header alone shouldn't be needed for stream-only providers, // but providerRequiresStreaming should still force true. - const result = resolveStreamFlag( - false, - "text/event-stream", - undefined, - { providerRequiresStreaming: true } - ); + const result = resolveStreamFlag(false, "text/event-stream", undefined, { + providerRequiresStreaming: true, + }); assert.equal(result, true); }); @@ -71,3 +63,84 @@ describe("resolveStreamFlag — forceStream / providerRequiresStreaming guard (# assert.equal(result, false); }); }); + +describe("resolveStreamFlag — global stream default via OMNIROUTE_STREAM_DEFAULT_MODE", () => { + const ENV_VAR = "OMNIROUTE_STREAM_DEFAULT_MODE"; + + function withEnv(value: string | undefined, fn: () => void) { + const previous = process.env[ENV_VAR]; + if (value === undefined) delete process.env[ENV_VAR]; + else process.env[ENV_VAR] = value; + try { + fn(); + } finally { + if (previous === undefined) delete process.env[ENV_VAR]; + else process.env[ENV_VAR] = previous; + } + } + + // A machine client (python-httpx et al.) omits `stream` and sends a wildcard Accept. + // The legacy default streams it, so an upstream failure arrives as an in-band error + // frame inside a committed 200 that the client reads as an empty success. + it("legacy default still streams a wildcard-Accept client that omits stream", () => { + withEnv(undefined, () => { + assert.equal(resolveStreamFlag(undefined, "*/*"), true); + }); + }); + + it("env json mode routes that same client through the JSON path", () => { + withEnv("json", () => { + assert.equal(resolveStreamFlag(undefined, "*/*"), false); + }); + }); + + it("explicit body stream and SSE Accept still win over the env default", () => { + withEnv("json", () => { + assert.equal(resolveStreamFlag(true, "*/*"), true); + assert.equal(resolveStreamFlag(undefined, "text/event-stream"), true); + }); + }); + + it("a per-key json mode still wins when no env default is set", () => { + withEnv(undefined, () => { + assert.equal( + resolveStreamFlag(undefined, "*/*", undefined, { streamDefaultMode: "json" }), + false + ); + }); + }); + + // Every key row is created with stream_default_mode = "legacy" (POST /api/keys), so a + // stored "legacy" is indistinguishable from "unset". Honouring it as an explicit + // opt-out made the env var dead code on any real database — the deployment default + // must reach keys carrying that creation-time value. + it("a stored legacy mode does not veto the env default", () => { + withEnv("json", () => { + assert.equal( + resolveStreamFlag(undefined, "*/*", undefined, { streamDefaultMode: "legacy" }), + false + ); + }); + }); + + it("a per-request SSE opt-in still wins over the env default for such a key", () => { + withEnv("json", () => { + assert.equal( + resolveStreamFlag(true, "*/*", undefined, { streamDefaultMode: "legacy" }), + true + ); + assert.equal( + resolveStreamFlag(undefined, "text/event-stream", undefined, { + streamDefaultMode: "legacy", + }), + true + ); + }); + }); + + it("ignores an unrecognized env value", () => { + withEnv("yes", () => { + assert.equal(resolveStreamFlag(undefined, "*/*"), true); + }); + }); +}); diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index 2cffb53ff240..d019deb73b40 100644 --- a/tests/unit/responses-translation-fixes.test.ts +++ b/tests/unit/responses-translation-fixes.test.ts @@ -184,6 +184,38 @@ test("Responses→Chat: null input normalizes to an empty list (not [null])", () assert.deepEqual(normalizeResponsesInputForChat(null), []); }); +test("Responses→Chat: web_search_call metadata is already ignored while paired tool output remains", () => { + const result = openaiResponsesToOpenAIRequest( + null, + { + model: "gpt-4", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Find docs" }] }, + { + type: "function_call", + call_id: "call_search", + name: "omniroute_web_search", + arguments: '{"query":"OmniRoute docs"}', + }, + { + type: "function_call_output", + call_id: "call_search", + output: '{"success":true}', + }, + { type: "web_search_call", id: "ws_call_search", status: "completed" }, + ], + }, + null, + null + ) as { messages: Array<{ role: string; content?: unknown }> }; + + assert.deepEqual( + result.messages.map((message) => message.role), + ["user", "assistant", "tool"] + ); + assert.equal(result.messages[2].content, '{"success":true}'); +}); + test("Responses→Chat: input_image without detail omits detail field", () => { const body = { model: "gpt-4", diff --git a/tests/unit/salutespeech-live-handler.test.ts b/tests/unit/salutespeech-live-handler.test.ts new file mode 100644 index 000000000000..96fd4c332ae1 --- /dev/null +++ b/tests/unit/salutespeech-live-handler.test.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openSaluteSpeechLiveRecognition, setSaluteSpeechLiveDependenciesForTests } = + await import("../../open-sse/handlers/saluteSpeechLive.ts"); + +test.afterEach(() => setSaluteSpeechLiveDependenciesForTests(null)); + +test("SaluteSpeech live recognition exchanges managed OAuth credentials and normalizes events", async () => { + const listeners = new Map(); + const writes = []; + const metadataValues = []; + setSaluteSpeechLiveDependenciesForTests({ + fetch: async () => + new Response(JSON.stringify({ access_token: "salute-access-token" }), { + headers: { "content-type": "application/json" }, + }), + createClient: () => ({ + Recognize(metadata) { + metadataValues.push(metadata.get("authorization")[0]); + return { + write(value) { + writes.push(value); + return true; + }, + end() {}, + cancel() {}, + on(event, listener) { + listeners.set(event, listener); + return this; + }, + }; + }, + close() {}, + }), + }); + const events = []; + const recognition = await openSaluteSpeechLiveRecognition({ + credentials: { apiKey: "client-secret", providerSpecificData: { clientId: "client-id" } }, + language: "ru-RU", + model: "general", + sampleRate: 16000, + onResult: (event) => events.push(event), + onError: () => assert.fail("unexpected provider error"), + onEnd: () => {}, + }); + + listeners.get("data")({ + transcription: { + eou: false, + results: [{ normalized_text: "частичный текст", confidence: 0.9 }], + }, + }); + recognition.writeAudio(Buffer.from([0, 0])); + recognition.close(); + + assert.equal(metadataValues[0], "Bearer salute-access-token"); + assert.deepEqual(events, [{ type: "partial", text: "частичный текст", confidence: 0.9 }]); + assert.equal(writes[0].options.sample_rate, 16000); + assert.deepEqual(writes.at(-1), { audio_chunk: Buffer.from([0, 0]) }); +}); diff --git a/tests/unit/schema-slot-keys.test.ts b/tests/unit/schema-slot-keys.test.ts new file mode 100644 index 000000000000..e7100ae3fc31 --- /dev/null +++ b/tests/unit/schema-slot-keys.test.ts @@ -0,0 +1,121 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + sanitizeClaudeToolSchemas, + stripInvalidSchemaConstructs, +} from "../../open-sse/translator/helpers/schemaCoercion.ts"; + +// Schema-valued positions supported by the existing sanitizer. +// A placeholder in any of them has to become the permissive {}: forwarding the +// string is invalid JSON Schema and is the 400 this sanitizer exists to prevent. +const SCHEMA_SLOTS = [ + "items", + "additionalProperties", + "propertyNames", + "contains", + "not", + "if", + "then", + "else", + "unevaluatedProperties", + "additionalItems", + "contentSchema", + "unevaluatedItems", +]; + +// Produced by logTruncation.ts once a schema is deeper than the log depth limit. +const PLACEHOLDERS = ["[MaxDepth]", "[Truncated]", "[Circular]", "[Object]", "[Array]"]; + +function strip(schema: unknown) { + return stripInvalidSchemaConstructs(schema) as Record; +} + +for (const key of SCHEMA_SLOTS) { + test(`a placeholder in ${key} becomes a permissive schema`, () => { + for (const placeholder of PLACEHOLDERS) { + const out = strip({ type: "object", [key]: placeholder }); + assert.deepEqual(out[key], {}, `${key} kept ${placeholder}`); + } + }); +} + +test("a boolean schema is preserved, not widened", () => { + // `contentSchema: false` and `unevaluatedItems: false` are valid and + // restrictive; turning either into {} would invite the model to invent data. + for (const key of ["contentSchema", "unevaluatedItems"]) { + assert.equal(strip({ [key]: false })[key], false); + assert.equal(strip({ [key]: true })[key], true); + } +}); + +test("a nested subschema is still walked", () => { + const out = strip({ + contentSchema: { type: "object", properties: { a: { enum: "[MaxDepth]" } } }, + unevaluatedItems: { items: "[MaxDepth]" }, + }); + const content = out.contentSchema as Record>; + assert.deepEqual(content.properties.a, {}, "an invalid enum is dropped, leaving {}"); + assert.deepEqual(out.unevaluatedItems, { items: {} }); +}); + +test("a string that is not a placeholder is left alone", () => { + // Only the placeholder shape is coerced. Anything else stays exactly as it + // arrived, so a schema this sanitizer does not understand is forwarded rather + // than rewritten. + for (const key of ["contentSchema", "unevaluatedItems"]) { + assert.equal(strip({ [key]: "text/plain" })[key], "text/plain"); + } +}); + +test("a property named like a slot keyword is not treated as one", () => { + // Property names live in their own space: a tool whose parameter is called + // contentSchema must keep its description string. + const out = strip({ + type: "object", + properties: { contentSchema: "[MaxDepth]", unevaluatedItems: { type: "string" } }, + }); + const properties = out.properties as Record; + assert.deepEqual( + properties.contentSchema, + {}, + "a placeholder property value is still a schema slot" + ); + assert.deepEqual(properties.unevaluatedItems, { type: "string" }); +}); + +test("Claude tool sanitization repairs nested slots without changing annotations or constraints", () => { + const tools = [ + { + name: "inspect_payload", + description: "[MaxDepth]", + input_schema: { + type: "object", + properties: { + payload: { type: "string", contentSchema: " [Truncated] ", description: "[Object]" }, + values: { + type: "array", + prefixItems: [{ type: "integer" }], + unevaluatedItems: "[Circular]", + }, + contentSchema: { type: "string", enum: ["[MaxDepth]", "literal"] }, + unevaluatedItems: false, + }, + required: ["payload", "values"], + additionalProperties: false, + $defs: { encoded: { contentSchema: "[Array]", unevaluatedItems: false } }, + }, + }, + ]; + const original = structuredClone(tools); + const expected = structuredClone(tools) as unknown as Array<{ + input_schema: Record; + }>; + const properties = expected[0].input_schema.properties as Record>; + properties.payload.contentSchema = {}; + properties.values.unevaluatedItems = {}; + ( + expected[0].input_schema.$defs as Record> + ).encoded.contentSchema = {}; + assert.deepEqual(sanitizeClaudeToolSchemas(tools), expected); + assert.deepEqual(tools, original, "the caller's schema must remain intact"); +}); diff --git a/tests/unit/semantic-cache-request-identity.test.ts b/tests/unit/semantic-cache-request-identity.test.ts new file mode 100644 index 000000000000..9503f0e7f49f --- /dev/null +++ b/tests/unit/semantic-cache-request-identity.test.ts @@ -0,0 +1,140 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { generateSignature, requestVariantOf } from "../../src/lib/semanticCache.ts"; +import { storeSemanticCacheResponse } from "../../open-sse/handlers/chatCore/semanticCacheStore.ts"; +import { storeStreamingSemanticCacheResponse } from "../../open-sse/handlers/chatCore/streamingSemanticCacheStore.ts"; + +const MESSAGES = [{ role: "user", content: "Describe a red bicycle." }]; + +function signature(body: Record, context?: Record) { + return generateSignature( + "model", + body.messages ?? body.input, + body.temperature, + body.top_p, + "key", + requestVariantOf(body, context) + ); +} + +test("output schemas, tools, and generation controls cannot collide", () => { + const base = { messages: MESSAGES, temperature: 0 }; + const variants = [ + base, + { ...base, response_format: { type: "json_object" } }, + { ...base, text: { format: { type: "json_schema", schema: { type: "object" } } } }, + { ...base, tools: [{ type: "function", function: { name: "lookup" } }] }, + { ...base, tool_choice: "required" }, + { ...base, max_completion_tokens: 100 }, + { ...base, seed: 42 }, + { ...base, stop: ["DONE"] }, + { ...base, reasoning: { effort: "high" } }, + ]; + + assert.equal(new Set(variants.map((body) => signature(body))).size, variants.length); +}); + +test("protocol identity is part of the key while streaming transport is not", () => { + const base = { messages: MESSAGES, temperature: 0 }; + const chat = signature(base, { endpoint: "/v1/chat/completions", sourceFormat: "openai" }); + const claude = signature(base, { endpoint: "/v1/messages", sourceFormat: "claude" }); + assert.notEqual(chat, claude); + assert.equal( + signature(base), + signature({ ...base, stream: true, stream_options: { include_usage: true } }) + ); +}); + +test("resolved provider identity is part of the key", () => { + const base = { messages: MESSAGES, temperature: 0 }; + assert.notEqual(signature(base, { provider: "codex" }), signature(base, { provider: "openai" })); +}); + +test("nested object key order is canonicalized", () => { + const first = { + messages: MESSAGES, + temperature: 0, + response_format: { type: "json_schema", json_schema: { strict: true, name: "answer" } }, + }; + const second = { + temperature: 0, + messages: MESSAGES, + response_format: { json_schema: { name: "answer", strict: true }, type: "json_schema" }, + }; + assert.equal(signature(first), signature(second)); +}); + +test("conversation tool-call fields are preserved in the key", () => { + const withFirstCall = [ + ...MESSAGES, + { role: "assistant", content: null, tool_calls: [{ id: "call-1", function: { name: "a" } }] }, + ]; + const withSecondCall = [ + ...MESSAGES, + { role: "assistant", content: null, tool_calls: [{ id: "call-2", function: { name: "b" } }] }, + ]; + assert.notEqual( + generateSignature("model", withFirstCall, 0, 1, "key"), + generateSignature("model", withSecondCall, 0, 1, "key") + ); +}); + +test("conversation content types cannot collide", () => { + assert.notEqual( + generateSignature("model", [{ role: "assistant", content: null }], 0, 1, "key"), + generateSignature("model", [{ role: "assistant", content: "null" }], 0, 1, "key") + ); +}); + +function recordingDeps() { + const generated: unknown[][] = []; + const stored: unknown[][] = []; + return { + generated, + stored, + deps: { + isCacheableForWrite: () => true, + isSmallEnoughForSemanticCache: () => true, + generateSignature: (...args: unknown[]) => { + generated.push(args); + return "generated"; + }, + setCachedResponse: (...args: unknown[]) => stored.push(args), + }, + }; +} + +test("precomputed identity is reused by non-streaming and streaming writes", () => { + const body = { messages: MESSAGES, temperature: 0, response_format: { type: "json_object" } }; + const nonStreaming = recordingDeps(); + storeSemanticCacheResponse( + { + enabled: true, + body, + headers: {}, + translatedResponse: { ok: true }, + model: "model", + signature: "captured-before-mutation", + }, + nonStreaming.deps as never + ); + assert.equal(nonStreaming.generated.length, 0); + assert.equal(nonStreaming.stored[0][0], "captured-before-mutation"); + + const streaming = recordingDeps(); + storeStreamingSemanticCacheResponse( + { + enabled: true, + streamStatus: 200, + streamResponseBody: { ok: true }, + body, + headers: {}, + model: "model", + signature: "captured-before-mutation", + }, + streaming.deps as never + ); + assert.equal(streaming.generated.length, 0); + assert.equal(streaming.stored[0][0], "captured-before-mutation"); +}); diff --git a/tests/unit/semantic-cache-runtime-settings.test.ts b/tests/unit/semantic-cache-runtime-settings.test.ts new file mode 100644 index 000000000000..66f01d71c300 --- /dev/null +++ b/tests/unit/semantic-cache-runtime-settings.test.ts @@ -0,0 +1,90 @@ +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-cache-settings-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const semanticCache = await import("../../src/lib/semanticCache.ts"); +const core = await import("../../src/lib/db/core.ts"); +const databaseSettings = await import("../../src/lib/db/databaseSettings.ts"); +const readCache = await import("../../src/lib/db/readCache.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); + +test.after(() => { + delete process.env.SEMANTIC_CACHE_MAX_SIZE; + delete process.env.SEMANTIC_CACHE_TTL_MS; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("dashboard semantic cache limits configure runtime and persisted TTL", async () => { + databaseSettings.updateDatabaseSettings({ + cache: { + semanticCacheEnabled: true, + semanticCacheMaxSize: 37, + semanticCacheTTL: 12_345, + promptCacheEnabled: true, + promptCacheStrategy: "auto", + alwaysPreserveClientCache: "auto", + }, + }); + + const settings = await readCache.getCachedDatabaseCacheSettings(); + semanticCache.configureSemanticCache({ + maxSize: settings.semanticCacheMaxSize, + ttlMs: settings.semanticCacheTTL, + }); + assert.deepEqual(semanticCache.getSemanticCacheRuntimeConfig(), { maxSize: 37, ttlMs: 12_345 }); + assert.equal((await settingsDb.getSettings()).semanticCacheTTL, 12_345); + + semanticCache.setCachedResponse("settings-ttl", "model", { ok: true }); + const row = core + .getDbInstance() + .prepare("SELECT created_at, expires_at FROM semantic_cache WHERE signature = ?") + .get("settings-ttl") as { created_at: string; expires_at: string }; + assert.equal(new Date(row.expires_at).getTime() - new Date(row.created_at).getTime(), 12_345); + + for (let index = 0; index < 40; index++) { + semanticCache.setCachedResponse(`lru-${index}`, "model", { index }); + } + assert.equal(semanticCache.getCacheStats().memoryEntries, 37); +}); + +test("environment variables remain explicit runtime overrides", () => { + process.env.SEMANTIC_CACHE_MAX_SIZE = "12"; + process.env.SEMANTIC_CACHE_TTL_MS = "6789"; + assert.deepEqual(semanticCache.getSemanticCacheRuntimeConfig(), { maxSize: 12, ttlMs: 6_789 }); + delete process.env.SEMANTIC_CACHE_MAX_SIZE; + delete process.env.SEMANTIC_CACHE_TTL_MS; +}); + +test("expired ISO timestamp rows are never served or counted as live", () => { + const db = core.getDbInstance(); + const now = Date.now(); + const liveEntriesBefore = semanticCache.getCacheStats().dbEntries; + db.prepare( + `INSERT OR REPLACE INTO semantic_cache + (id, signature, model, prompt_hash, response, tokens_saved, hit_count, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)` + ).run( + "expired-row", + "expired-signature", + "model", + "expired", + JSON.stringify({ shouldNotBeServed: true }), + new Date(now - 10_000).toISOString(), + new Date(now - 1_000).toISOString() + ); + + assert.equal(semanticCache.getCachedResponse("expired-signature"), null); + assert.equal(semanticCache.getCacheStats().dbEntries, liveEntriesBefore); +}); + +test("legacy flat cache settings are consolidated for the runtime", async () => { + await settingsDb.updateSettings({ semanticCacheMaxSize: 23 }); + const settings = await readCache.getCachedDatabaseCacheSettings(); + assert.equal(settings.semanticCacheMaxSize, 23); +}); diff --git a/tests/unit/semantic-cache.test.ts b/tests/unit/semantic-cache.test.ts index a83cf2a7992a..639a26e56538 100644 --- a/tests/unit/semantic-cache.test.ts +++ b/tests/unit/semantic-cache.test.ts @@ -46,12 +46,12 @@ describe("Semantic Cache", () => { assert.notEqual(sig1, sig2); }); - it("normalizes messages (strips extra fields)", () => { + it("preserves extra conversation fields that may affect provider output", () => { const msg1 = [{ role: "user", content: "hello", extra: true }]; const msg2 = [{ role: "user", content: "hello" }]; const sig1 = generateSignature("gpt-4", msg1, 0, 1); const sig2 = generateSignature("gpt-4", msg2, 0, 1); - assert.equal(sig1, sig2); + assert.notEqual(sig1, sig2); }); it("handles non-string content", () => { diff --git a/tests/unit/session-affinity-combo-timeout-eviction.test.ts b/tests/unit/session-affinity-combo-timeout-eviction.test.ts new file mode 100644 index 000000000000..d6a8fc139798 --- /dev/null +++ b/tests/unit/session-affinity-combo-timeout-eviction.test.ts @@ -0,0 +1,207 @@ +// #6219 follow-up — a COMBO per-model timeout must also evict the sticky session +// pin. +// +// Observed in production: combo "coding" [priority] pinned a session to one codex +// account. That account stalled past comboTargetTimeoutMs, the combo aborted the +// target and synthesized a 524, and — because a stall is not a quota/auth failure — +// nothing called markAccountUnavailable. The #6219 eviction only runs on that +// generic failover path, so the pin survived its full 30-minute TTL and every +// following request in the session was handed straight back to the stalled +// account: four consecutive requests, four 120s timeouts, "all targets exhausted" +// each time, while four sibling codex accounts sat healthy and unused. +// +// The fix classifies the abort reason (open-sse/services/combo/comboAbortReasons.ts) +// and evicts the connection-matched pin from the dispatch site in chat.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-combo-timeout-affinity-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-timeout-affinity-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const affinityDb = await import("../../src/lib/db/sessionAccountAffinity.ts"); +const pin = await import("../../src/sse/services/sessionAffinityPin.ts"); +const abortReasons = await import("../../open-sse/services/combo/comboAbortReasons.ts"); + +const PROVIDER = "codex"; +const SESSION = "session-combo-timeout"; +const STALLED = "conn-stalled"; +const HEALTHY = "conn-healthy"; +const TTL = 30 * 60_000; + +function abortedWith(reason: unknown): AbortSignal { + const controller = new AbortController(); + controller.abort(reason); + return controller.signal; +} + +const timedOutSignal = () => abortedWith(new Error(abortReasons.COMBO_PER_MODEL_TIMEOUT_REASON)); + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("evicts the pin when the combo per-model timeout abandons the pinned account", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: timedOutSignal(), + }); + + assert.equal(evicted, true, "a timed-out pinned account must lose its pin"); + assert.equal( + affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL), + null, + "the next request must be free to pick another account" + ); +}); + +test("leaves the pin intact on a client disconnect", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: abortedWith(new Error("request_signal_aborted")), + }); + + assert.equal(evicted, false, "a client hanging up says nothing about account health"); + assert.equal( + affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, + STALLED, + "pin must survive so the session keeps its prompt-cache locality" + ); +}); + +test("leaves the pin intact when a hedged sibling cancelled this target", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: abortedWith(new Error(abortReasons.COMBO_HEDGE_CANCELLED_REASON)), + }); + + assert.equal(evicted, false, "losing a hedge race is not an account failure"); + assert.equal(affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, STALLED); +}); + +test("leaves the pin intact when the dispatch was never aborted", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: new AbortController().signal, + }); + + assert.equal(evicted, false); + assert.equal(affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, STALLED); +}); + +test("never evicts a pin that points at a different (healthy) account", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, HEALTHY, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: timedOutSignal(), + }); + + assert.equal(evicted, false, "connection-matched guard must hold"); + assert.equal(affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, HEALTHY); +}); + +test("no-ops without a session key or connection id", () => { + const signal = timedOutSignal(); + assert.equal( + pin.evictSessionAffinityOnComboTimeout({ + sessionKey: null, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: signal, + }), + false + ); + assert.equal( + pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: null, + modelAbortSignal: signal, + }), + false + ); +}); + +test("isComboPerModelTimeoutAbort accepts a bare string abort reason", () => { + assert.equal( + abortReasons.isComboPerModelTimeoutAbort( + abortedWith(abortReasons.COMBO_PER_MODEL_TIMEOUT_REASON) + ), + true + ); + assert.equal(abortReasons.isComboPerModelTimeoutAbort(null), false); +}); + +test("the combo timeout runner aborts with the shared reason constant", () => { + const src = fs.readFileSync( + new URL("../../open-sse/services/combo/targetTimeoutRunner.ts", import.meta.url), + "utf8" + ); + assert.match( + src, + /timeoutController\.abort\(new Error\(COMBO_PER_MODEL_TIMEOUT_REASON\)\)/, + "the runner must use the constant the eviction predicate matches on" + ); +}); + +test("chat.ts routes its upstream dispatch through the eviction-aware seam", () => { + const src = fs.readFileSync(new URL("../../src/sse/handlers/chat.ts", import.meta.url), "utf8"); + assert.match( + src, + /dispatchChatWithAffinityEviction\(/, + "chat.ts must dispatch through the seam that owns the eviction" + ); + assert.doesNotMatch( + src, + /await executeChatWithBreaker\(/, + "chat.ts must not bypass the seam by calling executeChatWithBreaker directly" + ); +}); + +test("the dispatch seam evicts when a dispatch is abandoned", () => { + const src = fs.readFileSync( + new URL("../../src/sse/handlers/chatDispatch.ts", import.meta.url), + "utf8" + ); + assert.match( + src, + /evictSessionAffinityOnComboTimeout\(/, + "chatDispatch.ts must call the eviction" + ); + assert.match( + src, + /modelAbortSignal:\s*runtimeOptions\.modelAbortSignal/, + "chatDispatch.ts must pass the timeout signal into the upstream dispatch" + ); +}); diff --git a/tests/unit/settings-debugmode-default.test.ts b/tests/unit/settings-debugmode-default.test.ts new file mode 100644 index 000000000000..bd667dc1208f --- /dev/null +++ b/tests/unit/settings-debugmode-default.test.ts @@ -0,0 +1,26 @@ +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"; + +// Isolated DATA_DIR so persisted settings rows don't mask the default. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-settings-debugmode-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "settings-debugmode-test-secret"; + +const { getSettings } = await import("../../src/lib/db/settings.ts"); + +test("debugMode defaults to false for fresh installs (no persisted setting)", async () => { + const settings = await getSettings(); + assert.equal(settings.debugMode, false, "debugMode should default to false, not true"); +}); + +test("logToolSources defaults to false", async () => { + const settings = await getSettings(); + assert.equal(settings.logToolSources, false, "logToolSources should default to false"); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); diff --git a/tests/unit/stream-handler-terminal-marker.test.ts b/tests/unit/stream-handler-terminal-marker.test.ts new file mode 100644 index 000000000000..3acef101dd2e --- /dev/null +++ b/tests/unit/stream-handler-terminal-marker.test.ts @@ -0,0 +1,295 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + createDisconnectAwareStream, + createNoopAbortWritable, + createStreamController, +} from "../../open-sse/utils/streamHandler.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +/** + * A stream that already delivered its terminal SSE event must be finalized as a + * success even though the client then closes the socket. Split out of + * stream-handler.test.ts to keep both files under the test-size cap. + */ + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +async function readStreamText(stream) { + const reader = stream.getReader(); + const chunks = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + + return decoder.decode( + chunks.length === 1 ? chunks[0] : Uint8Array.from(chunks.flatMap((chunk) => Array.from(chunk))) + ); +} + +test("createDisconnectAwareStream treats errors after OpenAI DONE as successful completion", async () => { + let pullCount = 0; + let errorHandled = false; + const transformStream = { + readable: new ReadableStream({ + pull(controller) { + pullCount += 1; + if (pullCount === 1) { + controller.enqueue( + encoder.encode( + 'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":null}]}\n\ndata: [DONE]\n\n' + ) + ); + return; + } + controller.error(new Error("terminated")); + }, + }), + writable: { + getWriter() { + return { + abort() {}, + }; + }, + }, + }; + + const stream = createDisconnectAwareStream( + transformStream, + createStreamController({ + onError() { + errorHandled = true; + }, + }) + ); + const text = await readStreamText(stream); + + assert.match(text, /"content":"ok"/); + assert.match(text, /data: \[DONE\]/); + assert.equal(errorHandled, false); + assert.doesNotMatch(text, /"finish_reason":"error"/); + assert.doesNotMatch(text, /terminated/); +}); + +test("createDisconnectAwareStream treats cancel after OpenAI DONE as successful completion", async () => { + let disconnectHandled = false; + const transformStream = { + readable: new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + 'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":null}]}\n\ndata: [DONE]\n\n' + ) + ); + }, + }), + writable: { + getWriter() { + return { + abort() {}, + }; + }, + }, + }; + + const stream = createDisconnectAwareStream( + transformStream, + createStreamController({ + onDisconnect() { + disconnectHandled = true; + }, + }) + ); + const reader = stream.getReader(); + const content = await reader.read(); + assert.match(decoder.decode(content.value), /"content":"ok"/); + const terminal = await reader.read(); + assert.equal(decoder.decode(terminal.value), "data: [DONE]\n\n"); + await reader.cancel("request_signal_aborted"); + + assert.equal(disconnectHandled, false); +}); + +test("createDisconnectAwareStream treats cancel after Responses completed as successful completion", async () => { + let disconnectHandled = false; + const transformStream = { + readable: new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + 'event: response.completed\ndata: {"type":"response.completed","response":{"output_text":"ok"}}\n\n' + ) + ); + }, + }), + writable: { + getWriter() { + return { + abort() {}, + }; + }, + }, + }; + + const stream = createDisconnectAwareStream( + transformStream, + createStreamController({ + clientResponseFormat: FORMATS.OPENAI_RESPONSES, + onDisconnect() { + disconnectHandled = true; + }, + }) + ); + const reader = stream.getReader(); + const first = await reader.read(); + assert.match(decoder.decode(first.value), /response\.completed/); + await reader.cancel("request_signal_aborted"); + + assert.equal(disconnectHandled, false); +}); + +test("createDisconnectAwareStream treats cancel after legitimate Responses incomplete as successful completion", async () => { + let disconnectHandled = false; + const transformStream = { + readable: new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + 'event: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","output":[],"incomplete_details":{"reason":"max_output_tokens"}}}\n\n' + ) + ); + }, + }), + writable: createNoopAbortWritable(), + }; + + const stream = createDisconnectAwareStream( + transformStream, + createStreamController({ + clientResponseFormat: FORMATS.OPENAI_RESPONSES, + onDisconnect() { + disconnectHandled = true; + }, + }) + ); + const reader = stream.getReader(); + const terminal = await reader.read(); + assert.match(decoder.decode(terminal.value), /response\.incomplete/); + await reader.cancel("request_signal_aborted"); + + assert.equal(disconnectHandled, false); +}); + +test("createDisconnectAwareStream treats cancel after an oversized Responses completed event as successful completion", async () => { + // The terminal event carries the whole response, so its payload routinely runs + // to tens of KB. Scanning only a small trailing window used to slice the + // marker away and report a fully delivered stream as a client disconnect. + const hugeCompleted = `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { output_text: "y".repeat(64 * 1024) }, + })}\n\n`; + + let disconnectHandled = false; + const transformStream = { + readable: new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode('event: response.created\ndata: {"type":"response.created"}\n\n') + ); + controller.enqueue(encoder.encode(hugeCompleted)); + }, + }), + writable: createNoopAbortWritable(), + }; + + const stream = createDisconnectAwareStream( + transformStream, + createStreamController({ + clientResponseFormat: FORMATS.OPENAI_RESPONSES, + onDisconnect() { + disconnectHandled = true; + }, + }) + ); + const reader = stream.getReader(); + await reader.read(); + const terminal = await reader.read(); + assert.match(decoder.decode(terminal.value), /response\.completed/); + await reader.cancel("request_signal_aborted"); + + assert.equal(disconnectHandled, false); +}); + +test("createDisconnectAwareStream detects a Responses terminal marker split across chunks", async () => { + let disconnectHandled = false; + const transformStream = { + readable: new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"ok"}\n\n' + ) + ); + controller.enqueue(encoder.encode("event: response.comp")); + controller.enqueue(encoder.encode('leted\ndata: {"type":"response.completed"}\n\n')); + }, + }), + writable: createNoopAbortWritable(), + }; + + const stream = createDisconnectAwareStream( + transformStream, + createStreamController({ + clientResponseFormat: FORMATS.OPENAI_RESPONSES, + onDisconnect() { + disconnectHandled = true; + }, + }) + ); + const reader = stream.getReader(); + let delivered = ""; + while (!delivered.includes("response.completed")) { + const result = await reader.read(); + assert.equal(result.done, false); + delivered += decoder.decode(result.value); + } + await reader.cancel("request_signal_aborted"); + + assert.equal(disconnectHandled, false); +}); + +test("createDisconnectAwareStream still reports a disconnect when no terminal marker arrived", async () => { + let disconnectReason = null; + const transformStream = { + readable: new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `event: response.output_text.delta\ndata: {"delta":"${"x".repeat(8192)}"}\n\n` + ) + ); + }, + }), + writable: createNoopAbortWritable(), + }; + + const stream = createDisconnectAwareStream( + transformStream, + createStreamController({ + clientResponseFormat: FORMATS.OPENAI_RESPONSES, + onDisconnect(event) { + disconnectReason = event.reason; + }, + }) + ); + const reader = stream.getReader(); + await reader.read(); + await reader.cancel("request_signal_aborted"); + + assert.equal(disconnectReason, "request_signal_aborted"); +}); diff --git a/tests/unit/stream-handler.test.ts b/tests/unit/stream-handler.test.ts index 6271cd5fa58f..812fcc7d8362 100644 --- a/tests/unit/stream-handler.test.ts +++ b/tests/unit/stream-handler.test.ts @@ -59,114 +59,6 @@ test("createDisconnectAwareStream converts upstream errors into SSE error chunks assert.match(text, /\[DONE\]/); }); -test("createDisconnectAwareStream treats errors after OpenAI DONE as successful completion", async () => { - let pullCount = 0; - let errorHandled = false; - const transformStream = { - readable: new ReadableStream({ - pull(controller) { - pullCount += 1; - if (pullCount === 1) { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - return; - } - controller.error(new Error("terminated")); - }, - }), - writable: { - getWriter() { - return { - abort() {}, - }; - }, - }, - }; - - const stream = createDisconnectAwareStream( - transformStream, - createStreamController({ - onError() { - errorHandled = true; - }, - }) - ); - const text = await readStreamText(stream); - - assert.equal(text, "data: [DONE]\n\n"); - assert.equal(errorHandled, false); - assert.doesNotMatch(text, /finish_reason/); - assert.doesNotMatch(text, /terminated/); -}); - -test("createDisconnectAwareStream treats cancel after OpenAI DONE as successful completion", async () => { - let disconnectHandled = false; - const transformStream = { - readable: new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - }, - }), - writable: { - getWriter() { - return { - abort() {}, - }; - }, - }, - }; - - const stream = createDisconnectAwareStream( - transformStream, - createStreamController({ - onDisconnect() { - disconnectHandled = true; - }, - }) - ); - const reader = stream.getReader(); - const first = await reader.read(); - assert.equal(decoder.decode(first.value), "data: [DONE]\n\n"); - await reader.cancel("request_signal_aborted"); - - assert.equal(disconnectHandled, false); -}); - -test("createDisconnectAwareStream treats cancel after Responses completed as successful completion", async () => { - let disconnectHandled = false; - const transformStream = { - readable: new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode('event: response.completed\ndata: {"type":"response.completed"}\n\n') - ); - }, - }), - writable: { - getWriter() { - return { - abort() {}, - }; - }, - }, - }; - - const stream = createDisconnectAwareStream( - transformStream, - createStreamController({ - clientResponseFormat: FORMATS.OPENAI_RESPONSES, - onDisconnect() { - disconnectHandled = true; - }, - }) - ); - const reader = stream.getReader(); - const first = await reader.read(); - assert.match(decoder.decode(first.value), /response\.completed/); - await reader.cancel("request_signal_aborted"); - - assert.equal(disconnectHandled, false); -}); - test("createDisconnectAwareStream: Gemini 503 high-demand error becomes SSE error chunk with message preserved", async () => { const geminiMsg = "[503]: This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later."; @@ -497,6 +389,25 @@ test("createStreamController aborts after delayed disconnect and tolerates abort assert.equal(errorOnlyController.signal.aborted, false); }); +test("createStreamController propagates orchestration abort without reporting a client disconnect", async () => { + const upstream = new AbortController(); + let disconnectCalled = false; + const controller = createStreamController({ + upstreamAbortSignal: upstream.signal, + onDisconnect() { + disconnectCalled = true; + }, + }); + const reason = new Error("combo_per_model_timeout"); + + upstream.abort(reason); + await Promise.resolve(); + + assert.equal(controller.signal.aborted, true); + assert.equal(controller.signal.reason, reason); + assert.equal(disconnectCalled, false); +}); + test("pipeWithDisconnect pipes transformed bytes and marks the controller complete", async () => { const source = new ReadableStream({ start(controller) { diff --git a/tests/unit/stream-utilities.test.ts b/tests/unit/stream-utilities.test.ts index 0f3404869c7b..5fd6b78a71ed 100644 --- a/tests/unit/stream-utilities.test.ts +++ b/tests/unit/stream-utilities.test.ts @@ -133,7 +133,7 @@ test("createPassthroughStreamWithLogger synthesizes reasoning summary events fro assert.match(result, /event: response\.output_item\.done/); }); -test("createPassthroughStreamWithLogger shows a placeholder for encrypted reasoning items", async () => { +test("createPassthroughStreamWithLogger preserves encrypted reasoning without fabricating a summary", async () => { const transform = createPassthroughStreamWithLogger( "codex", null, @@ -169,9 +169,11 @@ test("createPassthroughStreamWithLogger shows a placeholder for encrypted reason result += decoder.decode(value); } - assert.match(result, /event: response\.reasoning_summary_text\.delta/); - assert.match(result, /Codex is reasoning/); - assert.match(result, /"summary":\[\{"type":"summary_text","text":"Codex is reasoning/); + assert.doesNotMatch(result, /event: response\.reasoning_summary_text\.delta/); + assert.doesNotMatch(result, /Codex is reasoning/); + assert.doesNotMatch(result, /OmniRoute cannot recover/); + assert.doesNotMatch(result, /"summary":/); + assert.match(result, /"encrypted_content":"enc_opaque_state"/); assert.match(result, /event: response\.output_item\.done/); }); diff --git a/tests/unit/system-fork-version-route.test.tsx b/tests/unit/system-fork-version-route.test.tsx new file mode 100644 index 000000000000..4ee6a8c897fc --- /dev/null +++ b/tests/unit/system-fork-version-route.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/shared/utils/apiAuth", () => ({ isAuthenticated: vi.fn() })); +vi.mock("@/lib/system/versionCheck", () => ({ + resolveLatestVersion: vi.fn(), + isNewer: vi.fn(() => true), +})); +vi.mock("@/lib/system/autoUpdate", () => ({ + ensureGitTagExists: vi.fn(), + getAutoUpdateConfig: vi.fn(() => ({ mode: "source" })), + launchAutoUpdate: vi.fn(), + validateAutoUpdateRuntime: vi.fn(async () => ({ supported: false })), + PROJECT_ROOT: "/unused", +})); +vi.mock("@/lib/system/globalPackagePath", () => ({ resolveGlobalOmniroutePath: vi.fn() })); +vi.mock("@/lib/services/installers/utils", () => ({ + buildNpmExecOptions: vi.fn(), + SERVICE_VERSION_PATTERN: /^\d+\.\d+\.\d+$/, +})); + +import { GET, POST } from "../../src/app/api/system/version/route"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { resolveLatestVersion } from "@/lib/system/versionCheck"; +import { + getAutoUpdateConfig, + launchAutoUpdate, + validateAutoUpdateRuntime, +} from "@/lib/system/autoUpdate"; + +const req = () => new NextRequest("http://localhost/api/system/version"); + +describe("fork version route", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("NEXT_PUBLIC_OMNIROUTE_FORK_VERSION", "fork · sha-1111111"); + vi.mocked(isAuthenticated).mockResolvedValue(true); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ workflow_runs: [] })) + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("requires authentication before checking or updating", async () => { + vi.mocked(isAuthenticated).mockResolvedValue(false); + expect((await GET(req())).status).toBe(401); + expect((await POST(req())).status).toBe(401); + expect(fetch).not.toHaveBeenCalled(); + expect(resolveLatestVersion).not.toHaveBeenCalled(); + }); + + it("fork GET bypasses npm, upstream news and updater runtime checks", async () => { + const response = await GET(req()); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + channel: "fork", + latest: "unavailable", + updateAvailable: false, + autoUpdateSupported: false, + news: null, + }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(resolveLatestVersion).not.toHaveBeenCalled(); + expect(getAutoUpdateConfig).not.toHaveBeenCalled(); + expect(validateAutoUpdateRuntime).not.toHaveBeenCalled(); + }); + + it("fork POST cannot invoke the upstream updater", async () => { + const response = await POST(req()); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ success: false, channel: "fork" }); + expect(fetch).not.toHaveBeenCalled(); + expect(resolveLatestVersion).not.toHaveBeenCalled(); + expect(launchAutoUpdate).not.toHaveBeenCalled(); + expect(getAutoUpdateConfig).not.toHaveBeenCalled(); + }); + + it("ordinary builds preserve their existing upstream lookup", async () => { + vi.stubEnv("NEXT_PUBLIC_OMNIROUTE_FORK_VERSION", ""); + vi.mocked(resolveLatestVersion).mockResolvedValue("3.8.50"); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ active: false })) + ); + const response = await GET(req()); + expect(await response.json()).toMatchObject({ latest: "3.8.50", channel: "source" }); + expect(resolveLatestVersion).toHaveBeenCalledOnce(); + expect(validateAutoUpdateRuntime).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/tls-profiles-valid-5591.test.mjs b/tests/unit/tls-profiles-valid-5591.test.mjs index 87f2a41e11f8..cf179a475637 100644 --- a/tests/unit/tls-profiles-valid-5591.test.mjs +++ b/tests/unit/tls-profiles-valid-5591.test.mjs @@ -33,7 +33,7 @@ const SOURCES = [ // Strip comments before scanning — explanatory comments may name the bad // profile ("chrome_149 absent in 2.3.1") without it ever reaching wreq-js. function stripComments(line) { - return line.replace(/\/\*.*?\*\//g, "").replace(/\/\/.*$/, ""); + return line.replace(/\/\*.*?\*\//g, "").replace(/\/\/[^\r\n]*/, ""); } test("#5591 all configured chrome_* TLS profiles exist in wreq-js", () => { diff --git a/tests/unit/trae-callback-security.test.ts b/tests/unit/trae-callback-security.test.ts new file mode 100644 index 000000000000..58d740638c2f --- /dev/null +++ b/tests/unit/trae-callback-security.test.ts @@ -0,0 +1,136 @@ +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 { SignJWT } from "jose"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-trae-callback-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "trae-callback-test-secret"; +process.env.JWT_SECRET = "trae-callback-jwt-secret"; +process.env.INITIAL_PASSWORD = "trae-callback-initial-password"; + +const core = await import("../../src/lib/db/core.ts"); +const providers = await import("../../src/lib/db/providers.ts"); +const settings = await import("../../src/lib/db/settings.ts"); +const callbackState = await import("../../src/lib/oauth/traeCallbackState.ts"); +const { parseTraeCallbackQuery } = await import("../../src/app/authorize/parseCallback.ts"); +const callbackRoute = await import("../../src/app/authorize/route.ts"); +const callbackStateRoute = await import("../../src/app/api/oauth/trae/callback-state/route.ts"); +const importRoute = await import("../../src/app/api/oauth/trae/import/route.ts"); + +function callbackUrl(state?: string, host = "https://api-us-east.trae.ai"): string { + const userJwt = JSON.stringify({ + ClientID: "en1oxy7wnw8j9n", + Token: "TEST_ACCESS_TOKEN", + RefreshToken: "TEST_REFRESH_TOKEN", + }); + const params = new URLSearchParams({ userJwt, host }); + if (state) params.set("loginTraceID", state); + return `http://127.0.0.1:20128/authorize?${params.toString()}`; +} + +async function traeConnectionCount(): Promise { + return (await providers.getProviderConnections({ provider: "trae" })).length; +} + +async function dashboardCookie(): Promise { + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(new TextEncoder().encode(process.env.JWT_SECRET)); + return `auth_token=${token}`; +} + +test.beforeEach(async () => { + core.resetDbInstance(); + callbackState.resetTraeCallbackStatesForTests(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + await settings.updateSettings({ requireLogin: true }); +}); + +test.after(() => { + core.resetDbInstance(); + callbackState.resetTraeCallbackStatesForTests(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Trae callback does not persist credentials without an issued state", async () => { + const response = await callbackRoute.GET(new Request(callbackUrl())); + const html = await response.text(); + + assert.equal(response.status, 200); + assert.match(html, /Invalid or expired callback state/); + assert.equal(await traeConnectionCount(), 0); +}); + +test("Trae callback state issuance requires management authentication", async () => { + const anonymous = await callbackStateRoute.POST( + new Request("http://localhost/api/oauth/trae/callback-state", { method: "POST" }) + ); + assert.equal(anonymous.status, 401); + + const authenticated = await callbackStateRoute.POST( + new Request("http://localhost/api/oauth/trae/callback-state", { + method: "POST", + headers: { cookie: await dashboardCookie() }, + }) + ); + const body = await authenticated.json(); + assert.equal(authenticated.status, 200); + assert.equal(authenticated.headers.get("cache-control"), "no-store"); + assert.match(body.state, /^[0-9a-f-]{36}$/); +}); + +test("Trae token import requires management authentication", async () => { + const response = await importRoute.POST( + new Request("http://localhost/api/oauth/trae/import", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accessToken: "TEST_ACCESS_TOKEN" }), + }) + ); + + assert.equal(response.status, 401); + assert.equal(await traeConnectionCount(), 0); +}); + +test("Trae callback persists once and rejects replay of the same state", async () => { + const { state } = callbackState.issueTraeCallbackState(); + + const first = await callbackRoute.GET(new Request(callbackUrl(state))); + assert.match(await first.text(), /Trae authorization ✓/); + assert.equal(await traeConnectionCount(), 1); + + const replay = await callbackRoute.GET(new Request(callbackUrl(state))); + assert.match(await replay.text(), /Invalid or expired callback state/); + assert.equal(await traeConnectionCount(), 1); +}); + +test("Trae callback rejects a loopback upstream host before persistence", async () => { + const { state } = callbackState.issueTraeCallbackState(); + const response = await callbackRoute.GET(new Request(callbackUrl(state, "http://127.0.0.1:1"))); + + assert.match(await response.text(), /Invalid Trae API host/); + assert.equal(await traeConnectionCount(), 0); +}); + +test("Trae callback parser rejects non-Trae and non-HTTPS API hosts", () => { + const userJwt = JSON.stringify({ ClientID: "en1oxy7wnw8j9n", Token: "T" }); + for (const host of [ + "http://127.0.0.1:1", + "https://api-us-east.trae.ai.evil.example", + "http://api-us-east.trae.ai", + ]) { + const result = parseTraeCallbackQuery(new URLSearchParams({ userJwt, host })); + assert.deepEqual(result, { ok: false, error: "Invalid Trae API host" }); + } +}); + +test("Trae callback parser rejects malformed userInfo instead of silently accepting it", () => { + const userJwt = JSON.stringify({ ClientID: "en1oxy7wnw8j9n", Token: "T" }); + const result = parseTraeCallbackQuery(new URLSearchParams({ userJwt, userInfo: "not-json{{{" })); + assert.deepEqual(result, { ok: false, error: "Malformed userInfo payload" }); +}); diff --git a/tests/unit/trae-executor.test.ts b/tests/unit/trae-executor.test.ts index e22d2adfd780..e5099c6b4a1d 100644 --- a/tests/unit/trae-executor.test.ts +++ b/tests/unit/trae-executor.test.ts @@ -43,10 +43,12 @@ function installMockFetch({ sessionBody, frames, sessionStatus = 200, + eventsStatus = 200, }: { sessionBody?: unknown; frames?: Array<{ event: string; data: unknown }>; sessionStatus?: number; + eventsStatus?: number; } = {}) { const calls: { sessionBody?: any; sessionHeaders?: Record; eventsUrl?: string } = {}; @@ -55,6 +57,9 @@ function installMockFetch({ const url = typeof input === "string" ? input : input.url; if (url.includes("/chat_sessions") && url.includes("/events")) { calls.eventsUrl = url; + if (eventsStatus !== 200) { + return jsonResponse({ code: 1001, message: "unauthorized" }, eventsStatus); + } return sseResponse(frames ?? [{ event: "done", data: { status: "completed" } }]); } if (url.endsWith("/chat_sessions")) { @@ -252,8 +257,8 @@ test("upstream error event surfaces as 502 (non-stream)", async () => { } }); -test("session create failure returns 502", async () => { - const { restore } = installMockFetch({ sessionBody: "nope", sessionStatus: 500 }); +test("session create failure propagates the upstream HTTP status", async () => { + const { restore } = installMockFetch({ sessionBody: "unauthorized", sessionStatus: 401 }); try { const ex = new TraeExecutor(); const { response } = await ex.execute({ @@ -262,7 +267,23 @@ test("session create failure returns 502", async () => { stream: false, credentials: CREDS, }); - assert.equal(response.status, 502); + assert.equal(response.status, 401); + } finally { + restore(); + } +}); + +test("events stream failure propagates the upstream HTTP status", async () => { + const { restore } = installMockFetch({ eventsStatus: 401 }); + try { + const ex = new TraeExecutor(); + const { response } = await ex.execute({ + model: "auto", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: CREDS, + }); + assert.equal(response.status, 401); } finally { restore(); } diff --git a/tests/unit/translator-claude-helper-thinking.test.ts b/tests/unit/translator-claude-helper-thinking.test.ts index a01a0b4b988c..6890e033810a 100644 --- a/tests/unit/translator-claude-helper-thinking.test.ts +++ b/tests/unit/translator-claude-helper-thinking.test.ts @@ -1,12 +1,12 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { prepareClaudeRequest, NON_ANTHROPIC_THINKING_PLACEHOLDER: PLACEHOLDER } = await import("../../open-sse/translator/helpers/claudeHelper.ts"); +const { prepareClaudeRequest, NON_ANTHROPIC_THINKING_PLACEHOLDER: PLACEHOLDER } = + await import("../../open-sse/translator/helpers/claudeHelper.ts"); const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = await import("../../open-sse/config/defaultThinkingSignature.ts"); const reasoningCache = await import("../../open-sse/services/reasoningCache.ts"); - function multiTurnBodyWithoutThinkingBlock() { return { thinking: { type: "enabled", budget_tokens: 4096 }, @@ -48,7 +48,7 @@ function multiTurnBodyWithThinkingBlock(thinkingText: string, toolUseId = "call_ test("claude provider — empty content, injects redacted_thinking{data} before tool_use", () => { const body = multiTurnBodyWithoutThinkingBlock(); - const result = prepareClaudeRequest(body as any, "claude"); + const result = prepareClaudeRequest(body as any, "claude", false, null, "reasoning-test-key"); const content = (result as any).messages[1].content; assert.equal(content.length, 2); assert.equal(content[0].type, "redacted_thinking"); @@ -84,7 +84,7 @@ test("claude provider — existing thinking block converted to redacted_thinking { role: "user", content: [{ type: "tool_result", tool_use_id: "call_z", content: "ok" }] }, ], }; - prepareClaudeRequest(body, "claude"); + prepareClaudeRequest(body, "claude", false, null, "reasoning-test-key"); // Older assistant: thinking rewritten to redacted_thinking const olderContent = body.messages[1].content; assert.equal(olderContent.length, 2, "no double-inject"); @@ -106,7 +106,13 @@ test("claude provider — existing thinking block converted to redacted_thinking test("anthropic-compatible-* provider — same as claude (redacted_thinking)", () => { const body = multiTurnBodyWithoutThinkingBlock(); - const result = prepareClaudeRequest(body as any, "anthropic-compatible-abc123"); + const result = prepareClaudeRequest( + body as any, + "anthropic-compatible-abc123", + false, + null, + "reasoning-test-key" + ); const content = (result as any).messages[1].content; assert.equal(content[0].type, "redacted_thinking"); assert.equal(content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE); @@ -117,7 +123,13 @@ test("anthropic-compatible-* provider — same as claude (redacted_thinking)", ( test("kimi-coding provider — empty content, injects plain thinking{text} with placeholder (cache miss)", () => { reasoningCache.clearReasoningCacheAll(); const body = multiTurnBodyWithoutThinkingBlock(); - const result = prepareClaudeRequest(body as any, "kimi-coding"); + const result = prepareClaudeRequest( + body as any, + "kimi-coding", + false, + null, + "reasoning-test-key" + ); const content = (result as any).messages[1].content; assert.equal(content.length, 2); assert.equal(content[0].type, "thinking"); @@ -133,10 +145,17 @@ test("kimi-coding provider — empty content + cache hit on tool_use.id, injects "call_x", "kimi-coding", "kimi-k2.6", - "the model actually thought this" + "the model actually thought this", + "reasoning-test-key" ); const body = multiTurnBodyWithoutThinkingBlock(); - const result = prepareClaudeRequest(body as any, "kimi-coding"); + const result = prepareClaudeRequest( + body as any, + "kimi-coding", + false, + null, + "reasoning-test-key" + ); const content = (result as any).messages[1].content; assert.equal(content[0].type, "thinking"); assert.equal(content[0].thinking, "the model actually thought this"); @@ -145,7 +164,13 @@ test("kimi-coding provider — empty content + cache hit on tool_use.id, injects test("kimi-coding provider — existing thinking block: client text preserved, signature stripped, data NOT added", () => { reasoningCache.clearReasoningCacheAll(); const body = multiTurnBodyWithThinkingBlock("client preserved reasoning", "call_y"); - const result = prepareClaudeRequest(body as any, "kimi-coding"); + const result = prepareClaudeRequest( + body as any, + "kimi-coding", + false, + null, + "reasoning-test-key" + ); const content = (result as any).messages[1].content; assert.equal(content.length, 2); assert.equal(content[0].type, "thinking"); @@ -160,7 +185,13 @@ test("kimi-coding provider — existing thinking block: client text preserved, s test("kimi-coding provider — existing redacted_thinking block (no text), cache hit injects real text", () => { reasoningCache.clearReasoningCacheAll(); - reasoningCache.cacheReasoning("call_z", "kimi-coding", "kimi-k2.6", "cached reasoning v2"); + reasoningCache.cacheReasoning( + "call_z", + "kimi-coding", + "kimi-k2.6", + "cached reasoning v2", + "reasoning-test-key" + ); const body = { thinking: { type: "enabled", budget_tokens: 4096 }, messages: [ @@ -178,7 +209,13 @@ test("kimi-coding provider — existing redacted_thinking block (no text), cache }, ], }; - const result = prepareClaudeRequest(body as any, "kimi-coding"); + const result = prepareClaudeRequest( + body as any, + "kimi-coding", + false, + null, + "reasoning-test-key" + ); const content = (result as any).messages[1].content; assert.equal(content[0].type, "thinking"); assert.equal(content[0].thinking, "cached reasoning v2", "cache substitutes redacted data"); @@ -204,7 +241,13 @@ test("kimi-coding provider — existing redacted_thinking block (no text), cache }, ], }; - const result = prepareClaudeRequest(body as any, "kimi-coding"); + const result = prepareClaudeRequest( + body as any, + "kimi-coding", + false, + null, + "reasoning-test-key" + ); const content = (result as any).messages[1].content; assert.equal(content[0].type, "thinking"); assert.equal(content[0].thinking, PLACEHOLDER); @@ -222,7 +265,7 @@ test("thinking disabled — no inject regardless of provider or tool_use", () => { role: "user", content: [{ type: "tool_result", tool_use_id: "x", content: "ok" }] }, ], }; - const result = prepareClaudeRequest(body as any, provider); + const result = prepareClaudeRequest(body as any, provider, false, null, "reasoning-test-key"); const content = (result as any).messages[1].content; assert.equal(content.length, 1, `${provider}: no inject when thinking disabled`); assert.equal(content[0].type, "tool_use"); @@ -235,7 +278,7 @@ test("thinking enabled + no tool_use — no precursor inject (single-turn text)" thinking: { type: "enabled", budget_tokens: 4096 }, messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], }; - const result = prepareClaudeRequest(body as any, provider); + const result = prepareClaudeRequest(body as any, provider, false, null, "reasoning-test-key"); const content = (result as any).messages[0].content; assert.ok(Array.isArray(content)); assert.equal(content.length, 1); @@ -269,7 +312,7 @@ test("preserves verbatim thinking on the LATEST assistant message; rewrites only ], }; - prepareClaudeRequest(body, "claude"); + prepareClaudeRequest(body, "claude", false, null, "reasoning-test-key"); const olderAssistant = body.messages[0]; const latestAssistant = body.messages[2]; @@ -314,7 +357,7 @@ test("non-Anthropic upstream: preserves latest assistant thinking text verbatim, ], }; - prepareClaudeRequest(body, "kimi-coding"); + prepareClaudeRequest(body, "kimi-coding", false, null, "reasoning-test-key"); // Latest assistant: text preserved verbatim assert.equal(body.messages[2].content[0].type, "thinking"); diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index ef4afd8d1fa6..8c50340726ca 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -1,3 +1,4 @@ +import { buildCapability } from "../fixtures/translator-capability.ts"; import test from "node:test"; import assert from "node:assert/strict"; @@ -13,29 +14,6 @@ const { cacheReasoningByKey, clearReasoningCacheAll, getReasoningCacheServiceSta const { clearModelsDevCapabilities, saveModelsDevCapabilities } = await import("../../src/lib/modelsDevSync.ts"); -function buildCapability(overrides = {}) { - return { - tool_call: null, - reasoning: null, - attachment: null, - structured_output: null, - temperature: null, - modalities_input: "[]", - modalities_output: "[]", - knowledge_cutoff: null, - release_date: null, - last_updated: null, - status: null, - family: null, - open_weights: null, - limit_context: null, - limit_input: null, - limit_output: null, - interleaved_field: null, - ...overrides, - }; -} - const originalMathRandom = Math.random; test.afterEach(() => { @@ -363,7 +341,9 @@ test("claudeHelper validates content, ordering and request preparation branches" ], }, "claude", - false + false, + null, + "reasoning-test-key" ); assert.deepEqual(prepared.system[0], { type: "text", text: "one" }); @@ -408,7 +388,9 @@ test("claudeHelper validates content, ordering and request preparation branches" tools: [{ name: "kept", cache_control: { type: "ephemeral" } }], }, "openai", - true + true, + null, + "reasoning-test-key" ); assert.deepEqual(preserved.messages[0].content[0].cache_control, { type: "ephemeral" }); assert.deepEqual(preserved.tools[0].cache_control, { type: "ephemeral" }); @@ -635,7 +617,8 @@ test("translateRequest replays cached reasoning-only messages when interleaved f "request:req_reasoning_only:message:0", "deepseek", "deepseek-v4-flash", - "cached reasoning only" + "cached reasoning only", + "reasoning-test-key" ); const result = translateRequest( @@ -651,7 +634,9 @@ test("translateRequest replays cached reasoning-only messages when interleaved f }, false, null, - "deepseek" + "deepseek", + null, + { reasoningCacheApiKeyId: "reasoning-test-key" } ); assert.equal(result.messages[1].reasoning_content, "cached reasoning only"); @@ -666,7 +651,8 @@ test("translateRequest does not replay reasoning-only messages for non-DeepSeek "request:req_kimi_reasoning_only:message:0", "kimi", "kimi-k2.5", - "cached kimi reasoning" + "cached kimi reasoning", + "reasoning-test-key" ); const result = translateRequest( @@ -682,7 +668,9 @@ test("translateRequest does not replay reasoning-only messages for non-DeepSeek }, false, null, - "kimi" + "kimi", + null, + { reasoningCacheApiKeyId: "reasoning-test-key" } ); assert.equal(result.messages[1].reasoning_content, undefined); @@ -690,180 +678,180 @@ test("translateRequest does not replay reasoning-only messages for non-DeepSeek clearReasoningCacheAll(); }); - test("translateRequest injects thinking block into Claude-format messages for Kimi K2 reasoning models", () => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - saveModelsDevCapabilities({ - "kimi-coding": { - "kimi-k2.5": buildCapability({ - interleaved_field: "reasoning_content", - reasoning: true, - tool_call: true, - }), - }, - }); - cacheReasoningByKey( - "toolu_kimi_claude", - "kimi-coding", - "kimi-k2.5", - "cached thinking for Kimi tool call" - ); - - // Claude-format request: assistant has tool_use in content[] but NO thinking block - // This simulates the scenario that causes infinite loops - const result = translateRequest( - FORMATS.OPENAI, - FORMATS.CLAUDE, - "kimi-k2.5", - { - thinking: { type: "enabled", budget_tokens: 2000 }, - messages: [ - { role: "user", content: "read the file" }, - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "toolu_kimi_claude", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { role: "tool", tool_call_id: "toolu_kimi_claude", content: "file data" }, - ], - }, - false, - null, - "kimi-coding" - ); - - const assistantMsg = result.messages.find((m) => m.role === "assistant"); - assert.ok(assistantMsg, "assistant message should exist"); - assert.ok(Array.isArray(assistantMsg.content), "content should be array"); - - // Should have a thinking block injected before tool_use - const thinkingBlock = assistantMsg.content.find((b) => b?.type === "thinking"); - assert.ok(thinkingBlock, "thinking block should be injected"); - assert.equal( - thinkingBlock.thinking, - "cached thinking for Kimi tool call", - "should use cached reasoning" - ); - - // Thinking block should appear before tool_use - const thinkingIdx = assistantMsg.content.indexOf(thinkingBlock); - const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use"); - assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use"); - - assert.equal(getReasoningCacheServiceStats().replays, 1); - clearModelsDevCapabilities(); - clearReasoningCacheAll(); +test("translateRequest injects thinking block into Claude-format messages for Kimi K2 reasoning models", () => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + "kimi-coding": { + "kimi-k2.5": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, }); + cacheReasoningByKey( + "toolu_kimi_claude", + "kimi-coding", + "kimi-k2.5", + "cached thinking for Kimi tool call", + "reasoning-test-key" + ); - test("translateRequest injects placeholder thinking block for Claude-format Kimi K2 on cache miss", () => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - saveModelsDevCapabilities({ - "kimi-coding": { - "kimi-k2.6": buildCapability({ - interleaved_field: "reasoning_content", - reasoning: true, - tool_call: true, - }), - }, - }); + // Claude-format request: assistant has tool_use in content[] but NO thinking block + // This simulates the scenario that causes infinite loops + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-k2.5", + { + thinking: { type: "enabled", budget_tokens: 2000 }, + messages: [ + { role: "user", content: "read the file" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_kimi_claude", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + { role: "tool", tool_call_id: "toolu_kimi_claude", content: "file data" }, + ], + }, + false, + null, + "kimi-coding", + null, + { reasoningCacheApiKeyId: "reasoning-test-key" } + ); - // No cache seeded - should fall back to placeholder - const result = translateRequest( - FORMATS.OPENAI, - FORMATS.CLAUDE, - "kimi-k2.6", - { - thinking: { type: "enabled", budget_tokens: 2000 }, - messages: [ - { role: "user", content: "do it" }, - { - role: "assistant", - content: [ - { type: "tool_use", id: "toolu_miss", name: "bash", input: { command: "ls" } }, - ], - }, - { role: "tool", tool_call_id: "toolu_miss", content: "output" }, - ], - }, - false, - null, - "kimi-coding" - ); - - const assistantMsg = result.messages.find((m) => m.role === "assistant"); - assert.ok(assistantMsg, "assistant message should exist"); - - const thinkingBlock = - Array.isArray(assistantMsg.content) && - assistantMsg.content.find((b) => b?.type === "thinking"); - assert.ok(thinkingBlock, "thinking block should be injected on cache miss"); - // Must be non-empty for kimi-coding - assert.ok( - thinkingBlock.thinking && thinkingBlock.thinking.length > 0, - "placeholder must be non-empty" - ); - - clearModelsDevCapabilities(); - clearReasoningCacheAll(); + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistantMsg, "assistant message should exist"); + assert.ok(Array.isArray(assistantMsg.content), "content should be array"); + + // Should have a thinking block injected before tool_use + const thinkingBlock = assistantMsg.content.find((b) => b?.type === "thinking"); + assert.ok(thinkingBlock, "thinking block should be injected"); + assert.equal( + thinkingBlock.thinking, + "cached thinking for Kimi tool call", + "should use cached reasoning" + ); + + // Thinking block should appear before tool_use + const thinkingIdx = assistantMsg.content.indexOf(thinkingBlock); + const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use"); + assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use"); + + assert.equal(getReasoningCacheServiceStats().replays, 1); + clearModelsDevCapabilities(); + clearReasoningCacheAll(); +}); + +test("translateRequest injects placeholder thinking block for Claude-format Kimi K2 on cache miss", () => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + "kimi-coding": { + "kimi-k2.6": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, }); - test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => { - clearReasoningCacheAll(); - clearModelsDevCapabilities(); - saveModelsDevCapabilities({ - "kimi-coding": { - "kimi-k2.5": buildCapability({ - interleaved_field: "reasoning_content", - reasoning: true, - tool_call: true, - }), - }, - }); + // No cache seeded - should fall back to placeholder + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-k2.6", + { + thinking: { type: "enabled", budget_tokens: 2000 }, + messages: [ + { role: "user", content: "do it" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_miss", name: "bash", input: { command: "ls" } }], + }, + { role: "tool", tool_call_id: "toolu_miss", content: "output" }, + ], + }, + false, + null, + "kimi-coding", + null, + { reasoningCacheApiKeyId: "reasoning-test-key" } + ); - const result = translateRequest( - FORMATS.OPENAI, - FORMATS.CLAUDE, - "kimi-k2.5", - { - messages: [ - { role: "user", content: "hi" }, - { - role: "assistant", - content: [ - { type: "thinking", thinking: "I already have this" }, - { type: "tool_use", id: "toolu_existing", name: "read", input: {} }, - ], - }, - { role: "tool", tool_call_id: "toolu_existing", content: "data" }, - ], - }, - false, - null, - "kimi-coding" - ); - - const assistantMsg = result.messages.find((m) => m.role === "assistant"); - const thinkingBlocks = - Array.isArray(assistantMsg.content) && - assistantMsg.content.filter((b) => b?.type === "thinking"); - assert.equal( - thinkingBlocks?.length, - 1, - "should have exactly one thinking block (no duplicate)" - ); - assert.equal( - thinkingBlocks[0].thinking, - "I already have this", - "original thinking should be preserved" - ); - - clearModelsDevCapabilities(); - clearReasoningCacheAll(); + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistantMsg, "assistant message should exist"); + + const thinkingBlock = + Array.isArray(assistantMsg.content) && assistantMsg.content.find((b) => b?.type === "thinking"); + assert.ok(thinkingBlock, "thinking block should be injected on cache miss"); + // Must be non-empty for kimi-coding + assert.ok( + thinkingBlock.thinking && thinkingBlock.thinking.length > 0, + "placeholder must be non-empty" + ); + + clearModelsDevCapabilities(); + clearReasoningCacheAll(); +}); + +test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => { + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + "kimi-coding": { + "kimi-k2.5": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, }); + + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-k2.5", + { + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I already have this" }, + { type: "tool_use", id: "toolu_existing", name: "read", input: {} }, + ], + }, + { role: "tool", tool_call_id: "toolu_existing", content: "data" }, + ], + }, + false, + null, + "kimi-coding", + null, + { reasoningCacheApiKeyId: "reasoning-test-key" } + ); + + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + const thinkingBlocks = + Array.isArray(assistantMsg.content) && + assistantMsg.content.filter((b) => b?.type === "thinking"); + assert.equal(thinkingBlocks?.length, 1, "should have exactly one thinking block (no duplicate)"); + assert.equal( + thinkingBlocks[0].thinking, + "I already have this", + "original thinking should be preserved" + ); + + clearModelsDevCapabilities(); + clearReasoningCacheAll(); +}); diff --git a/tests/unit/translator-resp-gemini-to-claude.test.ts b/tests/unit/translator-resp-gemini-to-claude.test.ts index d1e46da020d4..dd0a4a1ca9db 100644 --- a/tests/unit/translator-resp-gemini-to-claude.test.ts +++ b/tests/unit/translator-resp-gemini-to-claude.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { getLoggedInputTokens } from "../../src/lib/usage/tokenAccounting.ts"; const { geminiToClaudeResponse } = await import("../../open-sse/translator/response/gemini-to-claude.ts"); @@ -107,7 +108,7 @@ test("Gemini -> Claude stream: functionCall becomes tool_use and MAX_TOKENS maps assert.equal(result[2].delta.partial_json, JSON.stringify({ path: "/tmp/a" })); assert.equal(result[3].type, "content_block_stop"); assert.equal(result[4].delta.stop_reason, "tool_use"); - assert.equal(result[4].usage.input_tokens, 5); + assert.equal(result[4].usage.input_tokens, 4); assert.equal(result[4].usage.output_tokens, 5); assert.equal(result[4].usage.cache_read_input_tokens, 1); assert.equal(result[5].type, "message_stop"); @@ -158,3 +159,83 @@ test("Gemini -> Claude stream: response wrapper is supported and promptFeedback- assert.equal(wrapped[2].delta.text, "wrapped"); assert.equal(geminiToClaudeResponse({ promptFeedback: { blockReason: "SAFETY" } }, {}), null); }); + +for (const cachedTokens of [undefined, 0, 90, 100]) { + test(`Gemini -> Claude usage counts cached prefix ${cachedTokens ?? "omitted"} once`, () => { + const result = geminiToClaudeResponse( + { + candidates: [{ content: { parts: [{ text: "done" }] }, finishReason: "STOP" }], + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 7, + thoughtsTokenCount: 3, + ...(cachedTokens === undefined ? {} : { cachedContentTokenCount: cachedTokens }), + }, + }, + {} + ); + const delta = result.find((event) => event.type === "message_delta"); + assert.equal(delta.usage.input_tokens, 100 - (cachedTokens ?? 0)); + assert.equal(delta.usage.cache_read_input_tokens, cachedTokens || undefined); + assert.equal(delta.usage.output_tokens, 10); + assert.equal(getLoggedInputTokens(delta.usage), 100); + assert.equal(delta.delta.stop_reason, "end_turn"); + assert.equal(result.at(-1).type, "message_stop"); + }); +} + +test("Gemini -> Claude usage clamps the uncached input count to zero", () => { + const result = geminiToClaudeResponse( + { + candidates: [{ content: { parts: [{ text: "done" }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 10, cachedContentTokenCount: 12 }, + }, + {} + ); + const delta = result.find((event) => event.type === "message_delta"); + assert.equal(delta.usage.input_tokens, 0); + assert.equal(delta.usage.cache_read_input_tokens, 12); +}); + +for (const cachedTokens of [-5, Infinity, -Infinity, NaN, "90"]) { + test(`Gemini -> Claude ignores invalid cached usage: ${cachedTokens}`, () => { + const result = geminiToClaudeResponse( + { + candidates: [{ content: { parts: [] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 100, cachedContentTokenCount: cachedTokens }, + }, + {} + ); + const delta = result.find((event) => event.type === "message_delta"); + assert.deepEqual(delta.usage, { input_tokens: 100, output_tokens: 0 }); + }); +} + +test("Gemini -> Claude preserves cached usage from an earlier wrapped stream chunk", () => { + const state = {}; + geminiToClaudeResponse( + { + response: { + candidates: [{ content: { parts: [{ text: "hello" }] } }], + usageMetadata: { + promptTokenCount: 100, + cachedContentTokenCount: 90, + candidatesTokenCount: 7, + }, + }, + }, + state + ); + const result = geminiToClaudeResponse( + { response: { candidates: [{ content: { parts: [] }, finishReason: "STOP" }] } }, + state + ); + const delta = result.find((event) => event.type === "message_delta"); + assert.deepEqual(delta.usage, { + input_tokens: 10, + output_tokens: 7, + cache_read_input_tokens: 90, + }); + assert.equal(getLoggedInputTokens(delta.usage), 100); + assert.equal(result.at(-1).type, "message_stop"); +}); diff --git a/tests/unit/trusted-client-ip.test.ts b/tests/unit/trusted-client-ip.test.ts new file mode 100644 index 000000000000..77f9966bca7e --- /dev/null +++ b/tests/unit/trusted-client-ip.test.ts @@ -0,0 +1,137 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { NextRequest } from "next/server"; +import { getClientIpFromRequest } from "../../src/lib/ipUtils.ts"; +import { CLIENT_IP_HEADER, stampClientIp } from "../../src/server/authz/clientIpStamp.ts"; +import { PEER_IP_HEADER } from "../../src/server/authz/headers.ts"; +import { + checkLoginGuard, + recordLoginFailure, + resetLoginGuardForTests, +} from "../../src/server/auth/loginGuard.ts"; + +const originalToken = process.env.OMNIROUTE_PEER_STAMP_TOKEN; +const originalProxies = process.env.OMNIROUTE_TRUSTED_PROXY_IPS; +const token = "trusted-client-ip-fixture"; +test.beforeEach(() => { + process.env.OMNIROUTE_PEER_STAMP_TOKEN = token; + delete process.env.OMNIROUTE_TRUSTED_PROXY_IPS; + resetLoginGuardForTests(); +}); +test.after(() => { + if (originalToken === undefined) delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + else process.env.OMNIROUTE_PEER_STAMP_TOKEN = originalToken; + if (originalProxies === undefined) delete process.env.OMNIROUTE_TRUSTED_PROXY_IPS; + else process.env.OMNIROUTE_TRUSTED_PROXY_IPS = originalProxies; +}); +function request(headers: Record) { + return new NextRequest("https://audit.invalid/api/auth/login", { headers }); +} + +test("socket-less forged CF, forwarding and internal headers cannot select login buckets", () => { + for (const fake of ["203.0.113.1", "203.0.113.2"]) { + const ip = getClientIpFromRequest( + request({ + "cf-connecting-ip": fake, + "x-forwarded-for": fake, + [PEER_IP_HEADER]: `forged|${fake}`, + [CLIENT_IP_HEADER]: `${fake}|${"0".repeat(64)}`, + }) + ); + assert.equal(ip, "unknown"); + if (fake.endsWith(".1")) for (let i = 0; i < 5; i++) recordLoginFailure(ip, { enabled: true }); + assert.equal(checkLoginGuard(ip, { enabled: true }).allowed, false); + } +}); + +test("authenticated public peer wins over attacker forwarding headers", () => { + assert.equal( + getClientIpFromRequest( + request({ + [PEER_IP_HEADER]: `${token}|198.51.100.10`, + "cf-connecting-ip": "203.0.113.1", + "x-forwarded-for": "203.0.113.2", + }) + ), + "198.51.100.10" + ); +}); + +test("private peers require explicit proxy trust; Docker nginx retains actual client identity", () => { + const req = request({ + [PEER_IP_HEADER]: `${token}|172.19.0.5`, + "x-forwarded-for": "198.51.100.10", + "cf-connecting-ip": "203.0.113.1", + }); + assert.equal(getClientIpFromRequest(req), "172.19.0.5"); + process.env.OMNIROUTE_TRUSTED_PROXY_IPS = "172.19.0.0/16"; + assert.equal(getClientIpFromRequest(req), "198.51.100.10"); +}); + +test("forwarding traversal stops at nearest untrusted hop instead of spoofable leftmost IP", () => { + process.env.OMNIROUTE_TRUSTED_PROXY_IPS = "172.19.0.5,10.0.0.0/24"; + assert.equal( + getClientIpFromRequest( + request({ + [PEER_IP_HEADER]: `${token}|172.19.0.5`, + "x-forwarded-for": "203.0.113.99, 198.51.100.10, 10.0.0.3", + }) + ), + "198.51.100.10" + ); +}); + +test("malformed nearest hops and malformed trust configuration fail closed", () => { + process.env.OMNIROUTE_TRUSTED_PROXY_IPS = "garbage,0.0.0.0/999,172.19.0.0/"; + assert.equal( + getClientIpFromRequest( + request({ + [PEER_IP_HEADER]: `${token}|172.19.0.5`, + "x-forwarded-for": "203.0.113.99", + }) + ), + "172.19.0.5" + ); + assert.equal( + getClientIpFromRequest( + request({ + [PEER_IP_HEADER]: `${token}|127.0.0.1`, + "x-forwarded-for": "203.0.113.99, unknown", + }) + ), + "127.0.0.1" + ); +}); + +test("IPv6 and IPv4-mapped trusted peers preserve normalized client identity", () => { + process.env.OMNIROUTE_TRUSTED_PROXY_IPS = "fd00:1234::/64"; + assert.equal( + getClientIpFromRequest( + request({ + [PEER_IP_HEADER]: `${token}|fd00:1234::2`, + "x-forwarded-for": "2001:db8::10", + }) + ), + "2001:db8::10" + ); + assert.equal( + getClientIpFromRequest( + request({ + [PEER_IP_HEADER]: `${token}|::ffff:127.0.0.1`, + "x-forwarded-for": "::ffff:198.51.100.10", + }) + ), + "198.51.100.10" + ); +}); + +test("client stamp authenticates identity without forwarding the process secret", () => { + const signed = stampClientIp("198.51.100.10", token)!; + assert.equal(signed.includes(token), false); + assert.equal(getClientIpFromRequest(request({ [CLIENT_IP_HEADER]: signed })), "198.51.100.10"); + assert.equal( + getClientIpFromRequest(request({ [CLIENT_IP_HEADER]: signed.replace(".10|", ".11|") })), + "unknown" + ); + assert.equal(stampClientIp("unknown", token), null); +}); diff --git a/tests/unit/ui/add-api-key-modal-free-models.test.tsx b/tests/unit/ui/add-api-key-modal-free-models.test.tsx index c366485a9e5e..b68cbf53503a 100644 --- a/tests/unit/ui/add-api-key-modal-free-models.test.tsx +++ b/tests/unit/ui/add-api-key-modal-free-models.test.tsx @@ -106,20 +106,18 @@ describe("AddApiKeyModal — import only free models", () => { }); describe("AddApiKeyModal — quota scraping fields", () => { - it("saves OpenCode Go workspace and auth cookie in providerSpecificData", async () => { + it("renders only the API key for OpenCode Go and saves no scraping credentials", async () => { const onSave = vi.fn().mockResolvedValue(undefined); const el = render({ provider: "opencode-go", providerName: "OpenCode Go", onSave }); + expect(el.querySelector('input[name="opencodeGoWorkspaceId"]')).toBeNull(); + expect(el.querySelector('input[name="opencodeGoAuthCookie"]')).toBeNull(); + expect(el.querySelectorAll('input[type="password"]')).toHaveLength(1); + const nameInput = el.querySelector('input[placeholder="productionKey"]')!; const apiKeyInput = el.querySelector('input[type="password"]')!; - const workspaceInput = el.querySelector( - 'input[name="opencodeGoWorkspaceId"]' - )!; - const cookieInput = el.querySelector('input[name="opencodeGoAuthCookie"]')!; setInputValue(nameInput, "OpenCode Go"); setInputValue(apiKeyInput, "sk-opencode-go-test"); - setInputValue(workspaceInput, "workspace-123"); - setInputValue(cookieInput, "auth=opencode-cookie"); const saveBtn = Array.from(el.querySelectorAll("button")).find( (b) => b.textContent?.trim() === "save" @@ -130,8 +128,9 @@ describe("AddApiKeyModal — quota scraping fields", () => { await waitFor(() => onSave.mock.calls.length > 0); const payload = onSave.mock.calls[0][0]; - expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-123"); - expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie"); + expect(payload.apiKey).toBe("sk-opencode-go-test"); + expect(payload.providerSpecificData ?? {}).not.toHaveProperty("opencodeGoWorkspaceId"); + expect(payload.providerSpecificData ?? {}).not.toHaveProperty("opencodeGoAuthCookie"); }); it("saves Ollama Cloud usage cookie in providerSpecificData", async () => { diff --git a/tests/unit/ui/edit-connection-modal-free-models.test.tsx b/tests/unit/ui/edit-connection-modal-free-models.test.tsx index 757b0cff844e..e540b784fbfa 100644 --- a/tests/unit/ui/edit-connection-modal-free-models.test.tsx +++ b/tests/unit/ui/edit-connection-modal-free-models.test.tsx @@ -37,14 +37,6 @@ function render(props: Record) { return el; } -function setInputValue(input: HTMLInputElement, value: string) { - const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; - act(() => { - setter.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); - }); -} - async function waitFor(fn: () => boolean, timeoutMs = 2000) { const start = Date.now(); while (!fn()) { @@ -170,7 +162,7 @@ describe("EditConnectionModal — import only free models", () => { }); describe("EditConnectionModal — quota scraping fields", () => { - it("saves OpenCode Go workspace and replacement auth cookie", async () => { + it("renders only the API key for OpenCode Go and saves no scraping credentials", async () => { const onSave = vi.fn().mockResolvedValue(undefined); const el = render({ providerId: "opencode-go", @@ -184,13 +176,9 @@ describe("EditConnectionModal — quota scraping fields", () => { onSave, }); - const workspaceInput = el.querySelector( - 'input[name="opencodeGoWorkspaceId"]' - )!; - const cookieInput = el.querySelector('input[name="opencodeGoAuthCookie"]')!; - expect(workspaceInput.value).toBe("workspace-existing"); - setInputValue(workspaceInput, "workspace-updated"); - setInputValue(cookieInput, "auth=opencode-cookie"); + expect(el.querySelector('input[name="opencodeGoWorkspaceId"]')).toBeNull(); + expect(el.querySelector('input[name="opencodeGoAuthCookie"]')).toBeNull(); + expect(el.querySelector('input[placeholder="enterNewApiKey"]')).toBeTruthy(); const saveBtn = Array.from(el.querySelectorAll("button")).find( (b) => b.textContent?.trim() === "save" @@ -201,8 +189,8 @@ describe("EditConnectionModal — quota scraping fields", () => { await waitFor(() => onSave.mock.calls.length > 0); const payload = onSave.mock.calls[0][0]; - expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-updated"); - expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie"); + expect(payload.providerSpecificData).not.toHaveProperty("opencodeGoWorkspaceId"); + expect(payload.providerSpecificData).not.toHaveProperty("opencodeGoAuthCookie"); }); it("omits Ollama Cloud usage cookie when the edit field is left blank", async () => { diff --git a/tests/unit/v1-models-concurrent-6408.test.ts b/tests/unit/v1-models-concurrent-6408.test.ts index 4aa543e504f5..0373475032ad 100644 --- a/tests/unit/v1-models-concurrent-6408.test.ts +++ b/tests/unit/v1-models-concurrent-6408.test.ts @@ -24,6 +24,7 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-test-secret" const core = await import("../../src/lib/db/core.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); async function resetStorage() { @@ -100,3 +101,23 @@ test("#6408 — requests with different cache keys (prefix param) run the builde "distinct cache keys must not collapse into each other" ); }); + +test("catalog-affecting model updates invalidate the longer response cache immediately", async () => { + const request = new Request("http://localhost/v1/models"); + + const first = await v1ModelsCatalog.getUnifiedModelsResponse(request); + assert.equal(first.status, 200); + assert.equal(v1ModelsCatalog.__getCatalogBuilderRunsForTest(), 1); + + await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "catalog-cache-test", [ + { id: "catalog-cache-test-model", name: "Catalog Cache Test Model" }, + ]); + + const second = await v1ModelsCatalog.getUnifiedModelsResponse(request); + assert.equal(second.status, 200); + assert.equal( + v1ModelsCatalog.__getCatalogBuilderRunsForTest(), + 2, + "a synced model update must invalidate the catalog cache without waiting for its TTL" + ); +}); diff --git a/tests/unit/vscode-token-routes.test.ts b/tests/unit/vscode-token-routes.test.ts index 24dfebb4fe01..d126d1cc9d54 100644 --- a/tests/unit/vscode-token-routes.test.ts +++ b/tests/unit/vscode-token-routes.test.ts @@ -255,7 +255,7 @@ test("vscode combos route resolves combo names through Ollama api/show", async ( assert.equal(body.model, "show-combo"); assert.equal(body.modelfile, "FROM show-combo"); assert.equal(body.details.family, "show-combo"); - assert.equal(body.model_info.context_length, 500000); + assert.equal(body.model_info.context_length, 872000); assert.deepEqual(body.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); assert.equal(body.model_info.capabilities.reasoning, true); }); @@ -290,7 +290,7 @@ test("vscode tokenized combos root route exposes importable combo metadata", asy assert.equal(response.status, 200); assert.ok(combo, "expected balanced-load in combo root response"); assert.equal(combo.url.includes("/responses#models.ai.azure.com"), true); - assert.equal(combo.maxInputTokens, 372000); + assert.equal(combo.maxInputTokens, 872000); assert.equal(combo.toolCalling, true); assert.deepEqual(combo.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); }); @@ -1081,7 +1081,7 @@ test("vscode tokenized api/show route exposes explicit reasoning effort metadata assert.equal(body.configurationSchema?.properties?.reasoningEffort?.default, "none"); assert.equal(body.model_info["general.basename"], "Codex GPT 5.6 Sol (Default)"); assert.equal(body.model_info["general.architecture"], "codex"); - assert.equal(body.model_info["codex.context_length"], 500000); + assert.equal(body.model_info["codex.context_length"], 872000); assert.deepEqual(body.model_info.supports_reasoning_effort, [ "none", "low", diff --git a/tests/unit/webdav-handler-primitives-3485.test.ts b/tests/unit/webdav-handler-primitives-3485.test.ts new file mode 100644 index 000000000000..535504cfedd1 --- /dev/null +++ b/tests/unit/webdav-handler-primitives-3485.test.ts @@ -0,0 +1,217 @@ +/** Pure WebDAV path, authentication and XML tests split from the HTTP suite. */ +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 { pathToFileURL } from "node:url"; + +const HANDLER_PATH = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + "../../scripts/dev/webdav-handler.mjs" +); + +/** Import the handler module fresh (bust module cache for env-dependent tests). */ +async function importHandler(): Promise { + const url = pathToFileURL(HANDLER_PATH).href; + return import(`${url}?t=${Date.now()}-${Math.random().toString(36).slice(2)}`); +} + +function basicAuth(user: string, pass: string): string { + return "Basic " + Buffer.from(`${user}:${pass}`).toString("base64"); +} + +const VAULT_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-primitives-")); +test.after(() => fs.rmSync(VAULT_ROOT, { recursive: true, force: true })); + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Path traversal guard (resolveVaultPath) +// ───────────────────────────────────────────────────────────────────────────── + +test("resolveVaultPath: simple path inside vault resolves correctly", async () => { + const { resolveVaultPath } = await importHandler(); + const { absPath } = resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/notes/file.md"); + assert.equal(absPath, path.join(VAULT_ROOT, "notes", "file.md")); +}); + +test("resolveVaultPath: root path resolves to vaultRoot", async () => { + const { resolveVaultPath } = await importHandler(); + const { absPath } = resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/"); + assert.equal(absPath, path.resolve(VAULT_ROOT)); +}); + +test("resolveVaultPath: ../ traversal is rejected with 403", async () => { + const { resolveVaultPath } = await importHandler(); + assert.throws( + () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/../../../etc/passwd"), + (err: { status: number }) => err.status === 403 + ); +}); + +test("resolveVaultPath: encoded %2e%2e%2f traversal is rejected with 403", async () => { + const { resolveVaultPath } = await importHandler(); + assert.throws( + () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/%2e%2e%2fetc/passwd"), + (err: { status: number }) => err.status === 403 + ); +}); + +test("resolveVaultPath: encoded %2e%2e traversal variant is rejected", async () => { + const { resolveVaultPath } = await importHandler(); + assert.throws( + () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/%2e%2e/secret"), + (err: { status: number }) => err.status === 403 + ); +}); + +test("resolveVaultPath: absolute path injection outside vault is rejected", async () => { + const { resolveVaultPath } = await importHandler(); + // The guard must catch paths that escape the vault root after resolution. + // We test by passing a request path whose decoded form resolves outside VAULT_ROOT. + // The URL segment /api/v1/webdav is stripped, then the remaining path is decoded + // and resolved relative to vaultRoot. A path like /../../../outside must be caught. + + // Use an alternative vault root with a deeper subdir so we can craft an escape + const deepVault = path.join(VAULT_ROOT, "sub", "deep"); + // /api/v1/webdav/../../secret: after prefix strip → /../../secret + // stripped of leading slashes → ../../secret + // path.resolve(deepVault, "../../secret") → VAULT_ROOT/secret which is OUTSIDE deepVault + assert.throws( + () => resolveVaultPath(deepVault, "/api/v1/webdav/../../secret"), + (err: { status: number }) => err.status === 403 + ); +}); + +test("resolveDestinationPath: raw path that escapes vault is rejected", async () => { + const { resolveDestinationPath } = await importHandler(); + // Use a deep vault so we can craft an escape path. + // Pass a raw path (not a full URL) so the URL parser does not normalise away the .. + const deepVault = path.join(VAULT_ROOT, "sub", "deep"); + // A raw Destination header path that resolves outside deepVault + assert.throws( + () => resolveDestinationPath(deepVault, "/api/v1/webdav/../../escape-target"), + (err: { status: number }) => err.status === 403 + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. verifyBasicAuth +// ───────────────────────────────────────────────────────────────────────────── + +test("verifyBasicAuth: correct credentials return true", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth(basicAuth("alice", "s3cr3t"), "alice", "s3cr3t"), true); +}); + +test("verifyBasicAuth: wrong password returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth(basicAuth("alice", "wrong"), "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: wrong username returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth(basicAuth("bob", "s3cr3t"), "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: missing header returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth(undefined, "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: empty header returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth("", "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: non-Basic scheme returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth("Bearer some-token", "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: malformed base64 returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + assert.equal(verifyBasicAuth("Basic !!!notbase64!!!", "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: base64 with no colon separator returns false", async () => { + const { verifyBasicAuth } = await importHandler(); + const noColon = "Basic " + Buffer.from("alices3cr3t").toString("base64"); + assert.equal(verifyBasicAuth(noColon, "alice", "s3cr3t"), false); +}); + +test("verifyBasicAuth: constant-time path exercised (long password diff)", async () => { + const { verifyBasicAuth } = await importHandler(); + const longPassword = "a".repeat(1000); + // Should return false without throwing on length mismatch + assert.equal(verifyBasicAuth(basicAuth("alice", longPassword), "alice", "short"), false); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. buildPropfindXml +// ───────────────────────────────────────────────────────────────────────────── + +test("buildPropfindXml: produces valid XML with entries", async () => { + const { buildPropfindXml } = await importHandler(); + const now = new Date("2026-01-01T00:00:00Z"); + const xml = buildPropfindXml( + [ + { name: "notes", href: "/api/v1/webdav/notes/", isDir: true, size: 0, mtime: now }, + { + name: "file.md", + href: "/api/v1/webdav/notes/file.md", + isDir: false, + size: 1234, + mtime: now, + }, + ], + "/api/v1/webdav/" + ); + + assert.match(xml, / { + const { buildPropfindXml } = await importHandler(); + const xml = buildPropfindXml( + [ + { + name: "a & c 'quote' \"double\"", + href: "/api/v1/webdav/a", + isDir: false, + size: 10, + mtime: new Date(), + }, + ], + "/api/v1/webdav/" + ); + + // Unescaped < > & ' " must not appear inside element content + assert.doesNotMatch(xml, /[^<]*<[^/]/); + assert.match(xml, /</); + assert.match(xml, />/); + assert.match(xml, /&/); +}); + +test("buildPropfindXml: file entry has no D:collection resourcetype", async () => { + const { buildPropfindXml } = await importHandler(); + const xml = buildPropfindXml( + [ + { + name: "note.md", + href: "/api/v1/webdav/note.md", + isDir: false, + size: 99, + mtime: new Date(), + }, + ], + "/api/v1/webdav/" + ); + // File should have empty resourcetype, not a collection + assert.doesNotMatch(xml, /D:collection/); +}); diff --git a/tests/unit/webdav-server-3485.test.ts b/tests/unit/webdav-server-3485.test.ts index 92876c8b137e..5a3b2f2eb9c8 100644 --- a/tests/unit/webdav-server-3485.test.ts +++ b/tests/unit/webdav-server-3485.test.ts @@ -130,11 +130,9 @@ function basicAuth(user: string, pass: string): string { // Setup // ───────────────────────────────────────────────────────────────────────────── -const VAULT_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-vault-")); const ORIG_KEY = process.env.STORAGE_ENCRYPTION_KEY; test.after(() => { - fs.rmSync(VAULT_ROOT, { recursive: true, force: true }); if (ORIG_KEY === undefined) { delete process.env.STORAGE_ENCRYPTION_KEY; } else { @@ -142,131 +140,6 @@ test.after(() => { } }); -// ───────────────────────────────────────────────────────────────────────────── -// 1. Path traversal guard (resolveVaultPath) -// ───────────────────────────────────────────────────────────────────────────── - -test("resolveVaultPath: simple path inside vault resolves correctly", async () => { - const { resolveVaultPath } = await importHandler(); - const { absPath } = resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/notes/file.md"); - assert.equal(absPath, path.join(VAULT_ROOT, "notes", "file.md")); -}); - -test("resolveVaultPath: root path resolves to vaultRoot", async () => { - const { resolveVaultPath } = await importHandler(); - const { absPath } = resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/"); - assert.equal(absPath, path.resolve(VAULT_ROOT)); -}); - -test("resolveVaultPath: ../ traversal is rejected with 403", async () => { - const { resolveVaultPath } = await importHandler(); - assert.throws( - () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/../../../etc/passwd"), - (err: { status: number }) => err.status === 403 - ); -}); - -test("resolveVaultPath: encoded %2e%2e%2f traversal is rejected with 403", async () => { - const { resolveVaultPath } = await importHandler(); - assert.throws( - () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/%2e%2e%2fetc/passwd"), - (err: { status: number }) => err.status === 403 - ); -}); - -test("resolveVaultPath: encoded %2e%2e traversal variant is rejected", async () => { - const { resolveVaultPath } = await importHandler(); - assert.throws( - () => resolveVaultPath(VAULT_ROOT, "/api/v1/webdav/%2e%2e/secret"), - (err: { status: number }) => err.status === 403 - ); -}); - -test("resolveVaultPath: absolute path injection outside vault is rejected", async () => { - const { resolveVaultPath } = await importHandler(); - // The guard must catch paths that escape the vault root after resolution. - // We test by passing a request path whose decoded form resolves outside VAULT_ROOT. - // The URL segment /api/v1/webdav is stripped, then the remaining path is decoded - // and resolved relative to vaultRoot. A path like /../../../outside must be caught. - - // Use an alternative vault root with a deeper subdir so we can craft an escape - const deepVault = path.join(VAULT_ROOT, "sub", "deep"); - // /api/v1/webdav/../../secret: after prefix strip → /../../secret - // stripped of leading slashes → ../../secret - // path.resolve(deepVault, "../../secret") → VAULT_ROOT/secret which is OUTSIDE deepVault - assert.throws( - () => resolveVaultPath(deepVault, "/api/v1/webdav/../../secret"), - (err: { status: number }) => err.status === 403 - ); -}); - -test("resolveDestinationPath: raw path that escapes vault is rejected", async () => { - const { resolveDestinationPath } = await importHandler(); - // Use a deep vault so we can craft an escape path. - // Pass a raw path (not a full URL) so the URL parser does not normalise away the .. - const deepVault = path.join(VAULT_ROOT, "sub", "deep"); - // A raw Destination header path that resolves outside deepVault - assert.throws( - () => resolveDestinationPath(deepVault, "/api/v1/webdav/../../escape-target"), - (err: { status: number }) => err.status === 403 - ); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// 2. verifyBasicAuth -// ───────────────────────────────────────────────────────────────────────────── - -test("verifyBasicAuth: correct credentials return true", async () => { - const { verifyBasicAuth } = await importHandler(); - assert.equal(verifyBasicAuth(basicAuth("alice", "s3cr3t"), "alice", "s3cr3t"), true); -}); - -test("verifyBasicAuth: wrong password returns false", async () => { - const { verifyBasicAuth } = await importHandler(); - assert.equal(verifyBasicAuth(basicAuth("alice", "wrong"), "alice", "s3cr3t"), false); -}); - -test("verifyBasicAuth: wrong username returns false", async () => { - const { verifyBasicAuth } = await importHandler(); - assert.equal(verifyBasicAuth(basicAuth("bob", "s3cr3t"), "alice", "s3cr3t"), false); -}); - -test("verifyBasicAuth: missing header returns false", async () => { - const { verifyBasicAuth } = await importHandler(); - assert.equal(verifyBasicAuth(undefined, "alice", "s3cr3t"), false); -}); - -test("verifyBasicAuth: empty header returns false", async () => { - const { verifyBasicAuth } = await importHandler(); - assert.equal(verifyBasicAuth("", "alice", "s3cr3t"), false); -}); - -test("verifyBasicAuth: non-Basic scheme returns false", async () => { - const { verifyBasicAuth } = await importHandler(); - assert.equal( - verifyBasicAuth("Bearer some-token", "alice", "s3cr3t"), - false - ); -}); - -test("verifyBasicAuth: malformed base64 returns false", async () => { - const { verifyBasicAuth } = await importHandler(); - assert.equal(verifyBasicAuth("Basic !!!notbase64!!!", "alice", "s3cr3t"), false); -}); - -test("verifyBasicAuth: base64 with no colon separator returns false", async () => { - const { verifyBasicAuth } = await importHandler(); - const noColon = "Basic " + Buffer.from("alices3cr3t").toString("base64"); - assert.equal(verifyBasicAuth(noColon, "alice", "s3cr3t"), false); -}); - -test("verifyBasicAuth: constant-time path exercised (long password diff)", async () => { - const { verifyBasicAuth } = await importHandler(); - const longPassword = "a".repeat(1000); - // Should return false without throwing on length mismatch - assert.equal(verifyBasicAuth(basicAuth("alice", longPassword), "alice", "short"), false); -}); - // ───────────────────────────────────────────────────────────────────────────── // 3. decryptStored (port of encryption.ts) // ───────────────────────────────────────────────────────────────────────────── @@ -307,68 +180,6 @@ test("decryptStored: malformed enc:v1: format returns null", async () => { assert.equal(decryptStored("enc:v1:onlytwoparts:missing"), null); }); -// ───────────────────────────────────────────────────────────────────────────── -// 4. buildPropfindXml -// ───────────────────────────────────────────────────────────────────────────── - -test("buildPropfindXml: produces valid XML with entries", async () => { - const { buildPropfindXml } = await importHandler(); - const now = new Date("2026-01-01T00:00:00Z"); - const xml = buildPropfindXml( - [ - { name: "notes", href: "/api/v1/webdav/notes/", isDir: true, size: 0, mtime: now }, - { - name: "file.md", - href: "/api/v1/webdav/notes/file.md", - isDir: false, - size: 1234, - mtime: now, - }, - ], - "/api/v1/webdav/" - ); - - assert.match(xml, / { - const { buildPropfindXml } = await importHandler(); - const xml = buildPropfindXml( - [ - { - name: "a & c 'quote' \"double\"", - href: "/api/v1/webdav/a", - isDir: false, - size: 10, - mtime: new Date(), - }, - ], - "/api/v1/webdav/" - ); - - // Unescaped < > & ' " must not appear inside element content - assert.doesNotMatch(xml, /[^<]*<[^/]/); - assert.match(xml, /</); - assert.match(xml, />/); - assert.match(xml, /&/); -}); - -test("buildPropfindXml: file entry has no D:collection resourcetype", async () => { - const { buildPropfindXml } = await importHandler(); - const xml = buildPropfindXml( - [{ name: "note.md", href: "/api/v1/webdav/note.md", isDir: false, size: 99, mtime: new Date() }], - "/api/v1/webdav/" - ); - // File should have empty resourcetype, not a collection - assert.doesNotMatch(xml, /D:collection/); -}); - // ───────────────────────────────────────────────────────────────────────────── // 5. Integration: PUT/GET/DELETE/MKCOL/MOVE with real temp vault // Uses maybeHandleWebdav via a fake DB config injected via loadWebdavConfig mock. @@ -597,6 +408,147 @@ test("PROPFIND on vault root returns 207 with entries", async () => { assert.match(propfindRes.body, /propfind-test\.md/); }); +test("WebDAV confines all methods and destinations when vault symlinks point outside", async (t) => { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-outside-")); + const fixture = path.join(intVaultDir, "symlink-security"); + fs.mkdirSync(fixture); + fs.writeFileSync(path.join(outside, "secret.md"), "synthetic outside secret"); + fs.symlinkSync(outside, path.join(fixture, "outside-dir"), "dir"); + fs.symlinkSync(path.join(outside, "secret.md"), path.join(fixture, "outside-file")); + fs.symlinkSync(path.join(outside, "missing"), path.join(fixture, "dangling")); + fs.symlinkSync(path.join(fixture, "outside-dir"), path.join(fixture, "chain"), "dir"); + fs.writeFileSync(path.join(fixture, "source.md"), "safe source"); + t.after(() => { + fs.rmSync(fixture, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + }); + + const prefix = "/api/v1/webdav/symlink-security"; + for (const method of [ + "GET", + "HEAD", + "PROPFIND", + "PUT", + "DELETE", + "MKCOL", + "MOVE", + "COPY", + "LOCK", + "UNLOCK", + "OPTIONS", + ]) { + for (const resource of [ + "outside-file", + "outside-dir/secret.md", + "chain/secret.md", + "dangling/new.md", + ]) { + const result = await webdavRequest({ + method, + url: `${prefix}/${resource}`, + headers: { authorization: AUTH_HEADER, destination: `${prefix}/unused.md` }, + body: method === "PUT" ? Buffer.from("must not write") : undefined, + dataDir: intDataDir, + }); + assert.equal(result.status, 403, `${method} ${resource}`); + assert.doesNotMatch(result.body, /synthetic outside secret/); + } + } + + for (const method of ["PUT", "MKCOL"]) { + const result = await webdavRequest({ + method, + url: `${prefix}/outside-dir/new/nested`, + headers: { authorization: AUTH_HEADER }, + body: method === "PUT" ? Buffer.from("must not write") : undefined, + dataDir: intDataDir, + }); + assert.equal(result.status, 403, `${method} new destination`); + } + + for (const method of ["MOVE", "COPY"]) { + for (const destination of ["outside-file", "outside-dir/new/nested.md", "dangling/new.md"]) { + const result = await webdavRequest({ + method, + url: `${prefix}/source.md`, + headers: { + authorization: AUTH_HEADER, + destination: `http://localhost${prefix}/${destination}`, + }, + dataDir: intDataDir, + }); + assert.equal(result.status, 403, `${method} to ${destination}`); + assert.equal(fs.readFileSync(path.join(fixture, "source.md"), "utf8"), "safe source"); + } + } + + const listing = await webdavRequest({ + method: "PROPFIND", + url: prefix, + headers: { authorization: AUTH_HEADER, depth: "1" }, + dataDir: intDataDir, + }); + assert.equal(listing.status, 207); + assert.match(listing.body, /source\.md/); + assert.doesNotMatch(listing.body, /outside-file|outside-dir|dangling|chain/); + assert.equal( + fs.readFileSync(path.join(outside, "secret.md"), "utf8"), + "synthetic outside secret" + ); + assert.deepEqual(fs.readdirSync(outside), ["secret.md"]); +}); + +test("WebDAV preserves reads and new writes through symlinks confined within the vault", async (t) => { + const fixture = path.join(intVaultDir, "internal-links"); + fs.mkdirSync(path.join(fixture, "real"), { recursive: true }); + fs.symlinkSync("real", path.join(fixture, "alias"), "dir"); + t.after(() => fs.rmSync(fixture, { recursive: true, force: true })); + const url = "/api/v1/webdav/internal-links/alias/new/note.md"; + const put = await webdavRequest({ + method: "PUT", + url, + headers: { authorization: AUTH_HEADER }, + body: Buffer.from("inside vault"), + dataDir: intDataDir, + }); + assert.equal(put.status, 201); + assert.equal(fs.readFileSync(path.join(fixture, "real/new/note.md"), "utf8"), "inside vault"); + const get = await webdavRequest({ + method: "GET", + url, + headers: { authorization: AUTH_HEADER }, + dataDir: intDataDir, + }); + assert.equal(get.status, 200); + assert.equal(get.body, "inside vault"); +}); + +test("WebDAV PUT refuses a pre-existing temporary symlink without touching its target", async (t) => { + const fixture = path.join(intVaultDir, "temporary-link"); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "omni-webdav-temp-outside-")); + fs.mkdirSync(fixture); + const outsideFile = path.join(outside, "secret.md"); + fs.writeFileSync(outsideFile, "synthetic outside content"); + t.mock.method(Date, "now", () => 1700000000000); + const temporary = path.join(fixture, "note.md.omniroute-webdav-tmp-1700000000000"); + fs.symlinkSync(outsideFile, temporary); + t.after(() => { + fs.rmSync(fixture, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + }); + const result = await webdavRequest({ + method: "PUT", + url: "/api/v1/webdav/temporary-link/note.md", + headers: { authorization: AUTH_HEADER }, + body: Buffer.from("must not overwrite"), + dataDir: intDataDir, + }); + assert.equal(result.status, 500); + assert.equal(fs.readFileSync(outsideFile, "utf8"), "synthetic outside content"); + assert.equal(fs.existsSync(path.join(fixture, "note.md")), false); + assert.equal(fs.lstatSync(temporary).isSymbolicLink(), true); +}); + // ───────────────────────────────────────────────────────────────────────────── // 6. Auth failure paths // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/unit/xai-oauth-provider.test.ts b/tests/unit/xai-oauth-provider.test.ts new file mode 100644 index 000000000000..6fdba9a1ec77 --- /dev/null +++ b/tests/unit/xai-oauth-provider.test.ts @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { generateAuthData } from "../../src/lib/oauth/providers.ts"; +import { xaiOauth, decodeXaiIdTokenIdentity } from "../../src/lib/oauth/providers/xai-oauth.ts"; +import { XAI_OAUTH_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; +import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; +import { XaiExecutor } from "../../open-sse/executors/xai.ts"; +import { xai_oauthProvider } from "../../open-sse/config/providers/registry/xai-oauth/index.ts"; + +const originalFetch = globalThis.fetch; + +function createJwt(payload: Record) { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(payload)}.signature`; +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("xAI OAuth builds the official PKCE authorization request", () => { + const authData = generateAuthData("xai-oauth", "http://127.0.0.1:56121/callback"); + const url = new URL(authData.authUrl); + + assert.equal(url.origin, "https://auth.x.ai"); + assert.equal(url.pathname, "/oauth2/authorize"); + assert.equal(url.searchParams.get("client_id"), XAI_OAUTH_CONFIG.clientId); + assert.equal(url.searchParams.get("scope"), XAI_OAUTH_CONFIG.scope); + assert.equal(url.searchParams.get("code_challenge_method"), "S256"); + assert.equal(url.searchParams.get("plan"), "generic"); + assert.equal(url.searchParams.get("referrer"), "cli-proxy-api"); + assert.ok(url.searchParams.get("nonce")); + assert.equal(authData.codeVerifier.length, 128); + assert.equal(authData.fixedPort, 56121); + assert.equal(authData.callbackPath, "/callback"); + assert.equal(authData.callbackHost, "127.0.0.1"); +}); + +test("xAI OAuth exchanges a code with form-urlencoded PKCE fields", async () => { + globalThis.fetch = async (input, init) => { + assert.equal(String(input), XAI_OAUTH_CONFIG.tokenUrl); + assert.equal(init?.method, "POST"); + assert.equal(init?.headers?.["Content-Type"], "application/x-www-form-urlencoded"); + const body = init?.body as URLSearchParams; + assert.equal(body.get("grant_type"), "authorization_code"); + assert.equal(body.get("client_id"), XAI_OAUTH_CONFIG.clientId); + assert.equal(body.get("code"), "auth-code"); + assert.equal(body.get("redirect_uri"), "http://127.0.0.1:56121/callback"); + assert.equal(body.get("code_verifier"), "verifier"); + return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }); + }; + + const tokens = await xaiOauth.exchangeToken( + XAI_OAUTH_CONFIG, + "auth-code", + "http://127.0.0.1:56121/callback", + "verifier" + ); + assert.equal(tokens.access_token, "access"); +}); + +test("xAI OAuth maps refreshable tokens and safe id_token display metadata", () => { + const idToken = createJwt({ email: "user@example.com", name: "Grok User" }); + assert.deepEqual(decodeXaiIdTokenIdentity(idToken), { + email: "user@example.com", + name: "Grok User", + }); + + const mapped = xaiOauth.mapTokens({ + access_token: "access", + refresh_token: "refresh", + id_token: idToken, + expires_in: 3600, + scope: XAI_OAUTH_CONFIG.scope, + }); + assert.equal(mapped.accessToken, "access"); + assert.equal(mapped.refreshToken, "refresh"); + assert.equal(mapped.email, "user@example.com"); + assert.equal(mapped.name, "Grok User"); +}); + +test("xAI OAuth is a distinct OAuth registry entry backed by the xAI executor", () => { + assert.equal(xai_oauthProvider.authType, "oauth"); + assert.equal(xai_oauthProvider.baseUrl, "https://api.x.ai/v1/chat/completions"); + assert.ok(xai_oauthProvider.models?.some((model) => model.id === "grok-4.5")); + assert.equal(hasSpecializedExecutor("xai-oauth"), true); + assert.ok(getExecutor("xai-oauth") instanceof XaiExecutor); + + const headers = getExecutor("xai-oauth").buildHeaders({ accessToken: "oauth-access" }, false); + assert.equal(headers.Authorization, "Bearer oauth-access"); +}); + +test("xAI OAuth executor rotates refresh tokens", async () => { + globalThis.fetch = async (input, init) => { + assert.equal(String(input), XAI_OAUTH_CONFIG.tokenUrl); + const body = init?.body as URLSearchParams; + assert.equal(body.get("grant_type"), "refresh_token"); + assert.equal(body.get("client_id"), XAI_OAUTH_CONFIG.clientId); + assert.equal(body.get("refresh_token"), "old-refresh"); + return Response.json({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 7200, + }); + }; + + const executor = new XaiExecutor("xai-oauth"); + const refreshed = await executor.refreshCredentials({ refreshToken: "old-refresh" }, null); + assert.equal(refreshed?.accessToken, "new-access"); + assert.equal(refreshed?.refreshToken, "new-refresh"); + assert.ok(refreshed?.expiresAt); +});